From 9017a1b5d41c9539f7b77b0cb552edefc2c0e3ae Mon Sep 17 00:00:00 2001
From: sohamd22 <85427822+sohamd22@users.noreply.github.com>
Date: Thu, 18 Jun 2026 01:06:40 +0000
Subject: [PATCH] add weekly digests, log, and opt out options (#1107)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
### 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.
---
apps/web/app/(app)/page.tsx | 17 +-
apps/web/components/dashboard-view.tsx | 43 ++
apps/web/components/digests-view.tsx | 479 ++++++++++++++++++++++
apps/web/components/settings/account.tsx | 70 ++++
apps/web/hooks/use-digests.ts | 72 ++++
apps/web/lib/analytics.ts | 17 +-
apps/web/lib/search-params.ts | 1 +
apps/web/public/images/digest/feat-fs.svg | 135 ++++++
packages/lib/api.ts | 70 ++++
9 files changed, 900 insertions(+), 4 deletions(-)
create mode 100644 apps/web/components/digests-view.tsx
create mode 100644 apps/web/hooks/use-digests.ts
create mode 100644 apps/web/public/images/digest/feat-fs.svg
diff --git a/apps/web/app/(app)/page.tsx b/apps/web/app/(app)/page.tsx
index 98febe44..020cad49 100644
--- a/apps/web/app/(app)/page.tsx
+++ b/apps/web/app/(app)/page.tsx
@@ -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() {
void setViewMode("integrations")}
/>
+ ) : viewMode === "digests" ? (
+
+
+
) : viewMode === "graph" ? (
@@ -763,6 +773,7 @@ export default function NewPage() {
onHighlightsChat={handleHighlightsChat}
onHighlightsShowRelated={handleHighlightsShowRelated}
onResetHighlights={handleResetHighlights}
+ onOpenDigests={() => void setViewMode("digests")}
memoryOfDay={memoryOfDay}
/>
)}
diff --git a/apps/web/components/dashboard-view.tsx b/apps/web/components/dashboard-view.tsx
index fe2e3f0c..6edbc1ca 100644
--- a/apps/web/components/dashboard-view.tsx
+++ b/apps/web/components/dashboard-view.tsx
@@ -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
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({
+ {/* Weekly digest preview */}
+ {latestDigest && (
+
+
+
+
+
+
+ Weekly digest
+
+
+ {latestDigest.title || "Your week in Supermemory"} ·{" "}
+ {latestDigest.memoryCount} memories
+
+
+
+ View
+
+
+
+ )}
+
{/* Recently saved + Suggested for you */}
= {
+ 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 (
+
+
+
+ {isoWeekLabel(digest.isoWeek)}
+
+ {featured && (
+
+ Latest
+
+ )}
+
+
+
+ {digest.title || "Your week in Supermemory"}
+
+
+
+
+
+ {digest.memoryCount}
+
+
+ {digest.memoryCount === 1 ? "memory" : "memories"}
+
+
+
+ {formatIsoWeek(digest.isoWeek)}
+
+
+
+
+ )
+}
+
+// ─── 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 (
+
+
+
+ )
+ }
+ if (!digest) {
+ return (
+
+ Digest not found.
+
+ )
+ }
+
+ 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 (
+
+ {/* Header */}
+
+ Weekly digest · {formatIsoWeek(digest.isoWeek)}
+
+
+ {digestData.title || "Your week in Supermemory"}
+
+
+ {/* Greeting + intro, brain floated right */}
+
+
+
+ {digestData.intro}
+
+
+
+
+
+
+ {/* Highlights — numbered, no boxes */}
+ {digestData.highlights.length > 0 && (
+ <>
+
+ This week's highlights
+
+
+ {digestData.highlights.map((h, i) => (
+
+
+ {String(i + 1).padStart(2, "0")}
+
+
+
+ {h.title}
+
+
+ {h.content}
+
+
+
+ ))}
+
+ >
+ )}
+
+ {/* Worth trying — the one place we use boxes (subtle) */}
+ {digestData.featureRecommendations.length > 0 && (
+
+ )}
+
+ {/* Feedback */}
+
+
+
+ Was this digest useful?
+
+
+ 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]",
+ )}
+ >
+
+
+ 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]",
+ )}
+ >
+
+
+ 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]",
+ )}
+ >
+
+
+
+
+
+ {showInput && (
+
+ )}
+
+
+ )
+}
+
+// ─── Master-detail ─────────────────────────────────────────────────────────────
+export function DigestsView({ initialDigestId }: DigestsViewProps) {
+ const { data: digests, isLoading } = useDigests()
+ const [selectedId, setSelectedId] = useState(
+ 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 (
+
+
+
+ )
+ }
+
+ if (!digests || digests.length === 0) {
+ return (
+
+
+
+
+
No digests yet
+
+ Your first weekly digest arrives Monday. It'll show your highlights
+ and suggestions right here.
+
+
+ )
+ }
+
+ return (
+
+
+ {/* Left: digest list (scrolls independently) */}
+
+
+
+ Weekly Digests
+
+
+ {digests.length} {digests.length === 1 ? "digest" : "digests"} ·
+ new every Monday
+
+
+
+ {digests.map((d, i) => (
+ setSelectedId(d.id)}
+ />
+ ))}
+
+
+
+ {/* Right: selected digest in a document-viewer surface (not a modal) */}
+
+
+
+ {selected && (
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/apps/web/components/settings/account.tsx b/apps/web/components/settings/account.tsx
index 347f58be..33fd2175 100644
--- a/apps/web/components/settings/account.tsx
+++ b/apps/web/components/settings/account.tsx
@@ -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() {
+
+
@@ -1225,3 +1228,70 @@ export default function Account() {
)
}
+
+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 (
+
+ Notifications
+
+
+
+
+ Weekly digest
+
+
+ Personalized weekly recap of your memories, delivered every Monday
+
+
+
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",
+ )}
+ >
+
+
+
+
+
+ )
+}
diff --git a/apps/web/hooks/use-digests.ts b/apps/web/hooks/use-digests.ts
new file mode 100644
index 00000000..4814a4ca
--- /dev/null
+++ b/apps/web/hooks/use-digests.ts
@@ -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
({
+ 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({
+ 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,
+ })
+}
diff --git a/apps/web/lib/analytics.ts b/apps/web/lib/analytics.ts
index ea2742aa..71987d22 100644
--- a/apps/web/lib/analytics.ts
+++ b/apps/web/lib/analytics.ts
@@ -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),
}
diff --git a/apps/web/lib/search-params.ts b/apps/web/lib/search-params.ts
index 3978bc37..9afb6eab 100644
--- a/apps/web/lib/search-params.ts
+++ b/apps/web/lib/search-params.ts
@@ -29,6 +29,7 @@ const viewLiterals = [
"list",
"integrations",
"chat",
+ "digests",
// Integration sub-views — each card is its own view
"mcp",
"plugins",
diff --git a/apps/web/public/images/digest/feat-fs.svg b/apps/web/public/images/digest/feat-fs.svg
new file mode 100644
index 00000000..9df5f4fa
--- /dev/null
+++ b/apps/web/public/images/digest/feat-fs.svg
@@ -0,0 +1,135 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/lib/api.ts b/packages/lib/api.ts
index 483a3f65..ba403672 100644
--- a/packages/lib/api.ts
+++ b/packages/lib/api.ts
@@ -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({