@@ -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
+
+
+
+
+
+
+ )
+}
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({