mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
d6ad76b1e1
commit
1d592bef78
29 files changed
+183
-2719
No files matched your search
@@ -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
@@ -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") {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user