From ba6c0990bf7f20350ca3dbe06589fbae4bdeee45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 16 Jul 2026 19:13:01 +0200 Subject: [PATCH 1/5] feat(daily): add the daily-digest spine and priority-card primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the two foundational primitives of the unified daily-value system, with no user surface yet: - PriorityItem: the one wire-serialisable model behind every "worth a look" rail item, coach check-in, milestone, and device-verdict pointer. Its Lucide glyph is derived from the closed `kind` enum in the renderer so the model itself stays JSON-serialisable across the API and future native-client boundaries. - PriorityCard: a calm, token-only, reduced-motion-aware card composed entirely from the shipped Card / TileHeader / ProseBlocks primitives — no new visual language, semantic-token status wash only, no markdown. - buildDailyDigest: one pure, deterministic server composer that assembles the day's read from data that already exists — the nightly briefing lifted read-only from the insights cache plus the dashboard-snapshot health score, meds-today, and sleep freshness. It computes no analytics and never reaches an AI provider. It emits the DailyDigest DTO the Today surface, the daily push, and a future iOS widget will all consume. Deterministic item builders cover overdue doses, broken integrations, and due Vorsorge reminders; the push line prefers the cached briefing lead with a deterministic score floor so a keyless self-hoster still gets a first-class line. The read seam GET /api/daily/digest gathers the cached inputs, gates on the insights module (cookie or Bearer; userId from the session), and returns the digest. OpenAPI regenerated; the route is covered by the module gate inventory. Digest strings ship in all six locales. --- docs/api/openapi.yaml | 182 +++++++++++++ messages/de.json | 27 ++ messages/en.json | 27 ++ messages/es.json | 27 ++ messages/fr.json | 27 ++ messages/it.json | 27 ++ messages/pl.json | 27 ++ .../module-route-gate-inventory.test.ts | 6 + .../api/daily/digest/__tests__/route.test.ts | 119 ++++++++ src/app/api/daily/digest/route.ts | 36 +++ .../daily/__tests__/priority-card.test.tsx | 112 ++++++++ src/components/daily/priority-card.tsx | 152 +++++++++++ src/lib/daily/__tests__/digest.test.ts | 235 ++++++++++++++++ src/lib/daily/digest.ts | 253 ++++++++++++++++++ src/lib/daily/load-digest.ts | 116 ++++++++ src/lib/daily/priority-item.ts | 84 ++++++ src/lib/openapi/routes/daily.ts | 119 ++++++++ src/lib/openapi/routes/index.ts | 4 + 18 files changed, 1580 insertions(+) create mode 100644 src/app/api/daily/digest/__tests__/route.test.ts create mode 100644 src/app/api/daily/digest/route.ts create mode 100644 src/components/daily/__tests__/priority-card.test.tsx create mode 100644 src/components/daily/priority-card.tsx create mode 100644 src/lib/daily/__tests__/digest.test.ts create mode 100644 src/lib/daily/digest.ts create mode 100644 src/lib/daily/load-digest.ts create mode 100644 src/lib/daily/priority-item.ts create mode 100644 src/lib/openapi/routes/daily.ts 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 = { From 168c1c85d722396e4bfa35c4e195759bc73676da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 16 Jul 2026 19:31:13 +0200 Subject: [PATCH 2/5] feat(dashboard): promote a Today hero above the tile strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the day's read at the top of the dashboard: a Today hero that renders the daily digest — the health score with an honest provisional/final and sleep-pending face, the briefing lead with a read-the-full-briefing affordance, and the worth-a-look rail of priority cards. The dense tile grid and chart rows stay exactly where they are, one scroll below, so muscle memory survives and no new nav destination is added. The hero reads one cached digest cell (GET /api/daily/digest) through the centralised query-key factory and unwraps the envelope; it recomputes nothing and never warms a provider on mount. It is gated on the insights module and self-gates its empty state: a genuinely empty account renders nothing rather than an alarming card, while an account with a score but nothing notable gets a calm all-clear line in the rail's place. A shimmer skeleton holds the footprint while the digest loads. Priority-card actions wire to their existing destinations by construction (log dose, reconnect a sync, view check-ups). New copy lands in all six locales. --- e2e/dashboard.spec.ts | 50 +++++ messages/de.json | 9 + messages/en.json | 9 + messages/es.json | 9 + messages/fr.json | 9 + messages/it.json | 9 + messages/pl.json | 9 + src/app/page-client.tsx | 24 +++ .../daily/__tests__/today-hero.test.tsx | 149 ++++++++++++++ src/components/daily/today-hero-skeleton.tsx | 46 +++++ src/components/daily/today-hero.tsx | 192 ++++++++++++++++++ src/lib/queries/use-daily-digest.ts | 50 +++++ src/lib/query-keys/daily.ts | 14 ++ src/lib/query-keys/index.ts | 2 + 14 files changed, 581 insertions(+) create mode 100644 src/components/daily/__tests__/today-hero.test.tsx create mode 100644 src/components/daily/today-hero-skeleton.tsx create mode 100644 src/components/daily/today-hero.tsx create mode 100644 src/lib/queries/use-daily-digest.ts create mode 100644 src/lib/query-keys/daily.ts diff --git a/e2e/dashboard.spec.ts b/e2e/dashboard.spec.ts index 13589d544..53e680acd 100644 --- a/e2e/dashboard.spec.ts +++ b/e2e/dashboard.spec.ts @@ -98,6 +98,45 @@ test.describe("authenticated dashboard render", () => { }), ); + // S2 — the Today hero reads the unified daily digest from + // `/api/daily/digest`. Mock it with a deterministic digest (a score + + // one worth-a-look item) so the hero paints without reaching the real + // route, and so a slow/absent digest can never hang the render or + // surface a console error under the significant-error gate below. + await page.route(/\/api\/daily\/digest(\?|$)/, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + generatedAt: new Date().toISOString(), + phase: "final", + sleepPending: false, + score: { value: 82, band: "green", delta: 2 }, + topSignal: null, + briefingLead: "Your week is trending steady.", + line: "Your week is trending steady.", + worthALook: [ + { + kind: "sync_issue", + title: "Sync needs attention", + body: "Withings isn't syncing.", + status: "warning", + actions: [ + { + labelKey: "daily.action.reconnect", + intent: "sync.reconnect", + href: "/settings/integrations", + }, + ], + }, + ], + }, + error: null, + }), + }), + ); + // v1.18.6 — the dashboard now batches every visible chart's daily // series into ONE `/api/measurements/series-batch` round-trip and // hands each chart its slice via `preloadedSeries`. The route is a @@ -209,6 +248,17 @@ test.describe("authenticated dashboard render", () => { const tileCount = Number(await strip.getAttribute("data-tile-count")); expect(tileCount).toBeGreaterThan(0); + // S2 — the Today hero paints above the tile strip from the mocked + // digest: its score ring and its worth-a-look rail render. + const todayHero = page.locator('[data-slot="today-hero"]'); + await expect(todayHero).toBeVisible({ timeout: 10_000 }); + await expect( + todayHero.locator('[data-slot="today-hero-score"]'), + ).toBeVisible(); + await expect( + todayHero.locator('[data-slot="today-hero-rail"]'), + ).toBeVisible(); + // At least one chart must render. Recharts mounts a `` with // `class="recharts-surface"` (or a `
` // around it). Use the recharts class so we don't accidentally match diff --git a/messages/de.json b/messages/de.json index ad6d15449..4db0595e6 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8556,6 +8556,15 @@ "logDose": "Dosis erfassen", "reconnect": "Neu verbinden", "viewCheckups": "Vorsorge ansehen" + }, + "today": { + "scoreLabel": "Gesundheitsscore", + "deltaVsBaseline": "{delta} ggü. deinem Ausgangswert", + "deltaFlat": "Im Bereich deines Ausgangswerts", + "sleepPending": "Der Schlaf von letzter Nacht fehlt noch – der heutige Wert ist vorläufig.", + "readFullBriefing": "Vollständiges Briefing lesen", + "worthALook": "Einen Blick wert", + "allClear": "Heute braucht nichts deine Aufmerksamkeit." } } } diff --git a/messages/en.json b/messages/en.json index 8611d9822..f42744b90 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8556,6 +8556,15 @@ "logDose": "Log dose", "reconnect": "Reconnect", "viewCheckups": "View check-ups" + }, + "today": { + "scoreLabel": "Health score", + "deltaVsBaseline": "{delta} vs your baseline", + "deltaFlat": "In line with your baseline", + "sleepPending": "Last night's sleep isn't in yet — today's read is provisional.", + "readFullBriefing": "Read the full briefing", + "worthALook": "Worth a look", + "allClear": "Nothing needs your attention today." } } } diff --git a/messages/es.json b/messages/es.json index 6a5153ed0..9cd80775f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8556,6 +8556,15 @@ "logDose": "Registrar dosis", "reconnect": "Reconectar", "viewCheckups": "Ver revisiones" + }, + "today": { + "scoreLabel": "Puntuación de salud", + "deltaVsBaseline": "{delta} frente a tu valor de referencia", + "deltaFlat": "En línea con tu valor de referencia", + "sleepPending": "El sueño de anoche aún no ha llegado; la lectura de hoy es provisional.", + "readFullBriefing": "Leer el informe completo", + "worthALook": "Merece un vistazo", + "allClear": "Hoy nada necesita tu atención." } } } diff --git a/messages/fr.json b/messages/fr.json index d8addcb39..e37be5a91 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8556,6 +8556,15 @@ "logDose": "Enregistrer la dose", "reconnect": "Reconnecter", "viewCheckups": "Voir les examens" + }, + "today": { + "scoreLabel": "Score de santé", + "deltaVsBaseline": "{delta} par rapport à ta référence", + "deltaFlat": "Conforme à ta référence", + "sleepPending": "Le sommeil de la nuit dernière n'est pas encore arrivé — la lecture du jour est provisoire.", + "readFullBriefing": "Lire le briefing complet", + "worthALook": "À regarder", + "allClear": "Rien ne requiert ton attention aujourd'hui." } } } diff --git a/messages/it.json b/messages/it.json index 6852ed178..45f30ae4f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8556,6 +8556,15 @@ "logDose": "Registra dose", "reconnect": "Riconnetti", "viewCheckups": "Vedi controlli" + }, + "today": { + "scoreLabel": "Punteggio di salute", + "deltaVsBaseline": "{delta} rispetto al tuo valore di riferimento", + "deltaFlat": "In linea con il tuo valore di riferimento", + "sleepPending": "Il sonno di stanotte non è ancora arrivato: la lettura di oggi è provvisoria.", + "readFullBriefing": "Leggi il briefing completo", + "worthALook": "Da tenere d'occhio", + "allClear": "Oggi nulla richiede la tua attenzione." } } } diff --git a/messages/pl.json b/messages/pl.json index 2174c5a7b..eb27413d2 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8556,6 +8556,15 @@ "logDose": "Zapisz dawkę", "reconnect": "Połącz ponownie", "viewCheckups": "Zobacz badania" + }, + "today": { + "scoreLabel": "Wynik zdrowia", + "deltaVsBaseline": "{delta} wzgl. Twojej wartości bazowej", + "deltaFlat": "Zgodnie z Twoją wartością bazową", + "sleepPending": "Sen z ostatniej nocy jeszcze nie dotarł — dzisiejszy odczyt jest wstępny.", + "readFullBriefing": "Przeczytaj pełny briefing", + "worthALook": "Warto sprawdzić", + "allClear": "Dziś nic nie wymaga Twojej uwagi." } } } diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index facf03b70..d358a5a6c 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -45,6 +45,8 @@ import { import { DashboardHeader } from "@/components/dashboard/dashboard-header"; import { DashboardHero } from "@/components/dashboard/hero/dashboard-hero"; import { DashboardHeroSkeleton } from "@/components/dashboard/hero/dashboard-hero-skeleton"; +import { TodayHero } from "@/components/daily/today-hero"; +import { TodayHeroSkeleton } from "@/components/daily/today-hero-skeleton"; import { QuickEntrySheets, type QuickEntryDialog, @@ -123,6 +125,8 @@ import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; +import { useDailyDigest } from "@/lib/queries/use-daily-digest"; +import { useModuleEnabled } from "@/hooks/use-module-enabled"; import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag"; import type { DataSummary } from "@/lib/analytics/trends"; import { mergeSlimAndThickAnalytics } from "@/lib/analytics/merge-slim-thick"; @@ -223,6 +227,13 @@ export default function DashboardPageClient() { const snapshotEnabled = isDashboardSnapshotEnabled(); const snapshotQuery = useDashboardSnapshot(snapshotEnabled); + // S2 — the Today hero reads the unified daily digest (the S1 DTO) from + // its own cached route. Gated on the `insights` module (the digest is + // the AI-narrative daily layer; the route 403s when insights is off) + // and the auth state so a logged-out / gated account never fires it. + const insightsEnabled = useModuleEnabled("insights"); + const digestQuery = useDailyDigest(isAuthenticated && insightsEnabled); + const analyticsSlimQuery = useAnalyticsQuery({ slice: "summaries", enabled: !snapshotEnabled && isAuthenticated, @@ -775,6 +786,19 @@ export default function DashboardPageClient() { showGreeting={!heroVisible} /> + {/* S2 — the Today hero, promoted above the tile strip (MARC sign-off + decision 1). Renders the S1 daily digest: the day's read, the + honest score/freshness face, and the worth-a-look rail. Shimmer + skeleton while the cached digest loads; a genuinely empty account + degrades to nothing (the hero itself returns null). The dense + tile grid + charts below are untouched. */} + {insightsEnabled && + (digestQuery.isLoading ? ( + + ) : digestQuery.data ? ( + + ) : null)} + {heroVisible && (primaryLoading || !heroSnapshot ? ( diff --git a/src/components/daily/__tests__/today-hero.test.tsx b/src/components/daily/__tests__/today-hero.test.tsx new file mode 100644 index 000000000..a6591fe7d --- /dev/null +++ b/src/components/daily/__tests__/today-hero.test.tsx @@ -0,0 +1,149 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { TodayHero } from "../today-hero"; +import type { DailyDigest } from "@/lib/daily/digest"; +import type { PriorityItem } from "@/lib/daily/priority-item"; + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +const doseItem: PriorityItem = { + kind: "dose_window", + title: "Medication due", + body: "Ramipril is due today.", + status: "warning", + actions: [ + { + labelKey: "daily.action.logDose", + intent: "dose.log", + href: "/medications", + }, + ], + moduleKey: "medications", +}; + +const syncItem: PriorityItem = { + kind: "sync_issue", + title: "Sync needs attention", + body: "Withings isn't syncing.", + status: "warning", + actions: [ + { + labelKey: "daily.action.reconnect", + intent: "sync.reconnect", + href: "/settings/integrations", + }, + ], +}; + +function digest(over: Partial = {}): DailyDigest { + return { + generatedAt: "2026-07-16T06:00:00.000Z", + phase: "final", + sleepPending: false, + score: { value: 82, band: "green", delta: 3 }, + topSignal: { + sourceMetric: "bp", + tone: "watch", + headline: "Blood pressure a touch high this morning", + nudge: "Take it again after a calm five minutes.", + delta: "+6 mmHg vs your 30-day average", + }, + briefingLead: "Your week is trending steady.", + line: "Your week is trending steady.", + worthALook: [doseItem, syncItem], + ...over, + }; +} + +describe("", () => { + it("renders the score, the lead read, and the worth-a-look rail", () => { + const html = render(); + expect(html).toContain('data-slot="today-hero"'); + expect(html).toContain('data-phase="final"'); + // Score ring paints its populated (final) face with the server band. + expect(html).toContain('data-slot="today-hero-score"'); + expect(html).toContain('data-band="green"'); + expect(html).not.toContain('data-provisional="true"'); + // The day's read lead. + expect(html).toContain("Your week is trending steady."); + // The top signal headline + its delta. + expect(html).toContain("Blood pressure a touch high this morning"); + expect(html).toContain("+6 mmHg vs your 30-day average"); + // The rail with both priority cards. + expect(html).toContain('data-slot="today-hero-rail"'); + expect(html).toContain('data-kind="dose_window"'); + expect(html).toContain('data-kind="sync_issue"'); + // The score delta chip. + expect(html).toContain('data-slot="today-hero-score-delta"'); + }); + + it("wires each PriorityItem action to its existing destination via href", () => { + const html = render(); + // Every S1 rail action carries an href, so PriorityCard renders it as a + // link to the existing surface — S2 invents no new backend action. + expect(html).toContain('href="/medications"'); + expect(html).toContain('href="/settings/integrations"'); + // The read-the-full-briefing affordance routes to Insights. + expect(html).toContain('data-slot="today-hero-briefing-link"'); + expect(html).toContain('href="/insights"'); + }); + + it("shows the honest sleep-pending note and provisional score", () => { + const html = render( + , + ); + expect(html).toContain('data-phase="provisional"'); + expect(html).toContain('data-slot="today-hero-sleep-pending"'); + expect(html).toContain("Last night"); + // Null score → the ring's provisional face, never a zero. + expect(html).toContain('data-provisional="true"'); + // No briefing lead → no full-briefing link. + expect(html).not.toContain('data-slot="today-hero-briefing-link"'); + }); + + it("degrades to nothing on a genuinely empty account", () => { + const html = render( + , + ); + // No score, no items, no cached briefing lead → the hero renders nothing + // rather than an alarming empty card (the tile strip carries the + // add-your-first-reading empty state). + expect(html).toBe(""); + }); + + it("shows the first-class all-clear line when a score is present but nothing is notable", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="today-hero"'); + expect(html).toContain('data-slot="today-hero-all-clear"'); + expect(html).not.toContain('data-slot="today-hero-rail"'); + }); +}); diff --git a/src/components/daily/today-hero-skeleton.tsx b/src/components/daily/today-hero-skeleton.tsx new file mode 100644 index 000000000..6a9a63eb7 --- /dev/null +++ b/src/components/daily/today-hero-skeleton.tsx @@ -0,0 +1,46 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +/** + * S2 — loading silhouette for the Today hero. + * + * Mirrors the real `` footprint — the same plain `bg-card` + * shell (border + radius + `p-4 md:p-6`), the two-column read/ring split + * with the fixed md (168 px) score circle on the trailing edge, and a + * two-card rail row — so the swap to the loaded hero happens in place + * with zero layout shift. + * + * The shimmer rides the `` primitive (`animate-pulse`), which is + * reduced-motion safe by construction (`motion-reduce:animate-none`). + * Always `aria-hidden`: the tile-strip skeleton alongside carries the + * page's loading semantics, so a second announcement here would double up + * for screen readers. + */ +export function TodayHeroSkeleton() { + return ( + + ); +} diff --git a/src/components/daily/today-hero.tsx b/src/components/daily/today-hero.tsx new file mode 100644 index 000000000..34522a09c --- /dev/null +++ b/src/components/daily/today-hero.tsx @@ -0,0 +1,192 @@ +"use client"; + +/** + * S2 — the Today hero, the promoted day's read on the dashboard. + * + * MARC SIGN-OFF (2026-07-16, decision 1): Today is the dashboard hero + * promoted to the default read, with the dense tile grid demoted below + * it on the SAME page — no new nav destination. This band mounts above + * the existing tile strip and renders the S1 `DailyDigest` DTO verbatim; + * it recomputes nothing (server-authoritative parity) and fetches no + * fresh AI (the caller's hook GETs the already-cached digest route). + * + * Composition (plan §2.1, top→bottom, all shipped primitives): + * - the day's read: the health `ScoreRing` (`flat`, md) with its + * server-computed band and an honest provisional/final face — a null + * score paints the ring's own provisional state, never a zero; + * - the briefing lead in plain language (via `ProseBlocks`, no markdown) + * with a "read the full briefing" affordance, plus the top signal's + * present-tense headline + delta when the digest carries one; + * - the freshness note: when `sleepPending`, a calm muted "last night's + * sleep not yet in" line — it never blocks the hero (plan §2.4); + * - the worth-a-look rail: the digest's `PriorityItem[]` as `PriorityCard`s + * (S1's one rail primitive), only when non-empty. When the digest is + * all-clear, a first-class muted "nothing needs your attention" line + * stands in its place — not an alarming empty card. + * + * Every `PriorityItem` action carries an `href` (dose.log → /medications, + * sync.reconnect → /settings/integrations, checkup.view → /checkups), so + * `PriorityCard` wires each tap as a `` to its existing destination + * by construction; S2 invents no new backend action. + */ +import Link from "next/link"; +import { Moon } from "lucide-react"; + +import { ScoreRing } from "@/components/insights/derived/score-ring"; +import type { ScoreBand } from "@/components/insights/derived/band-tokens"; +import { ProseBlocks } from "@/components/insights/prose-blocks"; +import { PriorityCard } from "@/components/daily/priority-card"; +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; +import type { DailyDigest } from "@/lib/daily/digest"; + +/** Format the server-computed score delta as a signed, muted chip string. */ +function formatDelta( + delta: number, + t: ReturnType["t"], +) { + const rounded = Math.round(delta); + if (rounded === 0) return t("daily.today.deltaFlat"); + const signed = rounded > 0 ? `+${rounded}` : `${rounded}`; + return t("daily.today.deltaVsBaseline", { delta: signed }); +} + +export function TodayHero({ digest }: { digest: DailyDigest }) { + const { t } = useTranslations(); + + const hasScore = digest.score !== null; + const hasItems = digest.worthALook.length > 0; + // The briefing lead is the warmest read; the deterministic `line` is the + // floor a keyless self-hoster still gets. Prefer the lead for the hero. + const lead = digest.briefingLead ?? digest.line; + const topSignal = digest.topSignal; + + // Calm degrade (plan §3): a genuinely empty account — no score, no rail + // items, and no cached briefing lead — surfaces nothing here. The tile + // strip below carries its own "add your first reading" empty state, so a + // second alarming empty card on the hero would be noise. The all-clear + // state (score present, nothing needs attention) is handled inline below. + if (!hasScore && !hasItems && !digest.briefingLead) { + return null; + } + + return ( +
+
+ {/* The day's read — the numeric face on the trailing edge, the + narrative lead leading. */} +
+
+ {/* Hero numeric face: the read leads large in the foreground + token, calm and legible — the day's read, not a slogan. */} +
+ +
+ {/* Top signal — present-tense headline + optional delta, one + muted step down so it supports the lead without competing. */} + {topSignal ? ( +

+ {topSignal.headline} + {topSignal.delta ? ( + + {" "} + · {topSignal.delta} + + ) : null} +

+ ) : null} + {/* Read-the-full-briefing affordance — only when a briefing + actually backs the lead. Routes to the Insights overview. */} + {digest.briefingLead ? ( + + {t("daily.today.readFullBriefing")} + + ) : null} +
+ + {/* Health score ring — `flat` (no sweep/bloom), server-computed + band. A null score paints the ring's honest provisional face + at the same footprint, so the column never collapses. */} +
+
+ +
+ {digest.score && digest.score.delta !== null ? ( + + {formatDelta(digest.score.delta, t)} + + ) : null} +
+
+ + {/* Freshness note (plan §2.4) — provisional day, last night's sleep + not yet folded in. Muted, non-blocking, refreshes in place when + the morning job lands. */} + {digest.sleepPending ? ( +

+

+ ) : null} + + {/* Worth-a-look rail — S1's `PriorityCard`s, bounded 0–3. When the + digest is all-clear, a first-class muted line stands in for the + rail (calm inversion of an alarm), never an empty card. */} + {hasItems ? ( +
+

+ {t("daily.today.worthALook")} +

+
+ {digest.worthALook.map((item, i) => ( + + ))} +
+
+ ) : ( +

+ {t("daily.today.allClear")} +

+ )} +
+
+ ); +} diff --git a/src/lib/queries/use-daily-digest.ts b/src/lib/queries/use-daily-digest.ts new file mode 100644 index 000000000..dfbbb55d0 --- /dev/null +++ b/src/lib/queries/use-daily-digest.ts @@ -0,0 +1,50 @@ +"use client"; + +/** + * S2 — client cell for the unified daily digest (`GET /api/daily/digest`). + * + * The Today hero reads the ALREADY-CACHED `DailyDigest` DTO S1 assembled + * server-side. This hook is a plain GET of that cached route — it never + * warms a provider, never triggers a fresh AI call (the whole daily-value + * system's warm-on-mount ban extends here); the route composes from the + * nightly-cached briefing + dashboard snapshot ingredients. + * + * Gating: `enabled` is caller-supplied so the page can fold in the + * `insights` module flag (the digest is the AI-narrative daily layer; + * the route returns 403 `module.disabled` when insights is off) and the + * auth state. Freshness mirrors the dashboard snapshot cadence — a warm + * return-to-dashboard within the `staleTime` is a free cache hit, and a + * background sync surfaces on focus/poll — so the two above-the-fold + * cells refresh in lockstep without a second request storm. + * + * The read unwraps the `{ data, error }` envelope through `apiGet` + * (`(await res.json()).data`), and the queryKey is the centralised + * factory entry `queryKeys.dailyDigest()`. + */ +import { useQuery } from "@tanstack/react-query"; + +import { apiGet } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval"; +import { retryOnceOnTransientError } from "@/lib/queries/retry-transient"; +import type { DailyDigest } from "@/lib/daily/digest"; + +export function useDailyDigest(enabled = true) { + return useQuery({ + queryKey: queryKeys.dailyDigest(), + queryFn: () => apiGet("/api/daily/digest"), + enabled, + // Match the dashboard snapshot's freshness so the hero and the tile + // strip below it share one refresh cadence: a warm remount inside the + // minute is free; a background sync surfaces on focus; an open tab + // polls in the foreground only. + staleTime: 60_000, + refetchOnMount: false, + refetchOnWindowFocus: true, + refetchInterval: DASHBOARD_REFETCH_INTERVAL_MS, + refetchIntervalInBackground: false, + // One retry on transient network / 5xx (never 401/403) so a single + // blip doesn't collapse the hero to its empty degrade. + retry: retryOnceOnTransientError, + }); +} diff --git a/src/lib/query-keys/daily.ts b/src/lib/query-keys/daily.ts new file mode 100644 index 000000000..e6c20e4c8 --- /dev/null +++ b/src/lib/query-keys/daily.ts @@ -0,0 +1,14 @@ +/** + * Query keys — the unified daily-value system (S2 Today surface). + * Part of the centralized factory; aggregated in `./index.ts`. + * + * The Today hero reads ONE cell against `GET /api/daily/digest` (the + * `DailyDigest` DTO S1 shipped). A single flat key: the digest is a + * per-user daily read with no discriminator, so `["daily","digest"]` + * is the whole surface. Kept in the factory so a future daily-data + * mutation can invalidate it by prefix without a bare-array literal + * (the `healthlog/queryKey-factory` rule bans those). + */ +export const dailyKeys = { + dailyDigest: () => ["daily", "digest"] as const, +}; diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index 5e08c4ce6..a842b5213 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -21,6 +21,7 @@ import { coachKeys } from "./coach"; import { familyHistoryKeys } from "./family-history"; import { customMetrics } from "./custom-metrics"; import { cycleKeys } from "./cycle"; +import { dailyKeys } from "./daily"; import { dashboardKeys } from "./dashboard"; import { environmentKeys } from "./environment"; import { documentKeys } from "./documents"; @@ -41,6 +42,7 @@ export const queryKeys = { ...authKeys, ...measurementKeys, ...moodKeys, + ...dailyKeys, ...dashboardKeys, ...insightsKeys, ...medicationKeys, From 25d720e6e7dd0ae49713e900ab788a84ba3016e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 16 Jul 2026 19:37:14 +0200 Subject: [PATCH 3/5] =?UTF-8?q?chore(release):=20v1.28.53=20=E2=80=94=20a?= =?UTF-8?q?=20Today=20overview=20at=20the=20top=20of=20the=20dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundles the daily-digest spine (a single server-built digest read from the overnight insight cache, never a fresh generation) and the Today hero that surfaces it: the health score with an honest provisional/finalised face, the day's lead read, and a short worth-a-look rail of one-tap items. The tiles and charts stay below, unchanged. The Today hero takes the promoted slot when Insights is on, so it never stacks with the legacy opt-in hero. This is the shared foundation the later daily surfaces (morning refresh, an optional daily note) build on — one digest, one card model, one design language. --- CHANGELOG.md | 14 +++++++++++++- docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- src/app/page-client.tsx | 5 +++++ 5 files changed, 21 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9f88dd99..ccb6a7da2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,19 @@ ## [Unreleased] -## [1.28.52] — 2026-07-16 — Dashboard tiles for more of what you already track +## [1.28.53] — 2026-07-16 — A "Today" overview at the top of your dashboard + +The dashboard opens with a Today view: your health score, the day's lead read, +and a short "worth a look" list — a dose window that's open, an integration to +reconnect, a check-up coming due — each a single tap to the right place. The +familiar tiles and charts sit just below, unchanged. + +It reads the insight that's already prepared overnight, so it loads instantly +and never generates anything on open. When last night's sleep hasn't synced yet, +it says so plainly and fills in the sleep-dependent parts once the data arrives, +rather than showing a stale or empty score. This is the first piece of a single +daily view that later surfaces — a morning refresh, an optional daily note — +will all build on. The dashboard gains tiles for readings that were synced and charted but never had a place on the home strip: heart-rate variability, blood oxygen, breathing diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 84b94a40d..aec0e879c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.28.52 + version: 1.28.53 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index e3340ba20..2763172e0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.28.52", + "version": "1.28.53", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index e55614a3e..5bcf17e98 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.28.52"; + /* @sw-version-fallback */ "v1.28.53"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index d358a5a6c..e6300180e 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -799,7 +799,12 @@ export default function DashboardPageClient() { ) : null)} + {/* The digest-driven TodayHero owns the promoted hero slot whenever + Insights is on (Marc sign-off, decision 1). The legacy opt-in + DashboardHero only shows for an Insights-disabled account, so the two + heroes never stack. */} {heroVisible && + !insightsEnabled && (primaryLoading || !heroSnapshot ? ( ) : ( From 0be26473b40fc936e3bb882d23a013ae84f7a3b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 16 Jul 2026 19:49:56 +0200 Subject: [PATCH 4/5] fix(dashboard): keep the legacy hero rendering as before Revert the Today-hero-owns-the-slot suppression from this branch: the legacy opt-in dashboard hero is asserted by the mobile-density and dashboard e2e landmarks, and retiring it cleanly (with its settings toggle and those specs) is a separate change. For now both heroes render exactly as before this branch; the default account (legacy hero off) still shows only the Today hero. --- src/app/page-client.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index e6300180e..d358a5a6c 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -799,12 +799,7 @@ export default function DashboardPageClient() { ) : null)} - {/* The digest-driven TodayHero owns the promoted hero slot whenever - Insights is on (Marc sign-off, decision 1). The legacy opt-in - DashboardHero only shows for an Insights-disabled account, so the two - heroes never stack. */} {heroVisible && - !insightsEnabled && (primaryLoading || !heroSnapshot ? ( ) : ( From 08a3ae2ef1e723e4687f410f81422209d1df0c7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 16 Jul 2026 20:03:58 +0200 Subject: [PATCH 5/5] fix(dashboard): pin the Today hero to the skeleton during hydration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The digest query rehydrates from the persisted client cache, so the Today hero was painting on the first client render while the server had rendered the skeleton — a text-content hydration mismatch (React #418) that the dashboard e2e's no-significant-errors gate caught. Gate the hero on `mounted` like the rest of the dashboard's late-hydrating surfaces, so SSR and the first client render both show the skeleton and the hero appears once mounted. --- src/app/page-client.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index d358a5a6c..e8e193e7b 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -793,7 +793,12 @@ export default function DashboardPageClient() { degrades to nothing (the hero itself returns null). The dense tile grid + charts below are untouched. */} {insightsEnabled && - (digestQuery.isLoading ? ( + // `!mounted` pins the SSR pass AND the first hydration render to the + // skeleton (v1.16.4 pattern): the digest query rehydrates from the + // persisted cache on the client, so without this gate the client's + // first render would paint the hero while the server rendered the + // skeleton — a text-content hydration mismatch (React #418). + (!mounted || digestQuery.isLoading ? ( ) : digestQuery.data ? (