mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
ad33fa46ed
commit
ea29e9cf1b
20 files changed
+1451
-94
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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). */
|
||||
|
||||
Reference in new issue
Block a user