Merge pull request #408 from MBombeck/release/v1.27.7

Release v1.27.7 — score rings on the dashboard
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-05 16:15:43 +02:00
commit 95c8a320c9
26 files changed
+1269 -237

No files matched your search

+11
View File
@@ -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
+33 -1
View File
@@ -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
+4 -9
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.",
@@ -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",
+4 -9
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.",
@@ -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",
+4 -9
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.",
@@ -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",
+4 -9
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.",
@@ -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",
+4 -9
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.",
@@ -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",
+4 -9
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.",
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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/*`
+22 -5
View File
@@ -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<DashboardLayout> {
@@ -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({
@@ -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", () => {
@@ -302,7 +302,7 @@ describe("<DashboardHero> — verdict variants", () => {
expect(html).toMatch(/<button[^>]*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("<DashboardHero> — 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("<DashboardHero> — 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("<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 +438,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", () => {
@@ -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",
)}
>
<Link href="/insights">
{/* 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. */}
<Link href={METRIC_HREF[row.sourceMetric] ?? "/insights"}>
<span
aria-hidden="true"
className={cn(
@@ -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";
@@ -78,7 +83,11 @@ const VERDICT_MESSAGE_KEY: Record<
allQuiet: "dashboard.hero.verdict.allQuiet",
};
/** Variant → CTA label key. `allQuiet` carries no CTA (cta: null). */
/** Variant → CTA label key. `allQuiet` carries no CTA (cta: null).
* `scoreDrop` / `briefing` carry none either: their only destination was
* the Insights overview, which the health-score card already links — a
* second broad "open Insights" button on the hero was redundant. Only
* verdicts with a SPECIFIC destination or action keep a button. */
const CTA_LABEL_KEY: Partial<Record<DashboardVerdictVariant, string>> = {
doseOverdue: "dashboard.hero.action.takeDose",
doseUpcoming: "dashboard.hero.action.takeDose",
@@ -86,19 +95,28 @@ const CTA_LABEL_KEY: Partial<Record<DashboardVerdictVariant, string>> = {
weightDrift: "dashboard.hero.action.viewWeight",
shortNights: "dashboard.hero.action.viewSleep",
silence: "dashboard.hero.action.logMeasurement",
scoreDrop: "dashboard.hero.action.viewInsights",
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 +212,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 +277,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}
+1 -1
View File
@@ -44,7 +44,7 @@ import type { BriefingFailureClass } from "@/lib/insights/briefing-failure-marke
* dedicated routed sub-page yet (hrv, resting_hr, steps, …) fall
* through to `null` and render as a plain row (no link wrap).
*/
const METRIC_HREF: Record<
export const METRIC_HREF: Record<
DailyBriefingKeyFinding["sourceMetric"],
string | null
> = {
@@ -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" />
@@ -657,3 +657,92 @@ describe("resolveDashboardLayout() — iOS-only id retention (v1.7.0)", () => {
}
});
});
/**
* v1.27.7 — hero score rings (`selectedScoreRings`). Closed id set, max
* three, dedupe + unknown-drop on read, default single MED_COMPLIANCE
* ring for legacy blobs, explicit empty array respected.
*/
describe("resolveDashboardLayout() — selectedScoreRings", () => {
const base = {
version: 1,
widgets: [{ id: "weight", visible: true, tileVisible: true, order: 0 }],
};
it("defaults a legacy blob (field missing) to the single MED_COMPLIANCE ring", () => {
const resolved = resolveDashboardLayout(base);
expect(resolved.selectedScoreRings).toEqual(["MED_COMPLIANCE"]);
});
it("defaults a null / non-array field to MED_COMPLIANCE", () => {
for (const bad of [null, "READINESS", 3, { READINESS: true }]) {
const resolved = resolveDashboardLayout({
...base,
selectedScoreRings: bad,
});
expect(resolved.selectedScoreRings).toEqual(["MED_COMPLIANCE"]);
}
});
it("respects an explicitly-saved empty array (user chose no rings)", () => {
const resolved = resolveDashboardLayout({
...base,
selectedScoreRings: [],
});
expect(resolved.selectedScoreRings).toEqual([]);
});
it("drops unknown ids and dedupes, preserving selection order", () => {
const resolved = resolveDashboardLayout({
...base,
selectedScoreRings: [
"SLEEP_SCORE",
"STRESS_SCORE", // not a ring id — drops
"READINESS",
"SLEEP_SCORE", // duplicate — collapses
],
});
expect(resolved.selectedScoreRings).toEqual(["SLEEP_SCORE", "READINESS"]);
});
it("clamps the selection to three rings (first three win)", () => {
const resolved = resolveDashboardLayout({
...base,
selectedScoreRings: [
"READINESS",
"RECOVERY_SCORE",
"SLEEP_SCORE",
"MED_COMPLIANCE",
],
});
expect(resolved.selectedScoreRings).toEqual([
"READINESS",
"RECOVERY_SCORE",
"SLEEP_SCORE",
]);
});
it("serializeDashboardLayout persists the coerced selection explicitly", () => {
const serialized = serializeDashboardLayout({
...DEFAULT_DASHBOARD_LAYOUT,
selectedScoreRings: [
"MED_COMPLIANCE",
"MED_COMPLIANCE",
"READINESS",
"RECOVERY_SCORE",
"SLEEP_SCORE",
],
} as DashboardLayout);
expect(serialized.selectedScoreRings).toEqual([
"MED_COMPLIANCE",
"READINESS",
"RECOVERY_SCORE",
]);
});
it("the default layout carries the MED_COMPLIANCE ring", () => {
expect(DEFAULT_DASHBOARD_LAYOUT.selectedScoreRings).toEqual([
"MED_COMPLIANCE",
]);
});
});
+87
View File
@@ -4,6 +4,7 @@
* Single source of truth for which widgets show on /, what order, and
* what the default layout is for new users. Null / missing = default.
*/
import type { ModuleKey } from "@/lib/modules/registry";
/**
* Every widget the dashboard layout knows about. The order of
@@ -334,6 +335,76 @@ function coerceChartOverlayPrefsMap(value: unknown): ChartOverlayPrefsMap {
return out;
}
/**
* v1.27.7 — selectable hero score rings. The closed id set the hero's
* ring row can render next to the health score:
*
* - `READINESS` / `RECOVERY_SCORE` / `SLEEP_SCORE` resolve through the
* derived registry's engines (module-gated like the derived routes);
* - `MED_COMPLIANCE` is the pooled 7-day medication adherence from the
* canonical compliance engine — the ring that absorbs the retired
* hero dose row's information role.
*
* The preference piggy-backs on the layout blob like `heroVisible` (a UI
* affordance, no Prisma migration). The resolver drops unknown ids,
* dedupes, and clamps to `MAX_SELECTED_SCORE_RINGS`; a missing /
* malformed field falls back to the default single MED_COMPLIANCE ring,
* while an explicitly-saved empty array stays empty (the user chose no
* extra rings).
*/
export const SCORE_RING_IDS = [
"READINESS",
"RECOVERY_SCORE",
"SLEEP_SCORE",
"MED_COMPLIANCE",
] as const;
export type ScoreRingId = (typeof SCORE_RING_IDS)[number];
export const MAX_SELECTED_SCORE_RINGS = 3;
export const DEFAULT_SELECTED_SCORE_RINGS: ScoreRingId[] = ["MED_COMPLIANCE"];
/**
* Ring id → owning toggleable module. Mirrors the derived routes'
* `DERIVED_MODULE` map for the three derived rings (READINESS rides the
* recovery module like the recovery/strain/stress trio); MED_COMPLIANCE
* belongs to the medications module. Client-safe (type-only ModuleKey
* import) so the Settings picker and the server snapshot resolver gate
* on the same map — the `WIDGET_MODULE_BY_ID` pattern.
*/
export const SCORE_RING_MODULE: Record<ScoreRingId, ModuleKey> = {
READINESS: "recovery",
RECOVERY_SCORE: "recovery",
SLEEP_SCORE: "sleep",
MED_COMPLIANCE: "medications",
};
function isScoreRingId(value: unknown): value is ScoreRingId {
return (
typeof value === "string" &&
(SCORE_RING_IDS as readonly string[]).includes(value)
);
}
/**
* Coerce the persisted `selectedScoreRings` field: unknown ids drop,
* duplicates collapse (first occurrence wins), the list clamps to
* `MAX_SELECTED_SCORE_RINGS`. A non-array (missing field, legacy blob,
* malformed client) falls back to the default; an array — even an empty
* one — is respected as an explicit choice.
*/
function coerceSelectedScoreRings(value: unknown): ScoreRingId[] {
if (!Array.isArray(value)) return [...DEFAULT_SELECTED_SCORE_RINGS];
const out: ScoreRingId[] = [];
for (const entry of value) {
if (!isScoreRingId(entry)) continue;
if (out.includes(entry)) continue;
out.push(entry);
if (out.length >= MAX_SELECTED_SCORE_RINGS) break;
}
return out;
}
export interface DashboardLayout {
version: number;
widgets: DashboardWidgetConfig[];
@@ -351,6 +422,12 @@ export interface DashboardLayout {
* serializer persists the resolved boolean explicitly.
*/
heroVisible?: boolean;
/**
* v1.27.7 — hero score rings (max 3, closed `SCORE_RING_IDS` set)
* rendered next to the health-score ring. See the doc on
* `SCORE_RING_IDS`; the resolver clamps/dedupes/drops-unknown.
*/
selectedScoreRings?: ScoreRingId[];
}
const DASHBOARD_LAYOUT_VERSION = 1;
@@ -365,6 +442,9 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = {
// Hero (daily verdict) is off by default; users opt in via
// Settings → Dashboard.
heroVisible: false,
// One medication-adherence ring next to the health score by default —
// the successor of the hero dose row's information role.
selectedScoreRings: [...DEFAULT_SELECTED_SCORE_RINGS],
widgets: [
{ id: "weight", visible: true, tileVisible: true, order: 0 },
{ id: "bp", visible: true, tileVisible: true, order: 1 },
@@ -506,6 +586,10 @@ export function resolveDashboardLayout(raw: unknown): DashboardLayout {
// clamps to `false` (default-off for legacy blobs and malformed
// values alike; the hero is opt-in).
heroVisible: candidate.heroVisible === true,
// v1.27.7 — hero score rings: unknown ids drop, duplicates collapse,
// the list clamps to three. A legacy blob without the field gets the
// default single MED_COMPLIANCE ring.
selectedScoreRings: coerceSelectedScoreRings(candidate.selectedScoreRings),
};
}
@@ -536,5 +620,8 @@ export function serializeDashboardLayout(
// Persist hero visibility explicitly (same clamp as the resolver)
// so a re-read never has to guess the default.
heroVisible: layout.heroVisible === true,
// v1.27.7 — persist the score-ring selection through the same
// coercion the resolver runs so the wire shape is stable.
selectedScoreRings: coerceSelectedScoreRings(layout.selectedScoreRings),
};
}
@@ -0,0 +1,269 @@
/**
* v1.27.7 — unit tests for the hero score-ring resolver.
*
* The resolver is tested in isolation with the derived dispatcher and
* the compliance engine mocked, pinning:
* - module gating (owning module + the insights gate on derived rings);
* - the pass-through contract for derived scores (same resolvers the
* batch route calls, no recomputation, non-`ok` → no ring);
* - the pooled 7-day adherence math (Σtaken / Σ(taken+missed), skips
* out of the denominator, empty denominator → no ring);
* - the adherence band thresholds (≥90 / ≥70 — the targets-tile rule);
* - per-ring fail-softness (one throwing engine drops only its ring).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const computeDerivedMetric = vi.fn();
const loadBaselineProfile = vi.fn();
const calculateCompliance = vi.fn();
const buildComplianceMedicationContext = vi.fn();
const lastNonSkippedTakenAt = vi.fn();
vi.mock("@/lib/insights/derived", () => ({
computeDerivedMetric: (...a: unknown[]) => computeDerivedMetric(...a),
loadBaselineProfile: (...a: unknown[]) => loadBaselineProfile(...a),
isDerivedOk: (d: { status: string }) => d.status === "ok",
}));
vi.mock("@/lib/analytics/compliance", () => ({
calculateCompliance: (...a: unknown[]) => calculateCompliance(...a),
buildComplianceMedicationContext: (...a: unknown[]) =>
buildComplianceMedicationContext(...a),
lastNonSkippedTakenAt: (...a: unknown[]) => lastNonSkippedTakenAt(...a),
SCHEDULE_COMPLIANCE_SELECT: { id: true },
}));
import { buildScoreRingsBlock, complianceBandForRate } from "../score-rings";
import type { ModuleKey } from "@/lib/modules/gate";
const NOW = new Date("2026-07-01T10:00:00.000Z");
const TZ = "Europe/Berlin";
function moduleMap(
overrides: Partial<Record<ModuleKey, boolean>> = {},
): Record<ModuleKey, boolean> {
return {
recovery: true,
sleep: true,
medications: true,
insights: true,
...overrides,
} as Record<ModuleKey, boolean>;
}
const fakePrisma = {
medication: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
/** A minimal medication row matching the resolver's select. */
function med(id: string) {
return {
id,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
startsOn: null,
endsOn: null,
oneShot: false,
schedules: [{ id: `${id}-s` }],
scheduleRevisions: [],
pauseEras: [],
};
}
function complianceResult(taken: number, missed: number, skipped = 0) {
return {
totalExpected: taken + missed + skipped,
taken,
skipped,
missed,
rate: 0, // the resolver pools counts, never this per-med rate
streak: 0,
};
}
beforeEach(() => {
vi.clearAllMocks();
loadBaselineProfile.mockResolvedValue({ userId: "user-1" });
buildComplianceMedicationContext.mockReturnValue({ ctx: true });
lastNonSkippedTakenAt.mockReturnValue(null);
fakePrisma.medication.findMany.mockResolvedValue([]);
fakePrisma.medicationIntakeEvent.findMany.mockResolvedValue([]);
});
describe("complianceBandForRate()", () => {
it("bands on the targets-tile thresholds (≥90 green / ≥70 yellow / else red)", () => {
expect(complianceBandForRate(100)).toBe("green");
expect(complianceBandForRate(90)).toBe("green");
expect(complianceBandForRate(89)).toBe("yellow");
expect(complianceBandForRate(70)).toBe("yellow");
expect(complianceBandForRate(69)).toBe("red");
expect(complianceBandForRate(0)).toBe("red");
});
});
describe("buildScoreRingsBlock() — selection + module gating", () => {
it("returns [] for an empty selection without touching any engine", async () => {
const rings = await buildScoreRingsBlock(
fakePrisma,
"user-1",
TZ,
[],
moduleMap(),
NOW,
);
expect(rings).toEqual([]);
expect(loadBaselineProfile).not.toHaveBeenCalled();
expect(computeDerivedMetric).not.toHaveBeenCalled();
expect(fakePrisma.medication.findMany).not.toHaveBeenCalled();
});
it("drops rings whose owning module is disabled", async () => {
computeDerivedMetric.mockResolvedValue({
status: "ok",
value: { score: 80, band: "green" },
});
const rings = await buildScoreRingsBlock(
fakePrisma,
"user-1",
TZ,
["READINESS", "SLEEP_SCORE"],
moduleMap({ recovery: false }),
NOW,
);
expect(rings).toEqual([{ id: "SLEEP_SCORE", score: 80, band: "green" }]);
expect(computeDerivedMetric).toHaveBeenCalledTimes(1);
expect(computeDerivedMetric).toHaveBeenCalledWith(
expect.objectContaining({ metric: "SLEEP_SCORE", userId: "user-1" }),
);
});
it("the insights gate drops derived rings but keeps the adherence ring", async () => {
fakePrisma.medication.findMany.mockResolvedValue([med("m1")]);
calculateCompliance.mockReturnValue(complianceResult(9, 1));
const rings = await buildScoreRingsBlock(
fakePrisma,
"user-1",
TZ,
["READINESS", "MED_COMPLIANCE"],
moduleMap({ insights: false }),
NOW,
);
expect(computeDerivedMetric).not.toHaveBeenCalled();
expect(loadBaselineProfile).not.toHaveBeenCalled();
expect(rings).toEqual([{ id: "MED_COMPLIANCE", score: 90, band: "green" }]);
});
});
describe("buildScoreRingsBlock() — derived rings", () => {
it("passes through the engine's score + band (rounded) in selection order", async () => {
computeDerivedMetric.mockImplementation(
async ({ metric }: { metric: string }) =>
metric === "READINESS"
? { status: "ok", value: { score: 71.6, band: "green" } }
: { status: "ok", value: { score: 38, band: "red" } },
);
const rings = await buildScoreRingsBlock(
fakePrisma,
"user-1",
TZ,
["RECOVERY_SCORE", "READINESS"],
moduleMap(),
NOW,
);
expect(rings).toEqual([
{ id: "RECOVERY_SCORE", score: 38, band: "red" },
{ id: "READINESS", score: 72, band: "green" },
]);
// Profile loaded exactly once (the batch-route pattern).
expect(loadBaselineProfile).toHaveBeenCalledTimes(1);
});
it("a non-ok compute yields no ring (self-gating, never a fabricated value)", async () => {
computeDerivedMetric.mockResolvedValue({
status: "insufficient",
reason: "not_enough_history",
});
const rings = await buildScoreRingsBlock(
fakePrisma,
"user-1",
TZ,
["SLEEP_SCORE"],
moduleMap(),
NOW,
);
expect(rings).toEqual([]);
});
it("a throwing engine drops only its own ring (fail-soft)", async () => {
computeDerivedMetric.mockImplementation(
async ({ metric }: { metric: string }) => {
if (metric === "READINESS") throw new Error("engine down");
return { status: "ok", value: { score: 55, band: "yellow" } };
},
);
const rings = await buildScoreRingsBlock(
fakePrisma,
"user-1",
TZ,
["READINESS", "SLEEP_SCORE"],
moduleMap(),
NOW,
);
expect(rings).toEqual([{ id: "SLEEP_SCORE", score: 55, band: "yellow" }]);
});
});
describe("buildScoreRingsBlock() — pooled 7-day adherence", () => {
it("pools Σtaken / Σ(taken+missed) across medications; skips stay out", async () => {
fakePrisma.medication.findMany.mockResolvedValue([med("m1"), med("m2")]);
calculateCompliance
.mockReturnValueOnce(complianceResult(6, 1, 2)) // m1: skips ignored
.mockReturnValueOnce(complianceResult(2, 3));
const rings = await buildScoreRingsBlock(
fakePrisma,
"user-1",
TZ,
["MED_COMPLIANCE"],
moduleMap(),
NOW,
);
// (6+2) / (6+1+2+3) = 8/12 = 66.67 → 67, red band.
expect(rings).toEqual([{ id: "MED_COMPLIANCE", score: 67, band: "red" }]);
// The 7-day window rides the canonical engine call.
expect(calculateCompliance).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
7,
expect.anything(),
expect.objectContaining({ now: NOW }),
);
});
it("no active non-PRN medication → no ring", async () => {
fakePrisma.medication.findMany.mockResolvedValue([]);
const rings = await buildScoreRingsBlock(
fakePrisma,
"user-1",
TZ,
["MED_COMPLIANCE"],
moduleMap(),
NOW,
);
expect(rings).toEqual([]);
expect(fakePrisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled();
});
it("zero expected doses in the window → no ring (never a hollow 100%)", async () => {
fakePrisma.medication.findMany.mockResolvedValue([med("m1")]);
calculateCompliance.mockReturnValue(complianceResult(0, 0, 0));
const rings = await buildScoreRingsBlock(
fakePrisma,
"user-1",
TZ,
["MED_COMPLIANCE"],
moduleMap(),
NOW,
);
expect(rings).toEqual([]);
});
});
@@ -64,6 +64,12 @@ const resolveModuleMap = vi.fn();
vi.mock("@/lib/modules/gate", () => ({
resolveModuleMap: (...a: unknown[]) => resolveModuleMap(...a),
}));
// v1.27.7 — hero score rings. Mocked so the suite never touches the
// derived engines / compliance reads; the resolver has its own suite.
const buildScoreRingsBlock = vi.fn();
vi.mock("@/lib/dashboard/score-rings", () => ({
buildScoreRingsBlock: (...a: unknown[]) => buildScoreRingsBlock(...a),
}));
/** Every toggleable key on, with optional per-key overrides. */
function moduleMap(
@@ -173,6 +179,7 @@ beforeEach(() => {
});
computeUserHealthScoreFastPath.mockResolvedValue(null);
resolveModuleMap.mockResolvedValue(moduleMap());
buildScoreRingsBlock.mockResolvedValue([]);
});
describe("buildDashboardSnapshot — envelope shape", () => {
@@ -1171,3 +1178,62 @@ describe("buildDashboardSnapshot — ambient narrative (A4/A5/A6)", () => {
expect(snap.briefingMemory).toBeNull();
});
});
/**
* v1.27.7 — hero score rings on the snapshot wire. The resolver itself
* is pinned in `score-rings.test.ts`; here only the assembly contract:
* the builder receives the RESOLVED `selectedScoreRings` preference +
* the module map, its result rides `snapshot.scoreRings`, and a
* rejection degrades to an empty row (never a sunk snapshot).
*/
describe("buildDashboardSnapshot — scoreRings wire", () => {
it("threads the default selection (MED_COMPLIANCE) for a layout-less user", async () => {
probeRollupCoverage.mockResolvedValue(new Map());
buildScoreRingsBlock.mockResolvedValue([
{ id: "MED_COMPLIANCE", score: 92, band: "green" },
]);
const snap = await buildDashboardSnapshot(fakePrisma, baseUser());
expect(buildScoreRingsBlock).toHaveBeenCalledTimes(1);
const [, userId, tz, selected, modules] =
buildScoreRingsBlock.mock.calls[0];
expect(userId).toBe("user-1");
expect(tz).toBe("Europe/Berlin");
expect(selected).toEqual(["MED_COMPLIANCE"]);
expect(modules).toEqual(moduleMap());
expect(snap.scoreRings).toEqual([
{ id: "MED_COMPLIANCE", score: 92, band: "green" },
]);
});
it("threads a saved multi-ring selection in order", async () => {
probeRollupCoverage.mockResolvedValue(new Map());
buildScoreRingsBlock.mockResolvedValue([]);
await buildDashboardSnapshot(
fakePrisma,
baseUser({
dashboardWidgetsJson: {
version: 1,
widgets: [{ id: "weight", visible: true, order: 0 }],
selectedScoreRings: ["READINESS", "SLEEP_SCORE"],
},
}),
);
const [, , , selected] = buildScoreRingsBlock.mock.calls[0];
expect(selected).toEqual(["READINESS", "SLEEP_SCORE"]);
});
it("degrades a rejecting resolver to an empty ring row (fail-soft)", async () => {
probeRollupCoverage.mockResolvedValue(new Map());
buildScoreRingsBlock.mockRejectedValue(new Error("engine down"));
const snap = await buildDashboardSnapshot(fakePrisma, baseUser());
expect(snap.scoreRings).toEqual([]);
// The rest of the envelope is untouched by the failure.
expect(snap.tiles.summaries.WEIGHT).toBeDefined();
});
});
+245
View File
@@ -0,0 +1,245 @@
/**
* v1.27.7 — hero score-ring resolution for the dashboard snapshot.
*
* Resolves the user-selected `selectedScoreRings` (max 3, closed
* `SCORE_RING_IDS` set) into `{ id, score, band }` entries the hero
* renders next to the health-score ring:
*
* - `READINESS` / `RECOVERY_SCORE` / `SLEEP_SCORE` call the SAME
* `computeDerivedMetric` dispatcher the `/api/insights/derived`
* batch route uses (profile loaded once via the shared
* `loadBaselineProfile`) — no score logic is re-derived here, and a
* non-`ok` compute (insufficient data, engine gate) simply yields no
* ring, mirroring the wellness strip's self-gating.
* - `MED_COMPLIANCE` is the pooled 7-day adherence across active
* non-PRN medications through the canonical `calculateCompliance`
* engine (the exact per-medication pattern the targets tile and the
* health-score pillar use): per-med `ComplianceResult`s are pooled
* as Σtaken / Σ(taken + missed) — skips stay out of the denominator,
* matching the per-medication rate semantics — and banded on the
* same ≥90 / ≥70 thresholds the targets-tile classification carries.
*
* Module gating rides the client-safe `SCORE_RING_MODULE` map in
* `@/lib/dashboard-layout` (mirroring the derived routes'
* `DERIVED_MODULE`: readiness/recovery → recovery module, sleep score →
* sleep module, MED_COMPLIANCE → medications) plus the `insights` gate
* every derived read sits behind.
*
* Every ring resolves fail-soft: a throwing engine drops its ring, never
* the snapshot.
*/
import type { PrismaClient } from "@/generated/prisma/client";
import type { ModuleKey } from "@/lib/modules/gate";
import {
SCORE_RING_IDS,
SCORE_RING_MODULE,
type ScoreRingId,
} from "@/lib/dashboard-layout";
import {
computeDerivedMetric,
loadBaselineProfile,
isDerivedOk,
type DerivedMetricId,
} from "@/lib/insights/derived";
import {
buildComplianceMedicationContext,
calculateCompliance,
lastNonSkippedTakenAt,
SCHEDULE_COMPLIANCE_SELECT,
} from "@/lib/analytics/compliance";
export type ScoreRingBand = "green" | "yellow" | "red";
/** One resolved hero ring — score + band only, no component breakdown. */
export interface DashboardScoreRing {
id: ScoreRingId;
/** The 0..100 score (rounded). */
score: number;
band: ScoreRingBand;
}
/** The derived-registry id behind each derived ring (identity mapping). */
const DERIVED_RING_METRIC: Partial<Record<ScoreRingId, DerivedMetricId>> = {
READINESS: "READINESS",
RECOVERY_SCORE: "RECOVERY_SCORE",
SLEEP_SCORE: "SLEEP_SCORE",
};
/**
* Adherence % → band, on the thresholds the targets tile's
* MEDICATION_COMPLIANCE classification already uses (≥90 "very good" /
* ≥70 "good" / below "low"), so the ring and the targets surface can
* never disagree on colour for the same rate.
*/
export function complianceBandForRate(rate: number): ScoreRingBand {
if (rate >= 90) return "green";
if (rate >= 70) return "yellow";
return "red";
}
/** The compute payload slice every derived score ring reads. */
interface ScoreBandValue {
score: number;
band: ScoreRingBand;
}
/**
* Pooled 7-day medication adherence, or `null` when nothing was expected
* (no active non-PRN medication, or no due slot in the window) — the
* ring self-gates instead of asserting a hollow 100%.
*/
async function resolveMedComplianceRing(
prisma: PrismaClient,
userId: string,
userTz: string,
now: Date,
): Promise<DashboardScoreRing | null> {
const medications = await prisma.medication.findMany({
// As-needed (PRN) medications carry no expected doses and are
// excluded from every compliance rate (the v1.16.11 rule).
where: { userId, active: true, asNeeded: false },
select: {
id: true,
createdAt: true,
startsOn: true,
endsOn: true,
oneShot: true,
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
// Archived schedule eras + pause eras so a past day scores against
// the schedule that was live then and paused days drop out of the
// denominator — the same context every compliance caller threads.
scheduleRevisions: {
orderBy: { validFrom: "asc" },
select: {
id: true,
validFrom: true,
validUntil: true,
payload: true,
supersededByRevisionId: true,
},
},
pauseEras: { select: { pausedAt: true, resumedAt: true } },
},
});
if (medications.length === 0) return null;
// 30 days of intake events even though the rate window is 7: rolling /
// interval cadences anchor `nextDue` off the last non-skipped take,
// which can sit before the window start (the health-score pillar
// fetches the same margin).
const since = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const intakeEvents = await prisma.medicationIntakeEvent.findMany({
where: {
userId,
deletedAt: null,
medicationId: { in: medications.map((m) => m.id) },
scheduledFor: { gte: since, lte: now },
},
select: {
medicationId: true,
scheduledFor: true,
takenAt: true,
skipped: true,
},
});
const eventsByMed = new Map<string, typeof intakeEvents>();
for (const ev of intakeEvents) {
const list = eventsByMed.get(ev.medicationId);
if (list) list.push(ev);
else eventsByMed.set(ev.medicationId, [ev]);
}
let taken = 0;
let missed = 0;
for (const med of medications) {
const events = eventsByMed.get(med.id) ?? [];
const medicationContext = buildComplianceMedicationContext(
med,
lastNonSkippedTakenAt(events),
userTz,
);
const result = calculateCompliance(
events,
med.schedules,
7,
med.createdAt,
{
now,
medicationContext,
},
);
taken += result.taken;
missed += result.missed;
}
const denominator = taken + missed;
if (denominator === 0) return null;
const rate = Math.min(100, Math.round((taken / denominator) * 100));
return {
id: "MED_COMPLIANCE",
score: rate,
band: complianceBandForRate(rate),
};
}
/**
* Resolve the selected hero rings against the module map. Selection
* order is preserved; rings without data drop out. Every ring is
* fail-soft on its own — one throwing engine never takes the row (or
* the snapshot) down.
*/
export async function buildScoreRingsBlock(
prisma: PrismaClient,
userId: string,
userTz: string,
selected: ScoreRingId[],
modules: Record<ModuleKey, boolean>,
now: Date,
): Promise<DashboardScoreRing[]> {
const eligible = selected.filter((id) => {
if (!(SCORE_RING_IDS as readonly string[]).includes(id)) return false;
if (modules[SCORE_RING_MODULE[id]] === false) return false;
// The derived scores are an insights-layer read everywhere else
// (`/api/insights/derived*` sits behind the insights module) — the
// snapshot honours the same gate.
if (DERIVED_RING_METRIC[id] && modules.insights === false) return false;
return true;
});
if (eligible.length === 0) return [];
// Profile loaded ONCE via the shared loader (the batch-route pattern);
// only when a derived ring is actually selected.
const needsProfile = eligible.some((id) => DERIVED_RING_METRIC[id]);
const profile = needsProfile
? await loadBaselineProfile(prisma, userId)
: null;
const resolved = await Promise.all(
eligible.map(async (id): Promise<DashboardScoreRing | null> => {
try {
if (id === "MED_COMPLIANCE") {
return await resolveMedComplianceRing(prisma, userId, userTz, now);
}
const metric = DERIVED_RING_METRIC[id];
if (!metric || !profile) return null;
const derived = await computeDerivedMetric({
metric,
userId,
profile,
now,
});
if (!isDerivedOk(derived)) return null;
const value = derived.value as ScoreBandValue;
if (typeof value?.score !== "number" || !Number.isFinite(value.score)) {
return null;
}
return { id, score: Math.round(value.score), band: value.band };
} catch {
// Fail-soft per ring — a transient engine error drops the ring,
// never the snapshot.
return null;
}
}),
);
return resolved.filter((r): r is DashboardScoreRing => r !== null);
}
+75 -25
View File
@@ -93,6 +93,10 @@ import {
buildCoachMemoryBlock,
type CoachMemoryBlock,
} from "@/lib/ai/coach/memory-snapshot";
import {
buildScoreRingsBlock,
type DashboardScoreRing,
} from "@/lib/dashboard/score-rings";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import type { Locale } from "@/lib/i18n/config";
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
@@ -401,6 +405,17 @@ export interface DashboardSnapshot {
* thick phase alongside `extras`) and when no pillar is computable.
*/
healthScore: DashboardSnapshotHealthScore | null;
/**
* v1.27.7 — the user-selected hero score rings (max 3), resolved
* server-side: READINESS / RECOVERY_SCORE / SLEEP_SCORE through the
* same engines the derived batch route calls, MED_COMPLIANCE as the
* pooled 7-day adherence from the canonical compliance engine. Only
* rings with data appear (selection order preserved); module-disabled
* and data-less selections drop out, so the hero row self-gates.
* Optional on the type (additive contract) so older cached snapshots
* / fixtures stay valid; the live builder always sets it.
*/
scoreRings?: DashboardScoreRing[];
briefing: DailyBriefing | null;
/**
* v1.21.2 (A4) — server-resolved recall + forward-look for the briefing card.
@@ -1027,6 +1042,12 @@ export async function buildDashboardSnapshot(
* derived engines. Defaults to the real `buildScoreNarrativeBlock`.
*/
scoreNarrative?: typeof buildScoreNarrativeBlock;
/**
* v1.27.7 — injectable hero score-ring resolver so the snapshot tests
* can pin the wire without the derived engines / compliance reads.
* Defaults to the real `buildScoreRingsBlock`.
*/
scoreRings?: typeof buildScoreRingsBlock;
} = {},
): Promise<DashboardSnapshot> {
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
@@ -1045,28 +1066,56 @@ export async function buildDashboardSnapshot(
const coverage = await time("coverage", () => probeRollupCoverage(user.id));
const warm = isThickPhaseWarm(coverage);
const [slimRaw, moodRaw, extrasResult, flags, medsToday, modules] =
await Promise.all([
// A5 — reuse the coverage map already probed above so the slice
// doesn't re-run the identical `probeRollupCoverage` query.
time("summaries", () => computeSummariesSlice(user.id, coverage)),
time("mood", () => buildMoodBlock(prisma, user.id)),
warm
? time("extras", () =>
buildExtras(prisma, user, userTz, coverage, now, time),
)
: Promise.resolve(null),
time("flags", () => getAssistantFlags()),
// Fast phase — projection-backed today tally + earliest next-due.
time("medsToday", () =>
buildMedsTodayBlock(prisma, user.id, userTz, now),
// Resolve the stored layout once up front: the hero score rings read
// the `selectedScoreRings` preference off it, and the module-gated
// `layout` block below reuses the same resolution.
const storedLayout = resolveDashboardLayout(user.dashboardWidgetsJson);
// v1.18.0 — resolved module map (memoised per request); gates the
// toggleable tiles below at the build layer. Held as a shared promise
// so the score-ring task can await it inside the same `Promise.all`
// without a second resolver call.
const modulesPromise = time("modules", () =>
(options.modules ?? (() => resolveModuleMap(user.id)))(),
);
const [
slimRaw,
moodRaw,
extrasResult,
flags,
medsToday,
modules,
scoreRings,
] = await Promise.all([
// A5 — reuse the coverage map already probed above so the slice
// doesn't re-run the identical `probeRollupCoverage` query.
time("summaries", () => computeSummariesSlice(user.id, coverage)),
time("mood", () => buildMoodBlock(prisma, user.id)),
warm
? time("extras", () =>
buildExtras(prisma, user, userTz, coverage, now, time),
)
: Promise.resolve(null),
time("flags", () => getAssistantFlags()),
// Fast phase — projection-backed today tally + earliest next-due.
time("medsToday", () => buildMedsTodayBlock(prisma, user.id, userTz, now)),
modulesPromise,
// v1.27.7 — the selected hero score rings, resolved through the
// same engines the derived batch route calls (+ the canonical
// compliance engine for MED_COMPLIANCE). Fail-soft: a throwing
// resolver yields no rings, never a sunk snapshot.
time("scoreRings", async () =>
(options.scoreRings ?? buildScoreRingsBlock)(
prisma,
user.id,
userTz,
storedLayout.selectedScoreRings ?? [],
await modulesPromise,
now,
),
// v1.18.0 — resolved module map (memoised per request); gates the
// toggleable tiles below at the build layer.
time("modules", () =>
(options.modules ?? (() => resolveModuleMap(user.id)))(),
),
]);
).catch(() => [] as DashboardScoreRing[]),
]);
// v1.18.0 — strip disabled-module data before it leaves the server.
// Mood is blanked when the mood module is off; sleep / glucose summary
@@ -1090,10 +1139,7 @@ export async function buildDashboardSnapshot(
});
}
const layout = gateLayoutByModules(
resolveDashboardLayout(user.dashboardWidgetsJson),
modules,
);
const layout = gateLayoutByModules(storedLayout, modules);
// v1.18.0 — the Daily Briefing is the dashboard's AI-narrative surface,
// so the `insights` module gates it alongside the operator briefing
// flag + per-user coach opt-out. Disabling `insights` yields the same
@@ -1191,6 +1237,10 @@ export async function buildDashboardSnapshot(
returnToBand: scoreNarrative?.returnToBand ?? null,
}
: null,
// v1.27.7 — the resolved hero score rings (selection order, data-
// gated). Always set by the live builder; optional on the type so
// older cached snapshots stay valid.
scoreRings,
briefing: briefing.briefing,
// v1.21.2 (A4) — the briefing recall + forward-look. Null when there is no
// prior narrative on file or no briefing is shown.
@@ -852,6 +852,26 @@ export const dashboardSnapshotResponse = z
.describe(
"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.",
),
// v1.27.7 — user-selected hero score rings (max 3), resolved
// server-side next to the health score. Additive; optional so
// cached pre-v1.27.7 snapshots stay decodable.
scoreRings: z
.array(
z.object({
id: z.enum([
"READINESS",
"RECOVERY_SCORE",
"SLEEP_SCORE",
"MED_COMPLIANCE",
]),
score: z.number().int(),
band: z.enum(["green", "yellow", "red"]),
}),
)
.optional()
.describe(
"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.",
),
briefing: z.record(z.string(), z.unknown()).nullable(),
briefingState: z.enum(["ready", "preparing", "disabled", "no-provider"]),
briefingUpdatedAt: z.string().nullable(),