refactor(dashboard): retire the legacy opt-in hero

The digest-driven Today hero is the single promoted hero at the top of
the dashboard, so the old opt-in hero band is redundant — a user who had
opted in and has Insights on saw two heroes. Remove the legacy hero and
its heroVisible layout flag.

- Drop the DashboardHero / DashboardHeroSkeleton mount from the
  dashboard page; the header now renders the greeting unconditionally
  (its showGreeting gate is gone with the hero that owned it).
- Retire the heroVisible field from the dashboard layout (type,
  defaults, resolver, serializer) and the widgets PUT schema. The
  resolver is a whitelist constructor, so a stored heroVisible is
  dropped on read and never re-persisted — no stored layout breaks.
- Delete the now-orphaned hero components (band, skeleton, ring
  carousel, briefing spotlight) and their tests; drop the retired
  dashboard.hero copy across all locales.
- Remove the hero-visibility toggle from Settings → Dashboard. The
  score-ring picker no longer hangs off that flag and stays available,
  since the rings it configures still ride the server-computed
  dashboard snapshot.
- Repoint the phone-width density guard at the Today hero and mock the
  daily digest so it renders.
- Repoint the preserve-when-absent doc analogy off the removed field.
This commit is contained in:
Marc-André Bombeck committed 2026-07-16 21:34:06 +02:00
1 parent d6ad76b1e1
commit 1d592bef78
29 files changed
+183 -2719

No files matched your search

+4 -4
View File
@@ -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
+49 -15
View File
@@ -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") {
+3 -7
View File
@@ -231,11 +231,9 @@ export interface MockSnapshotOptions {
summaries?: Record<string, DataSummary>;
/** 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(),
+1 -33
View File
@@ -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",
+1 -33
View File
@@ -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",
+1 -33
View File
@@ -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",
+1 -33
View File
@@ -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",
+1 -33
View File
@@ -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",
+1 -33
View File
@@ -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",
+30 -49
View File
@@ -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]*?<DashboardHero[\s\S]*?\)\}/,
);
expect(block).not.toBeNull();
expect(block![0]).toMatch(
/primaryLoading\s*\|\|\s*!heroSnapshot\s*\?\s*\(\s*<DashboardHeroSkeleton\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("<DashboardHeader");
const heroIdx = src.indexOf("{heroVisible &&");
it("mounts the TodayHero above the tile strip", () => {
const heroIdx = src.indexOf("<TodayHero");
const stripIdx = src.indexOf("dashboard-tile-strip");
expect(headerIdx).toBeGreaterThan(-1);
expect(heroIdx).toBeGreaterThan(headerIdx);
expect(heroIdx).toBeGreaterThan(-1);
expect(stripIdx).toBeGreaterThan(heroIdx);
});
it("the page no longer derives the welcome text (it moved into the hero)", () => {
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(
"<DashboardHeader onQuickEntry={setQuickEntryDialog} />",
);
});
it("the header keeps the title + customize + add actions", () => {
@@ -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");
});
@@ -407,132 +407,6 @@ describe("PUT /api/dashboard/widgets — accept-and-ignore unknown ids (v1.7.0 #
const callGet = GET as unknown as () => Promise<Response>;
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<string, unknown>;
};
};
};
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({
+4 -16
View File
@@ -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);
+2 -2
View File
@@ -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
+1 -29
View File
@@ -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 (
<div className="space-y-6">
<DashboardHeader
onQuickEntry={setQuickEntryDialog}
showGreeting={!heroVisible}
/>
<DashboardHeader onQuickEntry={setQuickEntryDialog} />
{/* 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() {
<TodayHero digest={digestQuery.data} />
) : null)}
{heroVisible &&
(primaryLoading || !heroSnapshot ? (
<DashboardHeroSkeleton />
) : (
<DashboardHero
snapshot={heroSnapshot}
onQuickEntry={setQuickEntryDialog}
/>
))}
{/* 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
@@ -1,18 +1,15 @@
/**
* `<DashboardHeader>` 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("<DashboardHeader> — greeting", () => {
it("renders the greeting line when the hero is hidden (showGreeting)", () => {
const html = renderSSR(
<DashboardHeader onQuickEntry={() => undefined} showGreeting />,
);
it("renders the greeting line under the title", () => {
const html = renderSSR(<DashboardHeader onQuickEntry={() => 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("<DashboardHeader> — 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(<DashboardHeader onQuickEntry={() => 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");
});
+15 -22
View File
@@ -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 (`<DashboardHero>`) 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<QuickEntryDialog>) => 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({
<PageHeader
title={t("dashboard.title")}
description={
showGreeting ? (
/* `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. */
<span
data-slot="dashboard-header-greeting"
className="block min-h-5 truncate"
>
{welcomeText}
</span>
) : 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. */
<span
data-slot="dashboard-header-greeting"
className="block min-h-5 truncate"
>
{welcomeText}
</span>
}
actions={
<>
@@ -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";
/**
* `<BriefingSpotlight>` 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<typeof BriefingSpotlight>,
locale: "en" | "de" = "en",
) {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<BriefingSpotlight {...props} />
</I18nProvider>,
);
}
function briefing(overrides: Partial<DailyBriefing> = {}): DailyBriefing {
return {
paragraph: "x",
signalsOfDay: null,
keyFindings: [],
...overrides,
};
}
describe("<BriefingSpotlight>", () => {
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[^"]*"/,
);
});
});
@@ -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 `<DashboardHero>` (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("<DashboardHeroSkeleton>", () => {
const html = renderToStaticMarkup(<DashboardHeroSkeleton />);
it("carries the data-slot and aria-hidden contract", () => {
expect(html).toContain('data-slot="dashboard-hero-skeleton"');
const root = html.match(
/<div[^>]*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("<button");
expect(html).not.toContain("<a ");
expect(html).not.toContain("<input");
expect(html).not.toContain("tabindex");
});
});
@@ -1,808 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { I18nProvider } from "@/lib/i18n/context";
import { makeFormatters } from "@/lib/format-locale";
import type { DashboardSnapshot } from "@/lib/dashboard/snapshot";
import { buildTargetBands } from "@/lib/dashboard/snapshot";
import type { MedsTodayBlock } from "@/lib/dashboard/meds-today";
import type { DataSummary } from "@/lib/analytics/trends";
import { DashboardHero } from "../dashboard-hero";
/**
* `<DashboardHero>` 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>): 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> = {}): MedsTodayBlock {
return {
activeCount: 0,
scheduledToday: 0,
takenToday: 0,
skippedToday: 0,
nextDueAt: null,
nextDueOverdue: false,
nextDueMedicationName: null,
...partial,
};
}
function baseSnapshot(
overrides: Partial<DashboardSnapshot> = {},
): 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(
<I18nProvider initialLocale={opts.locale ?? "de"}>
<DashboardHero
snapshot={snapshot}
onQuickEntry={opts.onQuickEntry ?? (() => undefined)}
/>
</I18nProvider>,
);
}
describe("<DashboardHero> — 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(/<button[^>]*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(/<button[^>]*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("<DashboardHero> — 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("</svg>", 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(
/<div[^>]*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(
/<div[^>]*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(
/<button[^>]*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(
/<div[^>]*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(
/<div[^>]*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("<DashboardHero> — 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("<DashboardHero> — 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(
/<button[^>]*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("<DashboardHero> — 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("<DashboardHero> — 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(
/<div[^>]*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("<DashboardHero> — typographic hierarchy", () => {
it("the greeting leads: larger + heavier than the verdict line", () => {
const html = render(baseSnapshot());
const greeting = html.match(
/<p[^>]*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(
/<p[^>]*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("<DashboardHero> — 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(/<ScoreRing[^>]*\n(?:[^>]*\n)*?\s*flat\b/);
});
});
@@ -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<DailyBriefingKeyFinding["tone"], string> = {
good: "bg-success",
watch: "bg-warning",
info: "bg-info",
};
const TONE_TEXT_CLASSNAME: Record<DailyBriefingKeyFinding["tone"], string> = {
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 (
<div data-slot="dashboard-briefing-spotlight" className="space-y-2">
{/* 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. */}
<Link
href="/insights#daily-briefing"
data-slot="dashboard-briefing-spotlight-title"
className="text-muted-foreground hover:text-foreground inline-block text-[11px] font-semibold tracking-wide uppercase transition-colors"
>
{t("dashboard.hero.briefing.title")}
</Link>
<div className="space-y-2">
{rows.map((row, index) => {
const Icon = METRIC_ICON[row.sourceMetric];
return (
<ListRow
key={`${row.sourceMetric}-${index}`}
asChild
data-slot="dashboard-briefing-spotlight-row"
data-metric={row.sourceMetric}
className={cn(
"border-border/60 bg-card/40 relative flex items-start gap-3",
"hover:bg-accent/40 transition-colors",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
)}
>
{/* 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. */}
<Link href={METRIC_HREF[row.sourceMetric] ?? "/insights"}>
<span
aria-hidden="true"
className={cn(
"absolute top-3 bottom-3 left-0 w-[3px] rounded-r",
TONE_BAR_CLASSNAME[row.tone],
)}
/>
<Icon
className={cn(
"mt-0.5 h-4 w-4 shrink-0",
TONE_TEXT_CLASSNAME[row.tone],
)}
aria-hidden="true"
/>
{/* Phone widths stack headline over delta — a long
signal headline beside a long delta squeezed the
headline into a one-word-per-line column and pushed
the delta past the tile edge. At `sm+` the
side-by-side row returns. The headline always wraps
(content is never truncated); only the delta is meta
and may stay on one line. */}
<div className="flex min-w-0 flex-1 flex-col gap-1 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
<p className="text-foreground text-sm leading-snug font-medium">
{stripChartTokens(row.headline)}
</p>
{row.delta ? (
<span
data-slot="dashboard-briefing-spotlight-delta"
className={cn(
"self-start text-xs font-semibold tabular-nums sm:shrink-0",
TONE_TEXT_CLASSNAME[row.tone],
)}
>
{row.delta}
</span>
) : null}
</div>
</Link>
</ListRow>
);
})}
</div>
</div>
);
}
@@ -1,47 +0,0 @@
import { Skeleton } from "@/components/ui/skeleton";
/**
* Loading silhouette for the dashboard hero band.
*
* Mirrors the real `<DashboardHero>` 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 `<Skeleton>` primitive
* (`motion-reduce:animate-none`).
*/
export function DashboardHeroSkeleton() {
return (
<div
aria-hidden="true"
data-slot="dashboard-hero-skeleton"
className="bg-card border-border relative isolate min-h-[8.75rem] overflow-hidden rounded-xl border p-4 md:min-h-[9.5rem] md:p-6"
>
<div className="flex h-full flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="min-w-0 flex-1 space-y-3">
{/* Greeting line. */}
<Skeleton className="h-4 w-48 max-w-full" />
{/* Verdict sentence + CTA slot. */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<Skeleton className="h-5 w-72 max-w-full" />
<Skeleton className="h-8 w-32" />
</div>
</div>
{/* Ring row — the always-present health-score circle (120 px). */}
<div className="flex shrink-0 items-center justify-center gap-3 md:justify-end">
<Skeleton className="size-[120px] rounded-full" />
</div>
</div>
</div>
);
}
@@ -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 `<ScoreRing>` 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 `<DashboardHeader>`; 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<DashboardVerdictVariant, "briefing">,
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<Record<DashboardVerdictVariant, string>> = {
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<Record<ScoreRingId, RingHue>> = {
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<ScoreRingId, string> = {
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/<slug>`) — 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<ScoreRingId, string> = {
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<QuickEntryDialog>) => 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<DashboardVerdictVariant, "briefing">) =>
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 <Link> (in the
// carousel) keeps tap-vs-drag native on mobile.
const scoreRingSlides = new Map<HeroRingId, HeroRingSlide>(
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 ? (
<ScoreRing
score={ring.score}
band="green"
hue="meds"
valueText={`${ring.doses.taken}/${ring.doses.scheduled}`}
ariaLabel={t("dashboard.hero.ringDosesAria", {
taken: ring.doses.taken,
scheduled: ring.doses.scheduled,
})}
size="sm"
flat
label={t(RING_LABEL_KEY[ring.id])}
/>
) : (
<ScoreRing
score={ring.score}
band={ring.band}
size="sm"
flat
hue={RING_HUE_BY_ID[ring.id]}
label={t(RING_LABEL_KEY[ring.id])}
/>
),
},
]),
);
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: (
<ScoreRing
score={snapshot.healthScore?.score ?? null}
band={snapshot.healthScore?.band}
size="sm"
flat
label={
snapshot.healthScore
? t("dashboard.hero.scoreLabel")
: hasScoreInputs
? t("dashboard.hero.scoreComputing")
: t("dashboard.hero.scoreProvisional")
}
/>
),
};
// 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 (
<section
data-slot="dashboard-hero"
className={cn(
// The band sits in the same column as the chart cards + tile
// strip, so it wears the same surface: plain `bg-card` with the
// shared border + radius + padding (mirrors `<TrendCard>`). 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. */}
<div className="flex h-full flex-col gap-4 md:gap-2">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="min-w-0 flex-1 space-y-2">
{/* 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. */}
<p
data-slot="dashboard-hero-greeting"
className="text-foreground text-lg font-semibold tracking-tight sm:text-xl"
>
{welcomeText}
</p>
{/* 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. */}
<div className="flex flex-col items-start gap-1.5">
{sentenceHref ? (
<Link
href={sentenceHref}
data-slot="dashboard-hero-verdict"
data-verdict-variant={verdict.variant}
className="text-foreground/90 hover:text-foreground min-w-0 text-base font-medium transition-colors"
>
{sentence}
</Link>
) : (
<p
data-slot="dashboard-hero-verdict"
data-verdict-variant={verdict.variant}
className="text-foreground/90 min-w-0 text-base font-medium"
>
{sentence}
</p>
)}
{cta !== null && ctaLabelKey ? (
cta.kind === "link" ? (
<Button
asChild
size="default"
variant="outline"
className="min-h-11 w-auto self-start sm:min-h-9"
data-slot="dashboard-hero-cta"
>
<Link href={cta.href}>{t(ctaLabelKey)}</Link>
</Button>
) : (
<Button
size="default"
className="min-h-11 w-auto self-start sm:min-h-9"
data-slot="dashboard-hero-cta"
onClick={() => onQuickEntry(cta.target)}
>
{t(ctaLabelKey)}
</Button>
)
) : null}
</div>
</div>
{/* 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. */}
<HeroRingCarousel slides={ringSlides} />
</div>
{/* 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`. */}
<RestModeBanner annotation={snapshot.healthScore?.restMode ?? null} />
{/* 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. */}
<BriefingSpotlight
briefing={snapshot.briefing}
briefingState={snapshot.briefingState}
briefingStale={snapshot.briefingStale}
/>
</div>
</section>
);
}
@@ -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 `<ScoreRing>` 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<HTMLDivElement>(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 (
<div
data-slot="dashboard-hero-rings"
className="w-full md:w-auto md:shrink-0"
>
<div
ref={trackRef}
data-slot="dashboard-hero-ring-track"
role="group"
aria-label={t("dashboard.hero.ringCarousel.aria")}
tabIndex={multi ? 0 : undefined}
className={cn(
// Mobile: one full-width ring per viewport, native swipe between
// them; the scrollbar is hidden (the dots are the affordance).
"no-scrollbar flex snap-x snap-mandatory overflow-x-auto rounded-lg",
"motion-safe:scroll-smooth",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
// Desktop: back to the inline right-aligned ring row, every ring
// visible, no snapping — the pre-carousel layout unchanged.
"md:snap-none md:justify-end md:gap-6 md:overflow-x-visible md:rounded-none",
)}
>
{slides.map((slide) => (
<div
key={slide.key}
data-carousel-slide=""
{...(slide.ringId
? {
"data-slot": "dashboard-hero-ring",
"data-ring": slide.ringId,
}
: {})}
className={cn(
// Mobile: a full-width centred slide → exactly one ring shows.
"flex w-full shrink-0 snap-center items-center justify-center",
// Desktop: collapse to the ring's own width, inline in the row.
"md:w-auto",
)}
>
{slide.href ? (
// A real anchor sized to the ring: a tap navigates, a
// horizontal drag scrolls the track (native tap-vs-drag), and
// it is keyboard-focusable with a rounded focus ring.
<Link
href={slide.href}
aria-label={slide.linkLabel}
data-slot="dashboard-hero-ring-link"
className="focus-visible:ring-ring/50 rounded-full focus-visible:ring-2 focus-visible:outline-none"
>
{slide.node}
</Link>
) : (
slide.node
)}
</div>
))}
</div>
{multi ? (
<div
data-slot="dashboard-hero-ring-dots"
className="mt-2 flex items-center justify-center gap-1 md:hidden"
>
{slides.map((slide, i) => {
const isActive = i === active;
return (
<button
key={slide.key}
type="button"
onClick={() => goTo(i)}
data-slot="dashboard-hero-ring-dot"
data-active={isActive ? "true" : undefined}
aria-current={isActive ? "true" : undefined}
aria-label={t("dashboard.hero.ringCarousel.dot", {
index: i + 1,
total: slides.length,
})}
className="focus-visible:ring-ring/50 flex items-center justify-center rounded-full p-1.5 focus-visible:ring-2 focus-visible:outline-none"
>
<span
aria-hidden="true"
className={cn(
"block h-2 rounded-full transition-all",
isActive ? "bg-primary w-4" : "bg-muted-foreground/40 w-2",
)}
/>
</button>
);
})}
</div>
) : null}
</div>
);
}
@@ -159,11 +159,16 @@ describe("<DashboardLayoutSection> — drag-and-drop reorder", () => {
it("keeps the arrow buttons present after the drag handle lands (a11y fallback)", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
// 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("<DashboardLayoutSection> — 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("<DashboardLayoutSection> — hero visibility switch", () => {
it("renders one hero switch above the widget list", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
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(<DashboardLayoutSection id="dashboard-layout" />, "de");
expect(de).toContain("Tagesüberblick");
expect(de).toContain(
"Zeigt Begrüßung, Tagesfokus und Score-Ringe ganz oben auf dem Dashboard.",
);
const en = render(<DashboardLayoutSection id="dashboard-layout" />);
expect(en).toContain("Daily overview");
});
it("reflects heroVisible: default layout → unchecked, true → checked", () => {
const uncheckedHtml = render(
<DashboardLayoutSection id="dashboard-layout" />,
);
const unchecked = uncheckedHtml.match(
/<button[^>]*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(
<DashboardLayoutSection id="dashboard-layout" />,
);
const checked = checkedHtml.match(
/<button[^>]*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<DashboardLayout>\("\/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("<DashboardLayoutSection> — 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("<DashboardLayoutSection> — 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(<DashboardLayoutSection id="dashboard-layout" />);
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(<DashboardLayoutSection id="dashboard-layout" />);
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("<DashboardLayoutSection> — 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(<DashboardLayoutSection id="dashboard-layout" />);
const switches = html.match(/data-slot="score-ring-switch"/g) ?? [];
@@ -481,7 +417,7 @@ describe("<DashboardLayoutSection> — 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(<DashboardLayoutSection id="dashboard-layout" />);
const switches = html.match(/data-slot="score-ring-switch"/g) ?? [];
@@ -493,7 +429,6 @@ describe("<DashboardLayoutSection> — 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(<DashboardLayoutSection id="dashboard-layout" />);
@@ -522,7 +457,7 @@ describe("<DashboardLayoutSection> — hero score-ring picker (v1.27.7)", () =>
*/
describe("<DashboardLayoutSection> — 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(<DashboardLayoutSection id="dashboard-layout" />);
// A sortable ring row exists for the health-score anchor.
expect(html).toContain('data-slot="score-ring-row"');
@@ -543,7 +478,7 @@ describe("<DashboardLayoutSection> — 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(<DashboardLayoutSection id="dashboard-layout" />);
const anchorSwitch = html.match(
/<button[^>]*data-slot="score-ring-switch"[^>]*data-ring="HEALTH_SCORE"[^>]*>/,
@@ -554,7 +489,7 @@ describe("<DashboardLayoutSection> — 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(<DashboardLayoutSection id="dashboard-layout" />);
// 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("<DashboardLayoutSection> — 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"],
};
@@ -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 }) {
</div>
)}
{/* 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 && (
<div className="border-border bg-background/30 flex min-h-12 items-center justify-between gap-3 rounded-md border px-3 py-2">
<div className="min-w-0">
<label
htmlFor={heroToggleId}
className="text-foreground text-sm font-medium"
>
{t("dashboard.heroToggleLabel")}
</label>
<p className="text-muted-foreground text-xs">
{t("dashboard.heroToggleDescription")}
</p>
</div>
<Switch
id={heroToggleId}
checked={layout.heroVisible === true}
onCheckedChange={(v) => setHeroVisible(v)}
disabled={saveMutation.isPending}
data-slot="hero-visible-switch"
/>
</div>
)}
{/* 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 && (
<div
data-slot="score-rings-picker"
className="border-border bg-background/30 space-y-3 rounded-md border px-3 py-2"
+21 -43
View File
@@ -395,62 +395,40 @@ describe("resolveDashboardLayout() — chartOverlayPrefs (v1.4.18)", () => {
});
/**
* 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<string, unknown>;
expect(serialized).not.toHaveProperty("heroVisible");
});
});
+2 -22
View File
@@ -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,
@@ -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))