feat(dashboard): open the day with a verdict

The dashboard now leads with a daily band: the greeting, the one item
that matters right now — an overdue or upcoming dose, a very high
blood-pressure reading, weight drifting from its range, a run of short
nights, a quiet stretch, a score drop, or the briefing's key finding —
with a single action, today's dose tally beside the next due time, and
the health score as a ring on the right. The verdict resolves from a
fixed nine-rung ladder on the client with the thresholds shared from
the nudge engine through a dependency-free module, the blood-pressure
rung uses fixed crisis floors rather than personal target ranges, and
a cached next-due in the past never renders as overdue. The band
reserves its exact footprint from the first paint, swaps in with the
tiles in one pass, respects the hour-format preference, and can be
turned off in the dashboard settings; the greeting moves out of the
page header into the band.
This commit is contained in:
Marc-André Bombeck committed 2026-06-12 09:26:55 +02:00
1 parent ad33fa46ed
commit ea29e9cf1b
20 files changed
+1451 -94

No files matched your search

+30
View File
@@ -274,6 +274,34 @@
},
"welcomeBack": "{greeting}, willkommen zurück.",
"welcomeBackWithName": "{greeting} {name}, willkommen zurück.",
"hero": {
"scoreLabel": "Health Score",
"scoreProvisional": "Noch nicht genug Daten",
"doses": {
"summary": "{taken} von {scheduled} Dosen genommen",
"nextAt": "Nächste um {time}",
"allDone": "Alle Dosen für heute erledigt",
"none": "Heute keine Dosen geplant"
},
"verdict": {
"doseOverdue": "Eine Dosis {name} ist überfällig.",
"bpHigh": "Dein letzter Blutdruck war sehr hoch ({sys}/{dia}). Miss in Ruhe noch einmal nach.",
"doseUpcoming": "Um {time} steht {name} an.",
"weightDrift": "Dein Gewicht bewegt sich diese Woche vom Zielbereich weg.",
"shortNights": "Deine Nächte waren zuletzt kurz — im Schnitt {hours} h.",
"silence": "Seit {days} Tagen keine Messung. Ein kurzer Eintrag hält dein Bild aktuell.",
"scoreDrop": "Dein Score liegt {points} Punkte unter der Vorwoche.",
"allQuiet": "Alles ruhig — heute gibt es nichts, das deine Aufmerksamkeit braucht."
},
"action": {
"takeDose": "Jetzt eintragen",
"viewBp": "Blutdruck ansehen",
"viewWeight": "Gewicht ansehen",
"viewSleep": "Schlaf ansehen",
"logMeasurement": "Messung erfassen",
"viewInsights": "Insights öffnen"
}
},
"greetingSalutation": "Hallo, {name}",
"streakLabel": "Tage in Folge",
"weight": "Gewicht",
@@ -323,6 +351,8 @@
"compliance7d": "Therapietreue (7 Tage)",
"mood": "Stimmung",
"customizeTitle": "Dashboard-Layout",
"heroToggleLabel": "Tagesüberblick",
"heroToggleDescription": "Zeigt Score, Tagesfokus und Dosen-Status ganz oben auf dem Dashboard.",
"layoutReset": "Auf Defaults zurücksetzen",
"layoutSaveSuccess": "Dashboard-Layout gespeichert",
"layoutSaveError": "Layout konnte nicht gespeichert werden",
+30
View File
@@ -274,6 +274,34 @@
},
"welcomeBack": "{greeting}, welcome back.",
"welcomeBackWithName": "{greeting} {name}, welcome back.",
"hero": {
"scoreLabel": "Health Score",
"scoreProvisional": "Not enough data yet",
"doses": {
"summary": "{taken} of {scheduled} doses taken",
"nextAt": "Next at {time}",
"allDone": "All doses done for today",
"none": "No doses scheduled today"
},
"verdict": {
"doseOverdue": "A dose of {name} is overdue.",
"bpHigh": "Your last blood pressure was very high ({sys}/{dia}). Take another reading at rest.",
"doseUpcoming": "{name} is due at {time}.",
"weightDrift": "Your weight is moving away from the target range this week.",
"shortNights": "Your nights have been short lately — {hours} h on average.",
"silence": "No measurement for {days} days. A quick entry keeps your picture current.",
"scoreDrop": "Your score sits {points} points below last week.",
"allQuiet": "All quiet — nothing needs your attention today."
},
"action": {
"takeDose": "Log it now",
"viewBp": "View blood pressure",
"viewWeight": "View weight",
"viewSleep": "View sleep",
"logMeasurement": "Log measurement",
"viewInsights": "Open Insights"
}
},
"greetingSalutation": "Hi, {name}",
"streakLabel": "days in a row",
"weight": "Weight",
@@ -323,6 +351,8 @@
"compliance7d": "Adherence (7 days)",
"mood": "Mood",
"customizeTitle": "Dashboard layout",
"heroToggleLabel": "Daily overview",
"heroToggleDescription": "Shows your score, daily focus and dose status at the top of the dashboard.",
"layoutReset": "Reset to defaults",
"layoutSaveSuccess": "Dashboard layout saved",
"layoutSaveError": "Could not save dashboard layout",
+30
View File
@@ -274,6 +274,34 @@
},
"welcomeBack": "{greeting}, te damos la bienvenida de nuevo.",
"welcomeBackWithName": "{greeting} {name}, te damos la bienvenida de nuevo.",
"hero": {
"scoreLabel": "Health Score",
"scoreProvisional": "Aún no hay datos suficientes",
"doses": {
"summary": "{taken} de {scheduled} dosis tomadas",
"nextAt": "Próxima a las {time}",
"allDone": "Todas las dosis de hoy completadas",
"none": "Hoy no hay dosis programadas"
},
"verdict": {
"doseOverdue": "Una dosis de {name} está pendiente desde hace rato.",
"bpHigh": "Tu última tensión arterial fue muy alta ({sys}/{dia}). Vuelve a medirla con calma.",
"doseUpcoming": "A las {time} toca {name}.",
"weightDrift": "Tu peso se está alejando del rango objetivo esta semana.",
"shortNights": "Tus noches han sido cortas últimamente — {hours} h de media.",
"silence": "Sin mediciones desde hace {days} días. Un registro rápido mantiene tu panorama al día.",
"scoreDrop": "Tu puntuación está {points} puntos por debajo de la semana pasada.",
"allQuiet": "Todo tranquilo — hoy no hay nada que requiera tu atención."
},
"action": {
"takeDose": "Registrar ahora",
"viewBp": "Ver tensión arterial",
"viewWeight": "Ver peso",
"viewSleep": "Ver sueño",
"logMeasurement": "Registrar medición",
"viewInsights": "Abrir Insights"
}
},
"greetingSalutation": "Hola, {name}",
"streakLabel": "días seguidos",
"weight": "Peso",
@@ -323,6 +351,8 @@
"compliance7d": "Cumplimiento (7 días)",
"mood": "Estado de ánimo",
"customizeTitle": "Diseño del panel",
"heroToggleLabel": "Resumen del día",
"heroToggleDescription": "Muestra la puntuación, el foco del día y el estado de las dosis en la parte superior del panel.",
"layoutReset": "Restablecer valores por defecto",
"layoutSaveSuccess": "Diseño del panel guardado",
"layoutSaveError": "No se ha podido guardar el diseño",
+30
View File
@@ -274,6 +274,34 @@
},
"welcomeBack": "{greeting}, content de te revoir.",
"welcomeBackWithName": "{greeting} {name}, content de te revoir.",
"hero": {
"scoreLabel": "Health Score",
"scoreProvisional": "Pas encore assez de données",
"doses": {
"summary": "{taken} dose(s) prise(s) sur {scheduled}",
"nextAt": "Prochaine à {time}",
"allDone": "Toutes les doses du jour sont prises",
"none": "Aucune dose prévue aujourd’hui"
},
"verdict": {
"doseOverdue": "Une dose de {name} est en retard.",
"bpHigh": "Votre dernière tension était très élevée ({sys}/{dia}). Reprenez une mesure au calme.",
"doseUpcoming": "{name} est prévu à {time}.",
"weightDrift": "Votre poids s’éloigne de la plage cible cette semaine.",
"shortNights": "Vos nuits ont été courtes récemment — {hours} h en moyenne.",
"silence": "Aucune mesure depuis {days} jours. Une saisie rapide garde votre tableau à jour.",
"scoreDrop": "Votre score est {points} points sous la semaine dernière.",
"allQuiet": "Tout est calme — rien ne demande votre attention aujourd’hui."
},
"action": {
"takeDose": "Enregistrer maintenant",
"viewBp": "Voir la tension",
"viewWeight": "Voir le poids",
"viewSleep": "Voir le sommeil",
"logMeasurement": "Saisir une mesure",
"viewInsights": "Ouvrir Insights"
}
},
"greetingSalutation": "Bonjour, {name}",
"streakLabel": "jours d’affilée",
"weight": "Poids",
@@ -323,6 +351,8 @@
"compliance7d": "Observance (7 jours)",
"mood": "Humeur",
"customizeTitle": "Mise en page du tableau",
"heroToggleLabel": "Aperçu du jour",
"heroToggleDescription": "Affiche le score, le focus du jour et l’état des doses tout en haut du tableau de bord.",
"layoutReset": "Rétablir les valeurs par défaut",
"layoutSaveSuccess": "Mise en page enregistrée",
"layoutSaveError": "Impossible d’enregistrer la mise en page",
+30
View File
@@ -274,6 +274,34 @@
},
"welcomeBack": "{greeting}, bentornato.",
"welcomeBackWithName": "{greeting} {name}, bentornato.",
"hero": {
"scoreLabel": "Health Score",
"scoreProvisional": "Dati ancora insufficienti",
"doses": {
"summary": "{taken} dosi assunte su {scheduled}",
"nextAt": "Prossima alle {time}",
"allDone": "Tutte le dosi di oggi completate",
"none": "Nessuna dose in programma oggi"
},
"verdict": {
"doseOverdue": "Una dose di {name} è in ritardo.",
"bpHigh": "L’ultima pressione era molto alta ({sys}/{dia}). Misurala di nuovo con calma.",
"doseUpcoming": "Alle {time} è prevista {name}.",
"weightDrift": "Questa settimana il tuo peso si sta allontanando dall’intervallo obiettivo.",
"shortNights": "Le tue notti sono state corte di recente — in media {hours} h.",
"silence": "Nessuna misurazione da {days} giorni. Una voce rapida tiene aggiornato il quadro.",
"scoreDrop": "Il tuo punteggio è {points} punti sotto la settimana scorsa.",
"allQuiet": "Tutto tranquillo — oggi non c’è nulla che richieda la tua attenzione."
},
"action": {
"takeDose": "Registra ora",
"viewBp": "Vedi pressione",
"viewWeight": "Vedi peso",
"viewSleep": "Vedi sonno",
"logMeasurement": "Registra misurazione",
"viewInsights": "Apri Insights"
}
},
"greetingSalutation": "Ciao, {name}",
"streakLabel": "giorni di fila",
"weight": "Peso",
@@ -323,6 +351,8 @@
"compliance7d": "Aderenza (7 giorni)",
"mood": "Umore",
"customizeTitle": "Layout della dashboard",
"heroToggleLabel": "Panoramica del giorno",
"heroToggleDescription": "Mostra punteggio, focus del giorno e stato delle dosi in cima alla dashboard.",
"layoutReset": "Ripristina i valori predefiniti",
"layoutSaveSuccess": "Layout della dashboard salvato",
"layoutSaveError": "Impossibile salvare il layout",
+30
View File
@@ -274,6 +274,34 @@
},
"welcomeBack": "{greeting}, witaj ponownie.",
"welcomeBackWithName": "{greeting} {name}, witaj ponownie.",
"hero": {
"scoreLabel": "Health Score",
"scoreProvisional": "Za mało danych",
"doses": {
"summary": "Przyjęto {taken} z {scheduled} dawek",
"nextAt": "Następna o {time}",
"allDone": "Wszystkie dzisiejsze dawki przyjęte",
"none": "Brak zaplanowanych dawek na dziś"
},
"verdict": {
"doseOverdue": "Dawka {name} jest zaległa.",
"bpHigh": "Twoje ostatnie ciśnienie było bardzo wysokie ({sys}/{dia}). Zmierz je ponownie w spokoju.",
"doseUpcoming": "O {time} pora na {name}.",
"weightDrift": "Twoja waga oddala się w tym tygodniu od zakresu docelowego.",
"shortNights": "Twoje noce były ostatnio krótkie — średnio {hours} h.",
"silence": "Brak pomiarów od {days} dni. Szybki wpis utrzyma aktualny obraz.",
"scoreDrop": "Twój wynik jest o {points} punktów niższy niż w zeszłym tygodniu.",
"allQuiet": "Spokojnie — dziś nic nie wymaga Twojej uwagi."
},
"action": {
"takeDose": "Zapisz teraz",
"viewBp": "Zobacz ciśnienie",
"viewWeight": "Zobacz wagę",
"viewSleep": "Zobacz sen",
"logMeasurement": "Zapisz pomiar",
"viewInsights": "Otwórz Insights"
}
},
"greetingSalutation": "Cześć, {name}",
"streakLabel": "dni z rzędu",
"weight": "Waga",
@@ -323,6 +351,8 @@
"compliance7d": "Przestrzeganie (7 dni)",
"mood": "Nastrój",
"customizeTitle": "Układ panelu",
"heroToggleLabel": "Przegląd dnia",
"heroToggleDescription": "Pokazuje wynik, fokus dnia i status dawek na górze pulpitu.",
"layoutReset": "Przywróć wartości domyślne",
"layoutSaveSuccess": "Układ panelu zapisany",
"layoutSaveError": "Nie udało się zapisać układu",
@@ -0,0 +1,82 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
/**
* Dashboard hero — page + header wiring pins.
*
* The hero band mounts between the page header and the tile strip:
*
* - it gates on the layout's `heroVisible` flag (anything but the
* literal `false` renders — the resolver's clamp) AND on snapshot
* mode (the verdict needs the snapshot payload);
* - while `primaryLoading` is true (same `!mounted ||` pin the tile
* silhouettes use) a footprint-identical skeleton holds the band,
* so the hero and the tiles swap to content in the SAME render
* pass;
* - the greeting paragraph left the header for the hero. The header
* renders no name-bearing text at all any more, so the SSR pass
* stays name-free by construction (the hero itself only mounts
* once the snapshot resolved — post-hydration).
*/
const PAGE_PATH = join(process.cwd(), "src/app/page.tsx");
const HEADER_PATH = join(
process.cwd(),
"src/components/dashboard/dashboard-header.tsx",
);
describe("dashboard hero — page wiring", () => {
const src = readFileSync(PAGE_PATH, "utf8");
it("gates the hero on snapshot mode + the layout's heroVisible flag", () => {
expect(src).toMatch(
/const\s+heroVisible\s*=\s*snapshotEnabled\s*&&\s*layout\.heroVisible\s*!==\s*false;/,
);
});
it("holds the skeleton while primaryLoading and swaps the hero in the same pass", () => {
const block = src.match(
/\{heroVisible\s*&&[\s\S]*?<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 &&");
const stripIdx = src.indexOf("dashboard-tile-strip");
expect(headerIdx).toBeGreaterThan(-1);
expect(heroIdx).toBeGreaterThan(headerIdx);
expect(stripIdx).toBeGreaterThan(heroIdx);
});
it("the page no longer derives the welcome text (it moved into the hero)", () => {
expect(src).not.toContain("welcomeText");
expect(src).not.toContain("welcomeBackWithName");
expect(src).not.toContain("getHourForTimeZone");
});
});
describe("dashboard hero — header handoff", () => {
const header = readFileSync(HEADER_PATH, "utf8");
it("the header dropped the greeting paragraph and its reservation", () => {
expect(header).not.toContain("welcomeText");
expect(header).not.toContain("min-h-5");
});
it("the header keeps the title + customize + add actions", () => {
expect(header).toContain('t("dashboard.title")');
expect(header).toContain("dashboard-customize-shortcut");
expect(header).toContain("dashboard-quick-add");
});
});
@@ -74,15 +74,14 @@ describe("v1.4.40 — dashboard per-cell Suspense boundaries", () => {
expect(moduleSlice).toMatch(/const\s+DASHBOARD_QUERY_OPTS\s*=\s*\{/);
});
it("memoises the hour-of-day derivation against user.timezone (audit-H4)", () => {
it("keeps the per-render Intl hour walk off the page (audit-H4 successor)", () => {
const src = load(PAGE_PATH);
// The greeting hour only changes when the user's timezone changes
// — a `useMemo` keyed on the lifted `userTimezone` local keeps the
// `Intl.DateTimeFormat` instantiation off the per-render hot path.
// Post-W-INFRA Thread 2: `user?.timezone` is lifted to a `userTimezone`
// local one line above the `useMemo` so the dep array stays stable.
expect(src).toMatch(
/const\s+hour\s*=\s*useMemo\([\s\S]*?\[\s*userTimezone\s*\][\s\S]*?\);/,
);
// The audit-H4 `useMemo(getHourForTimeZone, [userTimezone])` pin
// retired with the greeting itself: the hero band derives the
// daypart from the snapshot's server-computed `greetingHour`
// (`dashboard-hero.tsx`), so the page no longer instantiates any
// `Intl.DateTimeFormat` for the greeting — per render or otherwise.
expect(src).not.toContain("getHourForTimeZone");
expect(src).not.toContain("welcomeText");
});
});
+45 -52
View File
@@ -36,12 +36,13 @@ import {
useDashboardChartReveal,
} from "@/components/dashboard/chart-reveal";
import { DashboardHeader } from "@/components/dashboard/dashboard-header";
import { DashboardHero } from "@/components/dashboard/hero/dashboard-hero";
import { DashboardHeroSkeleton } from "@/components/dashboard/hero/dashboard-hero-skeleton";
import {
QuickEntrySheets,
type QuickEntryDialog,
} from "@/components/dashboard/quick-entry-sheets";
import {
getHourForTimeZone,
getRangeColorClass,
getRangeHint,
toHoursSummary,
@@ -684,42 +685,49 @@ export default function DashboardPage() {
upperBound: 55,
},
);
// v1.4.40 W-RSC — memoise the per-render `Intl.DateTimeFormat`
// instantiation (audit-H4). The greeting derived from the hour only
// changes when the user's timezone changes; recomputing it every
// render churned the parts-array and walked the format ICU twice per
// render across a 1 400-line component body.
// Gated on `mounted`: `user` comes from the auth query, which can
// resolve before this boundary hydrates — the greeting text must
// match the name-less SSR output during hydration (React #418) and
// may only personalise from the first client re-render.
const userTimezone = mounted ? user?.timezone : undefined;
const hour = useMemo(
() => (userTimezone ? getHourForTimeZone(userTimezone) : null),
[userTimezone],
);
const timeGreeting =
hour == null
? t("dashboard.greeting.day")
: hour >= 5 && hour < 12
? t("dashboard.greeting.morning")
: hour >= 12 && hour < 18
? t("dashboard.greeting.day")
: t("dashboard.greeting.evening");
const welcomeText =
mounted && user?.username && user.username.trim().length > 0
? t("dashboard.welcomeBackWithName", {
greeting: timeGreeting,
name: user.username,
})
: t("dashboard.welcomeBack", { greeting: timeGreeting });
// v1.7.0 — the primary data source differs by flag. In snapshot
// mode `analyticsSlimQuery` is `enabled: false`, and a disabled
// TanStack query reports `fetchStatus: "idle"` → `isLoading` is
// always `false`. Gating the skeleton on the slim query then
// never fires under snapshot mode, so the empty-state branch
// below flashes for the whole snapshot fetch. Key the loading
// flag off whichever query is actually driving the tiles.
// v1.16.4 — `!mounted` pins the SSR pass AND the hydration
// render to the skeleton branch. Server-side the data queries
// are `enabled: false` (no auth) → `isLoading: false`, which
// used to select the empty state; a late-hydrating boundary
// then saw `isAuthenticated` already resolved and rendered the
// skeleton instead — React #418. See `useMounted`.
// v1.16.9 — hoisted out of the strip IIFE: the hero band below
// shares the same gate so its skeleton and the tile silhouettes
// swap to content in the same render pass.
const primaryLoading =
!mounted ||
(snapshotEnabled ? snapshotQuery.isLoading : analyticsSlimQuery.isLoading);
// v1.16.9 — the greeting paragraph moved off the header into the
// hero band (it derives from the snapshot's server-computed
// `greetingHour`, so the old client-side Intl hour walk retired
// with it). The hero gates on the layout's `heroVisible` flag
// (default on; anything but the literal `false` renders) and holds
// a footprint-identical skeleton while the snapshot is in flight,
// so the band swaps in place — no growth, no greeting flash.
const heroVisible = snapshotEnabled && layout.heroVisible !== false;
const heroSnapshot = snapshotQuery.data;
return (
<div className="space-y-6">
<DashboardHeader
welcomeText={welcomeText}
onQuickEntry={setQuickEntryDialog}
/>
<DashboardHeader onQuickEntry={setQuickEntryDialog} />
{heroVisible &&
(primaryLoading || !heroSnapshot ? (
<DashboardHeroSkeleton />
) : (
<DashboardHero
snapshot={heroSnapshot}
onQuickEntry={setQuickEntryDialog}
/>
))}
{/* v1.4.15 Phase B5 — spotlight tour for first-time users.
* Self-gates on `user.onboardingTourCompleted` (DB flag) plus
@@ -1403,24 +1411,9 @@ export default function DashboardPage() {
// strip tile) and undercounted `bp` (one widget id, two tiles),
// so the silhouettes reshuffled when the data landed.
const configuredTileCount = resolveConfiguredTileCount(layout);
// v1.7.0 — the primary data source differs by flag. In snapshot
// mode `analyticsSlimQuery` is `enabled: false`, and a disabled
// TanStack query reports `fetchStatus: "idle"` → `isLoading` is
// always `false`. Gating the skeleton on the slim query then
// never fires under snapshot mode, so the empty-state branch
// below flashes for the whole snapshot fetch. Key the loading
// flag off whichever query is actually driving the tiles.
// v1.16.4 — `!mounted` pins the SSR pass AND the hydration
// render to the skeleton branch. Server-side the data queries
// are `enabled: false` (no auth) → `isLoading: false`, which
// used to select the empty state; a late-hydrating boundary
// then saw `isAuthenticated` already resolved and rendered the
// skeleton instead — React #418. See `useMounted`.
const primaryLoading =
!mounted ||
(snapshotEnabled
? snapshotQuery.isLoading
: analyticsSlimQuery.isLoading);
// `primaryLoading` is derived once in the component body (above
// the return) — the hero band and this strip share the same
// gate so both surfaces swap in the same render pass.
// v1.16.8 — chart-row reservation while the snapshot is in
// flight. The chart visibility gates above need snapshot data
// (`count > 0`), so the cold chart row only carried the
+8 -25
View File
@@ -1,8 +1,10 @@
"use client";
/**
* Dashboard page header: title + greeting line, the customize shortcut,
* and the quick-add dropdown that opens the quick-entry sheets.
* Dashboard page header: title, the customize shortcut, and the
* quick-add dropdown that opens the quick-entry sheets. The greeting
* line moved into the hero band (`<DashboardHero>`), which derives it
* from the snapshot's server-computed `greetingHour`.
*
* Extracted from the dashboard page; the page owns the open-state the
* dropdown items set via `onQuickEntry`.
@@ -20,35 +22,16 @@ import { useTranslations } from "@/lib/i18n/context";
import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets";
export function DashboardHeader({
welcomeText,
onQuickEntry,
}: {
welcomeText: string;
onQuickEntry: (dialog: NonNullable<QuickEntryDialog>) => void;
}) {
const { t } = useTranslations();
return (
/* v1.4.37 W4a item 7 — centre-align the Hinzufügen button
against the 2-line title block on mobile (< sm). The
`welcomeText` line wraps under the title at < 380 px so the
button used to float at the top of the row without a
baseline anchor; `items-center sm:items-start` keeps the
mobile vertical centre while preserving the original
top-aligned posture on sm+ (where the title is one line). */
<div className="flex items-center justify-between gap-4 sm:items-start">
<div>
<h1 className="text-2xl font-bold tracking-tight">
{t("dashboard.title")}
</h1>
{/* v1.16.8 — `min-h-5` reserves the greeting's line box from the
SSR pass on: the text personalises (name appended) on the
first client re-render after hydration, and the reserved
line keeps the header from collapsing/growing around that
swap. */}
<p className="text-muted-foreground mt-1 min-h-5 text-sm">
{welcomeText}
</p>
</div>
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold tracking-tight">
{t("dashboard.title")}
</h1>
<div className="flex items-center gap-2">
{/* Customize shortcut to the dashboard-customization settings
(the tile/layout editor at /settings/dashboard). Sits to
@@ -0,0 +1,48 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { DashboardHeroSkeleton } from "../dashboard-hero-skeleton";
/**
* Structural pin for the hero loading silhouette: the skeleton must
* occupy the EXACT final footprint of `<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", () => {
for (const cls of [
"min-h-[8.75rem]",
"md:min-h-[9.5rem]",
"hero-gradient",
"rounded-xl",
]) {
expect(html).toContain(cls);
}
});
it("mirrors the two-column md:flex-row split with the fixed ring circle", () => {
expect(html).toContain("md:flex-row");
expect(html).toContain("size-[120px]");
expect(html).toContain("rounded-full");
});
it("contains no focusable elements", () => {
expect(html).not.toContain("<button");
expect(html).not.toContain("<a ");
expect(html).not.toContain("<input");
expect(html).not.toContain("tabindex");
});
});
@@ -0,0 +1,479 @@
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 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);
* - the dose row prints the day tally with next-at / all-done / none
* detail, and the documented stale-cache state (past `nextDueAt`,
* `nextDueOverdue: false`) renders as the plain summary — never as
* overdue;
* - the greeting derives from the snapshot's server-computed
* `greetingHour` and personalises from the snapshot username;
* - the right column always paints the `sm` ScoreRing — provisional
* (never "0") when the score is null.
*
* The component reads the wall clock (`new Date()`) to age the cached
* snapshot honestly, so the suite pins the system time; the snapshot's
* own timezone (`Europe/Berlin`) keeps every assertion independent of
* the host zone.
*/
const NOW = new Date("2026-06-10T10:00:00.000Z"); // 12:00 Europe/Berlin
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
function summary(partial: Partial<DataSummary>): 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: {},
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("bpCritical: fixed-floor sentence with the reading + link to the BP insight", () => {
const html = render(
baseSnapshot({
tiles: {
summaries: {
BLOOD_PRESSURE_SYS: summary({ latest: 184, count: 5 }),
BLOOD_PRESSURE_DIA: summary({ latest: 96, count: 5 }),
},
lastSeenByType: {
BLOOD_PRESSURE_SYS: {
lastSeenAt: isoHoursFromNow(-2),
daysAgo: 0,
},
},
mood: { summary: null, entries: [] },
},
}),
);
expect(html).toContain('data-verdict-variant="bpCritical"');
expect(html).toContain("sehr hoch (184/96)");
expect(html).toContain('href="/insights/blood-pressure"');
expect(html).toContain("Blutdruck ansehen");
});
it("doseUpcoming: preference-aware wall-clock time + name + quick-entry CTA", () => {
const nextDueAt = isoHoursFromNow(1.5); // 13:30 Berlin
const html = render(
baseSnapshot({
medsToday: medsToday({
scheduledToday: 2,
takenToday: 1,
nextDueAt,
nextDueMedicationName: "Jardiance",
}),
}),
);
const expectedTime = makeFormatters("de", "Europe/Berlin", "AUTO").time(
nextDueAt,
);
expect(html).toContain('data-verdict-variant="doseUpcoming"');
expect(html).toContain(`Um ${expectedTime} steht Jardiance an.`);
expect(html).toContain("Jetzt eintragen");
// The dose row repeats the same instant as "Nächste um …".
expect(html).toContain(`Nächste um ${expectedTime}`);
});
it("weightDrift: sentence + link to the weight insight", () => {
const html = render(
baseSnapshot({
user: { ...baseSnapshot().user, heightCm: 180 },
tiles: {
summaries: {
// Green range for 180 cm ≈ 59.9–80.7 kg; 7d mean sits 1 kg
// further outside than the 30d mean.
WEIGHT: summary({ avg7: 86, avg30: 85, count: 10 }),
},
lastSeenByType: {
WEIGHT: { lastSeenAt: isoHoursFromNow(-3), daysAgo: 0 },
},
mood: { summary: null, entries: [] },
},
}),
);
expect(html).toContain('data-verdict-variant="weightDrift"');
expect(html).toContain(
"Dein Gewicht bewegt sich diese Woche vom Zielbereich weg.",
);
expect(html).toContain('href="/insights/weight"');
expect(html).toContain("Gewicht ansehen");
});
it("shortNights: locale-formatted hours + link to the sleep insight", () => {
const html = render(
baseSnapshot({
tiles: {
summaries: {
// 372 min = 6.2 h — clearly under the 6.5 h fire line.
SLEEP_DURATION: summary({ avg7: 372, count: 7 }),
},
lastSeenByType: {
SLEEP_DURATION: { lastSeenAt: isoHoursFromNow(-5), daysAgo: 0 },
},
mood: { summary: null, entries: [] },
},
}),
);
expect(html).toContain('data-verdict-variant="shortNights"');
// German decimal comma via the locale number formatter.
expect(html).toContain("im Schnitt 6,2 h.");
expect(html).toContain('href="/insights/sleep"');
expect(html).toContain("Schlaf ansehen");
});
it("silence: day count + quick-entry measurement CTA", () => {
const html = render(
baseSnapshot({
tiles: {
summaries: {},
lastSeenByType: {
WEIGHT: { lastSeenAt: isoHoursFromNow(-9 * 24), daysAgo: 9 },
},
mood: { summary: null, entries: [] },
},
}),
);
expect(html).toContain('data-verdict-variant="silence"');
expect(html).toContain("Seit 9 Tagen keine Messung.");
expect(html).toContain("Messung erfassen");
expect(html).toMatch(/<button[^>]*data-slot="dashboard-hero-cta"/);
});
it("scoreDrop: point delta + link to /insights", () => {
const html = render(
baseSnapshot({
healthScore: { score: 58, band: "yellow", delta: -14 },
}),
);
expect(html).toContain('data-verdict-variant="scoreDrop"');
expect(html).toContain("liegt 14 Punkte unter der Vorwoche.");
expect(html).toContain('href="/insights"');
expect(html).toContain("Insights öffnen");
});
it("briefing: renders the model headline verbatim as plain text + Insights link", () => {
const headline = "Systolic crept up over seven days.";
const html = render(
baseSnapshot({
briefingState: "ready",
briefingStale: false,
briefingUpdatedAt: NOW.toISOString(),
briefing: {
paragraph: "p",
keyFindings: [
{
tone: "watch",
headline,
detail: "detail",
delta: null,
sourceWindow: "7d",
sourceMetric: "bp",
},
],
} as DashboardSnapshot["briefing"],
}),
);
expect(html).toContain('data-verdict-variant="briefing"');
expect(html).toContain(headline);
expect(html).toContain('href="/insights"');
expect(html).toContain("Insights öffnen");
});
it("allQuiet: sentence with NO CTA in the band", () => {
const html = render(baseSnapshot());
expect(html).toContain('data-verdict-variant="allQuiet"');
expect(html).toContain("Alles ruhig");
expect(html).not.toContain('data-slot="dashboard-hero-cta"');
});
it("exactly one CTA renders for an actionable verdict", () => {
const html = render(
baseSnapshot({
medsToday: medsToday({
scheduledToday: 1,
nextDueOverdue: true,
nextDueAt: isoHoursFromNow(-1),
nextDueMedicationName: "Metformin",
}),
healthScore: { score: 50, band: "yellow", delta: -20 },
}),
);
const ctas = html.match(/data-slot="dashboard-hero-cta"/g) ?? [];
expect(ctas).toHaveLength(1);
});
});
describe("<DashboardHero> — dose row", () => {
it("prints the day tally with the v1.16.6 card chrome and the Pill icon", () => {
const html = render(
baseSnapshot({
medsToday: medsToday({ scheduledToday: 3, takenToday: 1 }),
}),
);
expect(html).toContain("1 von 3 Dosen genommen");
const doseRow = html.match(
/<div[^>]*data-slot="dashboard-hero-doses"[^>]*>/,
);
expect(doseRow).not.toBeNull();
for (const cls of [
"bg-card/65",
"border-border/60",
"rounded-xl",
"shadow-sm",
"backdrop-blur-sm",
]) {
expect(doseRow![0]).toContain(cls);
}
});
it("all doses resolved (taken + skipped) → 'Alle Dosen für heute erledigt'", () => {
const html = render(
baseSnapshot({
medsToday: medsToday({
scheduledToday: 2,
takenToday: 1,
skippedToday: 1,
}),
}),
);
expect(html).toContain("1 von 2 Dosen genommen");
expect(html).toContain("Alle Dosen für heute erledigt");
expect(html).not.toContain("Nächste um");
});
it("no doses scheduled → 'Heute keine Dosen geplant'", () => {
const html = render(baseSnapshot());
expect(html).toContain("Heute keine Dosen geplant");
expect(html).not.toContain("Dosen genommen");
});
it("DEFENSIVE: a stale past nextDueAt with nextDueOverdue false renders the plain summary, never overdue", () => {
const html = render(
baseSnapshot({
medsToday: medsToday({
scheduledToday: 2,
takenToday: 1,
nextDueAt: isoHoursFromNow(-0.5), // anchor passed AFTER build
nextDueOverdue: false,
nextDueMedicationName: "Metformin",
}),
}),
);
expect(html).toContain("1 von 2 Dosen genommen");
expect(html).not.toContain("Nächste um");
expect(html).not.toContain("überfällig");
expect(html).toContain('data-verdict-variant="allQuiet"');
});
it("a next-due anchor on TOMORROW's local day stays off the dose row", () => {
const html = render(
baseSnapshot({
medsToday: medsToday({
scheduledToday: 2,
takenToday: 1,
nextDueAt: isoHoursFromNow(20), // 08:00 Berlin tomorrow
nextDueMedicationName: "Metformin",
}),
}),
);
expect(html).toContain("1 von 2 Dosen genommen");
expect(html).not.toContain("Nächste um");
});
});
describe("<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");
});
});
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("uses the hero-gradient chrome WITHOUT glow-purple", () => {
expect(src).toContain("hero-gradient");
expect(src).not.toContain("glow-purple");
});
});
@@ -0,0 +1,45 @@
import { Skeleton } from "@/components/ui/skeleton";
/**
* Loading silhouette for the dashboard hero band.
*
* Mirrors the real `<DashboardHero>` footprint exactly — same
* `.hero-gradient` chrome, same `min-h-[8.75rem] md:min-h-[9.5rem]`
* floor, same two-column `md:flex-row` split with the fixed 120 px ring
* circle on the right — so the swap to the loaded hero happens in place
* with zero layout shift. The left column reserves the greeting line,
* the verdict sentence + CTA row, and the dose-row pill.
*
* Always `aria-hidden` with no focusable content: the tile-strip
* skeleton alongside carries the page's loading semantics, and a second
* announcement here would double up for screen readers. Reduced motion
* is honoured inside the `<Skeleton>` primitive
* (`motion-reduce:animate-none`).
*/
export function DashboardHeroSkeleton() {
return (
<div
aria-hidden="true"
data-slot="dashboard-hero-skeleton"
className="hero-gradient relative isolate min-h-[8.75rem] overflow-hidden rounded-xl px-4 py-5 sm:px-6 md:min-h-[9.5rem]"
>
<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>
{/* Dose-row pill. */}
<Skeleton className="h-9 w-64 max-w-full rounded-xl" />
</div>
{/* Fixed right column — the ScoreRing's `sm` circle (120 px). */}
<div className="flex shrink-0 items-center justify-center md:justify-end">
<Skeleton className="size-[120px] rounded-full" />
</div>
</div>
</div>
);
}
@@ -0,0 +1,274 @@
"use client";
/**
* Dashboard hero — the daily-verdict band between the page header and
* the tile strip.
*
* Purely presentational: every input rides in on the already-resolved
* `DashboardSnapshot`, the verdict is re-derived on render with a fresh
* `now` (so a cached snapshot ages honestly), and the only callback is
* the quick-entry opener the page owns. Three blocks:
*
* - left column: greeting line (absorbed from the old header
* paragraph — the snapshot's server-computed `greetingHour` keeps
* the daypart free of any client `Intl` work), the verdict sentence
* with its single CTA button, and the dose row;
* - right column: the shared `<ScoreRing>` at `sm` geometry. A null
* score renders the ring's provisional state at the identical
* 120 px footprint, so the column never collapses.
*
* Defensive contract (cached snapshot): `medsToday.nextDueAt` in the
* past with `nextDueOverdue: false` means the slot's anchor passed
* after the snapshot was built. The verdict resolver already falls
* through (rung 1 keys on the flag, rung 3 requires a future anchor),
* and the dose row below only prints "next at" for a FUTURE anchor on
* the user's local today — the stale state renders as the plain day
* summary, never as overdue.
*
* Rung 8 (`briefing`) renders the model-authored headline VERBATIM as a
* plain React text child — no i18n key, no HTML, no markdown — per the
* repo-wide no-markdown-renderer rule.
*
* The page h1 stays in `<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 { Pill } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScoreRing } from "@/components/insights/derived/score-ring";
import {
resolveDashboardVerdict,
type DashboardVerdictVariant,
} from "@/lib/dashboard/verdict";
import type { DashboardSnapshot } from "@/lib/dashboard/snapshot";
import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets";
import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
import { makeFormatters } from "@/lib/format-locale";
import { cn } from "@/lib/utils";
/**
* Variant → message key. `briefing` is deliberately absent — its
* sentence is the model headline rendered verbatim, not a translation.
* The bpCritical rung surfaces under the `bpHigh` copy key.
*/
const VERDICT_MESSAGE_KEY: Record<
Exclude<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). */
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",
scoreDrop: "dashboard.hero.action.viewInsights",
briefing: "dashboard.hero.action.viewInsights",
};
/** YYYY-MM-DD key of `d`'s calendar day in `tz` (mirrors the resolver). */
function dayKeyInTz(d: Date, tz: string): string {
return new Intl.DateTimeFormat("en-CA", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(d);
}
export function DashboardHero({
snapshot,
onQuickEntry,
}: {
snapshot: DashboardSnapshot;
onQuickEntry: (dialog: NonNullable<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 ──────────────────────────────────────────
const sentence =
verdict.variant === "briefing"
? // Model headline VERBATIM as a plain text child — no key.
String(verdict.values.headline ?? "")
: t(VERDICT_MESSAGE_KEY[verdict.variant], {
...verdict.values,
// The resolver emits a fixed 24 h wall-clock string; re-format
// the underlying instant through the user's hour-cycle
// preference when we still hold it.
...(verdict.variant === "doseUpcoming" &&
snapshot.medsToday.nextDueAt !== null
? { time: fmt.time(snapshot.medsToday.nextDueAt) }
: {}),
...(verdict.variant === "shortNights" &&
typeof verdict.values.hours === "number"
? { hours: fmt.number(verdict.values.hours, 1) }
: {}),
});
const cta = verdict.cta;
const ctaLabelKey = CTA_LABEL_KEY[verdict.variant];
// ── Dose row ────────────────────────────────────────────────────────
const meds = snapshot.medsToday;
const now = new Date();
const tz = snapshot.user.timezone;
const unresolvedRemain =
meds.scheduledToday > meds.takenToday + meds.skippedToday;
const nextDueMs = meds.nextDueAt !== null ? Date.parse(meds.nextDueAt) : NaN;
// "Next at" only for a FUTURE anchor on the user's local today. A past
// anchor with `nextDueOverdue: false` is the documented stale-cache
// state and must render as the plain summary — never as overdue.
const showNextAt =
unresolvedRemain &&
Number.isFinite(nextDueMs) &&
nextDueMs >= now.getTime() &&
dayKeyInTz(new Date(nextDueMs), tz) === dayKeyInTz(now, tz);
const doseDetail =
meds.scheduledToday > 0
? !unresolvedRemain
? t("dashboard.hero.doses.allDone")
: showNextAt
? t("dashboard.hero.doses.nextAt", {
time: fmt.time(new Date(nextDueMs)),
})
: null
: null;
return (
<section
data-slot="dashboard-hero"
className={cn(
// `.hero-gradient` carries its own border tint; deliberately
// no glow treatment — the band sits above the tile strip and
// must read calmer than the /insights hero.
"hero-gradient relative isolate overflow-hidden rounded-xl",
"min-h-[8.75rem] px-4 py-5 sm:px-6 md:min-h-[9.5rem]",
)}
>
<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">
<p
data-slot="dashboard-hero-greeting"
className="text-muted-foreground text-sm"
>
{welcomeText}
</p>
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<p
data-slot="dashboard-hero-verdict"
data-verdict-variant={verdict.variant}
className="text-foreground text-base font-medium"
>
{sentence}
</p>
{cta !== null && ctaLabelKey ? (
cta.kind === "link" ? (
<Button
asChild
size="sm"
variant="outline"
data-slot="dashboard-hero-cta"
>
<Link href={cta.href}>{t(ctaLabelKey)}</Link>
</Button>
) : (
<Button
size="sm"
data-slot="dashboard-hero-cta"
onClick={() => onQuickEntry(cta.target)}
>
{t(ctaLabelKey)}
</Button>
)
) : null}
</div>
<div
data-slot="dashboard-hero-doses"
className="bg-card/65 border-border/60 inline-flex max-w-full items-center gap-2 rounded-xl border px-3 py-2 shadow-sm backdrop-blur-sm"
>
<Pill
className="text-muted-foreground h-4 w-4 shrink-0"
aria-hidden="true"
/>
<span className="text-foreground truncate text-sm">
{meds.scheduledToday > 0
? t("dashboard.hero.doses.summary", {
taken: meds.takenToday,
scheduled: meds.scheduledToday,
})
: t("dashboard.hero.doses.none")}
</span>
{doseDetail !== null ? (
<span className="text-muted-foreground shrink-0 text-sm">
· {doseDetail}
</span>
) : null}
</div>
</div>
{/* Right column — fixed 120 px ring footprint. A null score
renders the ring's provisional state (aria announces "not
enough data", never 0) at identical geometry, so the column
NEVER collapses. */}
<div className="flex shrink-0 items-center justify-center md:justify-end">
<ScoreRing
score={snapshot.healthScore?.score ?? null}
band={snapshot.healthScore?.band}
size="sm"
label={
snapshot.healthScore
? t("dashboard.hero.scoreLabel")
: t("dashboard.hero.scoreProvisional")
}
/>
</div>
</div>
</section>
);
}
@@ -1,5 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
DEFAULT_DASHBOARD_LAYOUT,
@@ -269,6 +271,76 @@ describe("<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 Score, Tagesfokus und Dosen-Status ganz oben auf dem Dashboard.",
);
const en = render(<DashboardLayoutSection id="dashboard-layout" />);
expect(en).toContain("Daily overview");
});
it("reflects heroVisible: default layout → checked, false → unchecked", () => {
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"');
queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: false };
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"');
});
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
@@ -153,6 +153,8 @@ export function DashboardLayoutSection({ id }: { id: string }) {
// tooltip. One hint paragraph is rendered once at the bottom of the list
// and referenced by every drag handle in this section.
const dragHintId = useId();
// Label ↔ switch hookup for the hero (Tagesüberblick) toggle below.
const heroToggleId = useId();
// v1.4.47 W4 — sensors: pointer for mouse/touch, keyboard for Tab + Space
// + arrow-key reordering. The KeyboardSensor still works for users who
@@ -243,6 +245,16 @@ export function DashboardLayoutSection({ id }: { id: string }) {
setDraft({ ...layout, comparisonBaseline: value });
}
/**
* Dashboard hero (daily verdict) visibility. Rides the layout blob
* (`heroVisible`, default on) and persists through the same PUT the
* widget toggles use — the Save button flushes it.
*/
function setHeroVisible(value: boolean) {
if (!layout) return;
setDraft({ ...layout, heroVisible: value });
}
/**
* v1.4.48 M6a — the arrow buttons now delegate to `reorderWidgets()`
* so the swap-and-renumber logic lives in exactly one place. The
@@ -377,6 +389,33 @@ export function DashboardLayoutSection({ id }: { id: string }) {
</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 !== false}
onCheckedChange={(v) => setHeroVisible(v)}
disabled={saveMutation.isPending}
data-slot="hero-visible-switch"
/>
</div>
)}
{isLoading || !layout ? (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
@@ -0,0 +1,134 @@
import { describe, it, expect } from "vitest";
import { readFileSync, existsSync, statSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
/**
* Client-safety pin for the verdict resolver's import graph.
*
* `resolveDashboardVerdict` runs inside the `"use client"` dashboard
* hero, so every VALUE import reachable from `verdict.ts` must stay off
* the server graph: no prisma, no `node:` builtins, no pg-boss, no
* `@/lib/jobs/coach-nudge` (whose dispatcher/feature-flag imports pull
* `node:async_hooks` — the exact regression class the dose-window
* editor hit when it imported `DOSE_WINDOW_DEFAULTS` from
* `@/lib/analytics/compliance`; see
* `src/lib/medications/scheduling/dose-window-defaults.ts`). The shared
* nudge thresholds therefore live in the dependency-free
* `@/lib/jobs/coach-nudge-thresholds` leaf.
*
* `import type` statements are erased by the compiler AND the bundler,
* so the walker skips them — the `DashboardSnapshot` type import from
* `@/lib/dashboard/snapshot` (a heavy server module) is legal exactly
* as long as it stays type-only. If someone drops the `type` keyword,
* the walker traverses into the snapshot builder and this suite fails.
*/
const SRC = join(process.cwd(), "src");
const ENTRY = join(SRC, "lib/dashboard/verdict.ts");
/** Specifier patterns that mark a server-only dependency. */
const BANNED_SPECIFIERS: RegExp[] = [
/^node:/,
/^@\/generated\//,
/^@\/lib\/db$/,
/^@prisma\//,
/^pg-boss$/,
/^server-only$/,
/^@\/lib\/jobs\/coach-nudge$/,
];
/**
* Extract VALUE-import specifiers from a module. Type-only statements
* (`import type … from`, `export type … from`) are erased at build
* time and skipped. Side-effect imports (`import "x"`) are included.
*/
function valueImportSpecifiers(source: string): string[] {
const out: string[] = [];
// Statement-anchored (`^import` / `^export`) and semicolon-bounded
// (`[^;]*?`) so the lazy scan can never swallow across statements or
// latch onto a `from "…"` inside an unrelated string / comment.
const stmt =
/^(import|export)(\s+type)?\s+[^;"']*?from\s*["']([^"']+)["']|^import\s*["']([^"']+)["']/gm;
let m: RegExpExecArray | null;
while ((m = stmt.exec(source)) !== null) {
if (m[4] !== undefined) {
out.push(m[4]); // bare side-effect import
continue;
}
if (m[2] !== undefined) continue; // `import type` / `export type` — erased
out.push(m[3]);
}
return out;
}
/** Resolve `@/…` and relative specifiers to a file inside `src/`. */
function resolveLocal(spec: string, fromFile: string): string | null {
let base: string;
if (spec.startsWith("@/")) base = join(SRC, spec.slice(2));
else if (spec.startsWith(".")) base = resolve(dirname(fromFile), spec);
else return null; // external package
for (const candidate of [
base,
`${base}.ts`,
`${base}.tsx`,
join(base, "index.ts"),
join(base, "index.tsx"),
]) {
if (existsSync(candidate) && statSync(candidate).isFile()) {
return candidate;
}
}
throw new Error(`unresolvable local import "${spec}" from ${fromFile}`);
}
function walkGraph(entry: string): {
visited: Set<string>;
specifiers: Set<string>;
} {
const visited = new Set<string>();
const specifiers = new Set<string>();
const queue = [entry];
while (queue.length > 0) {
const file = queue.pop()!;
if (visited.has(file)) continue;
visited.add(file);
for (const spec of valueImportSpecifiers(readFileSync(file, "utf8"))) {
specifiers.add(spec);
const local = resolveLocal(spec, file);
if (local !== null) queue.push(local);
}
}
return { visited, specifiers };
}
describe("verdict.ts — client-safe import graph", () => {
const { visited, specifiers } = walkGraph(ENTRY);
it("carries no server-only specifier anywhere in the value-import graph", () => {
const offenders = [...specifiers].filter((spec) =>
BANNED_SPECIFIERS.some((re) => re.test(spec)),
);
expect(offenders).toEqual([]);
});
it("never traverses into the snapshot builder (type-only boundary holds)", () => {
const snapshotModule = join(SRC, "lib/dashboard/snapshot.ts");
expect(visited.has(snapshotModule)).toBe(false);
});
it("reads the nudge thresholds from the dependency-free leaf", () => {
expect(specifiers.has("@/lib/jobs/coach-nudge-thresholds")).toBe(true);
const leaf = readFileSync(
join(SRC, "lib/jobs/coach-nudge-thresholds.ts"),
"utf8",
);
expect(valueImportSpecifiers(leaf)).toEqual([]);
});
it("the whole graph stays local — no external runtime package rides in", () => {
const external = [...specifiers].filter(
(spec) => !spec.startsWith("@/") && !spec.startsWith("."),
);
expect(external).toEqual([]);
});
});
+6 -4
View File
@@ -9,9 +9,11 @@
* Nine rungs, first hit wins. The ordering is a severity ladder:
* actionable medication states first, then a fixed-floor BP emergency,
* then trend nudges, then the briefing teaser, then the all-quiet
* fallback. Threshold constants are imported from the coach-nudge
* module where one exists so the hero and the nudge cron can never
* disagree on what "drift" or "deficit" means.
* fallback. Threshold constants are imported from the client-safe
* coach-nudge-thresholds leaf where one exists so the hero and the
* nudge cron can never disagree on what "drift" or "deficit" means —
* and so this module never pulls the cron's server graph into a
* client bundle (this resolver runs inside `"use client"` code).
*
* Freshness is re-derived from the snapshot's ISO timestamps against
* the injected `now` (not the snapshot's own `daysAgo` fields) so a
@@ -26,7 +28,7 @@
import {
COACH_NUDGE_WEIGHT_DRIFT_KG,
COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H,
} from "@/lib/jobs/coach-nudge";
} from "@/lib/jobs/coach-nudge-thresholds";
import { buildWeightRangeFromHeight } from "@/lib/analytics/value-bands";
import type { DashboardSnapshot } from "@/lib/dashboard/snapshot";
+19
View File
@@ -0,0 +1,19 @@
/**
* Coach-nudge trend thresholds shared with client-reachable code.
*
* Kept in a leaf module — free of any server-only import — because the
* dashboard hero's verdict resolver (`@/lib/dashboard/verdict`) reads
* these to stay numerically identical to the nudge cron, and that
* resolver runs inside a `"use client"` component. Importing them from
* `@/lib/jobs/coach-nudge` would drag the prisma/dispatcher server
* graph into the browser bundle (the same regression class the
* dose-window defaults hit — see
* `src/lib/medications/scheduling/dose-window-defaults.ts`).
* `@/lib/jobs/coach-nudge` re-exports both so server-side imports keep
* their path.
*/
/** Weight trigger: minimum kg the weekly mean must drift AWAY from the range. */
export const COACH_NUDGE_WEIGHT_DRIFT_KG = 0.5;
/** Sleep trigger: a night must undershoot the floor by this margin (h). */
export const COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H = 0.5;
+12 -4
View File
@@ -77,6 +77,18 @@ import { getServerTranslator } from "@/lib/i18n/server-translator";
import type { Locale } from "@/lib/i18n/config";
import { defaultLocale, locales } from "@/lib/i18n/config";
import { getEvent } from "@/lib/logging/context";
// Trend thresholds shared with the dashboard hero's verdict resolver
// live in a client-safe leaf module; re-exported below so server-side
// imports keep their path.
import {
COACH_NUDGE_WEIGHT_DRIFT_KG,
COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H,
} from "@/lib/jobs/coach-nudge-thresholds";
export {
COACH_NUDGE_WEIGHT_DRIFT_KG,
COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H,
} from "@/lib/jobs/coach-nudge-thresholds";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
@@ -109,14 +121,10 @@ export const COACH_NUDGE_SELF_CONTEXT_STALE_DAYS = 60;
export const COACH_NUDGE_COACH_ACTIVE_DAYS = 14;
/** Weight trigger: minimum readings per weekly window. */
export const COACH_NUDGE_WEIGHT_MIN_READINGS = 3;
/** Weight trigger: minimum kg the weekly mean must drift AWAY from the range. */
export const COACH_NUDGE_WEIGHT_DRIFT_KG = 0.5;
/** Sleep trigger: minimum recorded nights in the 7-day window. */
export const COACH_NUDGE_SLEEP_MIN_NIGHTS = 5;
/** Sleep trigger: nights under the floor required to fire. */
export const COACH_NUDGE_SLEEP_DEFICIT_NIGHTS = 4;
/** Sleep trigger: a night must undershoot the floor by this margin (h). */
export const COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H = 0.5;
/** Gap trigger: distinct active days required in the prior 3 weeks. */
export const COACH_NUDGE_GAP_MIN_ACTIVE_DAYS = 10;
/** Gap trigger: length of the silent window that fires (days). */