diff --git a/messages/de.json b/messages/de.json index 28604c9b1..f0799f5ed 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index df45fbd25..6efedd9c6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 35f95049a..08c8f7ae8 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 92682f7a4..629c762b6 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 6a20cc358..5b0c07d0c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 179663f05..b760ee5a2 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/components/dashboard/hero/__tests__/dashboard-hero-skeleton.test.tsx b/src/components/dashboard/hero/__tests__/dashboard-hero-skeleton.test.tsx index 0084eb4c6..3e903572e 100644 --- a/src/components/dashboard/hero/__tests__/dashboard-hero-skeleton.test.tsx +++ b/src/components/dashboard/hero/__tests__/dashboard-hero-skeleton.test.tsx @@ -23,14 +23,26 @@ describe("", () => { }); 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", () => { diff --git a/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx b/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx index 85c65068c..aadc2fbba 100644 --- a/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx +++ b/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx @@ -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(" — 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(" — verdict variants", () => { }); }); -describe(" — dose row", () => { - it("prints the day tally with the neutral chip chrome and the Pill icon", () => { +describe(" — 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( - /]*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( + /]*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(" — 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(" — greeting", () => { diff --git a/src/components/dashboard/hero/dashboard-hero-skeleton.tsx b/src/components/dashboard/hero/dashboard-hero-skeleton.tsx index 3b85873cc..5d73ebf9d 100644 --- a/src/components/dashboard/hero/dashboard-hero-skeleton.tsx +++ b/src/components/dashboard/hero/dashboard-hero-skeleton.tsx @@ -3,12 +3,16 @@ import { Skeleton } from "@/components/ui/skeleton"; /** * Loading silhouette for the dashboard hero band. * - * Mirrors the real `` footprint exactly — same - * `.hero-gradient` chrome, same `min-h-[8.75rem] md:min-h-[9.5rem]` + * Mirrors the real `` 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() { - {/* 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. */} -
+ {/* 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. */} +
+ {scoreRings.map((ring) => ( +
+ +
+ ))} — hero visibility switch", () => { const de = render(, "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(); expect(en).toContain("Daily overview"); @@ -438,3 +438,72 @@ describe(" — 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(" — hero score-ring picker (v1.27.7)", () => { + it("stays hidden while the hero is off (default layout)", () => { + const html = render(); + 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(); + 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( + /]*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(); + 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(); + 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(); + const compliance = html.match( + /]*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( + /]*data-slot="score-ring-switch"[^>]*data-ring="READINESS"[^>]*>/, + ); + expect(readiness).not.toBeNull(); + expect(readiness![0]).not.toContain('disabled=""'); + }); +}); diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index 52678914f..84a8c1e2b 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -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 = { 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 = { + 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 }) {
)} + {/* 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 && ( +
+
+

+ {t("dashboard.scoreRingsTitle")} +

+

+ {t("dashboard.scoreRingsDescription")} +

+
+
+ {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 ( +
+ + {t(SCORE_RING_LABEL_KEYS[ringId])} + + toggleScoreRing(ringId, v)} + disabled={saveMutation.isPending || atCap} + aria-label={t(SCORE_RING_LABEL_KEYS[ringId])} + data-slot="score-ring-switch" + data-ring={ringId} + /> +
+ ); + })} +
+
+ )} + {isLoading || !layout ? (