diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index aec0e879c..0feb6ab5d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -11535,8 +11535,8 @@ components: const: 1 view: description: Which presentation /medications renders in. Default "cards". Optional on PUT — when omitted the stored - value is preserved (preserve-when-absent, like `heroVisible` on the dashboard layout). Always present on - responses. + value is preserved (preserve-when-absent, like `chartOverlayPrefs` on the dashboard layout). Always present + on responses. type: string enum: - cards @@ -30064,8 +30064,8 @@ components: const: 1 view: description: Which presentation /medications renders in. Default "cards". Optional on PUT — when omitted the stored - value is preserved (preserve-when-absent, like `heroVisible` on the dashboard layout). Always present on - responses. + value is preserved (preserve-when-absent, like `chartOverlayPrefs` on the dashboard layout). Always present + on responses. type: string enum: - cards diff --git a/e2e/mobile-density.spec.ts b/e2e/mobile-density.spec.ts index 9a56f140c..af0251581 100644 --- a/e2e/mobile-density.spec.ts +++ b/e2e/mobile-density.spec.ts @@ -13,21 +13,21 @@ import { mockMoodInsights } from "./utils/mock-mood-insights"; * Phone-width density guard (390×844 iPhone class, 360×780 small * Android class). * - * Two invariants, per the report against the dashboard hero (a long - * briefing-signal headline squeezed into a one-word-per-line column - * beside a wide delta, the delta spilling past the tile edge at 360 px): + * Two invariants, per the original overflow report (long headline text + * squeezed into a one-word-per-line column beside a wide delta, the + * delta spilling past the tile edge at 360 px): * * 1. No horizontal page overflow — `scrollWidth` never exceeds the * viewport width (1 px sub-pixel tolerance). - * 2. Nothing escapes its tile — for every card / hero band / list + * 2. Nothing escapes its tile — for every card / Today hero / list * row, no descendant's visible box crosses the tile's left or * right edge (clip-aware: content genuinely hidden by an inner * `overflow-hidden` wrapper below the tile is fine; content cut * off at the tile's own edge is the bug). * - * The snapshot mock pins the worst case: hero band on, three score - * rings, and a fresh briefing whose signal rows pair wrapping German - * headlines with wide delta strings. + * The mocks pin the worst case: the Today hero (its score face + a + * worth-a-look rail) plus a fresh briefing whose rows pair wrapping + * German headlines with wide delta strings. * * Desktop project only — the assertions are viewport-driven via * `setViewportSize` (the `ipad-viewport.spec.ts` pattern). @@ -51,7 +51,7 @@ interface TileEscape { function collectTileEscapes(): TileEscape[] { const escapes: TileEscape[] = []; const tiles = document.querySelectorAll( - '[data-slot="card"], [data-slot="dashboard-hero"], [data-slot="list-row"]', + '[data-slot="card"], [data-slot="today-hero"], [data-slot="list-row"]', ); const TOLERANCE = 1; for (const tile of tiles) { @@ -113,10 +113,46 @@ test.describe("phone-width density guard", () => { await page.setViewportSize(viewport); await mockDashboardSnapshot(page, { summaries: POPULATED_SUMMARIES, - heroVisible: true, briefing: LONG_HEADLINE_BRIEFING, scoreRings: MOCK_SCORE_RINGS, }); + // 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 — mirror of the dashboard.spec mock. + 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, + }), + }), + ); if (route === "/insights/mood") { await mockMoodInsights(page); } @@ -124,14 +160,12 @@ test.describe("phone-width density guard", () => { await page.waitForLoadState("networkidle"); if (route === "/") { - // The guard must not pass vacuously — the hero band and its - // long-headline spotlight rows have to actually render. + // The guard must not pass vacuously — the Today hero and its + // score face have to actually render. + await expect(page.locator('[data-slot="today-hero"]')).toBeVisible(); await expect( - page.locator('[data-slot="dashboard-hero"]'), + page.locator('[data-slot="today-hero-score"]'), ).toBeVisible(); - await expect( - page.locator('[data-slot="dashboard-briefing-spotlight-row"]'), - ).toHaveCount(3); } if (route === "/insights/mood") { diff --git a/e2e/utils/mock-dashboard-snapshot.ts b/e2e/utils/mock-dashboard-snapshot.ts index 08a58c947..1edbc1e6a 100644 --- a/e2e/utils/mock-dashboard-snapshot.ts +++ b/e2e/utils/mock-dashboard-snapshot.ts @@ -231,11 +231,9 @@ export interface MockSnapshotOptions { summaries?: Record; /** Artificial latency in ms before the route fulfils (race specs). */ delayMs?: number; - /** Render the dashboard hero band (`layout.heroVisible` opt-in). */ - heroVisible?: boolean; - /** Fresh (`ready`, non-stale) briefing for the hero spotlight rows. */ + /** Fresh (`ready`, non-stale) briefing for the dashboard briefing card. */ briefing?: DailyBriefing; - /** Selected hero score rings (renders beside the health-score ring). */ + /** Selected score rings carried on the snapshot (server-computed). */ scoreRings?: DashboardScoreRing[]; } @@ -263,9 +261,7 @@ export function buildMockSnapshot( onboardingTourCompleted: true, greetingHour: 9, }, - layout: options.heroVisible - ? { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: true } - : DEFAULT_DASHBOARD_LAYOUT, + layout: DEFAULT_DASHBOARD_LAYOUT, layoutCatalogue: buildLayoutCatalogue(), metricStates: {}, targetBands: mockTargetBands(), diff --git a/messages/de.json b/messages/de.json index 4db0595e6..0a63548e1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -327,37 +327,7 @@ "welcomeBackWithName": "{greeting} {name}, willkommen zurück.", "hero": { "scoreLabel": "Health Score", - "ringLink": "{metric}-Details öffnen", - "scoreProvisional": "Noch nicht genug Daten", - "scoreComputing": "Score wird berechnet…", - "ringDoses": "Dosen heute", - "ringDosesAria": "{taken} von {scheduled} Dosen heute genommen", - "ringCarousel": { - "aria": "Wellness-Ringe", - "dot": "Ring {index} von {total} anzeigen" - }, - "verdict": { - "doseOverdue": "Eine Dosis {name} ist überfällig.", - "doseOverdueNoName": "Eine Dosis ist überfällig.", - "bpHigh": "Dein letzter Blutdruck war sehr hoch ({sys}/{dia}). Miss in Ruhe noch einmal nach.", - "doseUpcoming": "Um {time} steht {name} an.", - "doseUpcomingNoName": "Um {time} steht eine Dosis 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" - }, - "briefing": { - "title": "Tagesbriefing" - } + "ringDoses": "Dosen heute" }, "greetingSalutation": "Hallo, {name}", "streakLabel": "Tage in Folge", @@ -410,8 +380,6 @@ "compliance7d": "Therapietreue (7 Tage)", "mood": "Stimmung", "customizeTitle": "Dashboard-Layout", - "heroToggleLabel": "Tagesüberblick", - "heroToggleDescription": "Zeigt Begrüßung, Tagesfokus und Score-Ringe ganz oben auf dem Dashboard.", "scoreRingsTitle": "Score-Ringe", "scoreRingsDescription": "Bis zu drei zusätzliche Ringe neben dem Health Score – zum Beispiel Bereitschaft, Erholung, Schlaf oder Therapietreue. Zum Umsortieren ziehen; der Health Score steht standardmäßig vorne.", "layoutReset": "Auf Defaults zurücksetzen", diff --git a/messages/en.json b/messages/en.json index f42744b90..a040596f9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -327,37 +327,7 @@ "welcomeBackWithName": "{greeting} {name}, welcome back.", "hero": { "scoreLabel": "Health Score", - "ringLink": "Open {metric} details", - "scoreProvisional": "Not enough data yet", - "scoreComputing": "Calculating score…", - "ringDoses": "Doses today", - "ringDosesAria": "{taken} of {scheduled} doses taken today", - "ringCarousel": { - "aria": "Wellness rings", - "dot": "Show ring {index} of {total}" - }, - "verdict": { - "doseOverdue": "A dose of {name} is overdue.", - "doseOverdueNoName": "A dose is overdue.", - "bpHigh": "Your last blood pressure was very high ({sys}/{dia}). Take another reading at rest.", - "doseUpcoming": "{name} is due at {time}.", - "doseUpcomingNoName": "A dose 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" - }, - "briefing": { - "title": "Today’s briefing" - } + "ringDoses": "Doses today" }, "greetingSalutation": "Hi, {name}", "streakLabel": "days in a row", @@ -410,8 +380,6 @@ "compliance7d": "Adherence (7 days)", "mood": "Mood", "customizeTitle": "Dashboard layout", - "heroToggleLabel": "Daily overview", - "heroToggleDescription": "Shows your greeting, daily focus and score rings at the top of the dashboard.", "scoreRingsTitle": "Score rings", "scoreRingsDescription": "Up to three additional rings next to the health score — for example readiness, recovery, sleep or adherence. Drag to reorder; the health score leads by default.", "layoutReset": "Reset to defaults", diff --git a/messages/es.json b/messages/es.json index 9cd80775f..7fb730a3e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -327,37 +327,7 @@ "welcomeBackWithName": "{greeting} {name}, te damos la bienvenida de nuevo.", "hero": { "scoreLabel": "Health Score", - "ringLink": "Abrir detalles de {metric}", - "scoreProvisional": "Aún no hay datos suficientes", - "scoreComputing": "Calculando la puntuación …", - "ringDoses": "Dosis de hoy", - "ringDosesAria": "{taken} de {scheduled} dosis tomadas hoy", - "ringCarousel": { - "aria": "Anillos de bienestar", - "dot": "Mostrar anillo {index} de {total}" - }, - "verdict": { - "doseOverdue": "Una dosis de {name} está atrasada.", - "doseOverdueNoName": "Una dosis está atrasada.", - "bpHigh": "Tu última tensión arterial fue muy alta ({sys}/{dia}). Vuelve a medirla con calma.", - "doseUpcoming": "A las {time} toca {name}.", - "doseUpcomingNoName": "A las {time} toca una dosis.", - "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" - }, - "briefing": { - "title": "Resumen de hoy" - } + "ringDoses": "Dosis de hoy" }, "greetingSalutation": "Hola, {name}", "streakLabel": "días seguidos", @@ -410,8 +380,6 @@ "compliance7d": "Cumplimiento (7 días)", "mood": "Estado de ánimo", "customizeTitle": "Diseño del panel", - "heroToggleLabel": "Resumen del día", - "heroToggleDescription": "Muestra el saludo, el foco del día y los anillos de puntuación en la parte superior del panel.", "scoreRingsTitle": "Anillos de puntuación", "scoreRingsDescription": "Hasta tres anillos adicionales junto a la puntuación de salud, por ejemplo preparación, recuperación, sueño o cumplimiento. Arrastra para reordenar; la puntuación de salud va primero de forma predeterminada.", "layoutReset": "Restablecer valores por defecto", diff --git a/messages/fr.json b/messages/fr.json index e37be5a91..543e12aec 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -327,37 +327,7 @@ "welcomeBackWithName": "{greeting} {name}, content de te revoir.", "hero": { "scoreLabel": "Health Score", - "ringLink": "Ouvrir les détails de {metric}", - "scoreProvisional": "Pas encore assez de données", - "scoreComputing": "Calcul du score en cours …", - "ringDoses": "Doses du jour", - "ringDosesAria": "{taken} des {scheduled} doses prises aujourd'hui", - "ringCarousel": { - "aria": "Anneaux de bien-être", - "dot": "Afficher l'anneau {index} sur {total}" - }, - "verdict": { - "doseOverdue": "Une dose de {name} est en retard.", - "doseOverdueNoName": "Une dose 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}.", - "doseUpcomingNoName": "Une dose est prévue à {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" - }, - "briefing": { - "title": "Briefing du jour" - } + "ringDoses": "Doses du jour" }, "greetingSalutation": "Bonjour, {name}", "streakLabel": "jours d’affilée", @@ -410,8 +380,6 @@ "compliance7d": "Observance (7 jours)", "mood": "Humeur", "customizeTitle": "Mise en page du tableau", - "heroToggleLabel": "Aperçu du jour", - "heroToggleDescription": "Affiche le message d’accueil, le focus du jour et les anneaux de score tout en haut du tableau de bord.", "scoreRingsTitle": "Anneaux de score", "scoreRingsDescription": "Jusqu’à trois anneaux supplémentaires à côté du score de santé — par exemple préparation, récupération, sommeil ou observance. Glissez pour réordonner ; le score de santé est placé en premier par défaut.", "layoutReset": "Rétablir les valeurs par défaut", diff --git a/messages/it.json b/messages/it.json index 45f30ae4f..7102125f2 100644 --- a/messages/it.json +++ b/messages/it.json @@ -327,37 +327,7 @@ "welcomeBackWithName": "{greeting} {name}, bentornato.", "hero": { "scoreLabel": "Health Score", - "ringLink": "Apri i dettagli di {metric}", - "scoreProvisional": "Dati ancora insufficienti", - "scoreComputing": "Calcolo del punteggio in corso …", - "ringDoses": "Dosi di oggi", - "ringDosesAria": "{taken} di {scheduled} dosi assunte oggi", - "ringCarousel": { - "aria": "Anelli del benessere", - "dot": "Mostra anello {index} di {total}" - }, - "verdict": { - "doseOverdue": "Una dose di {name} è in ritardo.", - "doseOverdueNoName": "Una dose è in ritardo.", - "bpHigh": "L’ultima pressione era molto alta ({sys}/{dia}). Misurala di nuovo con calma.", - "doseUpcoming": "Alle {time} è prevista {name}.", - "doseUpcomingNoName": "Alle {time} è prevista una dose.", - "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" - }, - "briefing": { - "title": "Briefing di oggi" - } + "ringDoses": "Dosi di oggi" }, "greetingSalutation": "Ciao, {name}", "streakLabel": "giorni di fila", @@ -410,8 +380,6 @@ "compliance7d": "Aderenza (7 giorni)", "mood": "Umore", "customizeTitle": "Layout della dashboard", - "heroToggleLabel": "Panoramica del giorno", - "heroToggleDescription": "Mostra saluto, focus del giorno e anelli dei punteggi in cima alla dashboard.", "scoreRingsTitle": "Anelli dei punteggi", "scoreRingsDescription": "Fino a tre anelli aggiuntivi accanto al punteggio di salute, ad esempio prontezza, recupero, sonno o aderenza. Trascina per riordinare; il punteggio di salute è primo per impostazione predefinita.", "layoutReset": "Ripristina i valori predefiniti", diff --git a/messages/pl.json b/messages/pl.json index eb27413d2..2eb11e0be 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -327,37 +327,7 @@ "welcomeBackWithName": "{greeting} {name}, witaj ponownie.", "hero": { "scoreLabel": "Health Score", - "ringLink": "Otwórz szczegóły: {metric}", - "scoreProvisional": "Za mało danych", - "scoreComputing": "Trwa obliczanie wyniku …", - "ringDoses": "Dzisiejsze dawki", - "ringDosesAria": "Przyjęto {taken} z {scheduled} dawek dzisiaj", - "ringCarousel": { - "aria": "Pierścienie dobrostanu", - "dot": "Pokaż pierścień {index} z {total}" - }, - "verdict": { - "doseOverdue": "Dawka {name} jest zaległa.", - "doseOverdueNoName": "Dawka 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}.", - "doseUpcomingNoName": "O {time} pora na dawkę.", - "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" - }, - "briefing": { - "title": "Briefing dnia" - } + "ringDoses": "Dzisiejsze dawki" }, "greetingSalutation": "Cześć, {name}", "streakLabel": "dni z rzędu", @@ -410,8 +380,6 @@ "compliance7d": "Przestrzeganie (7 dni)", "mood": "Nastrój", "customizeTitle": "Układ panelu", - "heroToggleLabel": "Przegląd dnia", - "heroToggleDescription": "Pokazuje powitanie, fokus dnia i pierścienie wyników na górze pulpitu.", "scoreRingsTitle": "Pierścienie wyników", "scoreRingsDescription": "Maksymalnie trzy dodatkowe pierścienie obok wskaźnika zdrowia — np. gotowość, regeneracja, sen lub przestrzeganie zaleceń. Przeciągnij, aby zmienić kolejność; wskaźnik zdrowia jest domyślnie na początku.", "layoutReset": "Przywróć wartości domyślne", diff --git a/src/app/__tests__/dashboard-hero-wiring.test.ts b/src/app/__tests__/dashboard-hero-wiring.test.ts index 27c92c640..8a1bd9016 100644 --- a/src/app/__tests__/dashboard-hero-wiring.test.ts +++ b/src/app/__tests__/dashboard-hero-wiring.test.ts @@ -5,19 +5,16 @@ import { join } from "node:path"; /** * Dashboard hero — page + header wiring pins. * - * The hero band mounts between the page header and the tile strip: + * The legacy opt-in `DashboardHero` (gated on a layout `heroVisible` + * flag) was retired: the digest-driven `TodayHero` is the single, + * unambiguous hero, mounted above the tile strip. Pinned here: * - * - it gates on the layout's `heroVisible` flag (only the literal - * `true` renders — the hero is opt-in) 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 lives in the hero when that renders; when - * the hero does NOT render (snapshot flag off, or hidden via the - * layout toggle) the header takes it back behind `showGreeting`, - * fed from the page's hero gate — the greeting never disappears. + * - the page mounts `TodayHero` and no longer references the legacy + * hero, its skeleton, or the `heroVisible` gate; + * - the greeting paragraph lives in the header on every mount (the + * header owns it unconditionally now that the legacy hero — which + * once carried it — is gone), while the page itself derives no + * welcome text. */ const PAGE_PATH = join(process.cwd(), "src/app/page-client.tsx"); const HEADER_PATH = join( @@ -28,60 +25,44 @@ const HEADER_PATH = join( 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*true;/, - ); - }); - - 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(" { + const heroIdx = src.indexOf(" { + it("no longer references the retired legacy hero", () => { + expect(src).not.toContain("DashboardHero"); + expect(src).not.toContain("DashboardHeroSkeleton"); + expect(src).not.toContain("heroVisible"); + }); + + it("the page derives no welcome text (the header owns the greeting)", () => { expect(src).not.toContain("welcomeText"); expect(src).not.toContain("welcomeBackWithName"); expect(src).not.toContain("getHourForTimeZone"); }); }); -describe("dashboard hero — header handoff", () => { +describe("dashboard hero — header greeting", () => { const header = readFileSync(HEADER_PATH, "utf8"); - it("the header keeps the greeting behind showGreeting (hero-hidden fallback)", () => { - // The greeting renders here ONLY when the hero band does not — the - // prop gates the paragraph, and the `min-h-5` line-box reservation - // keeps the header stable through the post-hydration name swap. - expect(header).toContain("showGreeting"); - expect(header).toMatch(/\{\s*showGreeting\s*\?/); + it("renders the greeting unconditionally (no showGreeting gate)", () => { + // The greeting line renders on every mount — the legacy hero that + // once owned it is gone, so there is no `showGreeting` prop to gate + // it. The `min-h-5` line-box reservation keeps the header stable + // through the post-hydration name swap. + expect(header).not.toContain("showGreeting"); expect(header).toContain("min-h-5"); expect(header).toContain('data-slot="dashboard-header-greeting"'); }); - it("the page feeds showGreeting from the inverted hero gate", () => { + it("the page mounts the header without a greeting prop", () => { const src = readFileSync(PAGE_PATH, "utf8"); - expect(src).toMatch(/showGreeting=\{!heroVisible\}/); + expect(src).toContain( + "", + ); }); it("the header keeps the title + customize + add actions", () => { diff --git a/src/app/__tests__/dashboard-suspense-boundaries.test.ts b/src/app/__tests__/dashboard-suspense-boundaries.test.ts index d2d04e496..df4cc46dc 100644 --- a/src/app/__tests__/dashboard-suspense-boundaries.test.ts +++ b/src/app/__tests__/dashboard-suspense-boundaries.test.ts @@ -77,10 +77,10 @@ describe("v1.4.40 — dashboard per-cell Suspense boundaries", () => { it("keeps the per-render Intl hour walk off the page (audit-H4 successor)", () => { const src = load(PAGE_PATH); // 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. + // moved off the page with the greeting itself: the greeting now + // lives in the header (`dashboard-header.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/api/dashboard/widgets/__tests__/route.test.ts b/src/app/api/dashboard/widgets/__tests__/route.test.ts index 656f39d5c..53e9bc085 100644 --- a/src/app/api/dashboard/widgets/__tests__/route.test.ts +++ b/src/app/api/dashboard/widgets/__tests__/route.test.ts @@ -407,132 +407,6 @@ describe("PUT /api/dashboard/widgets — accept-and-ignore unknown ids (v1.7.0 # const callGet = GET as unknown as () => Promise; -describe("PUT /api/dashboard/widgets — heroVisible thread-through", () => { - const knownId = DASHBOARD_WIDGET_IDS[0]; - - it("persists an explicit heroVisible: false WITHOUT dropping stored chartOverlayPrefs", async () => { - // The regression class this route already guards for - // `chartOverlayPrefs`: a layout PUT that omits a sibling field must - // not wipe it. heroVisible sent + prefs omitted → prefs merge from - // the stored blob, heroVisible persists from the body. - const storedPrefs = { - bp: { - showTrendIndicator: true, - showTrendArrow: false, - showTargetRange: true, - comparisonBaseline: "lastMonth" as const, - }, - }; - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - dashboardWidgetsJson: serializeDashboardLayout({ - version: 1, - widgets: [{ id: knownId, visible: true, order: 0 }], - chartOverlayPrefs: storedPrefs, - }), - } as never); - vi.mocked(prisma.user.update).mockResolvedValue({} as never); - - const res = await callPut( - makeReq({ - version: 1, - widgets: [{ id: knownId, visible: true, order: 0 }], - heroVisible: false, - }), - ); - expect(res.status).toBe(200); - - const updateArg = vi.mocked(prisma.user.update).mock - .calls[0]?.[0] as unknown as { - data: { - dashboardWidgetsJson: { - heroVisible: boolean; - chartOverlayPrefs: Record; - }; - }; - }; - expect(updateArg.data.dashboardWidgetsJson.heroVisible).toBe(false); - expect(updateArg.data.dashboardWidgetsJson.chartOverlayPrefs).toEqual( - storedPrefs, - ); - - // The response echoes the persisted value. - const body = (await res.json()) as { data: { heroVisible: boolean } }; - expect(body.data.heroVisible).toBe(false); - }); - - it("preserves a stored heroVisible: false when the PUT omits the field", async () => { - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - dashboardWidgetsJson: serializeDashboardLayout({ - version: 1, - widgets: [{ id: knownId, visible: true, order: 0 }], - heroVisible: false, - }), - } as never); - vi.mocked(prisma.user.update).mockResolvedValue({} as never); - - const res = await callPut( - makeReq({ - version: 1, - widgets: [{ id: knownId, visible: true, order: 0 }], - }), - ); - expect(res.status).toBe(200); - - const updateArg = vi.mocked(prisma.user.update).mock - .calls[0]?.[0] as unknown as { - data: { dashboardWidgetsJson: { heroVisible: boolean } }; - }; - expect(updateArg.data.dashboardWidgetsJson.heroVisible).toBe(false); - }); - - it("defaults heroVisible to false for a first save with no stored layout", async () => { - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - dashboardWidgetsJson: null, - } as never); - vi.mocked(prisma.user.update).mockResolvedValue({} as never); - - const res = await callPut( - makeReq({ - version: 1, - widgets: [{ id: knownId, visible: true, order: 0 }], - }), - ); - expect(res.status).toBe(200); - - const updateArg = vi.mocked(prisma.user.update).mock - .calls[0]?.[0] as unknown as { - data: { dashboardWidgetsJson: { heroVisible: boolean } }; - }; - expect(updateArg.data.dashboardWidgetsJson.heroVisible).toBe(false); - }); - - it("422s on a non-boolean heroVisible", async () => { - const res = await callPut( - makeReq({ - version: 1, - widgets: [{ id: knownId, visible: true, order: 0 }], - heroVisible: "yes", - }), - ); - expect(res.status).toBe(422); - }); - - it("GET resolves heroVisible alongside the widget layout", async () => { - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - dashboardWidgetsJson: serializeDashboardLayout({ - version: 1, - widgets: [{ id: knownId, visible: true, order: 0 }], - heroVisible: false, - }), - } as never); - - const res = await callGet(); - expect(res.status).toBe(200); - const body = (await res.json()) as { data: { heroVisible: boolean } }; - expect(body.data.heroVisible).toBe(false); - }); -}); - describe("dashboard widgets — 27-id catalogue round-trip (v1.7.0 W1)", () => { it("PUT of a full 27-id layout persists every id (iOS-only round-trip)", async () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index 07bdbd727..3522e63b6 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -121,14 +121,8 @@ const layoutSchema = z.object({ }), ) .optional(), - // Dashboard hero (daily verdict) visibility. Optional so clients - // that predate the field can still PUT; when omitted the handler - // preserves the stored value (same preserve-when-absent contract as - // `chartOverlayPrefs` — a Settings save that doesn't know the field - // must not silently reset the user's choice). - heroVisible: z.boolean().optional(), // v1.27.7 — hero score rings (max 3, closed id set). Optional with the - // same preserve-when-absent contract as `heroVisible`; the resolver + // same preserve-when-absent contract as `chartOverlayPrefs`; the resolver // dedupes on read/serialize, `.max()` bounds the wire length. selectedScoreRings: z .array(z.enum(SCORE_RING_IDS)) @@ -284,19 +278,17 @@ export const PUT = apiHandler(async (request: NextRequest) => { // didn't send. The dashboard-layout PUT typically saves widget // visibility / order; chart prefs are PUT through their own route // (`/api/dashboard/chart-overlay-prefs`) and would otherwise be - // wiped here on a subsequent layout save. `heroVisible` and - // `selectedScoreRings` ride the same preserve-when-absent contract — - // an older client's layout save must not reset either choice. One + // wiped here on a subsequent layout save. `selectedScoreRings` and + // `heroRingOrder` ride the same preserve-when-absent contract — an + // older client's layout save must not reset either choice. One // stored-layout read covers all fallbacks. let mergedChartOverlayPrefs: ChartOverlayPrefsMap | undefined = parsed.data .chartOverlayPrefs as ChartOverlayPrefsMap | undefined; - let mergedHeroVisible: boolean | undefined = parsed.data.heroVisible; let mergedScoreRings: ScoreRingId[] | undefined = parsed.data.selectedScoreRings; let mergedHeroRingOrder: HeroRingId[] | undefined = parsed.data.heroRingOrder; if ( mergedChartOverlayPrefs === undefined || - mergedHeroVisible === undefined || mergedScoreRings === undefined || mergedHeroRingOrder === undefined ) { @@ -310,9 +302,6 @@ export const PUT = apiHandler(async (request: NextRequest) => { if (mergedChartOverlayPrefs === undefined) { mergedChartOverlayPrefs = existingLayout.chartOverlayPrefs ?? {}; } - if (mergedHeroVisible === undefined) { - mergedHeroVisible = existingLayout.heroVisible === true; - } if (mergedScoreRings === undefined) { mergedScoreRings = existingLayout.selectedScoreRings; } @@ -323,7 +312,6 @@ export const PUT = apiHandler(async (request: NextRequest) => { const normalized = serializeDashboardLayout({ ...parsed.data, chartOverlayPrefs: mergedChartOverlayPrefs, - heroVisible: mergedHeroVisible, selectedScoreRings: mergedScoreRings, heroRingOrder: mergedHeroRingOrder, } as DashboardLayout); diff --git a/src/app/api/medications/layout/route.ts b/src/app/api/medications/layout/route.ts index 5f501a98d..eab335688 100644 --- a/src/app/api/medications/layout/route.ts +++ b/src/app/api/medications/layout/route.ts @@ -4,7 +4,7 @@ * GET returns the resolved presentation (view + manual order, defaults * merged in if the user hasn't customised yet). PUT updates it with * preserve-when-absent semantics — a view-only PUT keeps the stored - * order and vice versa, matching the `heroVisible` contract on + * order and vice versa, matching the `chartOverlayPrefs` contract on * `/api/dashboard/widgets`. DELETE resets to default. * * Mirrors the shape and semantics of `/api/insights/layout`; the blob @@ -42,7 +42,7 @@ const layoutSchema = z.object({ version: z.literal(1), // Both fields are optional so a client can PUT exactly the field it // changed; the handler merges the absent one from the stored blob - // (preserve-when-absent, like `heroVisible` on the dashboard layout). + // (preserve-when-absent, like `chartOverlayPrefs` on the dashboard layout). view: z.enum(MEDICATION_LIST_VIEWS).optional(), // Medication ids are opaque here — display-only ordering, unknown / // deleted ids are ignored at apply time, so the schema bounds size diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index e8e193e7b..3ac4cbde1 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -43,8 +43,6 @@ 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 { TodayHero } from "@/components/daily/today-hero"; import { TodayHeroSkeleton } from "@/components/daily/today-hero-skeleton"; import { @@ -766,25 +764,9 @@ export default function DashboardPageClient() { !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 - // (opt-in; only the literal `true` renders) and holds - // a footprint-identical skeleton while the snapshot is in flight, - // so the band swaps in place — no growth, no greeting flash. When - // the hero does NOT render (toggle off, or snapshot flag off) the - // header takes the greeting back — it must never disappear with the - // band. - const heroVisible = snapshotEnabled && layout.heroVisible === true; - const heroSnapshot = snapshotQuery.data; - return (
- + {/* S2 — the Today hero, promoted above the tile strip (MARC sign-off decision 1). Renders the S1 daily digest: the day's read, the @@ -804,16 +786,6 @@ export default function DashboardPageClient() { ) : null)} - {heroVisible && - (primaryLoading || !heroSnapshot ? ( - - ) : ( - - ))} - {/* v1.18.6 — the spotlight tour launcher moved to the app-shell * (`AuthShell`) so its overlay survives the cross-page * `router.push`es the module tour makes. The dashboard no longer diff --git a/src/components/dashboard/__tests__/dashboard-header.test.tsx b/src/components/dashboard/__tests__/dashboard-header.test.tsx index da873f652..7746aaf6a 100644 --- a/src/components/dashboard/__tests__/dashboard-header.test.tsx +++ b/src/components/dashboard/__tests__/dashboard-header.test.tsx @@ -1,18 +1,15 @@ /** * `` greeting contract. * - * The greeting line lives in the hero band when that renders; the hero - * is optional (snapshot flag off, or hidden via the dashboard-layout - * toggle), so the page feeds `showGreeting` from its hero gate and the - * header restores the pre-hero greeting paragraph for exactly those - * mounts. Pinned here: + * The greeting line sits under the title on every dashboard mount — the + * promoted Today hero renders separately above the tile strip, so the + * header greeting is unconditional (the legacy opt-in hero that once + * owned it was retired). Pinned here: * - * 1. `showGreeting` renders the greeting line (with the `min-h-5` - * line-box reservation that keeps the header height stable through - * the post-hydration name personalisation); - * 2. without the prop (the hero owns the greeting) the line is absent - * — no duplicate greeting above the hero band; - * 3. the SSR pass renders the name-less fallback (hydration-safe). + * 1. the greeting line renders (with the `min-h-5` line-box + * reservation that keeps the header height stable through the + * post-hydration name personalisation); + * 2. the SSR pass renders the name-less fallback (hydration-safe). */ import { describe, it, expect, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; @@ -34,10 +31,8 @@ function renderSSR(node: React.ReactElement, locale: "en" | "de" = "de") { } describe(" — greeting", () => { - it("renders the greeting line when the hero is hidden (showGreeting)", () => { - const html = renderSSR( - undefined} showGreeting />, - ); + it("renders the greeting line under the title", () => { + const html = renderSSR( undefined} />); expect(html).toContain('data-slot="dashboard-header-greeting"'); // SSR pass: name-less fallback (personalises post-hydration only). expect(html).toContain("willkommen zurück."); @@ -45,12 +40,6 @@ describe(" — greeting", () => { // post-hydration personalisation swap. expect(html).toMatch(/data-slot="dashboard-header-greeting"[^>]*>/); expect(html).toContain("min-h-5"); - }); - - it("renders NO greeting line when the hero band owns it (default)", () => { - const html = renderSSR( undefined} />); - expect(html).not.toContain('data-slot="dashboard-header-greeting"'); - expect(html).not.toContain("willkommen zurück."); // The title itself stays. expect(html).toContain("Dashboard"); }); diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx index 7d686cac7..b8848b901 100644 --- a/src/components/dashboard/dashboard-header.tsx +++ b/src/components/dashboard/dashboard-header.tsx @@ -1,13 +1,11 @@ "use client"; /** - * Dashboard page header: title, the customize shortcut, and the - * quick-add dropdown that opens the quick-entry sheets. The greeting - * line lives in the hero band (``) when that renders — - * but the hero is optional (snapshot flag off, or hidden via the - * dashboard-layout toggle), and the greeting must never disappear with - * it. `showGreeting` (fed from the page's hero gate) restores the - * pre-hero greeting paragraph under the title for exactly those mounts. + * Dashboard page header: title, the greeting line, the customize + * shortcut, and the quick-add dropdown that opens the quick-entry + * sheets. The greeting sits under the title on every dashboard mount — + * the promoted Today hero renders separately above the tile strip and + * carries its own read, so the header greeting is unconditional. * * Extracted from the dashboard page; the page owns the open-state the * dropdown items set via `onQuickEntry`. @@ -31,11 +29,8 @@ import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets export function DashboardHeader({ onQuickEntry, - showGreeting = false, }: { onQuickEntry: (dialog: NonNullable) => void; - /** Render the greeting line here because the hero band does not. */ - showGreeting?: boolean; }) { const { t } = useTranslations(); const { user } = useAuth(); @@ -71,18 +66,16 @@ export function DashboardHeader({ - {welcomeText} - - ) : undefined + /* `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} + } actions={ <> diff --git a/src/components/dashboard/hero/__tests__/briefing-spotlight.test.tsx b/src/components/dashboard/hero/__tests__/briefing-spotlight.test.tsx deleted file mode 100644 index ed04df2dd..000000000 --- a/src/components/dashboard/hero/__tests__/briefing-spotlight.test.tsx +++ /dev/null @@ -1,213 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { I18nProvider } from "@/lib/i18n/context"; -import type { DailyBriefing } from "@/lib/ai/schema"; -import { BriefingSpotlight } from "../briefing-spotlight"; - -/** - * `` contract: - * - * - renders nothing unless the briefing is FRESH (`ready` + not stale) - * and carries at least one signal or finding; - * - prefers `signalsOfDay`, falling back to `keyFindings`; - * - caps the strip at 3 rows; - * - every row links to `/insights` and carries the tone-keyed accent; - * - headlines render as plain text children (no HTML / markdown). - */ - -function render( - props: React.ComponentProps, - locale: "en" | "de" = "en", -) { - return renderToStaticMarkup( - - - , - ); -} - -function briefing(overrides: Partial = {}): DailyBriefing { - return { - paragraph: "x", - signalsOfDay: null, - keyFindings: [], - ...overrides, - }; -} - -describe("", () => { - it("renders nothing when no briefing is present", () => { - expect( - render({ - briefing: null, - briefingState: "ready", - briefingStale: false, - }), - ).toBe(""); - }); - - it("renders nothing for a stale briefing", () => { - expect( - render({ - briefing: briefing({ - keyFindings: [ - { - tone: "watch", - headline: "BP creeping up", - detail: "d", - delta: "+6 mmHg", - sourceWindow: "30d", - sourceMetric: "bp", - }, - ], - }), - briefingState: "ready", - briefingStale: true, - }), - ).toBe(""); - }); - - it("renders nothing while the briefing is still preparing", () => { - expect( - render({ - briefing: briefing({ - keyFindings: [ - { - tone: "info", - headline: "h", - detail: "d", - delta: null, - sourceWindow: "30d", - sourceMetric: "weight", - }, - ], - }), - briefingState: "preparing", - briefingStale: false, - }), - ).toBe(""); - }); - - it("surfaces the present-focused signals and links each row to /insights", () => { - const html = render({ - briefing: briefing({ - signalsOfDay: [ - { - sourceMetric: "sleep", - tone: "watch", - headline: "Short on sleep this week", - nudge: "Aim for an earlier night", - delta: "-1.2 h", - }, - ], - keyFindings: [ - { - tone: "good", - headline: "should not appear when signals exist", - detail: "d", - delta: null, - sourceWindow: "30d", - sourceMetric: "bp", - }, - ], - }), - briefingState: "ready", - briefingStale: false, - }); - expect(html).toContain('data-slot="dashboard-briefing-spotlight"'); - expect(html).toContain("Short on sleep this week"); - // The heading itself links to the briefing card anchor; the former - // separate "view all" text link is gone. - expect(html).toContain('href="/insights#daily-briefing"'); - expect(html).not.toContain('data-slot="dashboard-briefing-spotlight-link"'); - expect(html).toContain("-1.2 h"); - // Signals win over key findings when present. - expect(html).not.toContain("should not appear when signals exist"); - }); - - it("falls back to key findings when no signals were generated", () => { - const html = render({ - briefing: briefing({ - signalsOfDay: [], - keyFindings: [ - { - tone: "watch", - headline: "Blood pressure trending up", - detail: "d", - delta: "+8 mmHg", - sourceWindow: "30d", - sourceMetric: "bp", - }, - ], - }), - briefingState: "ready", - briefingStale: false, - }); - expect(html).toContain("Blood pressure trending up"); - }); - - it("caps the strip at three rows", () => { - const html = render({ - briefing: briefing({ - signalsOfDay: [ - { - sourceMetric: "bp", - tone: "info", - headline: "one", - nudge: "n", - delta: null, - }, - { - sourceMetric: "weight", - tone: "info", - headline: "two", - nudge: "n", - delta: null, - }, - { - sourceMetric: "mood", - tone: "info", - headline: "three", - nudge: "n", - delta: null, - }, - ], - }), - briefingState: "ready", - briefingStale: false, - }); - const rows = html.match(/data-slot="dashboard-briefing-spotlight-row"/g); - expect(rows).toHaveLength(3); - }); - - it("stacks headline over delta below sm and keeps the row at sm+", () => { - const html = render({ - briefing: briefing({ - signalsOfDay: [ - { - sourceMetric: "bp", - tone: "watch", - headline: - "Systolischer Blutdruck deutlich unter deinem Monatsmittel gemessen", - nudge: "n", - delta: "−12 mmHg vs. 30-Tage-Mittel", - }, - ], - }), - briefingState: "ready", - briefingStale: false, - }); - // Phone-first column, row restored at sm+ — a wide delta must never - // squeeze the headline into a narrow multi-line column (or push - // itself past the tile edge). - expect(html).toMatch( - /class="[^"]*flex min-w-0 flex-1 flex-col gap-1 sm:flex-row[^"]*"/, - ); - // The delta is left-aligned content-width in the stacked state and - // only refuses to shrink in the sm+ row. - expect(html).toMatch( - /data-slot="dashboard-briefing-spotlight-delta"[^>]*class="[^"]*self-start[^"]*sm:shrink-0[^"]*"/, - ); - }); -}); diff --git a/src/components/dashboard/hero/__tests__/dashboard-hero-skeleton.test.tsx b/src/components/dashboard/hero/__tests__/dashboard-hero-skeleton.test.tsx deleted file mode 100644 index 3e903572e..000000000 --- a/src/components/dashboard/hero/__tests__/dashboard-hero-skeleton.test.tsx +++ /dev/null @@ -1,60 +0,0 @@ -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", () => { - // v1.27.7 — the shell mirrors the loaded band's plain `bg-card` - // surface (the old `hero-gradient` chrome was a leftover from the - // pre-v1.18.1 gradient hero and never matched the loaded state). - for (const cls of [ - "min-h-[8.75rem]", - "md:min-h-[9.5rem]", - "bg-card", - "border-border", - "rounded-xl", - ]) { - expect(html).toContain(cls); - } - expect(html).not.toContain("hero-gradient"); - }); - - it("reserves no dose-row pill — the ring row replaced it (v1.27.7)", () => { - // The left column carries exactly three skeleton bars (greeting + - // verdict + CTA); the old fourth dose-pill bar is gone. - const bars = html.match(/data-slot="skeleton"/g) ?? []; - expect(bars.length).toBe(4); // 3 left-column bars + 1 ring circle - }); - - 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); - * - v1.27.7 — the ring row replaced the dose text row: the selected - * `scoreRings` render beside the health-score ring (hue per ring, - * band semantics for the adherence ring) and self-gate on data; - * the documented stale-cache state (past `nextDueAt`, - * `nextDueOverdue: false`) still renders a calm verdict — never - * 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: {}, - targetBands: buildTargetBands({ - dateOfBirth: null, - gender: null, - heightCm: null, - }), - 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("doseOverdue: a null medication name renders the name-less sentence (no hole)", () => { - const html = render( - baseSnapshot({ - medsToday: medsToday({ - scheduledToday: 2, - nextDueAt: isoHoursFromNow(-1), - nextDueOverdue: true, - nextDueMedicationName: null, - }), - }), - ); - expect(html).toContain('data-verdict-variant="doseOverdue"'); - expect(html).toContain("Eine Dosis ist überfällig."); - // The named template with an empty interpolation leaves a double - // space — it must never render. - expect(html).not.toContain("Eine Dosis ist überfällig."); - }); - - it("doseUpcoming: a null medication name renders the name-less sentence (no hole)", () => { - const nextDueAt = isoHoursFromNow(1.5); // 13:30 Berlin - const html = render( - baseSnapshot({ - medsToday: medsToday({ - scheduledToday: 2, - takenToday: 1, - nextDueAt, - nextDueMedicationName: null, - }), - }), - ); - const expectedTime = makeFormatters("de", "Europe/Berlin", "AUTO").time( - nextDueAt, - ); - expect(html).toContain('data-verdict-variant="doseUpcoming"'); - expect(html).toContain(`Um ${expectedTime} steht eine Dosis an.`); - expect(html).not.toContain(`steht an`); - }); - - 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"); - }); - - 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, no broad Insights CTA", () => { - 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."); - // v1.27.7 — the overview-only CTA left the band; the health-score - // card carries the Insights link. Only specific-action verdicts - // keep a button. - expect(html).not.toContain('data-slot="dashboard-hero-cta"'); - }); - - it("briefing: renders the model headline verbatim as plain text, no broad CTA", () => { - 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); - // v1.27.7 — same rule as scoreDrop: no broad Insights CTA. - expect(html).not.toContain('data-slot="dashboard-hero-cta"'); - }); - - 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(" — ring row (v1.27.7)", () => { - it("renders the selected rings beside the health-score ring, in selection order", () => { - const html = render( - baseSnapshot({ - healthScore: { score: 82, band: "green", delta: 2 }, - scoreRings: [ - { id: "READINESS", score: 71, band: "green" }, - { - id: "MED_COMPLIANCE", - score: 33, - band: "yellow", - doses: { taken: 1, scheduled: 3 }, - }, - ], - }), - ); - const row = html.match(/data-slot="dashboard-hero-rings"/g) ?? []; - expect(row).toHaveLength(1); - // v1.27.27 — default order leads with the health-score ring, then the - // selected rings in selection order (3 rings total). - const healthIdx = html.indexOf('href="/insights"'); - const readinessIdx = html.indexOf('data-ring="READINESS"'); - const complianceIdx = html.indexOf('data-ring="MED_COMPLIANCE"'); - expect(healthIdx).toBeGreaterThan(-1); - expect(readinessIdx).toBeGreaterThan(healthIdx); - expect(complianceIdx).toBeGreaterThan(readinessIdx); - const rings = html.match(/data-slot="score-ring"/g) ?? []; - expect(rings).toHaveLength(3); - // Labels (de locale): the derived ring reuses its wellness-strip - // title; the dose ring names today's tally and shows it as - // taken/scheduled over the constant med-family arc (`--primary`, - // the hue the medication surfaces in Insights paint) — never the - // band gradient (a pending morning dose is not an alert state) and - // never the ring system's green (reserved for band semantics). - expect(html).toContain("Bereitschaft"); - expect(html).toContain("Dosen heute"); - expect(html).toContain(">1/3<"); - // Window the assertion to the dose ring's own SVG (its gradient defs - // close with the svg) so the trailing health-score ring's band-green - // gradient can't mask a regression. - const medIdx = html.indexOf('data-ring="MED_COMPLIANCE"'); - const doseRing = html.slice(medIdx, html.indexOf("", medIdx)); - expect(doseRing).toContain("var(--primary)"); - expect(doseRing).not.toContain("var(--ring-green-from)"); - expect(html).not.toContain("var(--ring-yellow-from)"); - }); - - it("mobile carousel: one scroll-snap track holds a slide per ring + a dot per slide", () => { - const html = render( - baseSnapshot({ - healthScore: { score: 82, band: "green", delta: 2 }, - scoreRings: [ - { id: "READINESS", score: 71, band: "green" }, - { id: "RECOVERY_SCORE", score: 74, band: "yellow" }, - { - id: "MED_COMPLIANCE", - score: 33, - band: "yellow", - doses: { taken: 1, scheduled: 3 }, - }, - ], - }), - ); - // Exactly one snap track, one dots row. - expect(html.match(/data-slot="dashboard-hero-ring-track"/g)).toHaveLength( - 1, - ); - expect(html.match(/data-slot="dashboard-hero-ring-dots"/g)).toHaveLength(1); - // A slide per ring: 3 selected + the health-score ring = 4 slides = 4 dots. - expect(html.match(/data-carousel-slide=""/g)).toHaveLength(4); - expect(html.match(/data-slot="dashboard-hero-ring-dot"/g)).toHaveLength(4); - // Track is a labelled, focusable scroll region that snaps. - const track = html.match( - /]*data-slot="dashboard-hero-ring-track"[^>]*>/, - ); - expect(track).not.toBeNull(); - expect(track![0]).toContain("snap-x"); - expect(track![0]).toContain("overflow-x-auto"); - expect(track![0]).toContain('tabindex="0"'); - // The desktop row is preserved: the track reverts to the inline - // right-aligned row (md:justify-end) with the dots hidden (md:hidden). - expect(track![0]).toContain("md:justify-end"); - expect(track![0]).toContain("md:overflow-x-visible"); - const dots = html.match( - /]*data-slot="dashboard-hero-ring-dots"[^>]*>/, - ); - expect(dots![0]).toContain("md:hidden"); - // The first dot is current on the server render (active slide 0). - const firstDot = html.match( - /]*data-slot="dashboard-hero-ring-dot"[^>]*>/, - ); - expect(firstDot![0]).toContain('aria-current="true"'); - }); - - it("a single slide (health ring only) renders no dots — not a carousel", () => { - const html = render( - baseSnapshot({ healthScore: { score: 60, band: "yellow", delta: 0 } }), - ); - expect(html.match(/data-carousel-slide=""/g)).toHaveLength(1); - expect(html).not.toContain('data-slot="dashboard-hero-ring-dots"'); - // A lone slide is not focusable (no scroll affordance to reach). - const track = html.match( - /]*data-slot="dashboard-hero-ring-track"[^>]*>/, - ); - expect(track![0]).not.toContain("tabindex"); - }); - - it("no dose text row renders anymore — the ring row carries the slot", () => { - const html = render( - baseSnapshot({ - medsToday: medsToday({ scheduledToday: 3, takenToday: 1 }), - }), - ); - expect(html).not.toContain('data-slot="dashboard-hero-doses"'); - expect(html).not.toContain("Dosen genommen"); - }); - - it("an empty / absent scoreRings block leaves the health ring alone (self-gating)", () => { - const html = render( - baseSnapshot({ healthScore: { score: 60, band: "yellow", delta: 0 } }), - ); - const rings = html.match(/data-slot="score-ring"/g) ?? []; - expect(rings).toHaveLength(1); - expect(html).not.toContain('data-slot="dashboard-hero-ring"'); - }); - - it("the adherence ring paints band semantics (data-band), not a metric hue", () => { - const html = render( - baseSnapshot({ - scoreRings: [{ id: "MED_COMPLIANCE", score: 65, band: "red" }], - }), - ); - const ring = html.match( - /]*data-ring="MED_COMPLIANCE"[\s\S]*?data-band="([a-z]+)"/, - ); - expect(ring).not.toBeNull(); - expect(ring![1]).toBe("red"); - }); - - it("DEFENSIVE: a stale past nextDueAt with nextDueOverdue false still renders a calm verdict, 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).not.toContain("überfällig"); - expect(html).toContain('data-verdict-variant="allQuiet"'); - }); -}); - -describe(" — ring links (v1.27.24)", () => { - it("each ring links to its natural detail surface with an aria-label", () => { - const html = render( - baseSnapshot({ - healthScore: { score: 82, band: "green", delta: 2 }, - scoreRings: [ - { id: "READINESS", score: 71, band: "green" }, - { id: "SLEEP_SCORE", score: 66, band: "yellow" }, - { - id: "MED_COMPLIANCE", - score: 33, - band: "yellow", - doses: { taken: 1, scheduled: 3 }, - }, - ], - }), - ); - // Derived rings → their score-anatomy detail page. - const readiness = html.slice( - html.indexOf('data-ring="READINESS"'), - html.indexOf('data-ring="SLEEP_SCORE"'), - ); - expect(readiness).toContain('data-slot="dashboard-hero-ring-link"'); - expect(readiness).toContain('href="/insights/scores/readiness"'); - expect(readiness).toContain("aria-label="); - const sleep = html.slice( - html.indexOf('data-ring="SLEEP_SCORE"'), - html.indexOf('data-ring="MED_COMPLIANCE"'), - ); - expect(sleep).toContain('href="/insights/scores/sleep"'); - // Dose ring → the medications surface. - const med = html.slice(html.indexOf('data-ring="MED_COMPLIANCE"')); - expect(med).toContain('href="/medications"'); - // Health-score ring → the Insights overview. - expect(html).toContain('href="/insights"'); - // Every ring link is keyboard-focusable (native anchor) — one per slide. - const links = html.match(/data-slot="dashboard-hero-ring-link"/g) ?? []; - expect(links).toHaveLength(4); - }); -}); - -describe(" — ring order (v1.27.27)", () => { - const threeRings = { - healthScore: { score: 82, band: "green" as const, delta: 2 }, - scoreRings: [ - { id: "READINESS" as const, score: 71, band: "green" as const }, - { id: "SLEEP_SCORE" as const, score: 66, band: "yellow" as const }, - { - id: "MED_COMPLIANCE" as const, - score: 33, - band: "yellow" as const, - doses: { taken: 1, scheduled: 3 }, - }, - ], - }; - - it("default (no persisted order) leads with the health-score ring", () => { - const html = render(baseSnapshot(threeRings)); - // The health-score slide (its /insights link) precedes every score ring. - const healthIdx = html.indexOf('href="/insights"'); - const readinessIdx = html.indexOf('data-ring="READINESS"'); - const sleepIdx = html.indexOf('data-ring="SLEEP_SCORE"'); - const medIdx = html.indexOf('data-ring="MED_COMPLIANCE"'); - expect(healthIdx).toBeGreaterThan(-1); - expect(readinessIdx).toBeGreaterThan(healthIdx); - expect(sleepIdx).toBeGreaterThan(readinessIdx); - expect(medIdx).toBeGreaterThan(sleepIdx); - // First carousel dot is current (slide 0 = health score). - const firstDot = html.match( - /]*data-slot="dashboard-hero-ring-dot"[^>]*>/, - ); - expect(firstDot![0]).toContain('aria-current="true"'); - }); - - it("honours a persisted custom order (health score moved off the leading edge)", () => { - const html = render( - baseSnapshot({ - ...threeRings, - layout: { - version: 1, - widgets: [], - heroRingOrder: ["READINESS", "MED_COMPLIANCE", "HEALTH_SCORE"], - selectedScoreRings: ["READINESS", "SLEEP_SCORE", "MED_COMPLIANCE"], - }, - }), - ); - const readinessIdx = html.indexOf('data-ring="READINESS"'); - const medIdx = html.indexOf('data-ring="MED_COMPLIANCE"'); - const healthIdx = html.indexOf('href="/insights"'); - // READINESS first, MED_COMPLIANCE second, health score last. - expect(readinessIdx).toBeGreaterThan(-1); - expect(medIdx).toBeGreaterThan(readinessIdx); - expect(healthIdx).toBeGreaterThan(medIdx); - // A rendered ring the order omits (SLEEP_SCORE) is still drawn (appended). - expect(html).toContain('data-ring="SLEEP_SCORE"'); - // Every slide still renders exactly once. - expect(html.match(/data-carousel-slide=""/g)).toHaveLength(4); - }); - - it("drops an ordered id whose ring isn't rendered (no data / disabled)", () => { - // The persisted order names RECOVERY_SCORE, but the snapshot resolved - // no such ring — it must not leave a gap or a phantom slide. - const html = render( - baseSnapshot({ - healthScore: { score: 60, band: "yellow", delta: 0 }, - scoreRings: [{ id: "READINESS", score: 71, band: "green" }], - layout: { - version: 1, - widgets: [], - heroRingOrder: ["RECOVERY_SCORE", "HEALTH_SCORE", "READINESS"], - selectedScoreRings: ["READINESS"], - }, - }), - ); - // Only the two rendered rings appear (health + readiness), in the - // surviving order: health score then readiness. - expect(html.match(/data-carousel-slide=""/g)).toHaveLength(2); - expect(html).not.toContain('data-ring="RECOVERY_SCORE"'); - const healthIdx = html.indexOf('href="/insights"'); - const readinessIdx = html.indexOf('data-ring="READINESS"'); - expect(healthIdx).toBeGreaterThan(-1); - expect(readinessIdx).toBeGreaterThan(healthIdx); - }); -}); - -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"); - }); - - // A null score on an account that already carries score inputs is a - // warm-up state (the rollup tier is mid-fold), not a data gap — the - // copy must say "computing", never "not enough data". - it("null score WITH existing score inputs renders the computing label, not the data-gap one", () => { - const html = render( - baseSnapshot({ - healthScore: null, - tiles: { - summaries: { WEIGHT: summary({ count: 12, latest: 80 }) }, - lastSeenByType: { - WEIGHT: { lastSeenAt: isoHoursFromNow(-2), daysAgo: 0 }, - }, - mood: { summary: null, entries: [] }, - }, - }), - ); - expect(html).toContain('data-provisional="true"'); - expect(html).toContain("Score wird berechnet"); - expect(html).not.toContain("Noch nicht genug Daten"); - }); - - it("active medications alone count as a score input (compliance pillar)", () => { - const html = render( - baseSnapshot({ - healthScore: null, - medsToday: medsToday({ activeCount: 1 }), - }), - ); - expect(html).toContain("Score wird berechnet"); - expect(html).not.toContain("Noch nicht genug Daten"); - }); - - it("keeps the genuine data-gap label when no score input exists at all", () => { - // Empty summaries, no mood entries, no active medications — the - // baseSnapshot. A non-score-pillar summary (steps) must not flip - // the copy either. - const html = render( - baseSnapshot({ - healthScore: null, - tiles: { - summaries: { ACTIVITY_STEPS: summary({ count: 30, latest: 9000 }) }, - lastSeenByType: {}, - mood: { summary: null, entries: [] }, - }, - }), - ); - expect(html).toContain("Noch nicht genug Daten"); - expect(html).not.toContain("Score wird berechnet"); - }); -}); - -describe(" — typographic hierarchy", () => { - it("the greeting leads: larger + heavier than the verdict line", () => { - const html = render(baseSnapshot()); - const greeting = html.match( - /]*data-slot="dashboard-hero-greeting"[^>]*>/, - ); - expect(greeting).not.toBeNull(); - expect(greeting![0]).toContain("text-lg"); - expect(greeting![0]).toContain("font-semibold"); - expect(greeting![0]).not.toContain("text-muted-foreground"); - const verdict = html.match( - /]*data-slot="dashboard-hero-verdict"[^>]*>/, - ); - expect(verdict).not.toBeNull(); - // The verdict stays one step down — base size, medium weight. - expect(verdict![0]).toContain("text-base"); - expect(verdict![0]).toContain("font-medium"); - expect(verdict![0]).not.toContain("font-semibold"); - }); -}); - -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("wears the plain card surface — no gradient, no glow", () => { - // v1.18.1: the band drops the gradient hero treatment and sits on the - // same `bg-card` + border + radius as the surrounding chart cards. - expect(src).toContain("bg-card"); - expect(src).toContain("border-border"); - expect(src).toContain("rounded-xl"); - expect(src).not.toContain("hero-gradient"); - expect(src).not.toContain("glow-purple"); - }); - - it("renders the score ring flat (no bloom / pulse / sheen / sweep)", () => { - expect(src).toMatch(/]*\n(?:[^>]*\n)*?\s*flat\b/); - }); -}); diff --git a/src/components/dashboard/hero/briefing-spotlight.tsx b/src/components/dashboard/hero/briefing-spotlight.tsx deleted file mode 100644 index f18dfa928..000000000 --- a/src/components/dashboard/hero/briefing-spotlight.tsx +++ /dev/null @@ -1,229 +0,0 @@ -"use client"; - -/** - * v1.18.11 — dashboard briefing spotlight. - * - * The daily briefing previously surfaced on the dashboard ONLY as the - * hero's rung-8 verdict — a single sentence that almost always lost the - * verdict slot to a routine state (an upcoming dose, a weight drift, the - * all-quiet fallback). A fresh, model-authored briefing was effectively - * invisible above the fold. - * - * This strip raises the briefing's prominence WITHOUT touching its - * content or generation: it lifts the briefing's own present-focused - * "Signals of the day" (falling back to the longer-horizon key findings) - * straight onto the dashboard hero, so the freshest read is always one - * glance away. The verdict ladder is unchanged — a real BP crisis still - * leads the verdict line; this band sits below it. - * - * Design contract: - * - reuses the established tone-bar + metric-icon row language from the - * `/insights` daily-briefing card so the two surfaces read identically; - * - self-gating — renders nothing unless a FRESH briefing (`ready`, not - * stale) carries at least one signal or finding, so a missing / - * stale / disabled briefing never leaves an empty band; - * - rows are tappable, routing to `/insights` for the full briefing; - * - plain text children only (no HTML / markdown) per the repo-wide - * no-markdown-renderer rule; headlines pass through `stripChartTokens` - * to defend against a stray chart token leaking into the prose. - * - * Pure presentational: the caller resolves the briefing from the - * server-authoritative snapshot and passes the already-validated payload. - */ -import Link from "next/link"; -import { - Activity, - Flame, - Footprints, - Heart, - HeartPulse, - Mountain, - Moon, - Pill, - Route, - Scale, - Smile, - Sparkles, - Thermometer, - Wind, - Zap, -} from "lucide-react"; -import { ListRow } from "@/components/ui/list-row"; -import { METRIC_HREF } from "@/components/insights/daily-briefing"; -import { useTranslations } from "@/lib/i18n/context"; -import { stripChartTokens } from "@/lib/insights/chart-tokens"; -import { cn } from "@/lib/utils"; -import type { DailyBriefing, DailyBriefingKeyFinding } from "@/lib/ai/schema"; - -/** - * Max rows the spotlight surfaces. The briefing caps `signalsOfDay` at 3 - * and `keyFindings` at 5; the dashboard strip stays a glance, so it shows - * at most the top 3 — the user opens `/insights` for the full list. - */ -const MAX_SPOTLIGHT_ROWS = 3; - -const METRIC_ICON: Record< - DailyBriefingKeyFinding["sourceMetric"], - React.ComponentType<{ className?: string }> -> = { - bp: Heart, - weight: Scale, - pulse: Activity, - mood: Smile, - compliance: Pill, - hrv: Wind, - sleep: Moon, - resting_hr: HeartPulse, - steps: Footprints, - active_energy: Flame, - flights: Mountain, - distance: Route, - vo2_max: Zap, - body_temp: Thermometer, - glp1_plateau: Pill, - readiness: Sparkles, - recovery: HeartPulse, -}; - -const TONE_BAR_CLASSNAME: Record = { - good: "bg-success", - watch: "bg-warning", - info: "bg-info", -}; - -const TONE_TEXT_CLASSNAME: Record = { - good: "text-success", - watch: "text-warning", - info: "text-info", -}; - -/** One spotlight row, normalised from a signal or a key finding. */ -interface SpotlightRow { - sourceMetric: DailyBriefingKeyFinding["sourceMetric"]; - tone: DailyBriefingKeyFinding["tone"]; - headline: string; - delta: string | null; -} - -/** - * Choose the rows to spotlight. Prefers the present-focused - * `signalsOfDay` (the rebuilt briefing's lead); falls back to the - * longer-horizon `keyFindings` when no signals were generated. Returns an - * empty array when neither carries content (the caller then renders - * nothing). - */ -function selectSpotlightRows(briefing: DailyBriefing): SpotlightRow[] { - const signals = briefing.signalsOfDay ?? []; - if (signals.length > 0) { - return signals.slice(0, MAX_SPOTLIGHT_ROWS).map((s) => ({ - sourceMetric: s.sourceMetric, - tone: s.tone, - headline: s.headline, - delta: s.delta, - })); - } - return briefing.keyFindings.slice(0, MAX_SPOTLIGHT_ROWS).map((f) => ({ - sourceMetric: f.sourceMetric, - tone: f.tone, - headline: f.headline, - delta: f.delta, - })); -} - -export function BriefingSpotlight({ - briefing, - briefingState, - briefingStale, -}: { - briefing: DailyBriefing | null; - briefingState: string; - briefingStale: boolean; -}) { - const { t } = useTranslations(); - - // Fresh-only gate: a ready, non-stale briefing with at least one row. - // A stale / preparing / disabled briefing falls through to nothing so - // the band never sits empty or narrates a days-old read as current. - if (briefingState !== "ready" || briefingStale || briefing === null) { - return null; - } - const rows = selectSpotlightRows(briefing); - if (rows.length === 0) return null; - - return ( -
- {/* The heading itself routes to the briefing card on /insights — - quiet (no underline, hover lift only). The former separate - "open Insights" text link is gone: it thickened the card while - every signal row below already deep-links. */} - - {t("dashboard.hero.briefing.title")} - -
- {rows.map((row, index) => { - const Icon = METRIC_ICON[row.sourceMetric]; - return ( - - {/* Deep-link each signal to its metric sub-page (the same - mapping the briefing card's finding rows use); metrics - without a routed sub-page fall back to the overview. */} - - - ); - })} -
-
- ); -} diff --git a/src/components/dashboard/hero/dashboard-hero-skeleton.tsx b/src/components/dashboard/hero/dashboard-hero-skeleton.tsx deleted file mode 100644 index 5d73ebf9d..000000000 --- a/src/components/dashboard/hero/dashboard-hero-skeleton.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { Skeleton } from "@/components/ui/skeleton"; - -/** - * Loading silhouette for the dashboard hero band. - * - * Mirrors the real `` footprint — same plain `bg-card` - * shell (border + radius + `p-4 md:p-6`, matching the loaded band since - * the v1.18.1 gradient removal), 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 and - * the verdict sentence + CTA row; the right column reserves the - * health-score circle (v1.27.7 — the old dose-row pill is gone; the - * user-selected extra rings self-gate on data, so the skeleton reserves - * only the always-present health ring and lets the row grow in place). - * - * 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 deleted file mode 100644 index 3d1e5fac5..000000000 --- a/src/components/dashboard/hero/dashboard-hero.tsx +++ /dev/null @@ -1,481 +0,0 @@ -"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; typographically the - * band's anchor, larger and heavier than the verdict so the - * personalised line is not drowned by the content below it) and the - * verdict sentence with its single CTA button; - * - right column: the ring row — the health-score `` at - * `sm` geometry in its `flat` treatment (no bloom / pulse / sheen / - * sweep) plus the user-selected score rings the snapshot resolved - * server-side (`scoreRings`, max 3: readiness / recovery / sleep in - * their wellness-strip hues, medication adherence in band - * semantics). v1.27.7 — the ring row replaces the old dose text row; - * the adherence ring carries its information role. Selected rings - * self-gate: an entry absent from `scoreRings` (no data, disabled - * module) renders nothing, exactly like the wellness strip. A null - * health score renders the ring's provisional state at the identical - * 120 px footprint, so the column never collapses. The provisional - * label is honest about WHY the score is null: when the snapshot - * already carries score inputs (weight / BP summaries, mood - * entries, active medications) the rollup tier is merely mid-fold - * and the label reads "computing"; only an account with no score - * inputs at all gets "not enough data". - * - * 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 2 keys on the flag, rung 3 requires a future anchor), - * so the stale state renders as a calm verdict, 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 { Button } from "@/components/ui/button"; -import { ScoreRing } from "@/components/insights/derived/score-ring"; -import type { RingHue } from "@/components/insights/derived/ring-hues"; -import { RestModeBanner } from "@/components/insights/rest-mode-banner"; -import { BriefingSpotlight } from "@/components/dashboard/hero/briefing-spotlight"; -import { - HeroRingCarousel, - type HeroRingSlide, -} from "@/components/dashboard/hero/hero-ring-carousel"; -import { METRIC_HREF } from "@/components/insights/daily-briefing"; -import { - resolveDashboardVerdict, - type DashboardVerdictVariant, -} from "@/lib/dashboard/verdict"; -import type { DashboardSnapshot } from "@/lib/dashboard/snapshot"; -import { - HEALTH_SCORE_RING_ID, - resolveHeroRingOrder, - type ScoreRingId, - type HeroRingId, -} from "@/lib/dashboard-layout"; -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). - * `scoreDrop` / `briefing` carry none either: their only destination was - * the Insights overview, which the health-score card already links — a - * second broad "open Insights" button on the hero was redundant. Only - * verdicts with a SPECIFIC destination or action keep a button. */ -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", -}; - -/** - * Per-ring hue for the selected score rings — the wellness-strip - * vocabulary, so every hero ring paints EXACTLY the hue its insights - * sibling paints. MED_COMPLIANCE rides the `meds` hue: the medication - * family in Insights paints `--primary` on every surface (compliance - * bars, compliance trend line, drug-level / dose-strength curves), so - * the dose ring holds that one constant tone — never a band gradient - * that would flash yellow/red over pending morning doses, and not the - * green arc the ring system reserves for band semantics. - */ -const RING_HUE_BY_ID: Partial> = { - READINESS: "readiness", - RECOVERY_SCORE: "recovery", - SLEEP_SCORE: "sleep", - MED_COMPLIANCE: "meds", -}; - -/** Per-ring label key — reuses the existing score labels; the dose ring - * names today's tally. */ -const RING_LABEL_KEY: Record = { - READINESS: "insights.derived.composite.READINESS.title", - RECOVERY_SCORE: "insights.derived.scores.recovery", - SLEEP_SCORE: "insights.derived.composite.SLEEP_SCORE.title", - MED_COMPLIANCE: "dashboard.hero.ringDoses", -}; - -/** - * v1.27.24 — per-ring detail destination. Each hero ring links to the - * SAME surface its sibling already owns elsewhere in the app, so tapping - * a ring opens the natural deeper view rather than inventing one: - * - * - the three derived rings route to their score-anatomy detail page - * (`/insights/scores/`) — exactly where the /insights wellness - * strip links each ring; - * - the dose ring routes to the medications surface (`/medications`) — - * the today/intake destination the dashboard dose CTA already uses. - * - * The health-score ring is wired separately to the Insights overview - * (`/insights`, the destination the health-score card carries). A ring - * with no sensible destination would be left non-interactive; every id - * in the closed set has one, so the map is total. - */ -const RING_HREF: Record = { - READINESS: "/insights/scores/readiness", - RECOVERY_SCORE: "/insights/scores/recovery", - SLEEP_SCORE: "/insights/scores/sleep", - MED_COMPLIANCE: "/medications", -}; - -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 ────────────────────────────────────────── - // The dose variants interpolate a medication name; a snapshot can carry - // none (the resolver emits `""`), and the named sentence would render - // with a hole ("Eine Dosis ist überfällig."). Swap to the name-less - // sibling key instead of patching a generic word into the gap. - const verdictName = - typeof verdict.values.name === "string" ? verdict.values.name.trim() : ""; - const messageKey = (variant: Exclude) => - variant === "doseOverdue" && verdictName.length === 0 - ? "dashboard.hero.verdict.doseOverdueNoName" - : variant === "doseUpcoming" && verdictName.length === 0 - ? "dashboard.hero.verdict.doseUpcomingNoName" - : VERDICT_MESSAGE_KEY[variant]; - const sentence = - verdict.variant === "briefing" - ? // Model headline VERBATIM as a plain text child — no key. - String(verdict.values.headline ?? "") - : t(messageKey(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]; - - // Link-CTA verdicts WITHOUT a button label (scoreDrop, briefing) make - // the sentence itself the link — after the broad "open Insights" - // button was dropped the sentence was a dead end. The briefing - // sentence deep-links to the picked finding's metric sub-page (the - // METRIC_HREF map the briefing rows use); metrics without a routed - // sub-page fall back to the resolver's `/insights` href. Verdicts - // with their own action (take dose, view BP, …) keep the button. - const sentenceHref = - cta !== null && cta.kind === "link" && !ctaLabelKey - ? verdict.variant === "briefing" && - typeof verdict.values.sourceMetric === "string" - ? (METRIC_HREF[ - verdict.values.sourceMetric as keyof typeof METRIC_HREF - ] ?? cta.href) - : cta.href - : null; - - // ── Provisional score copy ────────────────────────────────────────── - // The score's four pillars are weight, BP, mood, and medication - // compliance. When ANY of them already carries data, a null score is - // a warm-up state (the rollup tier hasn't folded the buckets the - // warm-phase score rides on yet) — telling an account with weeks of - // readings "not enough data" would be a lie. Only an account with no - // score inputs at all keeps the genuine empty-state copy. Cheap - // heuristic over fields the snapshot already carries; no extra reads. - const summaries = snapshot.tiles.summaries; - const hasScoreInputs = - (summaries.WEIGHT?.count ?? 0) > 0 || - (summaries.BLOOD_PRESSURE_SYS?.count ?? 0) > 0 || - (summaries.BLOOD_PRESSURE_DIA?.count ?? 0) > 0 || - snapshot.tiles.mood.entries.length > 0 || - snapshot.medsToday.activeCount > 0; - - // ── Ring row (v1.27.7) ───────────────────────────────────────────── - // The snapshot resolves the selected rings server-side; entries absent - // from the array (no data / disabled module) simply don't render — - // the wellness-strip self-gating rule. Optional on the type (additive - // contract), so an older cached snapshot renders the health ring alone. - const scoreRings = snapshot.scoreRings ?? []; - - // One slide per ring, keyed by its `HeroRingId`. Each score ring links - // to its natural existing detail surface; a real (in the - // carousel) keeps tap-vs-drag native on mobile. - const scoreRingSlides = new Map( - scoreRings.map((ring) => [ - ring.id, - { - key: ring.id, - ringId: ring.id, - href: RING_HREF[ring.id], - linkLabel: t("dashboard.hero.ringLink", { - metric: t(RING_LABEL_KEY[ring.id]), - }), - node: - // Dose ring — today's tally ("1/3") over the constant med-family - // arc (`hue="meds"` = --primary, the tone every medication surface - // in Insights paints); the arc still sweeps on the 0..100 progress - // `score`. `band="green"` stays as the stable data-band anchor — a - // pending morning dose is not an alert state — while the hue owns - // the paint. Falls through to the score render when a cached - // pre-doses snapshot carries no tally. - ring.id === "MED_COMPLIANCE" && ring.doses ? ( - - ) : ( - - ), - }, - ]), - ); - - const healthScoreSlide: HeroRingSlide = { - key: "health-score", - // The health-score ring opens the Insights overview — the same - // destination the health-score card links (why the hero verdict - // dropped its redundant "open Insights" button). - href: "/insights", - linkLabel: t("dashboard.hero.ringLink", { - metric: t("dashboard.hero.scoreLabel"), - }), - node: ( - - ), - }; - - // v1.27.27 — the hero ring sequence honours the persisted order (health - // score + selected rings). The reconciler defaults to health-score-first - // when the user hasn't customised, drops any ordered id whose ring isn't - // rendered (no data / disabled module), and appends a rendered ring the - // order omits — so the order can never hide or duplicate a slide. Feeds - // the carousel on mobile and the inline row on desktop from a SINGLE - // render of each ring node. - const heroRingOrder = resolveHeroRingOrder( - snapshot.layout?.heroRingOrder, - scoreRings.map((ring) => ring.id), - ); - const ringSlides: HeroRingSlide[] = heroRingOrder - .map((id) => - id === HEALTH_SCORE_RING_ID ? healthScoreSlide : scoreRingSlides.get(id), - ) - .filter((slide): slide is HeroRingSlide => slide !== undefined); - - return ( -
`). No - // gradient, no glow — it reads as one of the dashboard tiles. - "bg-card border-border relative isolate overflow-hidden rounded-xl border", - "min-h-[8.75rem] p-4 md:min-h-[9.5rem] md:p-6", - )} - > - {/* Vertical rhythm: greeting/ring row and the briefing sit one tight - spacing step apart on desktop (md:gap-2) so the briefing hugs the - ring row instead of drifting below a floating greeting; mobile - keeps the roomier gap-4. The top row aligns to its start on - desktop so the greeting anchors to the top edge instead of - drifting to the vertical centre of the taller ring column. */} -
-
-
- {/* Greeting leads the typographic hierarchy: larger + heavier - than the verdict so the personalised line reads first. - The verdict keeps its medium weight one step down — still - the content lead, clearly subordinate to the greeting. - Both stay on the existing foreground token; the band adds - no new colour. */} -

- {welcomeText} -

- {/* Sentence then action, stacked — the overdue-dose button sits - DIRECTLY beneath the verdict text (tight `gap-1.5`), left-aligned - and intrinsic width (`self-start w-auto`), on every viewport. An - earlier `justify-between` shoved the button to the far container - edge on desktop, stranding it from the sentence it acts on. */} -
- {sentenceHref ? ( - - {sentence} - - ) : ( -

- {sentence} -

- )} - {cta !== null && ctaLabelKey ? ( - cta.kind === "link" ? ( - - ) : ( - - ) - ) : null} -
-
- {/* Ring carousel — the selected score rings (max 3, each at the - fixed 120 px sm footprint, flat) lead into the health-score - ring on the trailing edge. On mobile they ride a one-ring - scroll-snap carousel with dot indicators (calmer than the old - wrap-to-grid row); at `md` and up the SAME nodes revert to the - right-aligned inline row. A null health score renders the - ring's provisional state (aria announces "not enough data", - never 0) at identical geometry, so the column NEVER - collapses; missing selected rings render nothing. */} - -
- {/* v1.18.1 — Rest Mode cue beneath the verdict/score row. Self-gating - (renders nothing unless an episode is active), value-free, and - untinted so it frames the score without alarming. Closes the - web↔iOS parity gap: iOS already mirrors `score.restMode`. */} - - {/* v1.18.11 — briefing spotlight. Lifts the fresh briefing's own - signals onto the hero so the daily read is always above the - fold, not buried behind the rung-8 verdict. Self-gating: renders - nothing unless a fresh (ready, non-stale) briefing carries - content. The rung-8 `briefing` verdict above still fires when no - higher rung claims the verdict line; this band is additive and - does not change which sentence the verdict shows. */} - -
-
- ); -} diff --git a/src/components/dashboard/hero/hero-ring-carousel.tsx b/src/components/dashboard/hero/hero-ring-carousel.tsx deleted file mode 100644 index 415d78556..000000000 --- a/src/components/dashboard/hero/hero-ring-carousel.tsx +++ /dev/null @@ -1,186 +0,0 @@ -"use client"; - -/** - * Hero ring carousel — the mobile face of the dashboard-hero ring row. - * - * Below `md` the wellness rings no longer wrap into a multi-row grid - * (which ate vertical space); instead a single ring shows centred and - * the user swipes left/right through the set. The mechanism is a - * dependency-free CSS scroll-snap track — a horizontal `overflow-x-auto` - * flex whose slides are each full-width `snap-center`, so exactly one - * ring is visible at a time and native touch/keyboard scrolling moves - * between them. A row of dot indicators below the track shows the - * position; the active dot rides `--primary`, inactive dots muted. - * - * At `md` and up the SAME track reverts, purely in CSS, to the inline - * right-aligned ring row it always was: `md:overflow-x-visible`, - * `md:snap-none`, `md:justify-end`, and slides collapse to their own - * width (`md:w-auto`). Every ring is visible at once and the dot row is - * hidden — the desktop layout is unchanged. - * - * a11y: the track is a focusable scroll region (native arrow-key - * scrolling), each ring carries its own metric label + aria, the dots - * are real buttons that scroll to their slide with an aria-current on - * the active one, and the programmatic scroll honours - * `prefers-reduced-motion` (jump, don't glide). The track scrolls inside - * itself, so no phone width overflows the page. - */ - -import { useEffect, useRef, useState } from "react"; -import Link from "next/link"; -import { useTranslations } from "@/lib/i18n/context"; -import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; -import type { ScoreRingId } from "@/lib/dashboard-layout"; -import { cn } from "@/lib/utils"; - -export interface HeroRingSlide { - /** Stable key + dot label anchor. */ - key: string; - /** Selected-ring id when the slide is a `scoreRings` entry (carries the - * `data-ring` contract the tests + wellness vocabulary rely on); the - * health-score slide leaves it unset. */ - ringId?: ScoreRingId; - /** The rendered `` node. */ - node: React.ReactNode; - /** Optional detail destination — wraps the ring in a focusable link so a - * tap opens the metric's detail. A real anchor keeps tap-vs-drag native - * on the mobile carousel: a swipe scrolls the track, only a tap fires - * navigation. Omit to leave the ring non-interactive. */ - href?: string; - /** aria-label for the ring link ("Open {metric} details"). */ - linkLabel?: string; -} - -export function HeroRingCarousel({ slides }: { slides: HeroRingSlide[] }) { - const { t } = useTranslations(); - const trackRef = useRef(null); - const [active, setActive] = useState(0); - // A single slide is not a carousel: no dots, no scroll affordance. - const multi = slides.length > 1; - - // Track the active slide from the scroll offset. Each slide is exactly - // the track's own width (full-width snap slide), so `scrollLeft / - // clientWidth` rounds straight to the slide index — no Intersection - // Observer bookkeeping needed. On desktop the track has no overflow, so - // `scrollLeft` stays 0 and the (hidden) dots stay on the first. - useEffect(() => { - const track = trackRef.current; - if (!track || !multi) return; - let raf = 0; - const onScroll = () => { - cancelAnimationFrame(raf); - raf = requestAnimationFrame(() => { - const w = track.clientWidth || 1; - const idx = Math.round(track.scrollLeft / w); - setActive(Math.min(slides.length - 1, Math.max(0, idx))); - }); - }; - track.addEventListener("scroll", onScroll, { passive: true }); - return () => { - track.removeEventListener("scroll", onScroll); - cancelAnimationFrame(raf); - }; - }, [multi, slides.length]); - - const goTo = (index: number) => { - const track = trackRef.current; - if (!track) return; - track.scrollTo({ - left: index * track.clientWidth, - behavior: prefersReducedMotion() ? "auto" : "smooth", - }); - }; - - return ( -
- - {multi ? ( -
- {slides.map((slide, i) => { - const isActive = i === active; - return ( - - ); - })} -
- ) : null} -
- ); -} diff --git a/src/components/settings/__tests__/dashboard-layout-section.test.tsx b/src/components/settings/__tests__/dashboard-layout-section.test.tsx index 12de28600..1be642928 100644 --- a/src/components/settings/__tests__/dashboard-layout-section.test.tsx +++ b/src/components/settings/__tests__/dashboard-layout-section.test.tsx @@ -159,11 +159,16 @@ describe(" — drag-and-drop reorder", () => { it("keeps the arrow buttons present after the drag handle lands (a11y fallback)", () => { const html = render(); // Move up + Move down translation keys resolve to the English - // strings — counted once per row. + // strings — counted once per row. Both the widget rows and the + // score-ring reorder rows (health-score anchor + selected rings) + // carry the same arrow pair, so the total is one per row of either + // kind. + const ringRows = (html.match(/data-slot="score-ring-row"/g) ?? []).length; + const expected = WEB_RENDERABLE_ROW_COUNT + ringRows; const moveUpCount = (html.match(/aria-label="Move up"/g) ?? []).length; const moveDownCount = (html.match(/aria-label="Move down"/g) ?? []).length; - expect(moveUpCount).toBe(WEB_RENDERABLE_ROW_COUNT); - expect(moveDownCount).toBe(WEB_RENDERABLE_ROW_COUNT); + expect(moveUpCount).toBe(expected); + expect(moveDownCount).toBe(expected); }); it("hint string localises to German", () => { @@ -283,76 +288,6 @@ 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 Begrüßung, Tagesfokus und Score-Ringe ganz oben auf dem Dashboard.", - ); - const en = render(); - expect(en).toContain("Daily overview"); - }); - - it("reflects heroVisible: default layout → unchecked, true → checked", () => { - const uncheckedHtml = render( - , - ); - const unchecked = uncheckedHtml.match( - /]*data-slot="hero-visible-switch"[^>]*>/, - ); - expect(unchecked).not.toBeNull(); - expect(unchecked![0]).toContain('data-state="unchecked"'); - - queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: true }; - const checkedHtml = render( - , - ); - const checked = checkedHtml.match( - /]*data-slot="hero-visible-switch"[^>]*>/, - ); - expect(checked).not.toBeNull(); - expect(checked![0]).toContain('data-state="checked"'); - }); - - 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 @@ -441,19 +376,20 @@ describe(" — disabled-module widget toggles", () => { }); /** - * v1.27.7 — hero score-ring picker. Rendered only while the hero is on - * (the rings live inside the hero band); offers only rings whose owning - * module is enabled; caps the selection at three via disabled switches. - * SSR smoke assertions, matching the rest of this suite. + * v1.27.7 — hero score-ring picker. Renders whenever the layout loads + * (it configures the server-computed score rings the dashboard snapshot + * carries); offers only rings whose owning module is enabled; caps the + * selection at three via disabled switches. SSR smoke assertions, + * matching the rest of this suite. */ describe(" — hero score-ring picker (v1.27.7)", () => { - it("stays hidden while the hero is off (default layout)", () => { + it("renders for the default layout", () => { const html = render(); - expect(html).not.toContain('data-slot="score-rings-picker"'); + expect(html).toContain('data-slot="score-rings-picker"'); }); - it("renders one switch per ring when the hero is on (fail-open module map)", () => { - queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: true }; + it("renders one switch per ring (fail-open module map)", () => { + queryState.layout = DEFAULT_DASHBOARD_LAYOUT; const html = render(); expect(html).toContain('data-slot="score-rings-picker"'); // v1.27.27 — one switch per score ring (4) plus the always-present @@ -469,7 +405,7 @@ describe(" — hero score-ring picker (v1.27.7)", () => }); it("hides rings whose owning module is disabled (anchor stays)", () => { - queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: true }; + queryState.layout = DEFAULT_DASHBOARD_LAYOUT; authState.modules = { recovery: false, sleep: false }; const html = render(); const switches = html.match(/data-slot="score-ring-switch"/g) ?? []; @@ -481,7 +417,7 @@ describe(" — hero score-ring picker (v1.27.7)", () => }); it("hides the derived rings when the insights module is off (anchor stays)", () => { - queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: true }; + queryState.layout = DEFAULT_DASHBOARD_LAYOUT; authState.modules = { insights: false }; const html = render(); const switches = html.match(/data-slot="score-ring-switch"/g) ?? []; @@ -493,7 +429,6 @@ describe(" — hero score-ring picker (v1.27.7)", () => it("disables the remaining switches at the three-ring cap", () => { queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, - heroVisible: true, selectedScoreRings: ["READINESS", "RECOVERY_SCORE", "SLEEP_SCORE"], }; const html = render(); @@ -522,7 +457,7 @@ describe(" — hero score-ring picker (v1.27.7)", () => */ describe(" — health-score anchor + reorder (v1.27.27)", () => { it("renders the health-score anchor as a reorderable row, leading by default", () => { - queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: true }; + queryState.layout = DEFAULT_DASHBOARD_LAYOUT; const html = render(); // A sortable ring row exists for the health-score anchor. expect(html).toContain('data-slot="score-ring-row"'); @@ -543,7 +478,7 @@ describe(" — health-score anchor + reorder (v1.27.27)" }); it("the anchor switch is checked and disabled (locked-on, no toggle-off)", () => { - queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: true }; + queryState.layout = DEFAULT_DASHBOARD_LAYOUT; const html = render(); const anchorSwitch = html.match( /]*data-slot="score-ring-switch"[^>]*data-ring="HEALTH_SCORE"[^>]*>/, @@ -554,7 +489,7 @@ describe(" — health-score anchor + reorder (v1.27.27)" }); it("the anchor carries a drag handle + move arrows (reorderable)", () => { - queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: true }; + queryState.layout = DEFAULT_DASHBOARD_LAYOUT; const html = render(); // The health-score row is a full sortable row: its drag handle exists. const handles = html.match(/data-slot="score-ring-drag-handle"/g) ?? []; @@ -565,7 +500,6 @@ describe(" — health-score anchor + reorder (v1.27.27)" it("honours a persisted order that moves the anchor off the lead", () => { queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, - heroVisible: true, selectedScoreRings: ["READINESS", "MED_COMPLIANCE"], heroRingOrder: ["READINESS", "HEALTH_SCORE", "MED_COMPLIANCE"], }; diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index 78815f821..2837c8ee7 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -202,8 +202,6 @@ 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 @@ -322,16 +320,6 @@ 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.27.8 — the ring choices apply INSTANTLY, outside the draft/Save * machine the rest of the section keeps. The first cut routed ring @@ -595,44 +583,17 @@ 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" - /> -
- )} - - {/* v1.27.7 — hero score-ring picker. Only rendered while the hero - is on (the rings live inside the hero band, so the picker would - be a dead control otherwise). Offers only rings whose owning - module is enabled — the WIDGET_MODULE_BY_ID gating pattern; the - three derived rings additionally sit behind the insights - module, mirroring the derived routes. Selection caps at - MAX_SELECTED_SCORE_RINGS; unchecked switches disable at the cap. + {/* v1.27.7 — hero score-ring picker. Configures the score rings the + server computes onto the dashboard snapshot (`selectedScoreRings` + / `heroRingOrder`), consumed by the native client. Offers only + rings whose owning module is enabled — the WIDGET_MODULE_BY_ID + gating pattern; the three derived rings additionally sit behind + the insights module, mirroring the derived routes. Selection caps + at MAX_SELECTED_SCORE_RINGS; unchecked switches disable at the cap. v1.27.8 — choices apply instantly (no Save round) and the selected rings reorder with the same drag-handle + arrow pair - as the widget rows below; the array order is the hero order. */} - {layout && layout.heroVisible === true && ( + as the widget rows below; the array order is the ring order. */} + {layout && (
{ }); /** - * Dashboard hero (daily verdict) visibility — `heroVisible` piggy-backs - * on the layout blob like the B8 comparison baseline. Resolver contract: - * anything that is not the literal `true` clamps back to `false` - * (the hero is opt-in; legacy blobs keep it off and a stale client - * cannot poison the field with a non-boolean). Serializer persists the - * resolved boolean explicitly so a re-read never has to guess the - * default. + * The legacy opt-in dashboard hero was retired; its `heroVisible` flag is + * gone from the layout type, defaults, resolver, and serializer. A stored + * blob that still carries the field must resolve without throwing and drop + * it silently (the resolver is a whitelist constructor, so an unknown key + * is ignored on read and never re-persisted). */ -describe("resolveDashboardLayout() — heroVisible", () => { - it("defaults to false for legacy layouts (no field saved)", () => { - const legacy = { - version: 1, - widgets: [{ id: "weight", visible: true, order: 0 }], - }; - expect(resolveDashboardLayout(legacy).heroVisible).toBe(false); - }); - - it("respects an explicit heroVisible: true", () => { +describe("resolveDashboardLayout() — retired heroVisible field", () => { + it("drops a stored heroVisible without throwing", () => { const saved = { version: 1, widgets: [{ id: "weight", visible: true, order: 0 }], heroVisible: true, }; - expect(resolveDashboardLayout(saved).heroVisible).toBe(true); + const resolved = resolveDashboardLayout(saved) as unknown as Record< + string, + unknown + >; + expect(resolved).not.toHaveProperty("heroVisible"); }); - it("clamps non-boolean values back to false", () => { - for (const poisoned of ["true", 0, null, 1, {}]) { - const saved = { - version: 1, - widgets: [{ id: "weight", visible: true, order: 0 }], - heroVisible: poisoned, - }; - expect(resolveDashboardLayout(saved).heroVisible).toBe(false); - } + it("does not carry heroVisible on DEFAULT_DASHBOARD_LAYOUT", () => { + expect(DEFAULT_DASHBOARD_LAYOUT).not.toHaveProperty("heroVisible"); }); - it("defaults heroVisible to false in DEFAULT_DASHBOARD_LAYOUT", () => { - expect(DEFAULT_DASHBOARD_LAYOUT.heroVisible).toBe(false); - }); - - it("preserves heroVisible: true through serialize → resolve round-trip", () => { - const layout: DashboardLayout = { - ...DEFAULT_DASHBOARD_LAYOUT, - heroVisible: true, - }; - const resolved = resolveDashboardLayout(serializeDashboardLayout(layout)); - expect(resolved.heroVisible).toBe(true); - }); - - it("serializer derives false when the field is missing on input", () => { - const layout: DashboardLayout = { + it("does not re-emit heroVisible on serialize", () => { + const saved = { version: 1, widgets: [{ id: "weight", visible: true, order: 0 }], + heroVisible: true, }; - expect(serializeDashboardLayout(layout).heroVisible).toBe(false); + const serialized = serializeDashboardLayout( + resolveDashboardLayout(saved), + ) as unknown as Record; + expect(serialized).not.toHaveProperty("heroVisible"); }); }); diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index fc2330cfc..685d5d222 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -357,8 +357,8 @@ function coerceChartOverlayPrefsMap(value: unknown): ChartOverlayPrefsMap { * canonical compliance engine — the ring that absorbs the retired * hero dose row's information role. * - * The preference piggy-backs on the layout blob like `heroVisible` (a UI - * affordance, no Prisma migration). The resolver drops unknown ids, + * The preference piggy-backs on the layout blob like `chartOverlayPrefs` + * (a UI affordance, no Prisma migration). The resolver drops unknown ids, * dedupes, and clamps to `MAX_SELECTED_SCORE_RINGS`; a missing / * malformed field falls back to the default single MED_COMPLIANCE ring, * while an explicitly-saved empty array stays empty (the user chose no @@ -505,16 +505,6 @@ export interface DashboardLayout { comparisonBaseline?: ComparisonBaseline; /** v1.4.18 — per-chart overlay-prefs (3 toggles per chart card). */ chartOverlayPrefs?: ChartOverlayPrefsMap; - /** - * Dashboard hero (daily verdict) visibility. Piggy-backs on the - * layout blob like the B8 comparison baseline — a UI affordance, not - * an analytical attribute, so no Prisma migration. Opt-in: the - * resolver clamps anything that is not the literal `true` back to - * `false` (legacy blobs without the field keep the hero off; a - * stale client cannot poison it with a non-boolean), and the - * serializer persists the resolved boolean explicitly. - */ - heroVisible?: boolean; /** * v1.27.7 — hero score rings (max 3, closed `SCORE_RING_IDS` set) * rendered next to the health-score ring. See the doc on @@ -540,9 +530,6 @@ const DASHBOARD_LAYOUT_VERSION = 1; */ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = { version: DASHBOARD_LAYOUT_VERSION, - // Hero (daily verdict) is off by default; users opt in via - // Settings → Dashboard. - heroVisible: false, // One medication-adherence ring next to the health score by default — // the successor of the hero dose row's information role. selectedScoreRings: [...DEFAULT_SELECTED_SCORE_RINGS], @@ -699,10 +686,6 @@ export function resolveDashboardLayout(raw: unknown): DashboardLayout { // client cannot poison the dashboard with values the renderer // doesn't know how to draw. chartOverlayPrefs: coerceChartOverlayPrefsMap(candidate.chartOverlayPrefs), - // Hero visibility — anything that is not the literal `true` - // clamps to `false` (default-off for legacy blobs and malformed - // values alike; the hero is opt-in). - heroVisible: candidate.heroVisible === true, // v1.27.7 — hero score rings: unknown ids drop, duplicates collapse, // the list clamps to three. A legacy blob without the field gets the // default single MED_COMPLIANCE ring. @@ -744,9 +727,6 @@ export function serializeDashboardLayout( // v1.4.18 — persist per-chart overlay prefs verbatim through the // same coercion the resolver runs so the wire shape is stable. chartOverlayPrefs: coerceChartOverlayPrefsMap(layout.chartOverlayPrefs), - // Persist hero visibility explicitly (same clamp as the resolver) - // so a re-read never has to guess the default. - heroVisible: layout.heroVisible === true, // v1.27.7 — persist the score-ring selection through the same // coercion the resolver runs so the wire shape is stable. selectedScoreRings: resolvedSelectedScoreRings, diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts index e2b06986c..ae2775fa3 100644 --- a/src/lib/openapi/routes/medications/schemas.ts +++ b/src/lib/openapi/routes/medications/schemas.ts @@ -743,7 +743,7 @@ export const medicationListLayoutSchema = z .enum(MEDICATION_LIST_VIEWS) .optional() .describe( - 'Which presentation /medications renders in. Default "cards". Optional on PUT — when omitted the stored value is preserved (preserve-when-absent, like `heroVisible` on the dashboard layout). Always present on responses.', + 'Which presentation /medications renders in. Default "cards". Optional on PUT — when omitted the stored value is preserved (preserve-when-absent, like `chartOverlayPrefs` on the dashboard layout). Always present on responses.', ), order: z .array(z.string().min(1).max(MEDICATION_ORDER_ID_MAX_LENGTH))