mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
add weekly digests, log, and opt out options (#1107)
### TL;DR Adds a Weekly Digests view to the web app, allowing users to browse and read their personalized weekly memory recaps directly in the UI, along with a notification preference toggle to opt out of digest emails. ### What changed? - Added a new `digests` view mode that renders a `DigestsView` component, accessible from the dashboard via a new weekly digest preview card that appears when a digest exists. - `DigestsView` displays a master-detail layout: a scrollable list of past weekly digests on the left, and a full digest content pane on the right. The detail pane renders the intro (with a floating brain illustration), numbered highlights, and feature recommendations styled to mirror the email layout. - Added thumbs-up/thumbs-down feedback controls and an optional free-text input on each digest, wired to analytics events. - Added `useDigests` and `useDigest` hooks that fetch digest list and detail data from the API, with 5- and 10-minute stale times respectively. - Registered four new API schema endpoints: `GET /digests`, `GET /digests/:id`, `GET /digests/preferences`, and `POST /digests/preferences`. - Added a `DigestPreferences` section to the account settings page with a toggle that lets users opt in or out of the weekly digest email. - Extended the `viewModeChanged` analytics event and the `viewLiterals` search param list to include `"digests"`, and added `digestViewed`, `digestFeedback`, and `digestFeedbackDetail` analytics events. - Added documentation for the `SUPERMEMORY_EMBEDDING_RAM_LIMIT` and `SUPERMEMORY_INGEST_CONCURRENCY` environment variables, explaining the memory-bounded ingestion queue and its live terminal status output. ### How to test? 1. Navigate to the dashboard and confirm the weekly digest preview card appears when a digest exists, and clicking it transitions to the `digests` view. 2. In the digests view, verify the list renders past digests with the most recent highlighted by a gradient border, and selecting a row loads the correct detail content. 3. Confirm the empty state renders correctly when no digests exist. 4. Use the thumbs-up/thumbs-down buttons and the detailed feedback textarea on a digest, verifying the toast confirmation appears on submission. 5. Open account settings, locate the "Notifications" section, and toggle the weekly digest switch on and off, verifying the preference persists without errors. 6. Confirm the `digests` view mode is reflected in the URL search params when active. ### Why make this change? Users currently receive weekly digest emails but have no way to revisit past digests within the app. This change surfaces digest history directly in the UI, adds in-product feedback collection on digest quality, and gives users control over whether they receive the emails — improving discoverability, engagement, and preference management.
This commit is contained in:
parent
d4a3a57a42
commit
9017a1b5d4
9 changed files with 900 additions and 4 deletions
|
|
@ -31,6 +31,7 @@ import { DocumentModal } from "@/components/document-modal"
|
|||
import { DocumentsCommandPalette } from "@/components/documents-command-palette"
|
||||
import { FullscreenNoteModal } from "@/components/fullscreen-note-modal"
|
||||
import type { HighlightItem } from "@/components/highlights-card"
|
||||
import { DigestsView } from "@/components/digests-view"
|
||||
import { HotkeysProvider } from "react-hotkeys-hook"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
|
|
@ -577,7 +578,10 @@ export default function NewPage() {
|
|||
|
||||
const isChatView = viewMode === "chat"
|
||||
const showNovaBackdrop =
|
||||
viewMode === "graph" || viewMode === "list" || viewMode === "dashboard"
|
||||
viewMode === "graph" ||
|
||||
viewMode === "list" ||
|
||||
viewMode === "dashboard" ||
|
||||
viewMode === "digests"
|
||||
const isDashboardShell =
|
||||
viewMode === "dashboard" || (viewMode === "graph" && isMobile)
|
||||
const isGraphMode = viewMode === "graph"
|
||||
|
|
@ -591,7 +595,8 @@ export default function NewPage() {
|
|||
<div
|
||||
className={cn(
|
||||
"relative flex min-h-dvh flex-col bg-[#05080D]",
|
||||
(isGraphMode || isChatView) && "h-dvh overflow-hidden",
|
||||
(isGraphMode || isChatView || viewMode === "digests") &&
|
||||
"h-dvh overflow-hidden",
|
||||
showBottomNav &&
|
||||
!isGraphMode &&
|
||||
"pb-[calc(4rem+env(safe-area-inset-bottom))]",
|
||||
|
|
@ -635,7 +640,8 @@ export default function NewPage() {
|
|||
transition={{ duration: 0.22, ease: [0.4, 0, 0.2, 1] }}
|
||||
className={cn(
|
||||
"relative z-10 flex min-h-0 flex-1 flex-col",
|
||||
(isGraphMode || isChatView) && "overflow-hidden",
|
||||
(isGraphMode || isChatView || viewMode === "digests") &&
|
||||
"overflow-hidden",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
|
|
@ -701,6 +707,10 @@ export default function NewPage() {
|
|||
<XBookmarksDetailView
|
||||
onBack={() => void setViewMode("integrations")}
|
||||
/>
|
||||
) : viewMode === "digests" ? (
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto lg:overflow-hidden">
|
||||
<DigestsView />
|
||||
</div>
|
||||
) : viewMode === "graph" ? (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<GraphLayoutView onOpenDocument={handleOpenDocumentById} />
|
||||
|
|
@ -763,6 +773,7 @@ export default function NewPage() {
|
|||
onHighlightsChat={handleHighlightsChat}
|
||||
onHighlightsShowRelated={handleHighlightsShowRelated}
|
||||
onResetHighlights={handleResetHighlights}
|
||||
onOpenDigests={() => void setViewMode("digests")}
|
||||
memoryOfDay={memoryOfDay}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import {
|
|||
} from "@/hooks/use-personalization"
|
||||
import { normalizePluginClientId } from "@/lib/plugin-catalog"
|
||||
import { detectPluginSpace } from "@/lib/plugin-space"
|
||||
import { useDigests } from "@/hooks/use-digests"
|
||||
|
||||
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
||||
type DocumentWithMemories = DocumentsResponse["documents"][0]
|
||||
|
|
@ -1153,6 +1154,7 @@ export function DashboardView({
|
|||
onHighlightsChat,
|
||||
onHighlightsShowRelated,
|
||||
onResetHighlights,
|
||||
onOpenDigests,
|
||||
memoryOfDay,
|
||||
}: {
|
||||
spaceLabel: string
|
||||
|
|
@ -1173,6 +1175,7 @@ export function DashboardView({
|
|||
onHighlightsChat: (highlightContent: string, userReply: string) => void
|
||||
onHighlightsShowRelated: (query: string) => void
|
||||
onResetHighlights: () => void
|
||||
onOpenDigests: () => void
|
||||
memoryOfDay: MemoryOfDay | null
|
||||
}) {
|
||||
const { user, org } = useAuth()
|
||||
|
|
@ -1279,6 +1282,9 @@ export function DashboardView({
|
|||
setProfession,
|
||||
} = usePersonalization()
|
||||
|
||||
const { data: digestList } = useDigests()
|
||||
const latestDigest = digestList?.[0]
|
||||
|
||||
const recents = recentsData?.documents ?? []
|
||||
const recentToolUsageItems = toolUsageItems
|
||||
.filter((item) => item.type === "Plugin" && item.lastDocument)
|
||||
|
|
@ -1472,6 +1478,43 @@ export function DashboardView({
|
|||
</p>
|
||||
</motion.section>
|
||||
|
||||
{/* Weekly digest preview */}
|
||||
{latestDigest && (
|
||||
<motion.button
|
||||
{...fadeUp}
|
||||
transition={{ ...fadeUp.transition, delay: 0.12 }}
|
||||
type="button"
|
||||
onClick={onOpenDigests}
|
||||
className={cn(
|
||||
"group flex w-full items-center gap-3 rounded-xl border border-white/[0.06] bg-white/[0.02] px-3 py-2.5 text-left transition-colors hover:border-white/[0.1] hover:bg-white/[0.04] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4BA0FA]/45",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-[#4BA0FA]/12">
|
||||
<Image
|
||||
src="/images/digest/feat-fs.svg"
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
className="size-4"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-baseline gap-2">
|
||||
<span className="shrink-0 text-[9px] font-semibold uppercase tracking-[0.12em] text-[#8BC6FF]">
|
||||
Weekly digest
|
||||
</span>
|
||||
<p className="truncate text-[12px] text-fg-muted">
|
||||
{latestDigest.title || "Your week in Supermemory"} ·{" "}
|
||||
{latestDigest.memoryCount} memories
|
||||
</p>
|
||||
</div>
|
||||
<span className="flex shrink-0 items-center gap-1 text-[11px] font-medium text-fg-faint transition-colors group-hover:text-fg-muted">
|
||||
View
|
||||
<ArrowRight className="size-3 transition-transform group-hover:translate-x-0.5" />
|
||||
</span>
|
||||
</motion.button>
|
||||
)}
|
||||
|
||||
{/* Recently saved + Suggested for you */}
|
||||
<motion.section
|
||||
{...fadeUp}
|
||||
|
|
|
|||
479
apps/web/components/digests-view.tsx
Normal file
479
apps/web/components/digests-view.tsx
Normal file
|
|
@ -0,0 +1,479 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts"
|
||||
import {
|
||||
Loader2,
|
||||
Mail,
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
MoreHorizontal,
|
||||
} from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { SyncLogoIcon } from "@ui/assets/icons"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { useDigests, useDigest, type DigestSummary } from "@/hooks/use-digests"
|
||||
|
||||
interface DigestsViewProps {
|
||||
initialDigestId?: string | null
|
||||
}
|
||||
|
||||
// feature → illustration (served from /images/digest/)
|
||||
const FEATURE_IMG: Record<string, string> = {
|
||||
connections: "feat-router.png",
|
||||
chat: "feat-memory.png",
|
||||
extension: "feat-retrieval.png",
|
||||
plugins: "feat-profiles.png",
|
||||
mcp: "feat-router.png",
|
||||
search: "feat-retrieval.png",
|
||||
}
|
||||
const BRAIN_IMG = "https://supermemory.ai/images/brain-head.png"
|
||||
|
||||
function formatIsoWeek(isoWeek: string): string {
|
||||
const match = isoWeek.match(/^(\d{4})-W(\d{2})$/)
|
||||
if (!match) return isoWeek
|
||||
const year = Number.parseInt(match[1] as string, 10)
|
||||
const week = Number.parseInt(match[2] as string, 10)
|
||||
const jan4 = new Date(year, 0, 4)
|
||||
const dow = jan4.getDay() || 7
|
||||
const start = new Date(jan4)
|
||||
start.setDate(jan4.getDate() - dow + 1 + (week - 1) * 7)
|
||||
const end = new Date(start)
|
||||
end.setDate(start.getDate() + 6)
|
||||
const months = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
]
|
||||
const sm = months[start.getMonth()] ?? ""
|
||||
const em = months[end.getMonth()] ?? ""
|
||||
return `${sm} ${start.getDate()}–${sm === em ? "" : `${em} `}${end.getDate()}, ${year}`
|
||||
}
|
||||
|
||||
function isoWeekLabel(isoWeek: string): string {
|
||||
const m = isoWeek.match(/-W(\d{2})$/)
|
||||
return m ? `W${Number.parseInt(m[1] as string, 10)}` : isoWeek
|
||||
}
|
||||
|
||||
// ─── Left list row ────────────────────────────────────────────────────────────
|
||||
function DigestRow({
|
||||
digest,
|
||||
featured,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
digest: DigestSummary
|
||||
featured: boolean
|
||||
selected: boolean
|
||||
onSelect: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
"group flex w-full flex-col gap-3 rounded-[12px] bg-[#14161A] p-4 text-left shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)] transition-colors hover:bg-[#16181D] focus:outline-none",
|
||||
selected && "bg-[#1C2026]",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"flex h-9 shrink-0 items-center justify-center rounded-[10px] bg-[#080B0F] px-2.5 text-[13px] font-semibold text-[#8BC6FF] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]",
|
||||
)}
|
||||
>
|
||||
{isoWeekLabel(digest.isoWeek)}
|
||||
</span>
|
||||
{featured && (
|
||||
<span className="shrink-0 pt-1.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-[#737373]">
|
||||
Latest
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"line-clamp-2 text-[14px] font-medium leading-snug text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{digest.title || "Your week in Supermemory"}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"flex items-center gap-1.5 text-[11px]",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="flex items-center gap-1 font-semibold"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(94deg, #369BFD 4.8%, #36FDFD 77.04%, #36FDB5 143.99%)",
|
||||
backgroundClip: "text",
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
}}
|
||||
>
|
||||
<SyncLogoIcon className="h-[10px] w-[12.33px]" />
|
||||
{digest.memoryCount}
|
||||
</span>
|
||||
<span className="text-[#737373]">
|
||||
{digest.memoryCount === 1 ? "memory" : "memories"}
|
||||
</span>
|
||||
</span>
|
||||
<span className={cn(dmSansClassName(), "text-[11px] text-[#737373]")}>
|
||||
{formatIsoWeek(digest.isoWeek)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Right pane: a single digest, faithful to the email layout ─────────────────
|
||||
function DigestContent({
|
||||
digestId,
|
||||
isoWeek,
|
||||
}: {
|
||||
digestId: string
|
||||
isoWeek: string
|
||||
}) {
|
||||
const { data: digest, isLoading } = useDigest(digestId)
|
||||
const [rating, setRating] = useState<"up" | "down" | null>(null)
|
||||
const [showInput, setShowInput] = useState(false)
|
||||
const [message, setMessage] = useState("")
|
||||
|
||||
// reset feedback state when switching digests
|
||||
useEffect(() => {
|
||||
setRating(null)
|
||||
setShowInput(false)
|
||||
setMessage("")
|
||||
analytics.digestViewed({ digest_id: digestId, iso_week: isoWeek })
|
||||
}, [digestId, isoWeek])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="size-6 animate-spin text-[#4BA0FA]" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!digest) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center text-sm text-[#A1A1AA]">
|
||||
Digest not found.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { digestData } = digest
|
||||
|
||||
const rate = (r: "up" | "down") => {
|
||||
setRating(r)
|
||||
analytics.digestFeedback({
|
||||
digest_id: digestId,
|
||||
iso_week: isoWeek,
|
||||
rating: r,
|
||||
})
|
||||
}
|
||||
const submitDetail = () => {
|
||||
if (!message.trim()) return
|
||||
analytics.digestFeedbackDetail({
|
||||
digest_id: digestId,
|
||||
iso_week: isoWeek,
|
||||
rating,
|
||||
message: message.trim(),
|
||||
})
|
||||
setMessage("")
|
||||
setShowInput(false)
|
||||
toast.success("Thanks for the feedback!")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("mx-auto w-full max-w-[560px]", dmSansClassName())}>
|
||||
{/* Header */}
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-[#8BC6FF]">
|
||||
Weekly digest · {formatIsoWeek(digest.isoWeek)}
|
||||
</p>
|
||||
<h1
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"mt-2 text-[30px] font-semibold leading-[1.12] tracking-tight text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{digestData.title || "Your week in Supermemory"}
|
||||
</h1>
|
||||
|
||||
{/* Greeting + intro, brain floated right */}
|
||||
<div className="mt-7">
|
||||
<img
|
||||
src={BRAIN_IMG}
|
||||
alt=""
|
||||
width={60}
|
||||
height={90}
|
||||
className="float-right ml-5 mb-2 h-[90px] w-auto"
|
||||
/>
|
||||
<p className="text-[15px] leading-relaxed text-[#C2C9D6]">
|
||||
{digestData.intro}
|
||||
</p>
|
||||
<div className="clear-both" />
|
||||
</div>
|
||||
|
||||
<div className="my-7 border-t border-white/[0.07]" />
|
||||
|
||||
{/* Highlights — numbered, no boxes */}
|
||||
{digestData.highlights.length > 0 && (
|
||||
<>
|
||||
<p className="mb-5 text-[10px] font-bold uppercase tracking-[0.14em] text-[#6B7585]">
|
||||
This week's highlights
|
||||
</p>
|
||||
<div className="flex flex-col gap-6">
|
||||
{digestData.highlights.map((h, i) => (
|
||||
<div key={h.id} className="flex gap-3.5">
|
||||
<span className="w-7 shrink-0 pt-0.5 text-[12px] font-bold tracking-wider text-[#4BA0FA]">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="mb-1 text-[16px] font-semibold leading-snug text-[#FAFAFA]">
|
||||
{h.title}
|
||||
</p>
|
||||
<p className="text-[14px] leading-relaxed text-[#A1A1AA]">
|
||||
{h.content}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Worth trying — the one place we use boxes (subtle) */}
|
||||
{digestData.featureRecommendations.length > 0 && (
|
||||
<div className="mt-8 rounded-2xl border border-[#4BA0FA]/15 bg-[#4BA0FA]/[0.06] p-5 sm:p-6">
|
||||
<p className="mb-5 text-[10px] font-bold uppercase tracking-[0.14em] text-[#8BC6FF]">
|
||||
Worth trying
|
||||
</p>
|
||||
<div className="flex flex-col gap-5">
|
||||
{digestData.featureRecommendations.map((r) => (
|
||||
<a
|
||||
key={r.feature}
|
||||
href={r.ctaUrl}
|
||||
className="group flex items-start gap-3.5"
|
||||
>
|
||||
<img
|
||||
src={`/images/digest/${FEATURE_IMG[r.feature] ?? "feat-memory.png"}`}
|
||||
alt=""
|
||||
width={44}
|
||||
height={44}
|
||||
className="size-11 shrink-0"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[14px] font-semibold leading-snug text-[#FAFAFA]">
|
||||
{r.headline}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[13px] leading-snug text-[#A1A1AA]">
|
||||
{r.body}{" "}
|
||||
<span className="whitespace-nowrap font-semibold text-[#4BA0FA] group-hover:underline">
|
||||
{r.ctaLabel} →
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feedback */}
|
||||
<div className="mt-8 border-t border-white/[0.07] pt-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[13px] text-[#A1A1AA]">
|
||||
Was this digest useful?
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Useful"
|
||||
onClick={() => rate("up")}
|
||||
className={cn(
|
||||
"flex size-8 items-center justify-center rounded-lg border transition-colors",
|
||||
rating === "up"
|
||||
? "border-[#4BA0FA]/50 bg-[#4BA0FA]/15 text-[#4BA0FA]"
|
||||
: "border-white/[0.08] bg-white/[0.02] text-[#A1A1AA] hover:bg-white/[0.05]",
|
||||
)}
|
||||
>
|
||||
<ThumbsUp className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Not useful"
|
||||
onClick={() => rate("down")}
|
||||
className={cn(
|
||||
"flex size-8 items-center justify-center rounded-lg border transition-colors",
|
||||
rating === "down"
|
||||
? "border-[#4BA0FA]/50 bg-[#4BA0FA]/15 text-[#4BA0FA]"
|
||||
: "border-white/[0.08] bg-white/[0.02] text-[#A1A1AA] hover:bg-white/[0.05]",
|
||||
)}
|
||||
>
|
||||
<ThumbsDown className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Leave detailed feedback"
|
||||
onClick={() => setShowInput((v) => !v)}
|
||||
className={cn(
|
||||
"flex size-8 items-center justify-center rounded-lg border transition-colors",
|
||||
showInput
|
||||
? "border-[#4BA0FA]/50 bg-[#4BA0FA]/15 text-[#4BA0FA]"
|
||||
: "border-white/[0.08] bg-white/[0.02] text-[#A1A1AA] hover:bg-white/[0.05]",
|
||||
)}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showInput && (
|
||||
<div className="mt-3">
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Tell us what you'd like to see in your digest…"
|
||||
rows={3}
|
||||
className="w-full resize-none rounded-xl border border-white/[0.08] bg-white/[0.02] px-3.5 py-2.5 text-[13px] text-[#FAFAFA] placeholder:text-[#6B7585] focus:border-[#4BA0FA]/50 focus:outline-none"
|
||||
/>
|
||||
<div className="mt-2 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitDetail}
|
||||
disabled={!message.trim()}
|
||||
className="rounded-lg bg-[#4BA0FA] px-3.5 py-1.5 text-[12px] font-semibold text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
Send feedback
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Master-detail ─────────────────────────────────────────────────────────────
|
||||
export function DigestsView({ initialDigestId }: DigestsViewProps) {
|
||||
const { data: digests, isLoading } = useDigests()
|
||||
const [selectedId, setSelectedId] = useState<string | null>(
|
||||
initialDigestId ?? null,
|
||||
)
|
||||
|
||||
// default to the most recent digest
|
||||
const effectiveId = selectedId ?? digests?.[0]?.id ?? null
|
||||
const selected = digests?.find((d) => d.id === effectiveId) ?? digests?.[0]
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="size-6 animate-spin text-[#4BA0FA]" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!digests || digests.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto flex max-w-md flex-col items-center justify-center gap-3 px-4 py-24 text-center",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<div className="flex size-12 items-center justify-center rounded-[12px] bg-[#4BA0FA]/12">
|
||||
<Mail className="size-5 text-[#4BA0FA]" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-[#FAFAFA]">No digests yet</p>
|
||||
<p className="max-w-xs text-[12px] leading-relaxed text-[#A1A1AA]">
|
||||
Your first weekly digest arrives Monday. It'll show your highlights
|
||||
and suggestions right here.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto w-full max-w-[1600px] px-4 pb-6 pt-4 sm:px-6 lg:h-full lg:overflow-hidden",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-6 lg:h-full lg:flex-row lg:gap-8">
|
||||
{/* Left: digest list (scrolls independently) */}
|
||||
<aside className="shrink-0 lg:min-h-0 lg:w-1/3 lg:min-w-[210px] lg:overflow-y-auto lg:pr-1">
|
||||
<div className="mb-4 flex items-baseline justify-between gap-3">
|
||||
<h1
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-xl font-semibold tracking-tight text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Weekly Digests
|
||||
</h1>
|
||||
<p className="shrink-0 text-[12px] text-[#737373]">
|
||||
{digests.length} {digests.length === 1 ? "digest" : "digests"} ·
|
||||
new every Monday
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{digests.map((d, i) => (
|
||||
<DigestRow
|
||||
key={d.id}
|
||||
digest={d}
|
||||
featured={i === 0}
|
||||
selected={d.id === effectiveId}
|
||||
onSelect={() => setSelectedId(d.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Right: selected digest in a document-viewer surface (not a modal) */}
|
||||
<main className="min-w-0 flex-1 lg:min-h-0 lg:overflow-hidden">
|
||||
<div
|
||||
className="flex h-full min-h-0 flex-col rounded-[22px] "
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-y-auto rounded-[14px] bg-[#14161A] px-5 py-8 sm:px-8 sm:py-10"
|
||||
style={{
|
||||
boxShadow:
|
||||
"inset 0 2px 4px rgba(0, 0, 0, 0.3), inset 0 1px 2px rgba(0, 0, 0, 0.1)",
|
||||
}}
|
||||
>
|
||||
{selected && (
|
||||
<DigestContent
|
||||
digestId={selected.id}
|
||||
isoWeek={selected.isoWeek}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -45,6 +45,7 @@ import { useTokenUsage } from "@/hooks/use-token-usage"
|
|||
import { useOrgSummaries } from "@/hooks/use-org-summaries"
|
||||
import { useCustomer } from "autumn-js/react"
|
||||
import { FileText, Layers, Plug, Search } from "lucide-react"
|
||||
import { $fetch } from "@lib/api"
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
|
|
@ -588,6 +589,8 @@ export default function Account() {
|
|||
|
||||
<OrgContext />
|
||||
|
||||
<DigestPreferences />
|
||||
|
||||
<section id="team-members" className="flex flex-col gap-4 px-1">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
|
|
@ -1225,3 +1228,70 @@ export default function Account() {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DigestPreferences() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["digest-preferences"],
|
||||
queryFn: async () => {
|
||||
const res = await $fetch("@get/digests/preferences")
|
||||
if (res.error) throw new Error("Failed")
|
||||
return res.data as { digestOptOut: boolean }
|
||||
},
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (digestOptOut: boolean) => {
|
||||
const res = await $fetch("@post/digests/preferences", {
|
||||
body: { digestOptOut },
|
||||
})
|
||||
if (res.error) throw new Error("Failed")
|
||||
return res.data as { digestOptOut: boolean }
|
||||
},
|
||||
onError: () => toast.error("Failed to update preference"),
|
||||
})
|
||||
|
||||
const optOut = mutation.data?.digestOptOut ?? data?.digestOptOut ?? false
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3 px-1">
|
||||
<SectionTitle>Notifications</SectionTitle>
|
||||
<SettingsCard>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] font-medium text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Weekly digest
|
||||
</p>
|
||||
<p
|
||||
className={cn(dmSans125ClassName(), "text-[12px] text-[#6B6B6B]")}
|
||||
>
|
||||
Personalized weekly recap of your memories, delivered every Monday
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={!optOut}
|
||||
disabled={isLoading || mutation.isPending}
|
||||
onClick={() => mutation.mutate(!optOut)}
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
!optOut ? "bg-[#2563FF]" : "bg-white/10",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"pointer-events-none inline-block size-4 rounded-full bg-white shadow-sm transition-transform",
|
||||
!optOut ? "translate-x-4" : "translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
72
apps/web/hooks/use-digests.ts
Normal file
72
apps/web/hooks/use-digests.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
"use client"
|
||||
|
||||
import { $fetch } from "@lib/api"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
|
||||
export type DigestSummary = {
|
||||
id: string
|
||||
isoWeek: string
|
||||
emailSubject: string | null
|
||||
title: string | null
|
||||
status: "pending" | "processing" | "completed" | "failed"
|
||||
sentAt: string | null
|
||||
generatedAt: string
|
||||
highlightCount: number
|
||||
memoryCount: number
|
||||
}
|
||||
|
||||
export type DigestDetail = {
|
||||
id: string
|
||||
isoWeek: string
|
||||
emailSubject: string | null
|
||||
status: "pending" | "processing" | "completed" | "failed"
|
||||
sentAt: string | null
|
||||
generatedAt: string
|
||||
digestData: {
|
||||
title: string
|
||||
intro: string
|
||||
highlights: Array<{
|
||||
id: string
|
||||
title: string
|
||||
content: string
|
||||
format: "paragraph" | "bullets" | "quote" | "one_liner"
|
||||
query: string
|
||||
sourceDocumentIds: string[]
|
||||
}>
|
||||
featureRecommendations: Array<{
|
||||
feature: string
|
||||
headline: string
|
||||
body: string
|
||||
ctaLabel: string
|
||||
ctaUrl: string
|
||||
}>
|
||||
memoryCount: number
|
||||
spaceCount: number
|
||||
}
|
||||
}
|
||||
|
||||
export function useDigests(page = 1, limit = 20) {
|
||||
return useQuery<DigestSummary[]>({
|
||||
queryKey: ["digests", page, limit],
|
||||
queryFn: async () => {
|
||||
const res = await $fetch("@get/digests", { query: { page, limit } })
|
||||
if (res.error) throw new Error("Failed to fetch digests")
|
||||
return res.data?.digests ?? []
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useDigest(id: string | null) {
|
||||
return useQuery<DigestDetail | null>({
|
||||
queryKey: ["digest", id],
|
||||
queryFn: async () => {
|
||||
if (!id) return null
|
||||
const res = await $fetch("@get/digests/:id", { params: { id } })
|
||||
if (res.error) throw new Error("Failed to fetch digest")
|
||||
return (res.data as DigestDetail) ?? null
|
||||
},
|
||||
enabled: !!id,
|
||||
staleTime: 10 * 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
|
@ -33,7 +33,7 @@ export const analytics = {
|
|||
chatDeleted: () => safeCapture("chat_deleted"),
|
||||
|
||||
viewModeChanged: (
|
||||
mode: "dashboard" | "graph" | "list" | "integrations" | "chat",
|
||||
mode: "dashboard" | "graph" | "list" | "integrations" | "chat" | "digests",
|
||||
) => safeCapture("view_mode_changed", { mode }),
|
||||
|
||||
documentCardClicked: () => safeCapture("document_card_clicked"),
|
||||
|
|
@ -182,4 +182,19 @@ export const analytics = {
|
|||
|
||||
documentEdited: (props: { document_id: string }) =>
|
||||
safeCapture("document_edited", props),
|
||||
|
||||
// weekly digest
|
||||
digestViewed: (props: { digest_id: string; iso_week: string }) =>
|
||||
safeCapture("digest_viewed", props),
|
||||
digestFeedback: (props: {
|
||||
digest_id: string
|
||||
iso_week: string
|
||||
rating: "up" | "down"
|
||||
}) => safeCapture("digest_feedback", props),
|
||||
digestFeedbackDetail: (props: {
|
||||
digest_id: string
|
||||
iso_week: string
|
||||
rating: "up" | "down" | null
|
||||
message: string
|
||||
}) => safeCapture("digest_feedback_detail", props),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ const viewLiterals = [
|
|||
"list",
|
||||
"integrations",
|
||||
"chat",
|
||||
"digests",
|
||||
// Integration sub-views — each card is its own view
|
||||
"mcp",
|
||||
"plugins",
|
||||
|
|
|
|||
135
apps/web/public/images/digest/feat-fs.svg
Normal file
135
apps/web/public/images/digest/feat-fs.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 28 KiB |
|
|
@ -348,6 +348,76 @@ export const apiSchema = createSchema({
|
|||
message: z.string(),
|
||||
}),
|
||||
},
|
||||
|
||||
// Weekly digest preferences
|
||||
"@get/digests/preferences": {
|
||||
output: z.object({ digestOptOut: z.boolean() }),
|
||||
},
|
||||
"@post/digests/preferences": {
|
||||
input: z.object({ digestOptOut: z.boolean() }),
|
||||
output: z.object({ digestOptOut: z.boolean() }),
|
||||
},
|
||||
|
||||
// Weekly digest endpoints
|
||||
"@get/digests": {
|
||||
output: z.object({
|
||||
digests: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
isoWeek: z.string(),
|
||||
emailSubject: z.string().nullable(),
|
||||
title: z.string().nullable(),
|
||||
status: z.enum(["pending", "processing", "completed", "failed"]),
|
||||
sentAt: z.string().nullable(),
|
||||
generatedAt: z.string(),
|
||||
highlightCount: z.number(),
|
||||
memoryCount: z.number(),
|
||||
}),
|
||||
),
|
||||
page: z.number(),
|
||||
limit: z.number(),
|
||||
}),
|
||||
query: z.object({
|
||||
page: z.number().optional(),
|
||||
limit: z.number().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
"@get/digests/:id": {
|
||||
output: z.object({
|
||||
id: z.string(),
|
||||
isoWeek: z.string(),
|
||||
emailSubject: z.string().nullable(),
|
||||
status: z.enum(["pending", "processing", "completed", "failed"]),
|
||||
sentAt: z.string().nullable(),
|
||||
generatedAt: z.string(),
|
||||
digestData: z.object({
|
||||
title: z.string(),
|
||||
intro: z.string(),
|
||||
highlights: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
content: z.string(),
|
||||
format: z.enum(["paragraph", "bullets", "quote", "one_liner"]),
|
||||
query: z.string(),
|
||||
sourceDocumentIds: z.array(z.string()),
|
||||
}),
|
||||
),
|
||||
featureRecommendations: z.array(
|
||||
z.object({
|
||||
feature: z.string(),
|
||||
headline: z.string(),
|
||||
body: z.string(),
|
||||
ctaLabel: z.string(),
|
||||
ctaUrl: z.string(),
|
||||
}),
|
||||
),
|
||||
memoryCount: z.number(),
|
||||
spaceCount: z.number(),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
export const $fetch = createFetch({
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue