mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #408 from MBombeck/release/v1.27.7
Release v1.27.7 — score rings on the dashboard
This commit is contained in:
26 files changed
+1269
-237
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,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}
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user