feat(dashboard): replace the hero dose row with the score-ring row

The hero's dose text row gives way to a ring row: the selected score
rings render at the health-score ring's sm flat geometry, readiness /
recovery / sleep in their wellness-strip hues and the 7-day adherence
ring in band semantics — the ring that carries the dose row's
information role. Rings self-gate on data, the verdict line above is
untouched, and the skeleton mirrors the new geometry (plain card shell,
no dose pill, the always-present health circle reserved). Settings ->
Dashboard gains a picker under the overview toggle: up to three rings,
module-gated rows, switches disable at the cap, persisted through the
existing widgets PUT. Locale bundles drop the retired dose-row strings
and pick up the picker copy in all six languages.
This commit is contained in:
Marc-André Bombeck committed 2026-07-05 15:08:20 +02:00
1 parent 5c0cde207f
commit 8d73542fde
12 files changed
+325 -181

No files matched your search

+3 -7
View File
@@ -307,12 +307,6 @@
"scoreLabel": "Health Score",
"scoreProvisional": "Noch nicht genug Daten",
"scoreComputing": "Score wird berechnet…",
"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.",
"doseOverdueNoName": "Eine Dosis ist überfällig.",
@@ -390,7 +384,9 @@
"mood": "Stimmung",
"customizeTitle": "Dashboard-Layout",
"heroToggleLabel": "Tagesüberblick",
"heroToggleDescription": "Zeigt Begrüßung, Score, Tagesfokus und Dosen-Status ganz oben auf dem Dashboard.",
"heroToggleDescription": "Zeigt Begrüßung, Tagesfokus und Score-Ringe ganz oben auf dem Dashboard.",
"scoreRingsTitle": "Score-Ringe",
"scoreRingsDescription": "Bis zu drei zusätzliche Ringe neben dem Health Score – zum Beispiel Bereitschaft, Erholung, Schlaf oder Therapietreue.",
"layoutReset": "Auf Defaults zurücksetzen",
"layoutSaveSuccess": "Dashboard-Layout gespeichert",
"layoutSaveError": "Layout konnte nicht gespeichert werden",
+3 -7
View File
@@ -307,12 +307,6 @@
"scoreLabel": "Health Score",
"scoreProvisional": "Not enough data yet",
"scoreComputing": "Calculating score…",
"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.",
"doseOverdueNoName": "A dose is overdue.",
@@ -390,7 +384,9 @@
"mood": "Mood",
"customizeTitle": "Dashboard layout",
"heroToggleLabel": "Daily overview",
"heroToggleDescription": "Shows your greeting, score, daily focus and dose status at the top of the dashboard.",
"heroToggleDescription": "Shows your greeting, daily focus and score rings at the top of the dashboard.",
"scoreRingsTitle": "Score rings",
"scoreRingsDescription": "Up to three additional rings next to the health score — for example readiness, recovery, sleep or adherence.",
"layoutReset": "Reset to defaults",
"layoutSaveSuccess": "Dashboard layout saved",
"layoutSaveError": "Could not save dashboard layout",
+3 -7
View File
@@ -307,12 +307,6 @@
"scoreLabel": "Health Score",
"scoreProvisional": "Aún no hay datos suficientes",
"scoreComputing": "Calculando la puntuación …",
"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á atrasada.",
"doseOverdueNoName": "Una dosis está atrasada.",
@@ -390,7 +384,9 @@
"mood": "Estado de ánimo",
"customizeTitle": "Diseño del panel",
"heroToggleLabel": "Resumen del día",
"heroToggleDescription": "Muestra el saludo, la puntuación, el foco del día y el estado de las dosis en la parte superior del panel.",
"heroToggleDescription": "Muestra el saludo, el foco del día y los anillos de puntuación en la parte superior del panel.",
"scoreRingsTitle": "Anillos de puntuación",
"scoreRingsDescription": "Hasta tres anillos adicionales junto a la puntuación de salud, por ejemplo preparación, recuperación, sueño o cumplimiento.",
"layoutReset": "Restablecer valores por defecto",
"layoutSaveSuccess": "Diseño del panel guardado",
"layoutSaveError": "No se ha podido guardar el diseño",
+3 -7
View File
@@ -307,12 +307,6 @@
"scoreLabel": "Health Score",
"scoreProvisional": "Pas encore assez de données",
"scoreComputing": "Calcul du score en cours …",
"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.",
"doseOverdueNoName": "Une dose est en retard.",
@@ -390,7 +384,9 @@
"mood": "Humeur",
"customizeTitle": "Mise en page du tableau",
"heroToggleLabel": "Aperçu du jour",
"heroToggleDescription": "Affiche le message d’accueil, le score, le focus du jour et l’état des doses tout en haut du tableau de bord.",
"heroToggleDescription": "Affiche le message d’accueil, le focus du jour et les anneaux de score tout en haut du tableau de bord.",
"scoreRingsTitle": "Anneaux de score",
"scoreRingsDescription": "Jusqu’à trois anneaux supplémentaires à côté du score de santé — par exemple préparation, récupération, sommeil ou observance.",
"layoutReset": "Rétablir les valeurs par défaut",
"layoutSaveSuccess": "Mise en page enregistrée",
"layoutSaveError": "Impossible d’enregistrer la mise en page",
+3 -7
View File
@@ -307,12 +307,6 @@
"scoreLabel": "Health Score",
"scoreProvisional": "Dati ancora insufficienti",
"scoreComputing": "Calcolo del punteggio in corso …",
"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.",
"doseOverdueNoName": "Una dose è in ritardo.",
@@ -390,7 +384,9 @@
"mood": "Umore",
"customizeTitle": "Layout della dashboard",
"heroToggleLabel": "Panoramica del giorno",
"heroToggleDescription": "Mostra saluto, punteggio, focus del giorno e stato delle dosi in cima alla dashboard.",
"heroToggleDescription": "Mostra saluto, focus del giorno e anelli dei punteggi in cima alla dashboard.",
"scoreRingsTitle": "Anelli dei punteggi",
"scoreRingsDescription": "Fino a tre anelli aggiuntivi accanto al punteggio di salute, ad esempio prontezza, recupero, sonno o aderenza.",
"layoutReset": "Ripristina i valori predefiniti",
"layoutSaveSuccess": "Layout della dashboard salvato",
"layoutSaveError": "Impossibile salvare il layout",
+3 -7
View File
@@ -307,12 +307,6 @@
"scoreLabel": "Health Score",
"scoreProvisional": "Za mało danych",
"scoreComputing": "Trwa obliczanie wyniku …",
"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.",
"doseOverdueNoName": "Dawka jest zaległa.",
@@ -390,7 +384,9 @@
"mood": "Nastrój",
"customizeTitle": "Układ panelu",
"heroToggleLabel": "Przegląd dnia",
"heroToggleDescription": "Pokazuje powitanie, wynik, fokus dnia i status dawek na górze pulpitu.",
"heroToggleDescription": "Pokazuje powitanie, fokus dnia i pierścienie wyników na górze pulpitu.",
"scoreRingsTitle": "Pierścienie wyników",
"scoreRingsDescription": "Maksymalnie trzy dodatkowe pierścienie obok wskaźnika zdrowia — np. gotowość, regeneracja, sen lub przestrzeganie zaleceń.",
"layoutReset": "Przywróć wartości domyślne",
"layoutSaveSuccess": "Układ panelu zapisany",
"layoutSaveError": "Nie udało się zapisać układu",
@@ -23,14 +23,26 @@ describe("<DashboardHeroSkeleton>", () => {
});
it("reserves the hero's exact footprint classes", () => {
// v1.27.7 — the shell mirrors the loaded band's plain `bg-card`
// surface (the old `hero-gradient` chrome was a leftover from the
// pre-v1.18.1 gradient hero and never matched the loaded state).
for (const cls of [
"min-h-[8.75rem]",
"md:min-h-[9.5rem]",
"hero-gradient",
"bg-card",
"border-border",
"rounded-xl",
]) {
expect(html).toContain(cls);
}
expect(html).not.toContain("hero-gradient");
});
it("reserves no dose-row pill — the ring row replaced it (v1.27.7)", () => {
// The left column carries exactly three skeleton bars (greeting +
// verdict + CTA); the old fourth dose-pill bar is gone.
const bars = html.match(/data-slot="skeleton"/g) ?? [];
expect(bars.length).toBe(4); // 3 left-column bars + 1 ring circle
});
it("mirrors the two-column md:flex-row split with the fixed ring circle", () => {
@@ -19,9 +19,11 @@ import { DashboardHero } from "../dashboard-hero";
* 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
* - v1.27.7 — the ring row replaced the dose text row: the selected
* `scoreRings` render beside the health-score ring (hue per ring,
* band semantics for the adherence ring) and self-gate on data;
* the documented stale-cache state (past `nextDueAt`,
* `nextDueOverdue: false`) still renders a calm verdict — never
* overdue;
* - the greeting derives from the snapshot's server-computed
* `greetingHour` and personalises from the snapshot username;
@@ -233,8 +235,6 @@ describe("<DashboardHero> — verdict variants", () => {
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", () => {
@@ -366,49 +366,65 @@ describe("<DashboardHero> — verdict variants", () => {
});
});
describe("<DashboardHero> — dose row", () => {
it("prints the day tally with the neutral chip chrome and the Pill icon", () => {
describe("<DashboardHero> — ring row (v1.27.7)", () => {
it("renders the selected rings beside the health-score ring, in selection order", () => {
const html = render(
baseSnapshot({
healthScore: { score: 82, band: "green", delta: 2 },
scoreRings: [
{ id: "READINESS", score: 71, band: "green" },
{ id: "MED_COMPLIANCE", score: 95, band: "green" },
],
}),
);
const row = html.match(/data-slot="dashboard-hero-rings"/g) ?? [];
expect(row).toHaveLength(1);
// Selection order preserved, health ring trailing (3 rings total).
const readinessIdx = html.indexOf('data-ring="READINESS"');
const complianceIdx = html.indexOf('data-ring="MED_COMPLIANCE"');
expect(readinessIdx).toBeGreaterThan(-1);
expect(complianceIdx).toBeGreaterThan(readinessIdx);
const rings = html.match(/data-slot="score-ring"/g) ?? [];
expect(rings).toHaveLength(3);
// Labels (de locale): the derived ring reuses its wellness-strip
// title, the adherence ring the medications adherence label.
expect(html).toContain("Bereitschaft");
expect(html).toContain("Therapietreue");
});
it("no dose text row renders anymore — the ring row carries the slot", () => {
const html = render(
baseSnapshot({
medsToday: medsToday({ scheduledToday: 3, takenToday: 1 }),
}),
);
expect(html).toContain("1 von 3 Dosen genommen");
const doseRow = html.match(
/<div[^>]*data-slot="dashboard-hero-doses"[^>]*>/,
);
expect(doseRow).not.toBeNull();
// v1.18.1: chip sits on a neutral muted surface over the plain card —
// no translucent card tint, no blur, no shadow (the gradient is gone).
for (const cls of ["bg-muted/50", "border-border/60", "rounded-xl"]) {
expect(doseRow![0]).toContain(cls);
}
expect(doseRow![0]).not.toContain("backdrop-blur");
expect(doseRow![0]).not.toContain("shadow-sm");
});
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('data-slot="dashboard-hero-doses"');
expect(html).not.toContain("Dosen genommen");
});
it("DEFENSIVE: a stale past nextDueAt with nextDueOverdue false renders the plain summary, never overdue", () => {
it("an empty / absent scoreRings block leaves the health ring alone (self-gating)", () => {
const html = render(
baseSnapshot({ healthScore: { score: 60, band: "yellow", delta: 0 } }),
);
const rings = html.match(/data-slot="score-ring"/g) ?? [];
expect(rings).toHaveLength(1);
expect(html).not.toContain('data-slot="dashboard-hero-ring"');
});
it("the adherence ring paints band semantics (data-band), not a metric hue", () => {
const html = render(
baseSnapshot({
scoreRings: [{ id: "MED_COMPLIANCE", score: 65, band: "red" }],
}),
);
const ring = html.match(
/<div[^>]*data-ring="MED_COMPLIANCE"[\s\S]*?data-band="([a-z]+)"/,
);
expect(ring).not.toBeNull();
expect(ring![1]).toBe("red");
});
it("DEFENSIVE: a stale past nextDueAt with nextDueOverdue false still renders a calm verdict, never overdue", () => {
const html = render(
baseSnapshot({
medsToday: medsToday({
@@ -420,26 +436,9 @@ describe("<DashboardHero> — dose row", () => {
}),
}),
);
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", () => {
@@ -3,12 +3,16 @@ 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]`
* Mirrors the real `<DashboardHero>` footprint — same plain `bg-card`
* shell (border + radius + `p-4 md:p-6`, matching the loaded band since
* the v1.18.1 gradient removal), same `min-h-[8.75rem] md:min-h-[9.5rem]`
* floor, same two-column `md:flex-row` split with the fixed 120 px ring
* circle on the right — so the swap to the loaded hero happens in place
* with zero layout shift. The left column reserves the greeting line,
* the verdict sentence + CTA row, and the dose-row pill.
* with zero layout shift. The left column reserves the greeting line and
* the verdict sentence + CTA row; the right column reserves the
* health-score circle (v1.27.7 — the old dose-row pill is gone; the
* user-selected extra rings self-gate on data, so the skeleton reserves
* only the always-present health ring and lets the row grow in place).
*
* Always `aria-hidden` with no focusable content: the tile-strip
* skeleton alongside carries the page's loading semantics, and a second
@@ -21,7 +25,7 @@ export function DashboardHeroSkeleton() {
<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]"
className="bg-card border-border relative isolate min-h-[8.75rem] overflow-hidden rounded-xl border p-4 md:min-h-[9.5rem] md:p-6"
>
<div className="flex h-full flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="min-w-0 flex-1 space-y-3">
@@ -32,11 +36,9 @@ export function DashboardHeroSkeleton() {
<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">
{/* Ring row — the always-present health-score circle (120 px). */}
<div className="flex shrink-0 items-center justify-center gap-3 md:justify-end">
<Skeleton className="size-[120px] rounded-full" />
</div>
</div>
@@ -13,12 +13,18 @@
* paragraph — the snapshot's server-computed `greetingHour` keeps
* the daypart free of any client `Intl` work; typographically the
* band's anchor, larger and heavier than the verdict so the
* personalised line is not drowned by the content below it), the
* verdict sentence with its single CTA button, and the dose row;
* - right column: the shared `<ScoreRing>` at `sm` geometry, in its
* `flat` treatment (no bloom / pulse / sheen / sweep) so the dial
* sits as calmly as the surrounding chart cards. A null score
* renders the ring's provisional state at the identical
* personalised line is not drowned by the content below it) and the
* verdict sentence with its single CTA button;
* - right column: the ring row — the health-score `<ScoreRing>` at
* `sm` geometry in its `flat` treatment (no bloom / pulse / sheen /
* sweep) plus the user-selected score rings the snapshot resolved
* server-side (`scoreRings`, max 3: readiness / recovery / sleep in
* their wellness-strip hues, medication adherence in band
* semantics). v1.27.7 — the ring row replaces the old dose text row;
* the adherence ring carries its information role. Selected rings
* self-gate: an entry absent from `scoreRings` (no data, disabled
* module) renders nothing, exactly like the wellness strip. A null
* health score renders the ring's provisional state at the identical
* 120 px footprint, so the column never collapses. The provisional
* label is honest about WHY the score is null: when the snapshot
* already carries score inputs (weight / BP summaries, mood
@@ -30,9 +36,7 @@
* past with `nextDueOverdue: false` means the slot's anchor passed
* after the snapshot was built. The verdict resolver already falls
* through (rung 2 keys on the flag, rung 3 requires a future anchor),
* 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.
* so the stale state renders as a calm verdict, never as overdue.
*
* Rung 8 (`briefing`) renders the model-authored headline VERBATIM as a
* plain React text child — no i18n key, no HTML, no markdown — per the
@@ -44,9 +48,9 @@
*/
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 type { RingHue } from "@/components/insights/derived/ring-hues";
import { RestModeBanner } from "@/components/insights/rest-mode-banner";
import { BriefingSpotlight } from "@/components/dashboard/hero/briefing-spotlight";
import {
@@ -54,6 +58,7 @@ import {
type DashboardVerdictVariant,
} from "@/lib/dashboard/verdict";
import type { DashboardSnapshot } from "@/lib/dashboard/snapshot";
import type { ScoreRingId } from "@/lib/dashboard-layout";
import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets";
import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
import { makeFormatters } from "@/lib/format-locale";
@@ -90,15 +95,26 @@ const CTA_LABEL_KEY: Partial<Record<DashboardVerdictVariant, string>> = {
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);
}
/**
* Per-ring hue for the selected score rings — the wellness-strip
* vocabulary. MED_COMPLIANCE deliberately carries NO hue: the adherence
* ring falls back to the ring's band gradient (green / yellow / red
* semantic tokens), because for adherence the band IS the message —
* exactly the classification colouring the targets tile uses.
*/
const RING_HUE_BY_ID: Partial<Record<ScoreRingId, RingHue>> = {
READINESS: "readiness",
RECOVERY_SCORE: "recovery",
SLEEP_SCORE: "sleep",
};
/** Per-ring label key — reuses the existing score / adherence labels. */
const RING_LABEL_KEY: Record<ScoreRingId, string> = {
READINESS: "insights.derived.composite.READINESS.title",
RECOVERY_SCORE: "insights.derived.scores.recovery",
SLEEP_SCORE: "insights.derived.composite.SLEEP_SCORE.title",
MED_COMPLIANCE: "medications.compliance",
};
export function DashboardHero({
snapshot,
@@ -194,31 +210,12 @@ export function DashboardHero({
snapshot.tiles.mood.entries.length > 0 ||
snapshot.medsToday.activeCount > 0;
// ── 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;
// ── Ring row (v1.27.7) ─────────────────────────────────────────────
// The snapshot resolves the selected rings server-side; entries absent
// from the array (no data / disabled module) simply don't render —
// the wellness-strip self-gating rule. Optional on the type (additive
// contract), so an older cached snapshot renders the health ring alone.
const scoreRings = snapshot.scoreRings ?? [];
return (
<section
@@ -278,34 +275,34 @@ export function DashboardHero({
)
) : null}
</div>
<div
data-slot="dashboard-hero-doses"
className="bg-muted/50 border-border/60 inline-flex max-w-full items-center gap-2 rounded-xl border px-3 py-2"
>
<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">
{/* Ring row — the selected score rings (max 3, each at the
fixed 120 px sm footprint, flat) lead into the health-score
ring on the trailing edge. A null health score renders the
ring's provisional state (aria announces "not enough data",
never 0) at identical geometry, so the column NEVER
collapses; missing selected rings render nothing. */}
<div
data-slot="dashboard-hero-rings"
className="flex shrink-0 flex-wrap items-center justify-center gap-3 md:justify-end"
>
{scoreRings.map((ring) => (
<div
key={ring.id}
data-slot="dashboard-hero-ring"
data-ring={ring.id}
className="flex shrink-0 items-center"
>
<ScoreRing
score={ring.score}
band={ring.band}
size="sm"
flat
hue={RING_HUE_BY_ID[ring.id]}
label={t(RING_LABEL_KEY[ring.id])}
/>
</div>
))}
<ScoreRing
score={snapshot.healthScore?.score ?? null}
band={snapshot.healthScore?.band}
@@ -305,7 +305,7 @@ describe("<DashboardLayoutSection> — hero visibility switch", () => {
const de = render(<DashboardLayoutSection id="dashboard-layout" />, "de");
expect(de).toContain("Tagesüberblick");
expect(de).toContain(
"Zeigt Begrüßung, Score, Tagesfokus und Dosen-Status ganz oben auf dem Dashboard.",
"Zeigt Begrüßung, Tagesfokus und Score-Ringe ganz oben auf dem Dashboard.",
);
const en = render(<DashboardLayoutSection id="dashboard-layout" />);
expect(en).toContain("Daily overview");
@@ -438,3 +438,72 @@ describe("<DashboardLayoutSection> — disabled-module widget toggles", () => {
expect(html).toContain(ACHIEVEMENTS_ARIA);
});
});
/**
* v1.27.7 — hero score-ring picker. Rendered only while the hero is on
* (the rings live inside the hero band); offers only rings whose owning
* module is enabled; caps the selection at three via disabled switches.
* SSR smoke assertions, matching the rest of this suite.
*/
describe("<DashboardLayoutSection> — hero score-ring picker (v1.27.7)", () => {
it("stays hidden while the hero is off (default layout)", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
expect(html).not.toContain('data-slot="score-rings-picker"');
});
it("renders one switch per ring when the hero is on (fail-open module map)", () => {
queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: true };
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
expect(html).toContain('data-slot="score-rings-picker"');
const switches = html.match(/data-slot="score-ring-switch"/g) ?? [];
expect(switches).toHaveLength(4);
// The default selection (MED_COMPLIANCE) reads checked.
const compliance = html.match(
/<button[^>]*data-slot="score-ring-switch"[^>]*data-ring="MED_COMPLIANCE"[^>]*>/,
);
expect(compliance).not.toBeNull();
expect(compliance![0]).toContain('data-state="checked"');
});
it("hides rings whose owning module is disabled", () => {
queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: true };
authState.modules = { recovery: false, sleep: false };
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
const switches = html.match(/data-slot="score-ring-switch"/g) ?? [];
// READINESS + RECOVERY_SCORE (recovery) and SLEEP_SCORE (sleep) are
// gone; only the adherence ring remains offered.
expect(switches).toHaveLength(1);
expect(html).toContain('data-ring="MED_COMPLIANCE"');
});
it("hides the derived rings when the insights module is off", () => {
queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: true };
authState.modules = { insights: false };
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
const switches = html.match(/data-slot="score-ring-switch"/g) ?? [];
expect(switches).toHaveLength(1);
expect(html).toContain('data-ring="MED_COMPLIANCE"');
});
it("disables the remaining switches at the three-ring cap", () => {
queryState.layout = {
...DEFAULT_DASHBOARD_LAYOUT,
heroVisible: true,
selectedScoreRings: ["READINESS", "RECOVERY_SCORE", "SLEEP_SCORE"],
};
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
const compliance = html.match(
/<button[^>]*data-slot="score-ring-switch"[^>]*data-ring="MED_COMPLIANCE"[^>]*>/,
);
expect(compliance).not.toBeNull();
// The rendered `disabled` HTML attribute — not the Tailwind
// `disabled:` variant prefixes that every switch class list carries.
expect(compliance![0]).toContain('disabled=""');
// The three selected rings stay enabled (unchecking must stay possible).
const readiness = html.match(
/<button[^>]*data-slot="score-ring-switch"[^>]*data-ring="READINESS"[^>]*>/,
);
expect(readiness).not.toBeNull();
expect(readiness![0]).not.toContain('disabled=""');
});
});
@@ -36,10 +36,14 @@ import {
type DashboardLayout,
type DashboardWidgetId,
type ComparisonBaseline,
type ScoreRingId,
COMPARISON_BASELINES,
DEFAULT_DASHBOARD_LAYOUT,
DASHBOARD_WIDGET_IDS,
IOS_PIN_ONLY_WIDGET_IDS,
SCORE_RING_IDS,
SCORE_RING_MODULE,
MAX_SELECTED_SCORE_RINGS,
} from "@/lib/dashboard-layout";
import {
Select,
@@ -152,6 +156,19 @@ const WIDGET_LABEL_KEYS: Record<DashboardWidgetId, string> = {
vorsorge: "measurementReminders.sectionTitle",
};
/**
* v1.27.7 — option labels for the hero score-ring picker. The three
* derived rings reuse their wellness-strip titles; the adherence ring
* reuses the existing "adherence (7 days)" label so the picker names
* the exact window the ring shows.
*/
const SCORE_RING_LABEL_KEYS: Record<ScoreRingId, string> = {
READINESS: "insights.derived.composite.READINESS.title",
RECOVERY_SCORE: "insights.derived.scores.recovery",
SLEEP_SCORE: "insights.derived.composite.SLEEP_SCORE.title",
MED_COMPLIANCE: "dashboard.compliance7d",
};
export function DashboardLayoutSection({ id }: { id: string }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
@@ -283,6 +300,24 @@ export function DashboardLayoutSection({ id }: { id: string }) {
setDraft({ ...layout, heroVisible: value });
}
/**
* v1.27.7 — toggle one hero score ring. Selection order is preserved
* (a newly-enabled ring appends), the count is capped at
* `MAX_SELECTED_SCORE_RINGS` (the remaining switches disable at the
* cap), and the choice persists as `selectedScoreRings` on the layout
* blob through the same PUT the widget toggles use.
*/
function toggleScoreRing(ringId: ScoreRingId, enabled: boolean) {
if (!layout) return;
const current = layout.selectedScoreRings ?? [];
const next = enabled
? current.includes(ringId)
? current
: [...current, ringId].slice(0, MAX_SELECTED_SCORE_RINGS)
: current.filter((id) => id !== ringId);
setDraft({ ...layout, selectedScoreRings: next });
}
/**
* v1.4.48 M6a — the arrow buttons now delegate to `reorderWidgets()`
* so the swap-and-renumber logic lives in exactly one place. The
@@ -439,6 +474,60 @@ export function DashboardLayoutSection({ id }: { id: string }) {
</div>
)}
{/* v1.27.7 — hero score-ring picker. Only rendered while the hero
is on (the rings live inside the hero band, so the picker would
be a dead control otherwise). Offers only rings whose owning
module is enabled — the WIDGET_MODULE_BY_ID gating pattern; the
three derived rings additionally sit behind the insights
module, mirroring the derived routes. Selection caps at
MAX_SELECTED_SCORE_RINGS; unchecked switches disable at the cap. */}
{layout && layout.heroVisible === true && (
<div
data-slot="score-rings-picker"
className="border-border bg-background/30 space-y-3 rounded-md border px-3 py-2"
>
<div className="min-w-0">
<p className="text-foreground text-sm font-medium">
{t("dashboard.scoreRingsTitle")}
</p>
<p className="text-muted-foreground text-xs">
{t("dashboard.scoreRingsDescription")}
</p>
</div>
<div className="space-y-2">
{SCORE_RING_IDS.filter((ringId) => {
if (modules?.[SCORE_RING_MODULE[ringId]] === false) return false;
if (ringId !== "MED_COMPLIANCE" && modules?.insights === false)
return false;
return true;
}).map((ringId) => {
const selected = layout.selectedScoreRings ?? [];
const checked = selected.includes(ringId);
const atCap =
!checked && selected.length >= MAX_SELECTED_SCORE_RINGS;
return (
<div
key={ringId}
className="flex min-h-9 items-center justify-between gap-3"
>
<span className="text-foreground truncate text-sm">
{t(SCORE_RING_LABEL_KEYS[ringId])}
</span>
<Switch
checked={checked}
onCheckedChange={(v) => toggleScoreRing(ringId, v)}
disabled={saveMutation.isPending || atCap}
aria-label={t(SCORE_RING_LABEL_KEYS[ringId])}
data-slot="score-ring-switch"
data-ring={ringId}
/>
</div>
);
})}
</div>
</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" />