From ea29e9cf1bdd2cce2b8b29319a553d47ffcc1d7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 12 Jun 2026 09:26:55 +0200 Subject: [PATCH] feat(dashboard): open the day with a verdict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard now leads with a daily band: the greeting, the one item that matters right now — an overdue or upcoming dose, a very high blood-pressure reading, weight drifting from its range, a run of short nights, a quiet stretch, a score drop, or the briefing's key finding — with a single action, today's dose tally beside the next due time, and the health score as a ring on the right. The verdict resolves from a fixed nine-rung ladder on the client with the thresholds shared from the nudge engine through a dependency-free module, the blood-pressure rung uses fixed crisis floors rather than personal target ranges, and a cached next-due in the past never renders as overdue. The band reserves its exact footprint from the first paint, swaps in with the tiles in one pass, respects the hour-format preference, and can be turned off in the dashboard settings; the greeting moves out of the page header into the band. --- messages/de.json | 30 ++ messages/en.json | 30 ++ messages/es.json | 30 ++ messages/fr.json | 30 ++ messages/it.json | 30 ++ messages/pl.json | 30 ++ .../__tests__/dashboard-hero-wiring.test.ts | 82 +++ .../dashboard-suspense-boundaries.test.ts | 17 +- src/app/page.tsx | 97 ++-- src/components/dashboard/dashboard-header.tsx | 33 +- .../dashboard-hero-skeleton.test.tsx | 48 ++ .../hero/__tests__/dashboard-hero.test.tsx | 479 ++++++++++++++++++ .../hero/dashboard-hero-skeleton.tsx | 45 ++ .../dashboard/hero/dashboard-hero.tsx | 274 ++++++++++ .../dashboard-layout-section.test.tsx | 72 +++ .../settings/dashboard-layout-section.tsx | 39 ++ .../__tests__/verdict-client-safety.test.ts | 134 +++++ src/lib/dashboard/verdict.ts | 10 +- src/lib/jobs/coach-nudge-thresholds.ts | 19 + src/lib/jobs/coach-nudge.ts | 16 +- 20 files changed, 1451 insertions(+), 94 deletions(-) create mode 100644 src/app/__tests__/dashboard-hero-wiring.test.ts create mode 100644 src/components/dashboard/hero/__tests__/dashboard-hero-skeleton.test.tsx create mode 100644 src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx create mode 100644 src/components/dashboard/hero/dashboard-hero-skeleton.tsx create mode 100644 src/components/dashboard/hero/dashboard-hero.tsx create mode 100644 src/lib/dashboard/__tests__/verdict-client-safety.test.ts create mode 100644 src/lib/jobs/coach-nudge-thresholds.ts diff --git a/messages/de.json b/messages/de.json index 7b5c36cd2..5d5fab0d6 100644 --- a/messages/de.json +++ b/messages/de.json @@ -274,6 +274,34 @@ }, "welcomeBack": "{greeting}, willkommen zurück.", "welcomeBackWithName": "{greeting} {name}, willkommen zurück.", + "hero": { + "scoreLabel": "Health Score", + "scoreProvisional": "Noch nicht genug Daten", + "doses": { + "summary": "{taken} von {scheduled} Dosen genommen", + "nextAt": "Nächste um {time}", + "allDone": "Alle Dosen für heute erledigt", + "none": "Heute keine Dosen geplant" + }, + "verdict": { + "doseOverdue": "Eine Dosis {name} ist überfällig.", + "bpHigh": "Dein letzter Blutdruck war sehr hoch ({sys}/{dia}). Miss in Ruhe noch einmal nach.", + "doseUpcoming": "Um {time} steht {name} an.", + "weightDrift": "Dein Gewicht bewegt sich diese Woche vom Zielbereich weg.", + "shortNights": "Deine Nächte waren zuletzt kurz — im Schnitt {hours} h.", + "silence": "Seit {days} Tagen keine Messung. Ein kurzer Eintrag hält dein Bild aktuell.", + "scoreDrop": "Dein Score liegt {points} Punkte unter der Vorwoche.", + "allQuiet": "Alles ruhig — heute gibt es nichts, das deine Aufmerksamkeit braucht." + }, + "action": { + "takeDose": "Jetzt eintragen", + "viewBp": "Blutdruck ansehen", + "viewWeight": "Gewicht ansehen", + "viewSleep": "Schlaf ansehen", + "logMeasurement": "Messung erfassen", + "viewInsights": "Insights öffnen" + } + }, "greetingSalutation": "Hallo, {name}", "streakLabel": "Tage in Folge", "weight": "Gewicht", @@ -323,6 +351,8 @@ "compliance7d": "Therapietreue (7 Tage)", "mood": "Stimmung", "customizeTitle": "Dashboard-Layout", + "heroToggleLabel": "Tagesüberblick", + "heroToggleDescription": "Zeigt Score, Tagesfokus und Dosen-Status ganz oben auf dem Dashboard.", "layoutReset": "Auf Defaults zurücksetzen", "layoutSaveSuccess": "Dashboard-Layout gespeichert", "layoutSaveError": "Layout konnte nicht gespeichert werden", diff --git a/messages/en.json b/messages/en.json index d50aa6490..8f1d22f32 100644 --- a/messages/en.json +++ b/messages/en.json @@ -274,6 +274,34 @@ }, "welcomeBack": "{greeting}, welcome back.", "welcomeBackWithName": "{greeting} {name}, welcome back.", + "hero": { + "scoreLabel": "Health Score", + "scoreProvisional": "Not enough data yet", + "doses": { + "summary": "{taken} of {scheduled} doses taken", + "nextAt": "Next at {time}", + "allDone": "All doses done for today", + "none": "No doses scheduled today" + }, + "verdict": { + "doseOverdue": "A dose of {name} is overdue.", + "bpHigh": "Your last blood pressure was very high ({sys}/{dia}). Take another reading at rest.", + "doseUpcoming": "{name} is due at {time}.", + "weightDrift": "Your weight is moving away from the target range this week.", + "shortNights": "Your nights have been short lately — {hours} h on average.", + "silence": "No measurement for {days} days. A quick entry keeps your picture current.", + "scoreDrop": "Your score sits {points} points below last week.", + "allQuiet": "All quiet — nothing needs your attention today." + }, + "action": { + "takeDose": "Log it now", + "viewBp": "View blood pressure", + "viewWeight": "View weight", + "viewSleep": "View sleep", + "logMeasurement": "Log measurement", + "viewInsights": "Open Insights" + } + }, "greetingSalutation": "Hi, {name}", "streakLabel": "days in a row", "weight": "Weight", @@ -323,6 +351,8 @@ "compliance7d": "Adherence (7 days)", "mood": "Mood", "customizeTitle": "Dashboard layout", + "heroToggleLabel": "Daily overview", + "heroToggleDescription": "Shows your score, daily focus and dose status at the top of the dashboard.", "layoutReset": "Reset to defaults", "layoutSaveSuccess": "Dashboard layout saved", "layoutSaveError": "Could not save dashboard layout", diff --git a/messages/es.json b/messages/es.json index 593d43f7b..35af7d077 100644 --- a/messages/es.json +++ b/messages/es.json @@ -274,6 +274,34 @@ }, "welcomeBack": "{greeting}, te damos la bienvenida de nuevo.", "welcomeBackWithName": "{greeting} {name}, te damos la bienvenida de nuevo.", + "hero": { + "scoreLabel": "Health Score", + "scoreProvisional": "Aún no hay datos suficientes", + "doses": { + "summary": "{taken} de {scheduled} dosis tomadas", + "nextAt": "Próxima a las {time}", + "allDone": "Todas las dosis de hoy completadas", + "none": "Hoy no hay dosis programadas" + }, + "verdict": { + "doseOverdue": "Una dosis de {name} está pendiente desde hace rato.", + "bpHigh": "Tu última tensión arterial fue muy alta ({sys}/{dia}). Vuelve a medirla con calma.", + "doseUpcoming": "A las {time} toca {name}.", + "weightDrift": "Tu peso se está alejando del rango objetivo esta semana.", + "shortNights": "Tus noches han sido cortas últimamente — {hours} h de media.", + "silence": "Sin mediciones desde hace {days} días. Un registro rápido mantiene tu panorama al día.", + "scoreDrop": "Tu puntuación está {points} puntos por debajo de la semana pasada.", + "allQuiet": "Todo tranquilo — hoy no hay nada que requiera tu atención." + }, + "action": { + "takeDose": "Registrar ahora", + "viewBp": "Ver tensión arterial", + "viewWeight": "Ver peso", + "viewSleep": "Ver sueño", + "logMeasurement": "Registrar medición", + "viewInsights": "Abrir Insights" + } + }, "greetingSalutation": "Hola, {name}", "streakLabel": "días seguidos", "weight": "Peso", @@ -323,6 +351,8 @@ "compliance7d": "Cumplimiento (7 días)", "mood": "Estado de ánimo", "customizeTitle": "Diseño del panel", + "heroToggleLabel": "Resumen del día", + "heroToggleDescription": "Muestra la puntuación, el foco del día y el estado de las dosis en la parte superior del panel.", "layoutReset": "Restablecer valores por defecto", "layoutSaveSuccess": "Diseño del panel guardado", "layoutSaveError": "No se ha podido guardar el diseño", diff --git a/messages/fr.json b/messages/fr.json index ea85d0e79..98c1c3f2b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -274,6 +274,34 @@ }, "welcomeBack": "{greeting}, content de te revoir.", "welcomeBackWithName": "{greeting} {name}, content de te revoir.", + "hero": { + "scoreLabel": "Health Score", + "scoreProvisional": "Pas encore assez de données", + "doses": { + "summary": "{taken} dose(s) prise(s) sur {scheduled}", + "nextAt": "Prochaine à {time}", + "allDone": "Toutes les doses du jour sont prises", + "none": "Aucune dose prévue aujourd’hui" + }, + "verdict": { + "doseOverdue": "Une dose de {name} est en retard.", + "bpHigh": "Votre dernière tension était très élevée ({sys}/{dia}). Reprenez une mesure au calme.", + "doseUpcoming": "{name} est prévu à {time}.", + "weightDrift": "Votre poids s’éloigne de la plage cible cette semaine.", + "shortNights": "Vos nuits ont été courtes récemment — {hours} h en moyenne.", + "silence": "Aucune mesure depuis {days} jours. Une saisie rapide garde votre tableau à jour.", + "scoreDrop": "Votre score est {points} points sous la semaine dernière.", + "allQuiet": "Tout est calme — rien ne demande votre attention aujourd’hui." + }, + "action": { + "takeDose": "Enregistrer maintenant", + "viewBp": "Voir la tension", + "viewWeight": "Voir le poids", + "viewSleep": "Voir le sommeil", + "logMeasurement": "Saisir une mesure", + "viewInsights": "Ouvrir Insights" + } + }, "greetingSalutation": "Bonjour, {name}", "streakLabel": "jours d’affilée", "weight": "Poids", @@ -323,6 +351,8 @@ "compliance7d": "Observance (7 jours)", "mood": "Humeur", "customizeTitle": "Mise en page du tableau", + "heroToggleLabel": "Aperçu du jour", + "heroToggleDescription": "Affiche le score, le focus du jour et l’état des doses tout en haut du tableau de bord.", "layoutReset": "Rétablir les valeurs par défaut", "layoutSaveSuccess": "Mise en page enregistrée", "layoutSaveError": "Impossible d’enregistrer la mise en page", diff --git a/messages/it.json b/messages/it.json index ef90af625..2a2466464 100644 --- a/messages/it.json +++ b/messages/it.json @@ -274,6 +274,34 @@ }, "welcomeBack": "{greeting}, bentornato.", "welcomeBackWithName": "{greeting} {name}, bentornato.", + "hero": { + "scoreLabel": "Health Score", + "scoreProvisional": "Dati ancora insufficienti", + "doses": { + "summary": "{taken} dosi assunte su {scheduled}", + "nextAt": "Prossima alle {time}", + "allDone": "Tutte le dosi di oggi completate", + "none": "Nessuna dose in programma oggi" + }, + "verdict": { + "doseOverdue": "Una dose di {name} è in ritardo.", + "bpHigh": "L’ultima pressione era molto alta ({sys}/{dia}). Misurala di nuovo con calma.", + "doseUpcoming": "Alle {time} è prevista {name}.", + "weightDrift": "Questa settimana il tuo peso si sta allontanando dall’intervallo obiettivo.", + "shortNights": "Le tue notti sono state corte di recente — in media {hours} h.", + "silence": "Nessuna misurazione da {days} giorni. Una voce rapida tiene aggiornato il quadro.", + "scoreDrop": "Il tuo punteggio è {points} punti sotto la settimana scorsa.", + "allQuiet": "Tutto tranquillo — oggi non c’è nulla che richieda la tua attenzione." + }, + "action": { + "takeDose": "Registra ora", + "viewBp": "Vedi pressione", + "viewWeight": "Vedi peso", + "viewSleep": "Vedi sonno", + "logMeasurement": "Registra misurazione", + "viewInsights": "Apri Insights" + } + }, "greetingSalutation": "Ciao, {name}", "streakLabel": "giorni di fila", "weight": "Peso", @@ -323,6 +351,8 @@ "compliance7d": "Aderenza (7 giorni)", "mood": "Umore", "customizeTitle": "Layout della dashboard", + "heroToggleLabel": "Panoramica del giorno", + "heroToggleDescription": "Mostra punteggio, focus del giorno e stato delle dosi in cima alla dashboard.", "layoutReset": "Ripristina i valori predefiniti", "layoutSaveSuccess": "Layout della dashboard salvato", "layoutSaveError": "Impossibile salvare il layout", diff --git a/messages/pl.json b/messages/pl.json index 84f135b09..2c8b818c3 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -274,6 +274,34 @@ }, "welcomeBack": "{greeting}, witaj ponownie.", "welcomeBackWithName": "{greeting} {name}, witaj ponownie.", + "hero": { + "scoreLabel": "Health Score", + "scoreProvisional": "Za mało danych", + "doses": { + "summary": "Przyjęto {taken} z {scheduled} dawek", + "nextAt": "Następna o {time}", + "allDone": "Wszystkie dzisiejsze dawki przyjęte", + "none": "Brak zaplanowanych dawek na dziś" + }, + "verdict": { + "doseOverdue": "Dawka {name} jest zaległa.", + "bpHigh": "Twoje ostatnie ciśnienie było bardzo wysokie ({sys}/{dia}). Zmierz je ponownie w spokoju.", + "doseUpcoming": "O {time} pora na {name}.", + "weightDrift": "Twoja waga oddala się w tym tygodniu od zakresu docelowego.", + "shortNights": "Twoje noce były ostatnio krótkie — średnio {hours} h.", + "silence": "Brak pomiarów od {days} dni. Szybki wpis utrzyma aktualny obraz.", + "scoreDrop": "Twój wynik jest o {points} punktów niższy niż w zeszłym tygodniu.", + "allQuiet": "Spokojnie — dziś nic nie wymaga Twojej uwagi." + }, + "action": { + "takeDose": "Zapisz teraz", + "viewBp": "Zobacz ciśnienie", + "viewWeight": "Zobacz wagę", + "viewSleep": "Zobacz sen", + "logMeasurement": "Zapisz pomiar", + "viewInsights": "Otwórz Insights" + } + }, "greetingSalutation": "Cześć, {name}", "streakLabel": "dni z rzędu", "weight": "Waga", @@ -323,6 +351,8 @@ "compliance7d": "Przestrzeganie (7 dni)", "mood": "Nastrój", "customizeTitle": "Układ panelu", + "heroToggleLabel": "Przegląd dnia", + "heroToggleDescription": "Pokazuje wynik, fokus dnia i status dawek na górze pulpitu.", "layoutReset": "Przywróć wartości domyślne", "layoutSaveSuccess": "Układ panelu zapisany", "layoutSaveError": "Nie udało się zapisać układu", diff --git a/src/app/__tests__/dashboard-hero-wiring.test.ts b/src/app/__tests__/dashboard-hero-wiring.test.ts new file mode 100644 index 000000000..9b2cf45d5 --- /dev/null +++ b/src/app/__tests__/dashboard-hero-wiring.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** + * Dashboard hero — page + header wiring pins. + * + * The hero band mounts between the page header and the tile strip: + * + * - it gates on the layout's `heroVisible` flag (anything but the + * literal `false` renders — the resolver's clamp) AND on snapshot + * mode (the verdict needs the snapshot payload); + * - while `primaryLoading` is true (same `!mounted ||` pin the tile + * silhouettes use) a footprint-identical skeleton holds the band, + * so the hero and the tiles swap to content in the SAME render + * pass; + * - the greeting paragraph left the header for the hero. The header + * renders no name-bearing text at all any more, so the SSR pass + * stays name-free by construction (the hero itself only mounts + * once the snapshot resolved — post-hydration). + */ +const PAGE_PATH = join(process.cwd(), "src/app/page.tsx"); +const HEADER_PATH = join( + process.cwd(), + "src/components/dashboard/dashboard-header.tsx", +); + +describe("dashboard hero — page wiring", () => { + const src = readFileSync(PAGE_PATH, "utf8"); + + it("gates the hero on snapshot mode + the layout's heroVisible flag", () => { + expect(src).toMatch( + /const\s+heroVisible\s*=\s*snapshotEnabled\s*&&\s*layout\.heroVisible\s*!==\s*false;/, + ); + }); + + it("holds the skeleton while primaryLoading and swaps the hero in the same pass", () => { + const block = src.match( + /\{heroVisible\s*&&[\s\S]*?/, + ); + expect(block![0]).toContain("onQuickEntry={setQuickEntryDialog}"); + }); + + it("derives primaryLoading once in the component body (shared with the tile gate)", () => { + const matches = src.match(/const\s+primaryLoading\s*=\s*!mounted/g) ?? []; + expect(matches).toHaveLength(1); + }); + + it("the hero sits between the header and the strip", () => { + const headerIdx = src.indexOf(" { + expect(src).not.toContain("welcomeText"); + expect(src).not.toContain("welcomeBackWithName"); + expect(src).not.toContain("getHourForTimeZone"); + }); +}); + +describe("dashboard hero — header handoff", () => { + const header = readFileSync(HEADER_PATH, "utf8"); + + it("the header dropped the greeting paragraph and its reservation", () => { + expect(header).not.toContain("welcomeText"); + expect(header).not.toContain("min-h-5"); + }); + + it("the header keeps the title + customize + add actions", () => { + expect(header).toContain('t("dashboard.title")'); + expect(header).toContain("dashboard-customize-shortcut"); + expect(header).toContain("dashboard-quick-add"); + }); +}); diff --git a/src/app/__tests__/dashboard-suspense-boundaries.test.ts b/src/app/__tests__/dashboard-suspense-boundaries.test.ts index 9db552cbe..30c4dee8d 100644 --- a/src/app/__tests__/dashboard-suspense-boundaries.test.ts +++ b/src/app/__tests__/dashboard-suspense-boundaries.test.ts @@ -74,15 +74,14 @@ describe("v1.4.40 — dashboard per-cell Suspense boundaries", () => { expect(moduleSlice).toMatch(/const\s+DASHBOARD_QUERY_OPTS\s*=\s*\{/); }); - it("memoises the hour-of-day derivation against user.timezone (audit-H4)", () => { + it("keeps the per-render Intl hour walk off the page (audit-H4 successor)", () => { const src = load(PAGE_PATH); - // The greeting hour only changes when the user's timezone changes - // — a `useMemo` keyed on the lifted `userTimezone` local keeps the - // `Intl.DateTimeFormat` instantiation off the per-render hot path. - // Post-W-INFRA Thread 2: `user?.timezone` is lifted to a `userTimezone` - // local one line above the `useMemo` so the dep array stays stable. - expect(src).toMatch( - /const\s+hour\s*=\s*useMemo\([\s\S]*?\[\s*userTimezone\s*\][\s\S]*?\);/, - ); + // The audit-H4 `useMemo(getHourForTimeZone, [userTimezone])` pin + // retired with the greeting itself: the hero band derives the + // daypart from the snapshot's server-computed `greetingHour` + // (`dashboard-hero.tsx`), so the page no longer instantiates any + // `Intl.DateTimeFormat` for the greeting — per render or otherwise. + expect(src).not.toContain("getHourForTimeZone"); + expect(src).not.toContain("welcomeText"); }); }); diff --git a/src/app/page.tsx b/src/app/page.tsx index 0e665bbd1..16c68c38b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -36,12 +36,13 @@ import { useDashboardChartReveal, } from "@/components/dashboard/chart-reveal"; 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 { QuickEntrySheets, type QuickEntryDialog, } from "@/components/dashboard/quick-entry-sheets"; import { - getHourForTimeZone, getRangeColorClass, getRangeHint, toHoursSummary, @@ -684,42 +685,49 @@ export default function DashboardPage() { upperBound: 55, }, ); - // v1.4.40 W-RSC — memoise the per-render `Intl.DateTimeFormat` - // instantiation (audit-H4). The greeting derived from the hour only - // changes when the user's timezone changes; recomputing it every - // render churned the parts-array and walked the format ICU twice per - // render across a 1 400-line component body. - // Gated on `mounted`: `user` comes from the auth query, which can - // resolve before this boundary hydrates — the greeting text must - // match the name-less SSR output during hydration (React #418) and - // may only personalise from the first client re-render. - const userTimezone = mounted ? user?.timezone : undefined; - const hour = useMemo( - () => (userTimezone ? getHourForTimeZone(userTimezone) : null), - [userTimezone], - ); - const timeGreeting = - hour == null - ? t("dashboard.greeting.day") - : hour >= 5 && hour < 12 - ? t("dashboard.greeting.morning") - : hour >= 12 && hour < 18 - ? t("dashboard.greeting.day") - : t("dashboard.greeting.evening"); - const welcomeText = - mounted && user?.username && user.username.trim().length > 0 - ? t("dashboard.welcomeBackWithName", { - greeting: timeGreeting, - name: user.username, - }) - : t("dashboard.welcomeBack", { greeting: timeGreeting }); + // v1.7.0 — the primary data source differs by flag. In snapshot + // mode `analyticsSlimQuery` is `enabled: false`, and a disabled + // TanStack query reports `fetchStatus: "idle"` → `isLoading` is + // always `false`. Gating the skeleton on the slim query then + // never fires under snapshot mode, so the empty-state branch + // below flashes for the whole snapshot fetch. Key the loading + // flag off whichever query is actually driving the tiles. + // v1.16.4 — `!mounted` pins the SSR pass AND the hydration + // render to the skeleton branch. Server-side the data queries + // are `enabled: false` (no auth) → `isLoading: false`, which + // used to select the empty state; a late-hydrating boundary + // then saw `isAuthenticated` already resolved and rendered the + // skeleton instead — React #418. See `useMounted`. + // v1.16.9 — hoisted out of the strip IIFE: the hero band below + // shares the same gate so its skeleton and the tile silhouettes + // swap to content in the same render pass. + const primaryLoading = + !mounted || + (snapshotEnabled ? snapshotQuery.isLoading : analyticsSlimQuery.isLoading); + + // v1.16.9 — the greeting paragraph moved off the header into the + // hero band (it derives from the snapshot's server-computed + // `greetingHour`, so the old client-side Intl hour walk retired + // with it). The hero gates on the layout's `heroVisible` flag + // (default on; anything but the literal `false` renders) and holds + // a footprint-identical skeleton while the snapshot is in flight, + // so the band swaps in place — no growth, no greeting flash. + const heroVisible = snapshotEnabled && layout.heroVisible !== false; + const heroSnapshot = snapshotQuery.data; return (
- + + + {heroVisible && + (primaryLoading || !heroSnapshot ? ( + + ) : ( + + ))} {/* v1.4.15 Phase B5 — spotlight tour for first-time users. * Self-gates on `user.onboardingTourCompleted` (DB flag) plus @@ -1403,24 +1411,9 @@ export default function DashboardPage() { // strip tile) and undercounted `bp` (one widget id, two tiles), // so the silhouettes reshuffled when the data landed. const configuredTileCount = resolveConfiguredTileCount(layout); - // v1.7.0 — the primary data source differs by flag. In snapshot - // mode `analyticsSlimQuery` is `enabled: false`, and a disabled - // TanStack query reports `fetchStatus: "idle"` → `isLoading` is - // always `false`. Gating the skeleton on the slim query then - // never fires under snapshot mode, so the empty-state branch - // below flashes for the whole snapshot fetch. Key the loading - // flag off whichever query is actually driving the tiles. - // v1.16.4 — `!mounted` pins the SSR pass AND the hydration - // render to the skeleton branch. Server-side the data queries - // are `enabled: false` (no auth) → `isLoading: false`, which - // used to select the empty state; a late-hydrating boundary - // then saw `isAuthenticated` already resolved and rendered the - // skeleton instead — React #418. See `useMounted`. - const primaryLoading = - !mounted || - (snapshotEnabled - ? snapshotQuery.isLoading - : analyticsSlimQuery.isLoading); + // `primaryLoading` is derived once in the component body (above + // the return) — the hero band and this strip share the same + // gate so both surfaces swap in the same render pass. // v1.16.8 — chart-row reservation while the snapshot is in // flight. The chart visibility gates above need snapshot data // (`count > 0`), so the cold chart row only carried the diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx index a8e7cb5c1..79b0a9589 100644 --- a/src/components/dashboard/dashboard-header.tsx +++ b/src/components/dashboard/dashboard-header.tsx @@ -1,8 +1,10 @@ "use client"; /** - * Dashboard page header: title + greeting line, the customize shortcut, - * and the quick-add dropdown that opens the quick-entry sheets. + * Dashboard page header: title, the customize shortcut, and the + * quick-add dropdown that opens the quick-entry sheets. The greeting + * line moved into the hero band (``), which derives it + * from the snapshot's server-computed `greetingHour`. * * Extracted from the dashboard page; the page owns the open-state the * dropdown items set via `onQuickEntry`. @@ -20,35 +22,16 @@ import { useTranslations } from "@/lib/i18n/context"; import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets"; export function DashboardHeader({ - welcomeText, onQuickEntry, }: { - welcomeText: string; onQuickEntry: (dialog: NonNullable) => void; }) { const { t } = useTranslations(); return ( - /* v1.4.37 W4a item 7 — centre-align the Hinzufügen button - against the 2-line title block on mobile (< sm). The - `welcomeText` line wraps under the title at < 380 px so the - button used to float at the top of the row without a - baseline anchor; `items-center sm:items-start` keeps the - mobile vertical centre while preserving the original - top-aligned posture on sm+ (where the title is one line). */ -
-
-

- {t("dashboard.title")} -

- {/* v1.16.8 — `min-h-5` reserves the greeting's line box from the - SSR pass on: the text personalises (name appended) on the - first client re-render after hydration, and the reserved - line keeps the header from collapsing/growing around that - swap. */} -

- {welcomeText} -

-
+
+

+ {t("dashboard.title")} +

{/* Customize shortcut to the dashboard-customization settings (the tile/layout editor at /settings/dashboard). Sits to diff --git a/src/components/dashboard/hero/__tests__/dashboard-hero-skeleton.test.tsx b/src/components/dashboard/hero/__tests__/dashboard-hero-skeleton.test.tsx new file mode 100644 index 000000000..0084eb4c6 --- /dev/null +++ b/src/components/dashboard/hero/__tests__/dashboard-hero-skeleton.test.tsx @@ -0,0 +1,48 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { DashboardHeroSkeleton } from "../dashboard-hero-skeleton"; + +/** + * Structural pin for the hero loading silhouette: the skeleton must + * occupy the EXACT final footprint of `` (same min-h + * floors, same md two-column split, same fixed 120 px ring circle) so + * the loaded band swaps in place with zero layout shift — and it must + * stay invisible to assistive tech with no focusable content. + */ +describe("", () => { + const html = renderToStaticMarkup(); + + it("carries the data-slot and aria-hidden contract", () => { + expect(html).toContain('data-slot="dashboard-hero-skeleton"'); + const root = html.match( + /]*data-slot="dashboard-hero-skeleton"[^>]*>/, + ); + expect(root).not.toBeNull(); + expect(root![0]).toContain('aria-hidden="true"'); + }); + + it("reserves the hero's exact footprint classes", () => { + for (const cls of [ + "min-h-[8.75rem]", + "md:min-h-[9.5rem]", + "hero-gradient", + "rounded-xl", + ]) { + expect(html).toContain(cls); + } + }); + + it("mirrors the two-column md:flex-row split with the fixed ring circle", () => { + expect(html).toContain("md:flex-row"); + expect(html).toContain("size-[120px]"); + expect(html).toContain("rounded-full"); + }); + + it("contains no focusable elements", () => { + expect(html).not.toContain("` contract: + * + * - every verdict variant renders its sentence + the single CTA the + * ladder assigns (link variants carry an href, quick-entry variants + * a button; allQuiet carries none); + * - the briefing rung renders the model headline VERBATIM as plain + * text (no i18n key); + * - the dose row prints the day tally with next-at / all-done / none + * detail, and the documented stale-cache state (past `nextDueAt`, + * `nextDueOverdue: false`) renders as the plain summary — never as + * overdue; + * - the greeting derives from the snapshot's server-computed + * `greetingHour` and personalises from the snapshot username; + * - the right column always paints the `sm` ScoreRing — provisional + * (never "0") when the score is null. + * + * The component reads the wall clock (`new Date()`) to age the cached + * snapshot honestly, so the suite pins the system time; the snapshot's + * own timezone (`Europe/Berlin`) keeps every assertion independent of + * the host zone. + */ + +const NOW = new Date("2026-06-10T10:00:00.000Z"); // 12:00 Europe/Berlin + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +function summary(partial: Partial): DataSummary { + return { + count: 0, + latest: null, + min: null, + max: null, + mean: null, + median: null, + avg7: null, + avg30: null, + slope7: null, + slope30: null, + slope90: null, + anomalyCount: 0, + ...partial, + }; +} + +function medsToday(partial: Partial = {}): MedsTodayBlock { + return { + activeCount: 0, + scheduledToday: 0, + takenToday: 0, + skippedToday: 0, + nextDueAt: null, + nextDueOverdue: false, + nextDueMedicationName: null, + ...partial, + }; +} + +function baseSnapshot( + overrides: Partial = {}, +): DashboardSnapshot { + return { + user: { + username: "tester", + timezone: "Europe/Berlin", + heightCm: null, + dateOfBirth: null, + gender: null, + glucoseUnit: null, + onboardingTourCompleted: true, + greetingHour: 12, + }, + layout: { version: 1, widgets: [] }, + layoutCatalogue: [], + metricStates: {}, + tiles: { + summaries: {}, + lastSeenByType: {}, + mood: { summary: null, entries: [] }, + }, + extras: null, + medsToday: medsToday(), + healthScore: null, + briefing: null, + briefingState: "preparing", + briefingUpdatedAt: null, + briefingStale: false, + generatedAt: NOW.toISOString(), + ...overrides, + }; +} + +function isoHoursFromNow(hours: number): string { + return new Date(NOW.getTime() + hours * 60 * 60 * 1000).toISOString(); +} + +function render( + snapshot: DashboardSnapshot, + opts: { locale?: "en" | "de"; onQuickEntry?: () => void } = {}, +) { + return renderToStaticMarkup( + + undefined)} + /> + , + ); +} + +describe(" — verdict variants", () => { + it("doseOverdue: sentence with the medication name + quick-entry CTA button", () => { + const html = render( + baseSnapshot({ + medsToday: medsToday({ + scheduledToday: 2, + nextDueAt: isoHoursFromNow(-1), + nextDueOverdue: true, + nextDueMedicationName: "Metformin", + }), + }), + ); + expect(html).toContain('data-verdict-variant="doseOverdue"'); + expect(html).toContain("Eine Dosis Metformin ist überfällig."); + expect(html).toContain('data-slot="dashboard-hero-cta"'); + expect(html).toContain("Jetzt eintragen"); + // Quick-entry CTA is a real button, not a link. + expect(html).toMatch(/]*data-slot="dashboard-hero-cta"/); + }); + + it("bpCritical: fixed-floor sentence with the reading + link to the BP insight", () => { + const html = render( + baseSnapshot({ + tiles: { + summaries: { + BLOOD_PRESSURE_SYS: summary({ latest: 184, count: 5 }), + BLOOD_PRESSURE_DIA: summary({ latest: 96, count: 5 }), + }, + lastSeenByType: { + BLOOD_PRESSURE_SYS: { + lastSeenAt: isoHoursFromNow(-2), + daysAgo: 0, + }, + }, + mood: { summary: null, entries: [] }, + }, + }), + ); + expect(html).toContain('data-verdict-variant="bpCritical"'); + expect(html).toContain("sehr hoch (184/96)"); + expect(html).toContain('href="/insights/blood-pressure"'); + expect(html).toContain("Blutdruck ansehen"); + }); + + it("doseUpcoming: preference-aware wall-clock time + name + quick-entry CTA", () => { + const nextDueAt = isoHoursFromNow(1.5); // 13:30 Berlin + const html = render( + baseSnapshot({ + medsToday: medsToday({ + scheduledToday: 2, + takenToday: 1, + nextDueAt, + nextDueMedicationName: "Jardiance", + }), + }), + ); + const expectedTime = makeFormatters("de", "Europe/Berlin", "AUTO").time( + nextDueAt, + ); + expect(html).toContain('data-verdict-variant="doseUpcoming"'); + expect(html).toContain(`Um ${expectedTime} steht Jardiance an.`); + expect(html).toContain("Jetzt eintragen"); + // The dose row repeats the same instant as "Nächste um …". + expect(html).toContain(`Nächste um ${expectedTime}`); + }); + + it("weightDrift: sentence + link to the weight insight", () => { + const html = render( + baseSnapshot({ + user: { ...baseSnapshot().user, heightCm: 180 }, + tiles: { + summaries: { + // Green range for 180 cm ≈ 59.9–80.7 kg; 7d mean sits 1 kg + // further outside than the 30d mean. + WEIGHT: summary({ avg7: 86, avg30: 85, count: 10 }), + }, + lastSeenByType: { + WEIGHT: { lastSeenAt: isoHoursFromNow(-3), daysAgo: 0 }, + }, + mood: { summary: null, entries: [] }, + }, + }), + ); + expect(html).toContain('data-verdict-variant="weightDrift"'); + expect(html).toContain( + "Dein Gewicht bewegt sich diese Woche vom Zielbereich weg.", + ); + expect(html).toContain('href="/insights/weight"'); + expect(html).toContain("Gewicht ansehen"); + }); + + it("shortNights: locale-formatted hours + link to the sleep insight", () => { + const html = render( + baseSnapshot({ + tiles: { + summaries: { + // 372 min = 6.2 h — clearly under the 6.5 h fire line. + SLEEP_DURATION: summary({ avg7: 372, count: 7 }), + }, + lastSeenByType: { + SLEEP_DURATION: { lastSeenAt: isoHoursFromNow(-5), daysAgo: 0 }, + }, + mood: { summary: null, entries: [] }, + }, + }), + ); + expect(html).toContain('data-verdict-variant="shortNights"'); + // German decimal comma via the locale number formatter. + expect(html).toContain("im Schnitt 6,2 h."); + expect(html).toContain('href="/insights/sleep"'); + expect(html).toContain("Schlaf ansehen"); + }); + + it("silence: day count + quick-entry measurement CTA", () => { + const html = render( + baseSnapshot({ + tiles: { + summaries: {}, + lastSeenByType: { + WEIGHT: { lastSeenAt: isoHoursFromNow(-9 * 24), daysAgo: 9 }, + }, + mood: { summary: null, entries: [] }, + }, + }), + ); + expect(html).toContain('data-verdict-variant="silence"'); + expect(html).toContain("Seit 9 Tagen keine Messung."); + expect(html).toContain("Messung erfassen"); + expect(html).toMatch(/]*data-slot="dashboard-hero-cta"/); + }); + + it("scoreDrop: point delta + link to /insights", () => { + const html = render( + baseSnapshot({ + healthScore: { score: 58, band: "yellow", delta: -14 }, + }), + ); + expect(html).toContain('data-verdict-variant="scoreDrop"'); + expect(html).toContain("liegt 14 Punkte unter der Vorwoche."); + expect(html).toContain('href="/insights"'); + expect(html).toContain("Insights öffnen"); + }); + + it("briefing: renders the model headline verbatim as plain text + Insights link", () => { + const headline = "Systolic crept up over seven days."; + const html = render( + baseSnapshot({ + briefingState: "ready", + briefingStale: false, + briefingUpdatedAt: NOW.toISOString(), + briefing: { + paragraph: "p", + keyFindings: [ + { + tone: "watch", + headline, + detail: "detail", + delta: null, + sourceWindow: "7d", + sourceMetric: "bp", + }, + ], + } as DashboardSnapshot["briefing"], + }), + ); + expect(html).toContain('data-verdict-variant="briefing"'); + expect(html).toContain(headline); + expect(html).toContain('href="/insights"'); + expect(html).toContain("Insights öffnen"); + }); + + it("allQuiet: sentence with NO CTA in the band", () => { + const html = render(baseSnapshot()); + expect(html).toContain('data-verdict-variant="allQuiet"'); + expect(html).toContain("Alles ruhig"); + expect(html).not.toContain('data-slot="dashboard-hero-cta"'); + }); + + it("exactly one CTA renders for an actionable verdict", () => { + const html = render( + baseSnapshot({ + medsToday: medsToday({ + scheduledToday: 1, + nextDueOverdue: true, + nextDueAt: isoHoursFromNow(-1), + nextDueMedicationName: "Metformin", + }), + healthScore: { score: 50, band: "yellow", delta: -20 }, + }), + ); + const ctas = html.match(/data-slot="dashboard-hero-cta"/g) ?? []; + expect(ctas).toHaveLength(1); + }); +}); + +describe(" — dose row", () => { + it("prints the day tally with the v1.16.6 card chrome and the Pill icon", () => { + const html = render( + baseSnapshot({ + medsToday: medsToday({ scheduledToday: 3, takenToday: 1 }), + }), + ); + expect(html).toContain("1 von 3 Dosen genommen"); + const doseRow = html.match( + /]*data-slot="dashboard-hero-doses"[^>]*>/, + ); + expect(doseRow).not.toBeNull(); + for (const cls of [ + "bg-card/65", + "border-border/60", + "rounded-xl", + "shadow-sm", + "backdrop-blur-sm", + ]) { + expect(doseRow![0]).toContain(cls); + } + }); + + it("all doses resolved (taken + skipped) → 'Alle Dosen für heute erledigt'", () => { + const html = render( + baseSnapshot({ + medsToday: medsToday({ + scheduledToday: 2, + takenToday: 1, + skippedToday: 1, + }), + }), + ); + expect(html).toContain("1 von 2 Dosen genommen"); + expect(html).toContain("Alle Dosen für heute erledigt"); + expect(html).not.toContain("Nächste um"); + }); + + it("no doses scheduled → 'Heute keine Dosen geplant'", () => { + const html = render(baseSnapshot()); + expect(html).toContain("Heute keine Dosen geplant"); + expect(html).not.toContain("Dosen genommen"); + }); + + it("DEFENSIVE: a stale past nextDueAt with nextDueOverdue false renders the plain summary, never overdue", () => { + const html = render( + baseSnapshot({ + medsToday: medsToday({ + scheduledToday: 2, + takenToday: 1, + nextDueAt: isoHoursFromNow(-0.5), // anchor passed AFTER build + nextDueOverdue: false, + nextDueMedicationName: "Metformin", + }), + }), + ); + expect(html).toContain("1 von 2 Dosen genommen"); + expect(html).not.toContain("Nächste um"); + expect(html).not.toContain("überfällig"); + expect(html).toContain('data-verdict-variant="allQuiet"'); + }); + + it("a next-due anchor on TOMORROW's local day stays off the dose row", () => { + const html = render( + baseSnapshot({ + medsToday: medsToday({ + scheduledToday: 2, + takenToday: 1, + nextDueAt: isoHoursFromNow(20), // 08:00 Berlin tomorrow + nextDueMedicationName: "Metformin", + }), + }), + ); + expect(html).toContain("1 von 2 Dosen genommen"); + expect(html).not.toContain("Nächste um"); + }); +}); + +describe(" — greeting", () => { + it("personalises from the snapshot username with the server greeting hour", () => { + const html = render( + baseSnapshot({ + user: { ...baseSnapshot().user, username: "Sam", greetingHour: 8 }, + }), + ); + expect(html).toContain("Guten Morgen Sam, willkommen zurück."); + }); + + it("falls back to the name-less line when the username is blank", () => { + const html = render( + baseSnapshot({ + user: { ...baseSnapshot().user, username: " ", greetingHour: 20 }, + }), + ); + expect(html).toContain("Guten Abend, willkommen zurück."); + }); + + it("derives the daypart from greetingHour without any client Intl walk", () => { + const day = render( + baseSnapshot({ + user: { ...baseSnapshot().user, greetingHour: 13 }, + }), + ); + expect(day).toContain("Hallo tester, willkommen zurück."); + }); +}); + +describe(" — score column", () => { + it("renders the sm ScoreRing with the score + band", () => { + const html = render( + baseSnapshot({ + healthScore: { score: 82, band: "green", delta: 2 }, + }), + ); + expect(html).toContain('data-slot="score-ring"'); + expect(html).toContain('data-band="green"'); + expect(html).toContain("Health Score"); + }); + + it("null score renders the provisional ring (not 0) at identical geometry", () => { + const html = render(baseSnapshot({ healthScore: null })); + expect(html).toContain('data-slot="score-ring"'); + expect(html).toContain('data-provisional="true"'); + expect(html).toContain("Noch nicht genug Daten"); + // The ring announces "not enough data", never a zero value. + const aria = html.match( + /]*data-slot="score-ring"[^>]*aria-label="([^"]+)"/, + ); + expect(aria).not.toBeNull(); + expect(aria![1]).not.toMatch(/\b0\b/); + // Same fixed footprint as the scored state — the column never + // collapses (the 120 px sm geometry rides an inline style). + expect(html).toContain("width:120px"); + }); +}); + +describe(" — source pins", () => { + const src = readFileSync( + join(process.cwd(), "src/components/dashboard/hero/dashboard-hero.tsx"), + "utf8", + ); + + it("quick-entry CTA wires the verdict's target into onQuickEntry", () => { + expect(src).toMatch(/onClick=\{\(\) => onQuickEntry\(cta\.target\)\}/); + }); + + it("re-derives the verdict with a fresh now per snapshot", () => { + expect(src).toMatch(/resolveDashboardVerdict\(snapshot,\s*new Date\(\)\)/); + }); + + it("uses the hero-gradient chrome WITHOUT glow-purple", () => { + expect(src).toContain("hero-gradient"); + expect(src).not.toContain("glow-purple"); + }); +}); diff --git a/src/components/dashboard/hero/dashboard-hero-skeleton.tsx b/src/components/dashboard/hero/dashboard-hero-skeleton.tsx new file mode 100644 index 000000000..3b85873cc --- /dev/null +++ b/src/components/dashboard/hero/dashboard-hero-skeleton.tsx @@ -0,0 +1,45 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +/** + * Loading silhouette for the dashboard hero band. + * + * Mirrors the real `` footprint exactly — same + * `.hero-gradient` chrome, same `min-h-[8.75rem] md:min-h-[9.5rem]` + * floor, same two-column `md:flex-row` split with the fixed 120 px ring + * circle on the right — so the swap to the loaded hero happens in place + * with zero layout shift. The left column reserves the greeting line, + * the verdict sentence + CTA row, and the dose-row pill. + * + * Always `aria-hidden` with no focusable content: the tile-strip + * skeleton alongside carries the page's loading semantics, and a second + * announcement here would double up for screen readers. Reduced motion + * is honoured inside the `` primitive + * (`motion-reduce:animate-none`). + */ +export function DashboardHeroSkeleton() { + return ( + + ); +} diff --git a/src/components/dashboard/hero/dashboard-hero.tsx b/src/components/dashboard/hero/dashboard-hero.tsx new file mode 100644 index 000000000..a746951d2 --- /dev/null +++ b/src/components/dashboard/hero/dashboard-hero.tsx @@ -0,0 +1,274 @@ +"use client"; + +/** + * Dashboard hero — the daily-verdict band between the page header and + * the tile strip. + * + * Purely presentational: every input rides in on the already-resolved + * `DashboardSnapshot`, the verdict is re-derived on render with a fresh + * `now` (so a cached snapshot ages honestly), and the only callback is + * the quick-entry opener the page owns. Three blocks: + * + * - left column: greeting line (absorbed from the old header + * paragraph — the snapshot's server-computed `greetingHour` keeps + * the daypart free of any client `Intl` work), the verdict sentence + * with its single CTA button, and the dose row; + * - right column: the shared `` at `sm` geometry. A null + * score renders the ring's provisional state at the identical + * 120 px footprint, so the column never collapses. + * + * Defensive contract (cached snapshot): `medsToday.nextDueAt` in the + * past with `nextDueOverdue: false` means the slot's anchor passed + * after the snapshot was built. The verdict resolver already falls + * through (rung 1 keys on the flag, rung 3 requires a future anchor), + * and the dose row below only prints "next at" for a FUTURE anchor on + * the user's local today — the stale state renders as the plain day + * summary, never as overdue. + * + * Rung 8 (`briefing`) renders the model-authored headline VERBATIM as a + * plain React text child — no i18n key, no HTML, no markdown — per the + * repo-wide no-markdown-renderer rule. + * + * The page h1 stays in ``; this band is supporting + * content, so it renders non-heading text only (matching the Insights + * hero strip, which also carries no heading element). + */ +import { useMemo } from "react"; +import Link from "next/link"; +import { Pill } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { ScoreRing } from "@/components/insights/derived/score-ring"; +import { + resolveDashboardVerdict, + type DashboardVerdictVariant, +} from "@/lib/dashboard/verdict"; +import type { DashboardSnapshot } from "@/lib/dashboard/snapshot"; +import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets"; +import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context"; +import { makeFormatters } from "@/lib/format-locale"; +import { cn } from "@/lib/utils"; + +/** + * Variant → message key. `briefing` is deliberately absent — its + * sentence is the model headline rendered verbatim, not a translation. + * The bpCritical rung surfaces under the `bpHigh` copy key. + */ +const VERDICT_MESSAGE_KEY: Record< + Exclude, + string +> = { + doseOverdue: "dashboard.hero.verdict.doseOverdue", + bpCritical: "dashboard.hero.verdict.bpHigh", + doseUpcoming: "dashboard.hero.verdict.doseUpcoming", + weightDrift: "dashboard.hero.verdict.weightDrift", + shortNights: "dashboard.hero.verdict.shortNights", + silence: "dashboard.hero.verdict.silence", + scoreDrop: "dashboard.hero.verdict.scoreDrop", + allQuiet: "dashboard.hero.verdict.allQuiet", +}; + +/** Variant → CTA label key. `allQuiet` carries no CTA (cta: null). */ +const CTA_LABEL_KEY: Partial> = { + doseOverdue: "dashboard.hero.action.takeDose", + doseUpcoming: "dashboard.hero.action.takeDose", + bpCritical: "dashboard.hero.action.viewBp", + weightDrift: "dashboard.hero.action.viewWeight", + shortNights: "dashboard.hero.action.viewSleep", + silence: "dashboard.hero.action.logMeasurement", + scoreDrop: "dashboard.hero.action.viewInsights", + briefing: "dashboard.hero.action.viewInsights", +}; + +/** YYYY-MM-DD key of `d`'s calendar day in `tz` (mirrors the resolver). */ +function dayKeyInTz(d: Date, tz: string): string { + return new Intl.DateTimeFormat("en-CA", { + timeZone: tz, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(d); +} + +export function DashboardHero({ + snapshot, + onQuickEntry, +}: { + snapshot: DashboardSnapshot; + onQuickEntry: (dialog: NonNullable) => void; +}) { + const { t, locale } = useTranslations(); + const timeFormat = useTimeFormatPreference(); + // Times format in the USER's timezone from the snapshot (the shared + // `useFormatters()` hook falls back to the display default), honouring + // the per-user hour-cycle preference like the medication cards do. + // Only ever invoked post-snapshot, so no pre-paint Intl work. + const fmt = useMemo( + () => makeFormatters(locale, snapshot.user.timezone, timeFormat), + [locale, snapshot.user.timezone, timeFormat], + ); + + // Fresh `now` per snapshot evaluation: the resolver re-derives every + // freshness window against the current instant, so a snapshot served + // from cache ages honestly between refetches. + const verdict = useMemo( + () => resolveDashboardVerdict(snapshot, new Date()), + [snapshot], + ); + + // ── Greeting — absorbed from the old header paragraph ────────────── + // The server-computed wall-clock hour keeps the daypart stable and + // Intl-free; the hero only mounts once the snapshot resolved (the + // skeleton carries the footprint before that), so the personalised + // line can never disagree with SSR output. + const hour = snapshot.user.greetingHour; + const timeGreeting = + hour >= 5 && hour < 12 + ? t("dashboard.greeting.morning") + : hour >= 12 && hour < 18 + ? t("dashboard.greeting.day") + : t("dashboard.greeting.evening"); + const name = snapshot.user.username.trim(); + const welcomeText = + name.length > 0 + ? t("dashboard.welcomeBackWithName", { greeting: timeGreeting, name }) + : t("dashboard.welcomeBack", { greeting: timeGreeting }); + + // ── Verdict sentence + CTA ────────────────────────────────────────── + const sentence = + verdict.variant === "briefing" + ? // Model headline VERBATIM as a plain text child — no key. + String(verdict.values.headline ?? "") + : t(VERDICT_MESSAGE_KEY[verdict.variant], { + ...verdict.values, + // The resolver emits a fixed 24 h wall-clock string; re-format + // the underlying instant through the user's hour-cycle + // preference when we still hold it. + ...(verdict.variant === "doseUpcoming" && + snapshot.medsToday.nextDueAt !== null + ? { time: fmt.time(snapshot.medsToday.nextDueAt) } + : {}), + ...(verdict.variant === "shortNights" && + typeof verdict.values.hours === "number" + ? { hours: fmt.number(verdict.values.hours, 1) } + : {}), + }); + const cta = verdict.cta; + const ctaLabelKey = CTA_LABEL_KEY[verdict.variant]; + + // ── Dose row ──────────────────────────────────────────────────────── + const meds = snapshot.medsToday; + const now = new Date(); + const tz = snapshot.user.timezone; + const unresolvedRemain = + meds.scheduledToday > meds.takenToday + meds.skippedToday; + const nextDueMs = meds.nextDueAt !== null ? Date.parse(meds.nextDueAt) : NaN; + // "Next at" only for a FUTURE anchor on the user's local today. A past + // anchor with `nextDueOverdue: false` is the documented stale-cache + // state and must render as the plain summary — never as overdue. + const showNextAt = + unresolvedRemain && + Number.isFinite(nextDueMs) && + nextDueMs >= now.getTime() && + dayKeyInTz(new Date(nextDueMs), tz) === dayKeyInTz(now, tz); + const doseDetail = + meds.scheduledToday > 0 + ? !unresolvedRemain + ? t("dashboard.hero.doses.allDone") + : showNextAt + ? t("dashboard.hero.doses.nextAt", { + time: fmt.time(new Date(nextDueMs)), + }) + : null + : null; + + return ( +
+
+
+

+ {welcomeText} +

+
+

+ {sentence} +

+ {cta !== null && ctaLabelKey ? ( + cta.kind === "link" ? ( + + ) : ( + + ) + ) : null} +
+
+
+
+ {/* Right column — fixed 120 px ring footprint. A null score + renders the ring's provisional state (aria announces "not + enough data", never 0) at identical geometry, so the column + NEVER collapses. */} +
+ +
+
+
+ ); +} diff --git a/src/components/settings/__tests__/dashboard-layout-section.test.tsx b/src/components/settings/__tests__/dashboard-layout-section.test.tsx index d6d740bde..71121b0b2 100644 --- a/src/components/settings/__tests__/dashboard-layout-section.test.tsx +++ b/src/components/settings/__tests__/dashboard-layout-section.test.tsx @@ -1,5 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { DEFAULT_DASHBOARD_LAYOUT, @@ -269,6 +271,76 @@ describe(" — iOS-only id skip (v1.7.0)", () => { }); }); +/** + * Dashboard hero (Tagesüberblick) visibility switch — one toggle above + * the widget list, persisted as `heroVisible` on the layout blob through + * the SAME PUT mutation the widget rows use (the Save button flushes the + * draft). SSR smoke assertions + source pins, matching the rest of this + * suite (no DOM runtime for state mutation). + */ +describe(" — hero visibility switch", () => { + it("renders one hero switch above the widget list", () => { + const html = render(); + const switches = html.match(/data-slot="hero-visible-switch"/g) ?? []; + expect(switches).toHaveLength(1); + // Above the list: the hero switch markup precedes the first widget row. + expect(html.indexOf('data-slot="hero-visible-switch"')).toBeLessThan( + html.indexOf('data-slot="widget-row"'), + ); + }); + + it("labels the switch with the localised copy + description", () => { + const de = render(, "de"); + expect(de).toContain("Tagesüberblick"); + expect(de).toContain( + "Zeigt Score, Tagesfokus und Dosen-Status ganz oben auf dem Dashboard.", + ); + const en = render(); + expect(en).toContain("Daily overview"); + }); + + it("reflects heroVisible: default layout → checked, false → unchecked", () => { + const checkedHtml = render( + , + ); + const checked = checkedHtml.match( + /]*data-slot="hero-visible-switch"[^>]*>/, + ); + expect(checked).not.toBeNull(); + expect(checked![0]).toContain('data-state="checked"'); + + queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: false }; + const uncheckedHtml = render( + , + ); + const unchecked = uncheckedHtml.match( + /]*data-slot="hero-visible-switch"[^>]*>/, + ); + expect(unchecked).not.toBeNull(); + expect(unchecked![0]).toContain('data-state="unchecked"'); + }); + + it("persists through the existing PUT draft flow (source pins)", () => { + const src = readFileSync( + join( + process.cwd(), + "src/components/settings/dashboard-layout-section.tsx", + ), + "utf8", + ); + // The toggle writes the flag onto the layout draft… + expect(src).toMatch( + /setDraft\(\{\s*\.\.\.layout,\s*heroVisible:\s*value\s*\}\)/, + ); + // …which the Save button flushes via the one save mutation + // (`apiPut("/api/dashboard/widgets", …)`) every other control uses. + expect(src).toMatch(/onCheckedChange=\{\(v\) => setHeroVisible\(v\)\}/); + expect(src).toMatch( + /apiPut\("\/api\/dashboard\/widgets", next\)/, + ); + }); +}); + /** * v1.11.2 HIGH-1 — the 8 B5 ids are WRITABLE (in DASHBOARD_WIDGET_IDS so the * iOS pin PUT validates them) but have NO web render path, so the web diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index 304cbcfe0..0699eb892 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -153,6 +153,8 @@ export function DashboardLayoutSection({ id }: { id: string }) { // tooltip. One hint paragraph is rendered once at the bottom of the list // and referenced by every drag handle in this section. const dragHintId = useId(); + // Label ↔ switch hookup for the hero (Tagesüberblick) toggle below. + const heroToggleId = useId(); // v1.4.47 W4 — sensors: pointer for mouse/touch, keyboard for Tab + Space // + arrow-key reordering. The KeyboardSensor still works for users who @@ -243,6 +245,16 @@ export function DashboardLayoutSection({ id }: { id: string }) { setDraft({ ...layout, comparisonBaseline: value }); } + /** + * Dashboard hero (daily verdict) visibility. Rides the layout blob + * (`heroVisible`, default on) and persists through the same PUT the + * widget toggles use — the Save button flushes it. + */ + function setHeroVisible(value: boolean) { + if (!layout) return; + setDraft({ ...layout, heroVisible: value }); + } + /** * v1.4.48 M6a — the arrow buttons now delegate to `reorderWidgets()` * so the swap-and-renumber logic lives in exactly one place. The @@ -377,6 +389,33 @@ export function DashboardLayoutSection({ id }: { id: string }) {
)} + {/* Hero (Tagesüberblick) visibility — one switch above the widget + list because the band sits above every widget on the dashboard. + Persists as `heroVisible` on the layout blob through the same + PUT mutation the rows below use (Save flushes the draft). */} + {layout && ( +
+
+ +

+ {t("dashboard.heroToggleDescription")} +

+
+ setHeroVisible(v)} + disabled={saveMutation.isPending} + data-slot="hero-visible-switch" + /> +
+ )} + {isLoading || !layout ? (
diff --git a/src/lib/dashboard/__tests__/verdict-client-safety.test.ts b/src/lib/dashboard/__tests__/verdict-client-safety.test.ts new file mode 100644 index 000000000..199f80bb8 --- /dev/null +++ b/src/lib/dashboard/__tests__/verdict-client-safety.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync, existsSync, statSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; + +/** + * Client-safety pin for the verdict resolver's import graph. + * + * `resolveDashboardVerdict` runs inside the `"use client"` dashboard + * hero, so every VALUE import reachable from `verdict.ts` must stay off + * the server graph: no prisma, no `node:` builtins, no pg-boss, no + * `@/lib/jobs/coach-nudge` (whose dispatcher/feature-flag imports pull + * `node:async_hooks` — the exact regression class the dose-window + * editor hit when it imported `DOSE_WINDOW_DEFAULTS` from + * `@/lib/analytics/compliance`; see + * `src/lib/medications/scheduling/dose-window-defaults.ts`). The shared + * nudge thresholds therefore live in the dependency-free + * `@/lib/jobs/coach-nudge-thresholds` leaf. + * + * `import type` statements are erased by the compiler AND the bundler, + * so the walker skips them — the `DashboardSnapshot` type import from + * `@/lib/dashboard/snapshot` (a heavy server module) is legal exactly + * as long as it stays type-only. If someone drops the `type` keyword, + * the walker traverses into the snapshot builder and this suite fails. + */ + +const SRC = join(process.cwd(), "src"); +const ENTRY = join(SRC, "lib/dashboard/verdict.ts"); + +/** Specifier patterns that mark a server-only dependency. */ +const BANNED_SPECIFIERS: RegExp[] = [ + /^node:/, + /^@\/generated\//, + /^@\/lib\/db$/, + /^@prisma\//, + /^pg-boss$/, + /^server-only$/, + /^@\/lib\/jobs\/coach-nudge$/, +]; + +/** + * Extract VALUE-import specifiers from a module. Type-only statements + * (`import type … from`, `export type … from`) are erased at build + * time and skipped. Side-effect imports (`import "x"`) are included. + */ +function valueImportSpecifiers(source: string): string[] { + const out: string[] = []; + // Statement-anchored (`^import` / `^export`) and semicolon-bounded + // (`[^;]*?`) so the lazy scan can never swallow across statements or + // latch onto a `from "…"` inside an unrelated string / comment. + const stmt = + /^(import|export)(\s+type)?\s+[^;"']*?from\s*["']([^"']+)["']|^import\s*["']([^"']+)["']/gm; + let m: RegExpExecArray | null; + while ((m = stmt.exec(source)) !== null) { + if (m[4] !== undefined) { + out.push(m[4]); // bare side-effect import + continue; + } + if (m[2] !== undefined) continue; // `import type` / `export type` — erased + out.push(m[3]); + } + return out; +} + +/** Resolve `@/…` and relative specifiers to a file inside `src/`. */ +function resolveLocal(spec: string, fromFile: string): string | null { + let base: string; + if (spec.startsWith("@/")) base = join(SRC, spec.slice(2)); + else if (spec.startsWith(".")) base = resolve(dirname(fromFile), spec); + else return null; // external package + for (const candidate of [ + base, + `${base}.ts`, + `${base}.tsx`, + join(base, "index.ts"), + join(base, "index.tsx"), + ]) { + if (existsSync(candidate) && statSync(candidate).isFile()) { + return candidate; + } + } + throw new Error(`unresolvable local import "${spec}" from ${fromFile}`); +} + +function walkGraph(entry: string): { + visited: Set; + specifiers: Set; +} { + const visited = new Set(); + const specifiers = new Set(); + const queue = [entry]; + while (queue.length > 0) { + const file = queue.pop()!; + if (visited.has(file)) continue; + visited.add(file); + for (const spec of valueImportSpecifiers(readFileSync(file, "utf8"))) { + specifiers.add(spec); + const local = resolveLocal(spec, file); + if (local !== null) queue.push(local); + } + } + return { visited, specifiers }; +} + +describe("verdict.ts — client-safe import graph", () => { + const { visited, specifiers } = walkGraph(ENTRY); + + it("carries no server-only specifier anywhere in the value-import graph", () => { + const offenders = [...specifiers].filter((spec) => + BANNED_SPECIFIERS.some((re) => re.test(spec)), + ); + expect(offenders).toEqual([]); + }); + + it("never traverses into the snapshot builder (type-only boundary holds)", () => { + const snapshotModule = join(SRC, "lib/dashboard/snapshot.ts"); + expect(visited.has(snapshotModule)).toBe(false); + }); + + it("reads the nudge thresholds from the dependency-free leaf", () => { + expect(specifiers.has("@/lib/jobs/coach-nudge-thresholds")).toBe(true); + const leaf = readFileSync( + join(SRC, "lib/jobs/coach-nudge-thresholds.ts"), + "utf8", + ); + expect(valueImportSpecifiers(leaf)).toEqual([]); + }); + + it("the whole graph stays local — no external runtime package rides in", () => { + const external = [...specifiers].filter( + (spec) => !spec.startsWith("@/") && !spec.startsWith("."), + ); + expect(external).toEqual([]); + }); +}); diff --git a/src/lib/dashboard/verdict.ts b/src/lib/dashboard/verdict.ts index f38f439ef..ae6c56cef 100644 --- a/src/lib/dashboard/verdict.ts +++ b/src/lib/dashboard/verdict.ts @@ -9,9 +9,11 @@ * Nine rungs, first hit wins. The ordering is a severity ladder: * actionable medication states first, then a fixed-floor BP emergency, * then trend nudges, then the briefing teaser, then the all-quiet - * fallback. Threshold constants are imported from the coach-nudge - * module where one exists so the hero and the nudge cron can never - * disagree on what "drift" or "deficit" means. + * fallback. Threshold constants are imported from the client-safe + * coach-nudge-thresholds leaf where one exists so the hero and the + * nudge cron can never disagree on what "drift" or "deficit" means — + * and so this module never pulls the cron's server graph into a + * client bundle (this resolver runs inside `"use client"` code). * * Freshness is re-derived from the snapshot's ISO timestamps against * the injected `now` (not the snapshot's own `daysAgo` fields) so a @@ -26,7 +28,7 @@ import { COACH_NUDGE_WEIGHT_DRIFT_KG, COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H, -} from "@/lib/jobs/coach-nudge"; +} from "@/lib/jobs/coach-nudge-thresholds"; import { buildWeightRangeFromHeight } from "@/lib/analytics/value-bands"; import type { DashboardSnapshot } from "@/lib/dashboard/snapshot"; diff --git a/src/lib/jobs/coach-nudge-thresholds.ts b/src/lib/jobs/coach-nudge-thresholds.ts new file mode 100644 index 000000000..8cf107bb9 --- /dev/null +++ b/src/lib/jobs/coach-nudge-thresholds.ts @@ -0,0 +1,19 @@ +/** + * Coach-nudge trend thresholds shared with client-reachable code. + * + * Kept in a leaf module — free of any server-only import — because the + * dashboard hero's verdict resolver (`@/lib/dashboard/verdict`) reads + * these to stay numerically identical to the nudge cron, and that + * resolver runs inside a `"use client"` component. Importing them from + * `@/lib/jobs/coach-nudge` would drag the prisma/dispatcher server + * graph into the browser bundle (the same regression class the + * dose-window defaults hit — see + * `src/lib/medications/scheduling/dose-window-defaults.ts`). + * `@/lib/jobs/coach-nudge` re-exports both so server-side imports keep + * their path. + */ + +/** Weight trigger: minimum kg the weekly mean must drift AWAY from the range. */ +export const COACH_NUDGE_WEIGHT_DRIFT_KG = 0.5; +/** Sleep trigger: a night must undershoot the floor by this margin (h). */ +export const COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H = 0.5; diff --git a/src/lib/jobs/coach-nudge.ts b/src/lib/jobs/coach-nudge.ts index 5b4c4281a..d931e1733 100644 --- a/src/lib/jobs/coach-nudge.ts +++ b/src/lib/jobs/coach-nudge.ts @@ -77,6 +77,18 @@ import { getServerTranslator } from "@/lib/i18n/server-translator"; import type { Locale } from "@/lib/i18n/config"; import { defaultLocale, locales } from "@/lib/i18n/config"; import { getEvent } from "@/lib/logging/context"; +// Trend thresholds shared with the dashboard hero's verdict resolver +// live in a client-safe leaf module; re-exported below so server-side +// imports keep their path. +import { + COACH_NUDGE_WEIGHT_DRIFT_KG, + COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H, +} from "@/lib/jobs/coach-nudge-thresholds"; + +export { + COACH_NUDGE_WEIGHT_DRIFT_KG, + COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H, +} from "@/lib/jobs/coach-nudge-thresholds"; const MS_PER_DAY = 24 * 60 * 60 * 1000; @@ -109,14 +121,10 @@ export const COACH_NUDGE_SELF_CONTEXT_STALE_DAYS = 60; export const COACH_NUDGE_COACH_ACTIVE_DAYS = 14; /** Weight trigger: minimum readings per weekly window. */ export const COACH_NUDGE_WEIGHT_MIN_READINGS = 3; -/** Weight trigger: minimum kg the weekly mean must drift AWAY from the range. */ -export const COACH_NUDGE_WEIGHT_DRIFT_KG = 0.5; /** Sleep trigger: minimum recorded nights in the 7-day window. */ export const COACH_NUDGE_SLEEP_MIN_NIGHTS = 5; /** Sleep trigger: nights under the floor required to fire. */ export const COACH_NUDGE_SLEEP_DEFICIT_NIGHTS = 4; -/** Sleep trigger: a night must undershoot the floor by this margin (h). */ -export const COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H = 0.5; /** Gap trigger: distinct active days required in the prior 3 weeks. */ export const COACH_NUDGE_GAP_MIN_ACTIVE_DAYS = 10; /** Gap trigger: length of the silent window that fires (days). */