diff --git a/CHANGELOG.md b/CHANGELOG.md index da859c289..db831e9a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## [Unreleased] +## [1.27.7] — 2026-07-05 — Score rings on the dashboard + +### Added + +- The dashboard hero can now show up to three score rings of your choice beside the health score — readiness, recovery, sleep, or medication adherence — picked under Settings → Dashboard. Rings only offer themselves when their module is on, and a ring without data simply stays away. The default shows medication adherence, which replaces the old "doses taken" text line. + +### Changed + +- Tapping a briefing signal on the dashboard now opens that metric's own page instead of the Insights overview. +- The hero's broad "Open Insights" button is gone; the health-score card already links there. Verdict buttons with a specific action (take dose, view blood pressure, …) stay. + ## [1.27.6] — 2026-07-05 — A calmer mental-wellbeing check-in ### Changed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index bd43dea0e..d3626456a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.27.6 + version: 1.27.7 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -21421,6 +21421,38 @@ components: description: Personal health score summary (0..100 score, traffic-light band, week-over-week delta). Null on a rollup-coverage miss (it rides the thick phase alongside `extras`) and when no pillar is computable. Component breakdown is deliberately not serialised here. + scoreRings: + description: "User-selected hero score rings (max 3, `selectedScoreRings` on the dashboard layout), resolved + server-side: READINESS / RECOVERY_SCORE / SLEEP_SCORE via the derived engines (module-gated like + `/api/insights/derived`), MED_COMPLIANCE as the pooled 7-day medication adherence (0..100, banded ≥90 green + / ≥70 yellow / else red). Only rings with data appear, in selection order — a missing entry means no data or + a disabled module, never zero. Clients render what arrives and never recompute." + type: array + items: + type: object + properties: + id: + type: string + enum: + - READINESS + - RECOVERY_SCORE + - SLEEP_SCORE + - MED_COMPLIANCE + score: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + band: + type: string + enum: + - green + - yellow + - red + required: + - id + - score + - band + additionalProperties: false briefing: anyOf: - type: object diff --git a/messages/de.json b/messages/de.json index 5b15961ad..76d77dcbb 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.", @@ -330,8 +324,7 @@ "viewBp": "Blutdruck ansehen", "viewWeight": "Gewicht ansehen", "viewSleep": "Schlaf ansehen", - "logMeasurement": "Messung erfassen", - "viewInsights": "Insights öffnen" + "logMeasurement": "Messung erfassen" }, "briefing": { "title": "Tagesbriefing", @@ -390,7 +383,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 e6752beca..6e7531238 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.", @@ -330,8 +324,7 @@ "viewBp": "View blood pressure", "viewWeight": "View weight", "viewSleep": "View sleep", - "logMeasurement": "Log measurement", - "viewInsights": "Open Insights" + "logMeasurement": "Log measurement" }, "briefing": { "title": "Today’s briefing", @@ -390,7 +383,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 578367b41..63288bf42 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.", @@ -330,8 +324,7 @@ "viewBp": "Ver tensión arterial", "viewWeight": "Ver peso", "viewSleep": "Ver sueño", - "logMeasurement": "Registrar medición", - "viewInsights": "Abrir Insights" + "logMeasurement": "Registrar medición" }, "briefing": { "title": "Resumen de hoy", @@ -390,7 +383,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 625a8dd27..b05f33f29 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.", @@ -330,8 +324,7 @@ "viewBp": "Voir la tension", "viewWeight": "Voir le poids", "viewSleep": "Voir le sommeil", - "logMeasurement": "Saisir une mesure", - "viewInsights": "Ouvrir Insights" + "logMeasurement": "Saisir une mesure" }, "briefing": { "title": "Briefing du jour", @@ -390,7 +383,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 689c35b3d..992787bc2 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.", @@ -330,8 +324,7 @@ "viewBp": "Vedi pressione", "viewWeight": "Vedi peso", "viewSleep": "Vedi sonno", - "logMeasurement": "Registra misurazione", - "viewInsights": "Apri Insights" + "logMeasurement": "Registra misurazione" }, "briefing": { "title": "Briefing di oggi", @@ -390,7 +383,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 e15b6fe37..0ea37c756 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.", @@ -330,8 +324,7 @@ "viewBp": "Zobacz ciśnienie", "viewWeight": "Zobacz wagę", "viewSleep": "Zobacz sen", - "logMeasurement": "Zapisz pomiar", - "viewInsights": "Otwórz Insights" + "logMeasurement": "Zapisz pomiar" }, "briefing": { "title": "Briefing dnia", @@ -390,7 +383,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/package.json b/package.json index 9a1f32b06..efb12c048 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.27.6", + "version": "1.27.7", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index c81588410..f930747ee 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.27.6"; + /* @sw-version-fallback */ "v1.27.7"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index 15121fc65..989dd17f4 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -22,8 +22,11 @@ import { DASHBOARD_WIDGET_CATALOGUE_IDS, COMPARISON_BASELINES, CHART_OVERLAY_KEYS, + SCORE_RING_IDS, + MAX_SELECTED_SCORE_RINGS, type ChartOverlayPrefsMap, type DashboardLayout, + type ScoreRingId, } from "@/lib/dashboard-layout"; import { Prisma } from "@/generated/prisma/client"; import { z } from "zod/v4"; @@ -121,6 +124,13 @@ const layoutSchema = z.object({ // `chartOverlayPrefs` — a Settings save that doesn't know the field // must not silently reset the user's choice). heroVisible: z.boolean().optional(), + // v1.27.7 — hero score rings (max 3, closed id set). Optional with the + // same preserve-when-absent contract as `heroVisible`; the resolver + // dedupes on read/serialize, `.max()` bounds the wire length. + selectedScoreRings: z + .array(z.enum(SCORE_RING_IDS)) + .max(MAX_SELECTED_SCORE_RINGS) + .optional(), }); async function buildDashboardLayout(userId: string): Promise { @@ -262,16 +272,19 @@ export const PUT = apiHandler(async (request: NextRequest) => { // didn't send. The dashboard-layout PUT typically saves widget // visibility / order; chart prefs are PUT through their own route // (`/api/dashboard/chart-overlay-prefs`) and would otherwise be - // wiped here on a subsequent layout save. `heroVisible` rides the - // same preserve-when-absent contract — an older client's layout - // save must not reset the hero toggle. One stored-layout read - // covers both fallbacks. + // wiped here on a subsequent layout save. `heroVisible` and + // `selectedScoreRings` ride the same preserve-when-absent contract — + // an older client's layout save must not reset either choice. One + // stored-layout read covers all fallbacks. let mergedChartOverlayPrefs: ChartOverlayPrefsMap | undefined = parsed.data .chartOverlayPrefs as ChartOverlayPrefsMap | undefined; let mergedHeroVisible: boolean | undefined = parsed.data.heroVisible; + let mergedScoreRings: ScoreRingId[] | undefined = + parsed.data.selectedScoreRings; if ( mergedChartOverlayPrefs === undefined || - mergedHeroVisible === undefined + mergedHeroVisible === undefined || + mergedScoreRings === undefined ) { const existing = await prisma.user.findUnique({ where: { id: user.id }, @@ -286,11 +299,15 @@ export const PUT = apiHandler(async (request: NextRequest) => { if (mergedHeroVisible === undefined) { mergedHeroVisible = existingLayout.heroVisible === true; } + if (mergedScoreRings === undefined) { + mergedScoreRings = existingLayout.selectedScoreRings; + } } const normalized = serializeDashboardLayout({ ...parsed.data, chartOverlayPrefs: mergedChartOverlayPrefs, heroVisible: mergedHeroVisible, + selectedScoreRings: mergedScoreRings, } as DashboardLayout); await prisma.user.update({ 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..b7e02fe1a 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", () => { @@ -302,7 +302,7 @@ describe(" — verdict variants", () => { expect(html).toMatch(/]*data-slot="dashboard-hero-cta"/); }); - it("scoreDrop: point delta + link to /insights", () => { + it("scoreDrop: point delta, no broad Insights CTA", () => { const html = render( baseSnapshot({ healthScore: { score: 58, band: "yellow", delta: -14 }, @@ -310,11 +310,13 @@ describe(" — verdict variants", () => { ); expect(html).toContain('data-verdict-variant="scoreDrop"'); expect(html).toContain("liegt 14 Punkte unter der Vorwoche."); - expect(html).toContain('href="/insights"'); - expect(html).toContain("Insights öffnen"); + // v1.27.7 — the overview-only CTA left the band; the health-score + // card carries the Insights link. Only specific-action verdicts + // keep a button. + expect(html).not.toContain('data-slot="dashboard-hero-cta"'); }); - it("briefing: renders the model headline verbatim as plain text + Insights link", () => { + it("briefing: renders the model headline verbatim as plain text, no broad CTA", () => { const headline = "Systolic crept up over seven days."; const html = render( baseSnapshot({ @@ -338,8 +340,8 @@ describe(" — verdict variants", () => { ); expect(html).toContain('data-verdict-variant="briefing"'); expect(html).toContain(headline); - expect(html).toContain('href="/insights"'); - expect(html).toContain("Insights öffnen"); + // v1.27.7 — same rule as scoreDrop: no broad Insights CTA. + expect(html).not.toContain('data-slot="dashboard-hero-cta"'); }); it("allQuiet: sentence with NO CTA in the band", () => { @@ -366,49 +368,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 +438,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/briefing-spotlight.tsx b/src/components/dashboard/hero/briefing-spotlight.tsx index ae222e0b8..dcdc6f95a 100644 --- a/src/components/dashboard/hero/briefing-spotlight.tsx +++ b/src/components/dashboard/hero/briefing-spotlight.tsx @@ -49,6 +49,7 @@ import { Zap, } from "lucide-react"; import { ListRow } from "@/components/ui/list-row"; +import { METRIC_HREF } from "@/components/insights/daily-briefing"; import { useTranslations } from "@/lib/i18n/context"; import { stripChartTokens } from "@/lib/insights/chart-tokens"; import { cn } from "@/lib/utils"; @@ -178,7 +179,10 @@ export function BriefingSpotlight({ "focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none", )} > - + {/* Deep-link each signal to its metric sub-page (the same + mapping the briefing card's finding rows use); metrics + without a routed sub-page fall back to the overview. */} +