diff --git a/messages/de.json b/messages/de.json
index 7b5c36cd2..5d5fab0d6 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -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",
diff --git a/messages/en.json b/messages/en.json
index d50aa6490..8f1d22f32 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -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",
diff --git a/messages/es.json b/messages/es.json
index 593d43f7b..35af7d077 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -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",
diff --git a/messages/fr.json b/messages/fr.json
index ea85d0e79..98c1c3f2b 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -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",
diff --git a/messages/it.json b/messages/it.json
index ef90af625..2a2466464 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -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",
diff --git a/messages/pl.json b/messages/pl.json
index 84f135b09..2c8b818c3 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -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",
diff --git a/src/app/__tests__/dashboard-hero-wiring.test.ts b/src/app/__tests__/dashboard-hero-wiring.test.ts
new file mode 100644
index 000000000..9b2cf45d5
--- /dev/null
+++ b/src/app/__tests__/dashboard-hero-wiring.test.ts
@@ -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]*?/,
+ );
+ 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(" {
+ 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");
+ });
+});
diff --git a/src/app/__tests__/dashboard-suspense-boundaries.test.ts b/src/app/__tests__/dashboard-suspense-boundaries.test.ts
index 9db552cbe..30c4dee8d 100644
--- a/src/app/__tests__/dashboard-suspense-boundaries.test.ts
+++ b/src/app/__tests__/dashboard-suspense-boundaries.test.ts
@@ -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");
});
});
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 0e665bbd1..16c68c38b 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -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 (
-
+
+
+ {heroVisible &&
+ (primaryLoading || !heroSnapshot ? (
+
+ ) : (
+
+ ))}
{/* 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
diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx
index a8e7cb5c1..79b0a9589 100644
--- a/src/components/dashboard/dashboard-header.tsx
+++ b/src/components/dashboard/dashboard-header.tsx
@@ -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 (``), 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) => 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). */
-
-
-
- {t("dashboard.title")}
-
- {/* 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. */}
-
- {welcomeText}
-
-
+
+
+ {t("dashboard.title")}
+
{/* Customize shortcut to the dashboard-customization settings
(the tile/layout editor at /settings/dashboard). Sits to
diff --git a/src/components/dashboard/hero/__tests__/dashboard-hero-skeleton.test.tsx b/src/components/dashboard/hero/__tests__/dashboard-hero-skeleton.test.tsx
new file mode 100644
index 000000000..0084eb4c6
--- /dev/null
+++ b/src/components/dashboard/hero/__tests__/dashboard-hero-skeleton.test.tsx
@@ -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 `` (same min-h
+ * floors, same md two-column split, same fixed 120 px ring circle) so
+ * the loaded band swaps in place with zero layout shift — and it must
+ * stay invisible to assistive tech with no focusable content.
+ */
+describe("", () => {
+ const html = renderToStaticMarkup();
+
+ it("carries the data-slot and aria-hidden contract", () => {
+ expect(html).toContain('data-slot="dashboard-hero-skeleton"');
+ const root = html.match(
+ /
]*data-slot="dashboard-hero-skeleton"[^>]*>/,
+ );
+ expect(root).not.toBeNull();
+ expect(root![0]).toContain('aria-hidden="true"');
+ });
+
+ it("reserves the hero's exact footprint classes", () => {
+ 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("