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,