mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
5c0cde207f
commit
8d73542fde
12 files changed
+325
-181
No files matched your search
+3
-7
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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" />
|
||||
|
||||
Reference in new issue
Block a user