diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index ca8d24167..84b94a40d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -10706,6 +10706,26 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/daily/digest: + get: + tags: + - Insights + summary: The unified daily digest + description: "Assembles the day's read from already-cached data: the nightly briefing lifted read-only from the insights + cache, the dashboard-snapshot health score / meds-today / sleep freshness, plus deterministic integration-status + and Vorsorge reads for the 'worth a look' rail. No provider call, no warm-on-mount. Requires the insights + module. Cookie or Bearer auth." + responses: + "200": + description: The daily digest. + content: + application/json: + schema: + $ref: "#/components/schemas/DailyDigestEnvelope" + "401": *a1 + "403": *a5 + "422": *a3 + "429": *a4 components: schemas: EncryptedExportRequest: @@ -29772,6 +29792,168 @@ components: - data - error additionalProperties: false + DailyDigestEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/DailyDigest" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DailyDigest: + type: object + properties: + generatedAt: + type: string + description: ISO-8601 instant the digest was read. + phase: + type: string + enum: + - provisional + - final + description: Freshness lifecycle — 'final' once last night's sleep is in. + sleepPending: + type: boolean + description: Sleep tracked but last night not yet recorded. + score: + anyOf: + - type: object + properties: + value: + type: number + band: + type: string + delta: + anyOf: + - type: number + - type: "null" + required: + - value + - band + - delta + additionalProperties: false + - type: "null" + description: Health score + band + week-over-week delta; null when none. + topSignal: + anyOf: + - type: object + properties: + sourceMetric: + type: string + tone: + type: string + enum: + - good + - watch + - info + headline: + type: string + nudge: + type: string + delta: + anyOf: + - type: string + - type: "null" + required: + - sourceMetric + - tone + - headline + - nudge + - delta + additionalProperties: false + - type: "null" + description: Clinical-priority top signal, lifted from the cached briefing. + briefingLead: + anyOf: + - type: string + - type: "null" + description: First sentence of the cached briefing paragraph. + line: + type: string + description: "Push / lock-screen line: cached-AI lead with a deterministic floor." + worthALook: + type: array + items: + $ref: "#/components/schemas/DailyPriorityItem" + description: Bounded 0–3 rail items, never padded. + required: + - generatedAt + - phase + - sleepPending + - score + - topSignal + - briefingLead + - line + - worthALook + additionalProperties: false + description: The one data spine of the daily-value system — composed from already-cached data, never a fresh AI call. + Consumed by the Today surface, the daily push, and a future iOS widget. + DailyPriorityItem: + type: object + properties: + kind: + type: string + enum: + - coach_checkin + - dose_window + - preventive_care + - sync_issue + - milestone + - ecg_new_recording + - tension_window + description: Closed rail-item kind. + title: + type: string + description: Localised headline (resolved server-side). + body: + description: Grounded one-liner, rendered as plain text. + type: string + status: + description: Semantic status wash — meaning, not decoration. + type: string + enum: + - success + - warning + - info + - destructive + actions: + maxItems: 3 + type: array + items: + type: object + properties: + labelKey: + type: string + description: i18n key, resolved client-side. + intent: + type: string + description: Stable action token. + href: + description: Deep-link when navigational. + type: string + required: + - labelKey + - intent + additionalProperties: false + description: 1–3 one-tap actions. + moduleKey: + description: Provenance of the gate that admitted the item. + type: string + required: + - kind + - title + - actions + additionalProperties: false + description: One 'worth a look' rail item. The single model every daily-value consumer renders through PriorityCard. MedicationScheduleOutput: type: object properties: diff --git a/messages/de.json b/messages/de.json index f52346a16..ad6d15449 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8530,5 +8530,32 @@ "water": "Wasser", "caffeine": "Koffein" } + }, + "daily": { + "line": { + "score": "Dein Gesundheitsscore heute liegt bei {score}.", + "allClear": "Heute braucht nichts deine Aufmerksamkeit — alles verläuft normal." + }, + "item": { + "doseWindow": { + "title": "Medikament fällig", + "body": "Heute ist eine Dosis fällig.", + "bodyNamed": "{name} ist heute fällig." + }, + "preventiveCare": { + "title": "Vorsorge fällig", + "body": "{label} ist fällig.", + "bodyMany": "{count} Vorsorgetermine sind fällig." + }, + "syncIssue": { + "title": "Synchronisierung braucht Aufmerksamkeit", + "body": "{integration} synchronisiert nicht — verbinde neu, um deine Daten aktuell zu halten." + } + }, + "action": { + "logDose": "Dosis erfassen", + "reconnect": "Neu verbinden", + "viewCheckups": "Vorsorge ansehen" + } } } diff --git a/messages/en.json b/messages/en.json index c89cc3935..8611d9822 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8530,5 +8530,32 @@ "water": "Water", "caffeine": "Caffeine" } + }, + "daily": { + "line": { + "score": "Your health score today is {score}.", + "allClear": "Nothing needs your attention today — everything's tracking normally." + }, + "item": { + "doseWindow": { + "title": "Medication due", + "body": "A dose is due today.", + "bodyNamed": "{name} is due today." + }, + "preventiveCare": { + "title": "Check-up due", + "body": "{label} is due.", + "bodyMany": "{count} check-ups are due." + }, + "syncIssue": { + "title": "Sync needs attention", + "body": "{integration} isn't syncing — reconnect to keep your data current." + } + }, + "action": { + "logDose": "Log dose", + "reconnect": "Reconnect", + "viewCheckups": "View check-ups" + } } } diff --git a/messages/es.json b/messages/es.json index 8db8034d5..6a5153ed0 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8530,5 +8530,32 @@ "water": "Agua", "caffeine": "Cafeína" } + }, + "daily": { + "line": { + "score": "Tu puntuación de salud hoy es {score}.", + "allClear": "Hoy nada requiere tu atención: todo evoluciona con normalidad." + }, + "item": { + "doseWindow": { + "title": "Medicación pendiente", + "body": "Hoy toca una dosis.", + "bodyNamed": "{name} toca hoy." + }, + "preventiveCare": { + "title": "Revisión pendiente", + "body": "{label} está pendiente.", + "bodyMany": "Hay {count} revisiones pendientes." + }, + "syncIssue": { + "title": "La sincronización necesita atención", + "body": "{integration} no se está sincronizando: vuelve a conectarlo para mantener tus datos al día." + } + }, + "action": { + "logDose": "Registrar dosis", + "reconnect": "Reconectar", + "viewCheckups": "Ver revisiones" + } } } diff --git a/messages/fr.json b/messages/fr.json index e016d3a1d..d8addcb39 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8530,5 +8530,32 @@ "water": "Eau", "caffeine": "Caféine" } + }, + "daily": { + "line": { + "score": "Ton score de santé aujourd'hui est de {score}.", + "allClear": "Rien ne requiert ton attention aujourd'hui — tout évolue normalement." + }, + "item": { + "doseWindow": { + "title": "Médicament à prendre", + "body": "Une dose est prévue aujourd'hui.", + "bodyNamed": "{name} est prévu aujourd'hui." + }, + "preventiveCare": { + "title": "Examen à faire", + "body": "{label} est à faire.", + "bodyMany": "{count} examens sont à faire." + }, + "syncIssue": { + "title": "La synchronisation demande votre attention", + "body": "{integration} ne se synchronise pas — reconnecte-le pour garder tes données à jour." + } + }, + "action": { + "logDose": "Enregistrer la dose", + "reconnect": "Reconnecter", + "viewCheckups": "Voir les examens" + } } } diff --git a/messages/it.json b/messages/it.json index 117e6fa7c..6852ed178 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8530,5 +8530,32 @@ "water": "Acqua", "caffeine": "Caffeina" } + }, + "daily": { + "line": { + "score": "Il tuo punteggio di salute oggi è {score}.", + "allClear": "Oggi nulla richiede la tua attenzione — tutto procede normalmente." + }, + "item": { + "doseWindow": { + "title": "Farmaco da assumere", + "body": "Oggi è prevista una dose.", + "bodyNamed": "{name} è previsto oggi." + }, + "preventiveCare": { + "title": "Controllo da fare", + "body": "{label} è da fare.", + "bodyMany": "Ci sono {count} controlli da fare." + }, + "syncIssue": { + "title": "La sincronizzazione richiede attenzione", + "body": "{integration} non si sincronizza — riconnettilo per mantenere aggiornati i tuoi dati." + } + }, + "action": { + "logDose": "Registra dose", + "reconnect": "Riconnetti", + "viewCheckups": "Vedi controlli" + } } } diff --git a/messages/pl.json b/messages/pl.json index bbc1d20d6..2174c5a7b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8530,5 +8530,32 @@ "water": "Woda", "caffeine": "Kofeina" } + }, + "daily": { + "line": { + "score": "Twój dzisiejszy wynik zdrowia to {score}.", + "allClear": "Dziś nic nie wymaga Twojej uwagi — wszystko przebiega normalnie." + }, + "item": { + "doseWindow": { + "title": "Lek do przyjęcia", + "body": "Dziś przypada dawka.", + "bodyNamed": "{name} przypada dziś." + }, + "preventiveCare": { + "title": "Badanie do wykonania", + "body": "{label} jest do wykonania.", + "bodyMany": "Do wykonania jest {count} badań." + }, + "syncIssue": { + "title": "Synchronizacja wymaga uwagi", + "body": "{integration} się nie synchronizuje — połącz ponownie, aby dane były aktualne." + } + }, + "action": { + "logDose": "Zapisz dawkę", + "reconnect": "Połącz ponownie", + "viewCheckups": "Zobacz badania" + } } } diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts index 2487d7d87..5132e2a40 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -111,6 +111,12 @@ const MODULE_ROUTE_TREES: ReadonlyArray = [ // ungated insights AI route fails this test BY NAME rather than leaking the // surface over a Bearer token when the account turned insights off. "src/app/api/insights", + // v1.28 — the unified daily-digest read (`GET /api/daily/digest`). The digest + // is the AI-narrative daily layer, so it gates on the `insights` module via + // `requireModuleEnabled(user.id, "insights")` directly. Walking the tree means + // a NEW ungated daily route fails BY NAME rather than leaking over a Bearer + // token when the account turned insights off. + "src/app/api/daily", // v1.28 — the nutrient-intake sync (opt-in `nutrients` module). Unlike the // data-layer-exempt siblings this domain is REFUSE-INGEST-WHEN-OFF (the // mental-health posture): both the batch ingest and the window-summary diff --git a/src/app/api/daily/digest/__tests__/route.test.ts b/src/app/api/daily/digest/__tests__/route.test.ts new file mode 100644 index 000000000..cac7f1bd8 --- /dev/null +++ b/src/app/api/daily/digest/__tests__/route.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +import { apiError } from "@/lib/api-response"; +import type { DailyDigest } from "@/lib/daily/digest"; + +/** + * `GET /api/daily/digest` — the P3 read seam. + * + * Under test: cookie/Bearer auth narrows the user, the `insights` module gate + * returns a 403 `module.disabled` envelope when off (even for a valid + * session), and the happy path returns the `DailyDigest` DTO shape. The digest + * itself is composed by `loadDailyDigest`, mocked here — the route must reach + * no provider (there is nothing AI-shaped in this module graph). + */ + +vi.mock("@/lib/db", () => ({ + prisma: { appSettings: { findUnique: vi.fn().mockResolvedValue(null) } }, +})); +vi.mock("@/lib/modules/gate", async (importOriginal) => ({ + ...(await importOriginal()), + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), +})); +vi.mock("@/lib/daily/load-digest", () => ({ loadDailyDigest: vi.fn() })); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { loadDailyDigest } from "@/lib/daily/load-digest"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "tester", + role: "USER" as const, + locale: "en", + }, +}; + +const DIGEST: DailyDigest = { + generatedAt: "2026-07-16T09:00:00.000Z", + phase: "final", + sleepPending: false, + score: { value: 82, band: "good", delta: 3 }, + topSignal: null, + briefingLead: "Blood pressure is holding steady.", + line: "Blood pressure is holding steady.", + worthALook: [], +}; + +const callGet = GET as unknown as (req: NextRequest) => Promise; +function makeReq(): NextRequest { + return new NextRequest(new URL("http://localhost/api/daily/digest")); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); + vi.mocked(loadDailyDigest).mockResolvedValue(DIGEST); +}); + +describe("GET /api/daily/digest", () => { + it("returns 401 when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null as never); + const res = await callGet(makeReq()); + expect(res.status).toBe(401); + expect(vi.mocked(loadDailyDigest)).not.toHaveBeenCalled(); + }); + + it("returns the 403 module.disabled envelope when insights is off", async () => { + vi.mocked(requireModuleEnabled).mockResolvedValue({ + enabled: false, + response: apiError('Module "insights" is not enabled', 403, { + errorCode: "module.disabled", + module: "insights", + }), + }); + const res = await callGet(makeReq()); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.meta?.errorCode).toBe("module.disabled"); + expect(vi.mocked(loadDailyDigest)).not.toHaveBeenCalled(); + }); + + it("gates on the insights module key", async () => { + await callGet(makeReq()); + expect(vi.mocked(requireModuleEnabled)).toHaveBeenCalledWith( + "user-1", + "insights", + ); + }); + + it("returns 200 with the DailyDigest DTO on the happy path", async () => { + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.error).toBeNull(); + expect(body.data).toEqual(DIGEST); + expect(vi.mocked(loadDailyDigest)).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/app/api/daily/digest/route.ts b/src/app/api/daily/digest/route.ts new file mode 100644 index 000000000..666d3857c --- /dev/null +++ b/src/app/api/daily/digest/route.ts @@ -0,0 +1,36 @@ +/** + * GET /api/daily/digest + * + * The read seam for the unified daily-value system (P3). Returns the + * `DailyDigest` DTO the Today surface, the daily push, and a future iOS widget + * all consume — assembled by `loadDailyDigest` from ALREADY-CACHED data (the + * nightly briefing lifted read-only from `User.insightsCachedText`, the + * dashboard-snapshot health score / meds-today / sleep freshness) plus two + * light deterministic reads (broken integrations, overdue Vorsorge). No + * provider call is reachable from this path, and nothing warms on mount. + * + * Cookie OR Bearer auth via `requireAuth()`; `userId` is narrowed from the + * resolved session — never a body field. Gated on the `insights` module (the + * daily digest is the AI-narrative daily layer); a disabled account gets the + * standard 403 `module.disabled` envelope even over a Bearer token. The rail's + * data-tile inputs inherit their own module gates via the snapshot builder. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers"; +import { loadDailyDigest } from "@/lib/daily/load-digest"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; + + const digest = await loadDailyDigest(user); + + const response = apiSuccess(digest); + response.headers.set("Cache-Control", NO_STORE_BUT_BFCACHE); + return response; +}); diff --git a/src/components/daily/__tests__/priority-card.test.tsx b/src/components/daily/__tests__/priority-card.test.tsx new file mode 100644 index 000000000..9ba0dd84c --- /dev/null +++ b/src/components/daily/__tests__/priority-card.test.tsx @@ -0,0 +1,112 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { PriorityCard } from "../priority-card"; +import { + PRIORITY_ITEM_KINDS, + type PriorityItem, + type PriorityItemKind, +} from "@/lib/daily/priority-item"; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +function item( + over: Partial & { kind: PriorityItemKind }, +): PriorityItem { + return { + title: "Something to look at", + actions: [ + { + labelKey: "daily.action.reconnect", + intent: "sync.reconnect", + href: "/settings/integrations", + }, + ], + ...over, + }; +} + +describe("", () => { + it("renders every kind of the discriminated union without throwing", () => { + for (const kind of PRIORITY_ITEM_KINDS) { + const html = render(); + expect(html).toContain(`data-kind="${kind}"`); + expect(html).toContain("Something to look at"); + } + }); + + it("renders the grounded body one-liner", () => { + const html = render( + , + ); + expect(html).toContain("Ramipril is due today."); + }); + + it("applies a semantic status wash (meaning, not decoration)", () => { + const warn = render( + , + ); + expect(warn).toContain("bg-warning/10"); + const info = render( + , + ); + expect(info).toContain("bg-info/10"); + }); + + it("renders a navigation action as a link carrying the href", () => { + const html = render( + , + ); + expect(html).toContain('href="/settings/integrations"'); + expect(html).toContain("Reconnect"); + }); + + it("renders a non-navigation action as a button (no href)", () => { + const html = render( + , + ); + expect(html).toContain(" { + const html = render( + , + ); + expect(html).toContain("View check-ups"); + }); +}); diff --git a/src/components/daily/priority-card.tsx b/src/components/daily/priority-card.tsx new file mode 100644 index 000000000..70789a128 --- /dev/null +++ b/src/components/daily/priority-card.tsx @@ -0,0 +1,152 @@ +"use client"; + +/** + * P1 — `PriorityCard`, the ONE card behind every "worth a look" rail item. + * + * It renders a `PriorityItem` and never fetches. Every later consumer reuses + * it verbatim: the Today rail (S2), a coach check-in (S3), a milestone + * celebration (S12), a new-ECG pointer (S10), a tension window (S11). A new + * rail type is a new `kind` on the item + a server builder — never a new + * component (§1.2 P1). + * + * Design: composed ENTIRELY from shipped primitives — `Card` (compact + * density) + `TileHeader` (size `sm`, foreground icon) + `ProseBlocks` for the + * grounded one-liner, semantic-token status wash only, no raw palette colour + * (the `no-raw-palette-color` rule errors), no markdown. The Lucide glyph is + * derived from `kind` so the wire DTO stays serialisable (the icon lives with + * the renderer, the closed enum with the model). The `animate-insight-in` + * reveal carries its own reduced-motion fallback in `globals.css`. + */ +import Link from "next/link"; +import { + Activity, + Award, + CalendarClock, + HeartPulse, + MessageCircle, + Pill, + RefreshCw, + type LucideIcon, +} from "lucide-react"; + +import { Card, CardContent } from "@/components/ui/card"; +import { TileHeader } from "@/components/insights/tile-header"; +import { ProseBlocks } from "@/components/insights/prose-blocks"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; +import type { + PriorityItem, + PriorityItemAction, + PriorityItemKind, + PriorityItemStatus, +} from "@/lib/daily/priority-item"; + +/** Deterministic glyph per kind — the icon the serialisable item cannot hold. */ +const KIND_ICON: Record = { + dose_window: Pill, + preventive_care: CalendarClock, + sync_issue: RefreshCw, + coach_checkin: MessageCircle, + milestone: Award, + ecg_new_recording: HeartPulse, + tension_window: Activity, +}; + +/** + * Status wash — a subtle semantic-token tint carrying MEANING, not decoration + * (the `recommendation-card` pattern). Static classes so Tailwind sees them. + */ +const STATUS_WASH: Record = { + success: "bg-success/10 border-success/25", + warning: "bg-warning/10 border-warning/25", + info: "bg-info/10 border-info/25", + destructive: "bg-destructive/10 border-destructive/25", +}; + +export interface PriorityCardProps { + item: PriorityItem; + /** + * Tap handler for non-navigation actions. Receives the action's stable + * `intent` token. Navigation actions (those carrying an `href`) render as + * links and do not call this. + */ + onAction?: (intent: string) => void; + className?: string; +} + +/** Tap-target floor per P1: `min-h-11` on touch, `min-h-9` on pointer. */ +const ACTION_SIZE = "min-h-11 sm:min-h-9"; + +export function PriorityCard({ item, onAction, className }: PriorityCardProps) { + const { t } = useTranslations(); + const Icon = KIND_ICON[item.kind]; + const actions = item.actions.slice(0, 3); + + return ( + + + + {item.body ? ( +
+ +
+ ) : null} + {actions.length > 0 ? ( +
+ {actions.map((action) => ( + + ))} +
+ ) : null} +
+
+ ); +} + +function ActionButton({ + action, + label, + onAction, +}: { + action: PriorityItemAction; + label: string; + onAction?: (intent: string) => void; +}) { + if (action.href) { + return ( + + {label} + + ); + } + return ( + + ); +} diff --git a/src/lib/daily/__tests__/digest.test.ts b/src/lib/daily/__tests__/digest.test.ts new file mode 100644 index 000000000..95d2b32d1 --- /dev/null +++ b/src/lib/daily/__tests__/digest.test.ts @@ -0,0 +1,235 @@ +import { describe, it, expect } from "vitest"; + +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import type { DailyBriefing } from "@/lib/ai/schema"; +import type { MedsTodayBlock } from "@/lib/dashboard/meds-today"; +import { + buildDailyDigest, + MAX_WORTH_A_LOOK, + type DailyDigestInput, +} from "@/lib/daily/digest"; + +const t = getServerTranslator("en").t; +const NOW = new Date("2026-07-16T09:00:00.000Z"); + +function meds(over: Partial = {}): MedsTodayBlock { + return { + activeCount: 0, + scheduledToday: 0, + takenToday: 0, + skippedToday: 0, + nextDueAt: null, + nextDueOverdue: false, + nextDueMedicationName: null, + ...over, + }; +} + +const briefing: DailyBriefing = { + paragraph: + "Your blood pressure is holding steady this week. Sleep dipped slightly last night.", + signalsOfDay: [ + { + sourceMetric: "bp", + tone: "good", + headline: "Blood pressure is holding steady", + nudge: "Keep the evening walks going.", + delta: null, + }, + ], + keyFindings: [], +}; + +function input(over: Partial = {}): DailyDigestInput { + return { + now: NOW, + modules: {}, + score: { value: 82, band: "good", delta: 3 }, + briefing, + medsToday: meds(), + sleepLastSeenDaysAgo: 0, + syncIssues: [], + preventiveDue: [], + ...over, + }; +} + +describe("buildDailyDigest — composition", () => { + it("lifts score, top signal, and briefing lead from cached inputs (no recompute)", () => { + const d = buildDailyDigest(input(), t); + expect(d.generatedAt).toBe(NOW.toISOString()); + expect(d.score).toEqual({ value: 82, band: "good", delta: 3 }); + expect(d.topSignal?.headline).toBe("Blood pressure is holding steady"); + expect(d.briefingLead).toBe( + "Your blood pressure is holding steady this week.", + ); + }); + + it("prefers the briefing lead for the push line", () => { + const d = buildDailyDigest(input(), t); + expect(d.line).toBe("Your blood pressure is holding steady this week."); + }); + + it("falls back to the top-signal headline when there is no paragraph", () => { + const d = buildDailyDigest( + input({ briefing: { ...briefing, paragraph: "" } }), + t, + ); + expect(d.line).toBe("Blood pressure is holding steady"); + }); + + it("falls back to a deterministic score floor when no briefing exists", () => { + const d = buildDailyDigest(input({ briefing: null }), t); + expect(d.topSignal).toBeNull(); + expect(d.briefingLead).toBeNull(); + expect(d.line).toBe("Your health score today is 82."); + }); + + it("degrades to the honest all-clear line with neither briefing nor score", () => { + const d = buildDailyDigest(input({ briefing: null, score: null }), t); + expect(d.line).toContain("Nothing needs your attention today"); + expect(d.score).toBeNull(); + expect(d.worthALook).toEqual([]); + }); + + it("is a synchronous pure function — repeated calls are identical", () => { + const first = buildDailyDigest(input(), t); + const second = buildDailyDigest(input(), t); + expect(first).toEqual(second); + // No provider/AI dependency: the composer never returns a promise. + expect(first).not.toBeInstanceOf(Promise); + }); +}); + +describe("buildDailyDigest — freshness (provisional/final)", () => { + it("is final when last night's sleep is in", () => { + const d = buildDailyDigest(input({ sleepLastSeenDaysAgo: 0 }), t); + expect(d.phase).toBe("final"); + expect(d.sleepPending).toBe(false); + }); + + it("is provisional when sleep is tracked but last night is not yet in", () => { + const d = buildDailyDigest(input({ sleepLastSeenDaysAgo: 2 }), t); + expect(d.phase).toBe("provisional"); + expect(d.sleepPending).toBe(true); + }); + + it("is provisional when sleep has never been recorded", () => { + const d = buildDailyDigest(input({ sleepLastSeenDaysAgo: null }), t); + expect(d.sleepPending).toBe(true); + }); + + it("is final (not pending) when the sleep module is off", () => { + const d = buildDailyDigest( + input({ modules: { sleep: false }, sleepLastSeenDaysAgo: null }), + t, + ); + expect(d.phase).toBe("final"); + expect(d.sleepPending).toBe(false); + }); +}); + +describe("buildDailyDigest — worth-a-look rail item builders", () => { + it("emits a dose-window item when a dose is overdue and medications is on", () => { + const d = buildDailyDigest( + input({ + medsToday: meds({ + nextDueOverdue: true, + nextDueMedicationName: "Ramipril", + }), + }), + t, + ); + const dose = d.worthALook.find((i) => i.kind === "dose_window"); + expect(dose).toBeDefined(); + expect(dose?.status).toBe("warning"); + expect(dose?.moduleKey).toBe("medications"); + expect(dose?.title).toBe("Medication due"); + expect(dose?.body).toBe("Ramipril is due today."); + expect(dose?.actions).toHaveLength(1); + expect(dose?.actions[0].intent).toBe("dose.log"); + }); + + it("does NOT emit a dose-window item when the medications module is off", () => { + const d = buildDailyDigest( + input({ + modules: { medications: false }, + medsToday: meds({ + nextDueOverdue: true, + nextDueMedicationName: "Ramipril", + }), + }), + t, + ); + expect(d.worthALook.some((i) => i.kind === "dose_window")).toBe(false); + }); + + it("does NOT emit a dose-window item when nothing is overdue", () => { + const d = buildDailyDigest( + input({ medsToday: meds({ nextDueOverdue: false }) }), + t, + ); + expect(d.worthALook.some((i) => i.kind === "dose_window")).toBe(false); + }); + + it("maps one sync-issue item per broken integration", () => { + const d = buildDailyDigest( + input({ + syncIssues: [ + { integration: "withings", state: "error_reauth" }, + { integration: "moodlog", state: "parked" }, + ], + }), + t, + ); + const sync = d.worthALook.filter((i) => i.kind === "sync_issue"); + expect(sync).toHaveLength(2); + expect(sync[0].status).toBe("warning"); + expect(sync[0].body).toContain("Withings"); + expect(sync[0].actions[0].href).toBe("/settings/integrations"); + }); + + it("summarises a single preventive-care item with the label", () => { + const d = buildDailyDigest( + input({ preventiveDue: [{ label: "Blood panel" }] }), + t, + ); + const care = d.worthALook.find((i) => i.kind === "preventive_care"); + expect(care?.status).toBe("info"); + expect(care?.body).toBe("Blood panel is due."); + }); + + it("summarises many preventive-care items into one counted item", () => { + const d = buildDailyDigest( + input({ + preventiveDue: [{ label: "A" }, { label: "B" }, { label: "C" }], + }), + t, + ); + const care = d.worthALook.filter((i) => i.kind === "preventive_care"); + expect(care).toHaveLength(1); + expect(care[0].body).toBe("3 check-ups are due."); + }); + + it("bounds the rail at three items, never padded", () => { + const d = buildDailyDigest( + input({ + medsToday: meds({ nextDueOverdue: true, nextDueMedicationName: "X" }), + syncIssues: [ + { integration: "withings", state: "error_reauth" }, + { integration: "moodlog", state: "parked" }, + { integration: "fitbit", state: "error_reauth" }, + ], + preventiveDue: [{ label: "Blood panel" }], + }), + t, + ); + expect(d.worthALook.length).toBeLessThanOrEqual(MAX_WORTH_A_LOOK); + expect(d.worthALook.length).toBe(3); + }); + + it("returns an empty rail when nothing needs attention", () => { + const d = buildDailyDigest(input(), t); + expect(d.worthALook).toEqual([]); + }); +}); diff --git a/src/lib/daily/digest.ts b/src/lib/daily/digest.ts new file mode 100644 index 000000000..6765aba33 --- /dev/null +++ b/src/lib/daily/digest.ts @@ -0,0 +1,253 @@ +/** + * P3 — `buildDailyDigest`, the ONE data spine of the daily-value system. + * + * A pure, deterministic composer that assembles the day's read from data that + * ALREADY EXISTS — the nightly `insight-pregenerate` output lifted read-only + * from `User.insightsCachedText`, the dashboard-snapshot ingredients (health + * score, meds-today, sleep last-seen), plus two light deterministic reads + * (integration status, due Vorsorge reminders). It computes no analytics and + * NEVER triggers an AI/provider call — the warm-on-mount ban extends here. The + * IO that gathers its input lives in `./load-digest.ts`; this module is the + * tested spine. + * + * The emitted `DailyDigest` is the single source of truth every later consumer + * reads: the Today surface (S2), the daily push line (S5), and a future iOS + * widget — none recompute, none fork a second digest path. + * + * Freshness (§2.4): S1 derives `phase` / `sleepPending` DETERMINISTICALLY from + * whether last night's sleep is already in the record. The event-driven + * provisional→final refresh (sleep-arrival debounce) is S4's work; it will + * populate the same two fields the DTO already carries, so no consumer changes. + */ +import type { DailyBriefing, DailyBriefingSignal } from "@/lib/ai/schema"; +import type { MedsTodayBlock } from "@/lib/dashboard/meds-today"; +import type { ModuleKey } from "@/lib/modules/registry"; +import type { ServerTranslator } from "@/lib/i18n/server-translator"; +import { + MAX_PRIORITY_ACTIONS, + type PriorityItem, +} from "@/lib/daily/priority-item"; + +/** At most three rail items — a glance, never a wall (§2.5, never padded). */ +export const MAX_WORTH_A_LOOK = 3; + +/** Trim a briefing-lead sentence to a lock-screen-friendly length. */ +const MAX_LINE_LENGTH = 160; + +type Translate = ServerTranslator["t"]; + +/** Module map in the registry's DISABLED-allowlist shape (missing = enabled). */ +export type DigestModuleMap = Partial>; + +function moduleEnabled(map: DigestModuleMap, key: ModuleKey): boolean { + return map[key] !== false; +} + +export interface DailyDigestScore { + value: number; + band: string; + delta: number | null; +} + +/** A broken integration, deterministically derived from `IntegrationStatus`. */ +export interface DailyDigestSyncIssue { + /** Integration token (`withings`, `moodlog`, …). */ + integration: string; + /** The failure state the row carries (`error_reauth`, `disconnected`, …). */ + state: string; +} + +/** A Vorsorge / measurement reminder whose next-due instant has passed. */ +export interface DailyDigestPreventiveDue { + label: string; +} + +/** The fully-resolved, IO-free input the composer folds into a digest. */ +export interface DailyDigestInput { + now: Date; + modules: DigestModuleMap; + score: DailyDigestScore | null; + /** The cached daily briefing (paragraph + signals-of-day), or null. */ + briefing: DailyBriefing | null; + medsToday: MedsTodayBlock; + /** Days since the freshest sleep reading; null when never recorded. */ + sleepLastSeenDaysAgo: number | null; + syncIssues: DailyDigestSyncIssue[]; + preventiveDue: DailyDigestPreventiveDue[]; +} + +export interface DailyDigest { + generatedAt: string; + /** §2.4 freshness lifecycle — `final` once last night's sleep is in. */ + phase: "provisional" | "final"; + /** Honest-degradation flag: sleep enabled but last night not yet in. */ + sleepPending: boolean; + score: DailyDigestScore | null; + /** + * The clinical-priority top signal, lifted from the cached briefing's + * `signalsOfDay[0]`. Typed as the CACHED `DailyBriefingSignal` (not the raw + * `SignalOfDay`): only the grounded, already-localised projection is + * persisted — re-deriving the raw signal would mean a fresh measurement + * fan-out on every read, exactly the warm-on-mount load the plan forbids. + */ + topSignal: DailyBriefingSignal | null; + /** First sentence of the cached briefing paragraph, read-only. */ + briefingLead: string | null; + /** The push / lock-screen line (cached-AI lead with a deterministic floor). */ + line: string; + /** Bounded 0–3 rail items, never padded. */ + worthALook: PriorityItem[]; +} + +/** First sentence of a paragraph, trimmed; null when empty. */ +function firstSentence(paragraph: string | null | undefined): string | null { + if (!paragraph) return null; + const trimmed = paragraph.trim(); + if (!trimmed) return null; + const match = trimmed.match(/^[\s\S]*?[.!?](?:\s|$)/); + let sentence = (match ? match[0] : trimmed).trim(); + if (sentence.length > MAX_LINE_LENGTH) { + sentence = `${sentence.slice(0, MAX_LINE_LENGTH - 1).trimEnd()}…`; + } + return sentence.length > 0 ? sentence : null; +} + +/** Title-case a lowercase integration token for user-facing copy. */ +function integrationLabel(token: string): string { + if (!token) return token; + return token.charAt(0).toUpperCase() + token.slice(1); +} + +/** + * Dose-window item — fires when an active-medication dose is open and overdue. + * Gated on the `medications` module; carries a single log-dose action. + */ +function buildDoseWindowItem( + meds: MedsTodayBlock, + modules: DigestModuleMap, + t: Translate, +): PriorityItem | null { + if (!moduleEnabled(modules, "medications")) return null; + if (!meds.nextDueOverdue) return null; + const name = meds.nextDueMedicationName; + return { + kind: "dose_window", + title: t("daily.item.doseWindow.title"), + body: name + ? t("daily.item.doseWindow.bodyNamed", { name }) + : t("daily.item.doseWindow.body"), + status: "warning", + actions: [ + { + labelKey: "daily.action.logDose", + intent: "dose.log", + href: "/medications", + }, + ], + moduleKey: "medications", + }; +} + +/** One rail item per broken integration — reconnect to keep data current. */ +function buildSyncIssueItems( + issues: DailyDigestSyncIssue[], + t: Translate, +): PriorityItem[] { + return issues.map((issue) => ({ + kind: "sync_issue" as const, + title: t("daily.item.syncIssue.title"), + body: t("daily.item.syncIssue.body", { + integration: integrationLabel(issue.integration), + }), + status: "warning" as const, + actions: [ + { + labelKey: "daily.action.reconnect", + intent: "sync.reconnect", + href: "/settings/integrations", + }, + ], + })); +} + +/** A single preventive-care item summarising the due Vorsorge reminders. */ +function buildPreventiveCareItem( + due: DailyDigestPreventiveDue[], + t: Translate, +): PriorityItem | null { + if (due.length === 0) return null; + const body = + due.length === 1 + ? t("daily.item.preventiveCare.body", { label: due[0].label }) + : t("daily.item.preventiveCare.bodyMany", { count: due.length }); + return { + kind: "preventive_care", + title: t("daily.item.preventiveCare.title"), + body, + status: "info", + actions: [ + { + labelKey: "daily.action.viewCheckups", + intent: "checkup.view", + href: "/checkups", + }, + ], + }; +} + +/** + * The push / lock-screen line: prefer the warmer cached briefing lead, fall + * back to the top signal's headline, then a deterministic score floor, then + * the honest all-clear. NEVER a fresh AI call — every branch reads cache or a + * deterministic string, so a keyless self-hoster still gets a first-class line. + */ +function composeLine( + briefingLead: string | null, + topSignal: DailyBriefingSignal | null, + score: DailyDigestScore | null, + t: Translate, +): string { + if (briefingLead) return briefingLead; + if (topSignal?.headline) return topSignal.headline.trim(); + if (score) return t("daily.line.score", { score: score.value }); + return t("daily.line.allClear"); +} + +export function buildDailyDigest( + input: DailyDigestInput, + t: Translate, +): DailyDigest { + const sleepEnabled = moduleEnabled(input.modules, "sleep"); + const sleepPending = + sleepEnabled && + (input.sleepLastSeenDaysAgo === null || input.sleepLastSeenDaysAgo >= 1); + const phase: DailyDigest["phase"] = sleepPending ? "provisional" : "final"; + + const topSignal = input.briefing?.signalsOfDay?.[0] ?? null; + const briefingLead = firstSentence(input.briefing?.paragraph); + + // Priority order: an overdue dose is the most time-sensitive daily action, a + // broken sync next, a preventive check-up least urgent. Bounded to 3. + const worthALook: PriorityItem[] = []; + const dose = buildDoseWindowItem(input.medsToday, input.modules, t); + if (dose) worthALook.push(dose); + worthALook.push(...buildSyncIssueItems(input.syncIssues, t)); + const preventive = buildPreventiveCareItem(input.preventiveDue, t); + if (preventive) worthALook.push(preventive); + + // Defence-in-depth: no card ever exceeds the P1 action cap. + for (const item of worthALook) { + item.actions = item.actions.slice(0, MAX_PRIORITY_ACTIONS); + } + + return { + generatedAt: input.now.toISOString(), + phase, + sleepPending, + score: input.score, + topSignal, + briefingLead, + line: composeLine(briefingLead, topSignal, input.score, t), + worthALook: worthALook.slice(0, MAX_WORTH_A_LOOK), + }; +} diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts new file mode 100644 index 000000000..f00632900 --- /dev/null +++ b/src/lib/daily/load-digest.ts @@ -0,0 +1,116 @@ +/** + * IO seam for the daily digest (P3) — gathers the ALREADY-CACHED inputs and + * hands them to the pure `buildDailyDigest` composer. + * + * The heavy read (health score, meds-today, sleep last-seen, the daily + * briefing lift) rides `readDashboardSnapshotCached` — the SAME SWR cell the + * dashboard already serves, so the digest never re-runs the snapshot builder + * within its TTL and never reaches an AI provider (the briefing is lifted + * read-only from `User.insightsCachedText`). Two light deterministic reads add + * the rail's non-snapshot inputs: broken integrations and overdue Vorsorge + * reminders. Module gating is inherited from the snapshot (already + * module-gated at the build layer) plus the resolved module map the item + * builders consult. + */ +import type { User } from "@/generated/prisma/client"; + +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { resolveModuleMap } from "@/lib/modules/gate"; +import { readDashboardSnapshotCached } from "@/lib/dashboard/snapshot-read"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; +import { + buildDailyDigest, + type DailyDigest, + type DailyDigestPreventiveDue, + type DailyDigestScore, + type DailyDigestSyncIssue, +} from "@/lib/daily/digest"; + +/** Integration states that mean "your action is needed to keep data flowing". */ +const SYNC_ISSUE_STATES = ["error_reauth", "parked"] as const; + +/** Defensive cap on how many overdue reminders we read for the rail summary. */ +const PREVENTIVE_DUE_READ_LIMIT = 20; + +function resolveLocale(locale: string | null | undefined): Locale { + return locales.includes(locale as Locale) + ? (locale as Locale) + : defaultLocale; +} + +export async function loadDailyDigest( + user: User, + now: Date = new Date(), +): Promise { + const [{ body: snapshot, locale }, modules, syncRows, dueReminders] = + await Promise.all([ + readDashboardSnapshotCached(user), + resolveModuleMap(user.id), + prisma.integrationStatus.findMany({ + where: { userId: user.id, state: { in: [...SYNC_ISSUE_STATES] } }, + select: { integration: true, state: true }, + orderBy: { integration: "asc" }, + }), + prisma.measurementReminder.findMany({ + where: { + userId: user.id, + enabled: true, + deletedAt: null, + nextDueAt: { not: null, lte: now }, + }, + select: { label: true }, + orderBy: { nextDueAt: "asc" }, + take: PREVENTIVE_DUE_READ_LIMIT, + }), + ]); + + const score: DailyDigestScore | null = snapshot.healthScore + ? { + value: snapshot.healthScore.score, + band: snapshot.healthScore.band, + delta: snapshot.healthScore.delta, + } + : null; + + const sleepSlot = snapshot.tiles.lastSeenByType["SLEEP_DURATION"]; + const sleepLastSeenDaysAgo = sleepSlot ? sleepSlot.daysAgo : null; + + const syncIssues: DailyDigestSyncIssue[] = syncRows.map((row) => ({ + integration: row.integration, + state: row.state, + })); + + const preventiveDue: DailyDigestPreventiveDue[] = dueReminders.map((row) => ({ + label: row.label, + })); + + const { t } = getServerTranslator(resolveLocale(locale)); + + const digest = buildDailyDigest( + { + now, + modules, + score, + briefing: snapshot.briefing, + medsToday: snapshot.medsToday, + sleepLastSeenDaysAgo, + syncIssues, + preventiveDue, + }, + t, + ); + + annotate({ + action: { name: "daily.digest.build" }, + meta: { + daily_digest_phase: digest.phase, + daily_digest_sleep_pending: digest.sleepPending, + daily_digest_item_count: digest.worthALook.length, + daily_digest_has_score: digest.score !== null, + }, + }); + + return digest; +} diff --git a/src/lib/daily/priority-item.ts b/src/lib/daily/priority-item.ts new file mode 100644 index 000000000..c84d0267e --- /dev/null +++ b/src/lib/daily/priority-item.ts @@ -0,0 +1,84 @@ +/** + * P1 — `PriorityItem`, the ONE typed model behind every "worth a look" item. + * + * A `PriorityItem` is the single rendering unit for every rail entry the + * unified daily-value system produces: a coach check-in, a dose-window CTA, a + * Vorsorge nudge, a failed-sync retry, a durable milestone, a new-ECG pointer, + * and an elevated-at-rest tension window. Later slices (Today rail S2, coach + * check-in S3, ECG weave S10, milestones S12, tension S11) each add ONE `kind` + * plus a server-side item builder — never a new component. `PriorityCard` + * renders it; `buildDailyDigest` emits it. + * + * The type is deliberately WIRE-SERIALISABLE: the digest DTO crosses the + * `GET /api/daily/digest` boundary (and, later, a native-client / iOS-widget + * boundary), so an item carries no React component. The card derives its + * Lucide glyph deterministically from `kind` (see `KIND_ICON` in + * `src/components/daily/priority-card.tsx`) — the icon lives with the + * renderer, the closed `kind` enum with the model. This is the one intentional + * departure from the Fable §1.2 code block (which sketched `icon: LucideIcon` + * inline); a serialisable single-source-of-truth DTO cannot hold a component. + */ +import type { ModuleKey } from "@/lib/modules/registry"; + +/** + * Closed set of rail-item kinds. It GROWS BY PR — a new rail item type is a new + * entry here + a new server builder, nothing else. S1 ships builders for + * `dose_window`, `preventive_care`, and `sync_issue`; the remaining kinds are + * reserved for the slices that own them (coach check-in S3, milestone S12, + * new-ECG S10, tension window S11) so their consumers can already type against + * the union. + */ +export const PRIORITY_ITEM_KINDS = [ + "coach_checkin", + "dose_window", + "preventive_care", + "sync_issue", + "milestone", + "ecg_new_recording", + "tension_window", +] as const; + +export type PriorityItemKind = (typeof PRIORITY_ITEM_KINDS)[number]; + +/** + * Semantic status — meaning, not decoration. Maps to a `text-` plus a + * `bg-/10` wash in the card (§3 status-colour tier), never raw palette. + */ +export type PriorityItemStatus = "success" | "warning" | "info" | "destructive"; + +/** + * One of the 1–3 one-tap actions a card offers. `labelKey` is an i18n key + * resolved CLIENT-side (the card owns the user's locale); `intent` is a stable + * token the consumer switches on to wire the tap; `href` deep-links when the + * action is pure navigation. + */ +export interface PriorityItemAction { + /** i18n key under `daily.action.*`, resolved by `PriorityCard`. */ + labelKey: string; + /** Stable action token (`dose.log`, `sync.reconnect`, …). */ + intent: string; + /** Deep-link target when the action is navigation. */ + href?: string; +} + +/** + * The one model every rail consumer reuses. `title` and `body` are already + * localised server-side (the builder resolves them through the server + * translator); action labels stay as keys for the client. + */ +export interface PriorityItem { + kind: PriorityItemKind; + /** i18n-resolved headline (server-side). */ + title: string; + /** Optional grounded one-liner — a plain string rendered via `ProseBlocks`. */ + body?: string; + /** Semantic status wash; omitted for a neutral card. */ + status?: PriorityItemStatus; + /** 1–3 one-tap actions. Never zero on a live rail item, never more than 3. */ + actions: PriorityItemAction[]; + /** Provenance of the gate that admitted the item, when module-scoped. */ + moduleKey?: ModuleKey; +} + +/** Hard cap on one-tap actions per card (P1 anatomy). */ +export const MAX_PRIORITY_ACTIONS = 3; diff --git a/src/lib/openapi/routes/daily.ts b/src/lib/openapi/routes/daily.ts new file mode 100644 index 000000000..058b3a728 --- /dev/null +++ b/src/lib/openapi/routes/daily.ts @@ -0,0 +1,119 @@ +/** + * OpenAPI route module — the unified daily digest (P3). + * + * GET /api/daily/digest returns the `DailyDigest` DTO the Today surface, the + * daily push, and a future iOS widget all consume. It is a pure read of + * already-cached data (nightly briefing lift + dashboard-snapshot ingredients + * + two light deterministic reads) — no provider call, no warm-on-mount. Gated + * on the `insights` module. Part of the OpenAPI route table; aggregated in + * `./index.ts`. + */ +import { z } from "zod/v4"; +import type { ZodOpenApiObject } from "zod-openapi"; + +import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item"; +import { dataEnvelope, moduleDisabledResponse, stdResponses } from "./shared"; + +const priorityItemSchema = z + .object({ + kind: z.enum([...PRIORITY_ITEM_KINDS]).describe("Closed rail-item kind."), + title: z.string().describe("Localised headline (resolved server-side)."), + body: z + .string() + .optional() + .describe("Grounded one-liner, rendered as plain text."), + status: z + .enum(["success", "warning", "info", "destructive"]) + .optional() + .describe("Semantic status wash — meaning, not decoration."), + actions: z + .array( + z.object({ + labelKey: z.string().describe("i18n key, resolved client-side."), + intent: z.string().describe("Stable action token."), + href: z.string().optional().describe("Deep-link when navigational."), + }), + ) + .max(3) + .describe("1–3 one-tap actions."), + moduleKey: z + .string() + .optional() + .describe("Provenance of the gate that admitted the item."), + }) + .meta({ + id: "DailyPriorityItem", + description: + "One 'worth a look' rail item. The single model every daily-value consumer renders through PriorityCard.", + }); + +const dailyDigestResponse = z + .object({ + generatedAt: z.string().describe("ISO-8601 instant the digest was read."), + phase: z + .enum(["provisional", "final"]) + .describe("Freshness lifecycle — 'final' once last night's sleep is in."), + sleepPending: z + .boolean() + .describe("Sleep tracked but last night not yet recorded."), + score: z + .object({ + value: z.number(), + band: z.string(), + delta: z.number().nullable(), + }) + .nullable() + .describe("Health score + band + week-over-week delta; null when none."), + topSignal: z + .object({ + sourceMetric: z.string(), + tone: z.enum(["good", "watch", "info"]), + headline: z.string(), + nudge: z.string(), + delta: z.string().nullable(), + }) + .nullable() + .describe( + "Clinical-priority top signal, lifted from the cached briefing.", + ), + briefingLead: z + .string() + .nullable() + .describe("First sentence of the cached briefing paragraph."), + line: z + .string() + .describe( + "Push / lock-screen line: cached-AI lead with a deterministic floor.", + ), + worthALook: z + .array(priorityItemSchema) + .describe("Bounded 0–3 rail items, never padded."), + }) + .meta({ + id: "DailyDigest", + description: + "The one data spine of the daily-value system — composed from already-cached data, never a fresh AI call. Consumed by the Today surface, the daily push, and a future iOS widget.", + }); + +export const dailyPaths: NonNullable = { + "/api/daily/digest": { + get: { + tags: ["Insights"], + summary: "The unified daily digest", + description: + "Assembles the day's read from already-cached data: the nightly briefing lifted read-only from the insights cache, the dashboard-snapshot health score / meds-today / sleep freshness, plus deterministic integration-status and Vorsorge reads for the 'worth a look' rail. No provider call, no warm-on-mount. Requires the insights module. Cookie or Bearer auth.", + responses: { + "200": { + description: "The daily digest.", + content: { + "application/json": { + schema: dataEnvelope(dailyDigestResponse, "DailyDigestEnvelope"), + }, + }, + }, + ...moduleDisabledResponse, + ...stdResponses, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index fee926293..e9fa1fe32 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -33,6 +33,7 @@ import { } from "./coach"; import { consentPaths } from "./consent"; import { customMetricPaths } from "./custom-metrics"; +import { dailyPaths } from "./daily"; import { cyclePaths } from "./cycle"; import { biomarkerPaths } from "./biomarkers"; import { inboundDocumentPaths } from "./documents"; @@ -101,6 +102,9 @@ export const openApiPaths: NonNullable = { // v1.28 — HealthKit integration config (appended; spread order is // load-bearing). ...integrationPaths, + // v1.28 — the unified daily digest (P3 spine; appended, spread order is + // load-bearing). + ...dailyPaths, }; export const openApiComponents: NonNullable = {