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] 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,