From 62140a4aeed1fe97eb5aaa3624da1a52df60f608 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 9 Oct 2026 16:45:39 +0200 Subject: [PATCH 1/2] feat(insights): follow every score over time with one range selector The health score had no history anywhere, readiness and the sleep score showed a fixed sparkline, and only the stored nightly scores offered the range tabs. Every score page now charts its course with the same 7 / 30 / 90 / All tabs, remembered per page, and each point opens its day. - GET /api/insights/score-history?score=&days= serves the daily course of the health score (its stored day rows), readiness (the nightly blend, filed on its wake day) and the sleep score (each night against the trailing window ending on it), so a point equals what the day view shows for that day. MANAGE record read, module-gated for readiness and sleep, days 1 to 3650, additive. - The health score's line breaks where its recipe changed (configuration or algorithm version); the usual range is formed from the newest side of the last seam. - The range tabs move into one shared component used by every chart; recovery, stress and strain keep their measurement chart, now with the range remembered and no overlay dropdown. - New page /insights/health-score, linked from the score panel and from the day view's score tile. --- docs/api/openapi.yaml | 161 ++++++++ eslint.config.mjs | 1 + messages/de.json | 10 +- messages/en.json | 10 +- messages/es.json | 10 +- messages/fr.json | 10 +- messages/it.json | 10 +- messages/ko.json | 10 +- messages/pl.json | 10 +- src/__tests__/sharing-surface-guard.test.ts | 15 +- src/app/api/__tests__/ai-route-inventory.ts | 2 + ...inline-schema-strictness-inventory.test.ts | 4 + src/app/api/insights/score-history/route.ts | 98 +++++ .../health-score/__tests__/page.test.tsx | 90 +++++ src/app/insights/health-score/page.tsx | 41 ++ .../__tests__/health-chart-mini.test.tsx | 14 + src/components/charts/chart-range-tabs.tsx | 71 ++++ src/components/charts/chart-runtime.ts | 1 + src/components/charts/health-chart.tsx | 116 ++---- .../__tests__/health-score-card.test.tsx | 13 + .../composite-score-anatomy-coach.test.tsx | 2 +- .../__tests__/score-history-wiring.test.tsx | 74 ++-- .../__tests__/score-trend-chart.test.tsx | 195 +++++++++ .../derived/composite-score-anatomy.tsx | 39 +- .../insights/derived/score-history.tsx | 70 +--- .../insights/derived/score-trend-chart.tsx | 375 ++++++++++++++++++ src/components/insights/health-score-card.tsx | 27 +- src/lib/dashboard-layout.ts | 8 + src/lib/day/contract.ts | 2 +- .../insights/__tests__/score-history.test.ts | 192 +++++++++ .../__tests__/sleep-score-history.test.ts | 174 ++++++++ src/lib/insights/derived/sleep-score.ts | 96 +++++ src/lib/insights/score-history-ids.ts | 18 + src/lib/insights/score-history.ts | 216 ++++++++++ src/lib/openapi/routes/insights/paths.ts | 28 ++ src/lib/openapi/routes/insights/schemas.ts | 62 +++ src/lib/query-keys/insights.ts | 7 + tests/integration/score-history.test.ts | 338 ++++++++++++++++ 38 files changed, 2437 insertions(+), 183 deletions(-) create mode 100644 src/app/api/insights/score-history/route.ts create mode 100644 src/app/insights/health-score/__tests__/page.test.tsx create mode 100644 src/app/insights/health-score/page.tsx create mode 100644 src/components/charts/chart-range-tabs.tsx create mode 100644 src/components/insights/derived/__tests__/score-trend-chart.test.tsx create mode 100644 src/components/insights/derived/score-trend-chart.tsx create mode 100644 src/lib/insights/__tests__/score-history.test.ts create mode 100644 src/lib/insights/derived/__tests__/sleep-score-history.test.ts create mode 100644 src/lib/insights/score-history-ids.ts create mode 100644 src/lib/insights/score-history.ts create mode 100644 tests/integration/score-history.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d1292d8de..fc8300146 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -12346,6 +12346,56 @@ paths: "403": *a11 "422": *a4 "429": *a5 + /api/insights/score-history: + get: + tags: + - Insights + summary: A score's daily history + description: "v1.42 — the daily course of the health score, readiness or the sleep score over a trailing window, one + point per local day, for the history chart on the score's page. Each point is the value the day view shows for + that day: the stored health-score day, the nightly readiness blend filed on its wake day, the sleep score of the + night that ended that morning. The health score's points carry `seamBreak` where the recipe changed, and the + chart draws no line across it. `band` is the usual range behind the newest point. Readiness is gated on the + `recovery` module and the sleep score on `sleep` (403 `module.disabled`); the health score is core. Pure reads, + no AI. Delegable at MANAGE level over the whole record. Additive. Cookie or Bearer auth." + parameters: + - in: query + name: score + schema: + type: string + enum: + - HEALTH_SCORE + - READINESS + - SLEEP_SCORE + description: The score to chart. `HEALTH_SCORE` reads the stored day scores, `READINESS` the nightly readiness blend, + `SLEEP_SCORE` the per-night sleep score. The nightly recovery, stress and strain scores are stored + measurements and chart through the measurement series instead. + required: true + description: The score to chart. `HEALTH_SCORE` reads the stored day scores, `READINESS` the nightly readiness blend, + `SLEEP_SCORE` the per-night sleep score. The nightly recovery, stress and strain scores are stored + measurements and chart through the measurement series instead. + - in: query + name: days + schema: + type: integer + minimum: 1 + maximum: 3650 + description: Trailing window in local calendar days ending today (1–3650). The web chart sends 7, 30, 90, or 3650 for + its "All" tab. Out of range is a 422, never a silent clamp. + required: true + description: Trailing window in local calendar days ending today (1–3650). The web chart sends 7, 30, 90, or 3650 for + its "All" tab. Out of range is a 422, never a silent clamp. + responses: + "200": + description: The score's daily points and usual range. + content: + application/json: + schema: + $ref: "#/components/schemas/ScoreHistoryResponseEnvelope" + "401": *a1 + "403": *a11 + "422": *a4 + "429": *a5 /api/insights/glp1-plateau: get: tags: @@ -32504,6 +32554,12 @@ components: - painNrs - waistCircumference - waistToHeight + - scoreHealth + - scoreReadiness + - scoreRecovery + - scoreSleep + - scoreStress + - scoreStrain description: Which chart's overlays are being written. Closed enum. prefs: type: object @@ -32683,6 +32739,12 @@ components: - painNrs - waistCircumference - waistToHeight + - scoreHealth + - scoreReadiness + - scoreRecovery + - scoreSleep + - scoreStress + - scoreStrain additionalProperties: type: object properties: @@ -61988,6 +62050,93 @@ components: description: Batched derived-metric values. One request resolves the whole dashboard grid (the wellness scores + the derived re-frames + one baseline per vital) instead of N concurrent single-metric requests sharing the Prisma pool. Pure compute over the rollup tier — no LLM, no narrative, no cache table. + ScoreHistoryResponseEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ScoreHistoryResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ScoreHistoryResponse: + type: object + properties: + score: + type: string + enum: + - HEALTH_SCORE + - READINESS + - SLEEP_SCORE + days: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: The window the request asked for. + points: + type: array + items: + type: object + properties: + day: + type: string + description: The local calendar day the value describes, `YYYY-MM-DD`; the day the day view opens for it. + value: + type: integer + minimum: 0 + maximum: 100 + seamBreak: + type: boolean + description: "True on the first day scored under a different recipe (what counts, or the algorithm version) than the day + before. Draw no line from the previous point to this one: the values either side are averages of + different things. Only the health score has seams; never true on the first point." + required: + - day + - value + - seamBreak + additionalProperties: false + description: One point per day that has a value, oldest first. A day without one is absent, never zero. + band: + anyOf: + - type: object + properties: + lo: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + hi: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + n: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Days the range was formed from. + required: + - lo + - hi + - n + additionalProperties: false + - type: "null" + description: "The person's usual range behind the newest point: median and scaled MAD of the values in the 30 days + before it, from its own side of the last seam. Null with fewer than seven such days. Describes, does not + grade." + required: + - score + - days + - points + - band + additionalProperties: false + description: One score's daily course over a trailing window, for a history chart with a day per point. InsightsGlp1PlateauResponseEnvelope: type: object properties: @@ -76044,6 +76193,12 @@ components: - painNrs - waistCircumference - waistToHeight + - scoreHealth + - scoreReadiness + - scoreRecovery + - scoreSleep + - scoreStress + - scoreStrain additionalProperties: type: object properties: @@ -76295,6 +76450,12 @@ components: - painNrs - waistCircumference - waistToHeight + - scoreHealth + - scoreReadiness + - scoreRecovery + - scoreSleep + - scoreStress + - scoreStrain additionalProperties: type: object properties: diff --git a/eslint.config.mjs b/eslint.config.mjs index 897750959..d1d6373ee 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -153,6 +153,7 @@ const eslintConfig = defineConfig([ "src/components/charts/workout-hr-chart.tsx", "src/components/charts/workout-elevation-chart.tsx", "src/components/insights/coach-panel/result-chart.tsx", + "src/components/insights/derived/score-trend-chart.tsx", ], rules: { "no-restricted-imports": [ diff --git a/messages/de.json b/messages/de.json index bebe091e0..3776f3684 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2765,7 +2765,8 @@ "coverage": { "recommendedMet": "Deckt die drei Felder ab, die der Score empfiehlt", "areas": "{present} von {required} Feldern der Gesundheit" - } + }, + "historyLink": "Verlauf" }, "dailyBriefing": { "title": "Tagesbriefing", @@ -4747,6 +4748,13 @@ }, "medicationsEmpty": { "coachPrefill": "Ich habe noch keine Medikamente angelegt. Was sollte ich wissen, bevor ich hier anfange, meine Therapietreue zu verfolgen?" + }, + "scoreHistory": { + "healthTitle": "Verlauf des Gesundheitsscores", + "healthDescription": "Dein Gesundheitsscore Tag für Tag, so wie er an jedem Tag angezeigt wurde.", + "emptyDescription": "In diesem Zeitraum wurde noch kein Score erfasst.", + "usualRange": "Schattiert: dein üblicher Bereich, {lo} bis {hi}.", + "seam": "Die Linie bricht dort ab, wo sich geändert hat, was in den Score einfließt." } }, "thresholds": { diff --git a/messages/en.json b/messages/en.json index a966393a1..9bd00706f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2765,7 +2765,8 @@ "coverage": { "recommendedMet": "Covers the three areas the score recommends", "areas": "{present} of {required} areas of health" - } + }, + "historyLink": "History" }, "dailyBriefing": { "title": "Daily Briefing", @@ -4747,6 +4748,13 @@ }, "medicationsEmpty": { "coachPrefill": "I haven’t added any medications yet. What should I know before I start tracking medication compliance here?" + }, + "scoreHistory": { + "healthTitle": "Health Score history", + "healthDescription": "Your Health Score day by day, as it was shown on each day.", + "emptyDescription": "No score was recorded in this range yet.", + "usualRange": "Shaded: your usual range, {lo} to {hi}.", + "seam": "The line breaks where what counts toward the score changed." } }, "thresholds": { diff --git a/messages/es.json b/messages/es.json index 4b5b3de6c..937e152ac 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2765,7 +2765,8 @@ "coverage": { "recommendedMet": "Cubre los tres ámbitos que la puntuación recomienda", "areas": "{present} de {required} ámbitos de salud" - } + }, + "historyLink": "Evolución" }, "dailyBriefing": { "title": "Resumen del día", @@ -4747,6 +4748,13 @@ }, "medicationsEmpty": { "coachPrefill": "Todavía no he añadido medicamentos: ¿qué debería saber antes de empezar a seguir aquí la adherencia?" + }, + "scoreHistory": { + "healthTitle": "Evolución de la puntuación de salud", + "healthDescription": "Tu puntuación de salud día a día, tal como se mostró cada día.", + "emptyDescription": "Todavía no hay ninguna puntuación registrada en este periodo.", + "usualRange": "Sombreado: tu rango habitual, de {lo} a {hi}.", + "seam": "La línea se interrumpe donde cambió lo que cuenta para la puntuación." } }, "thresholds": { diff --git a/messages/fr.json b/messages/fr.json index a876ff161..620b79947 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2765,7 +2765,8 @@ "coverage": { "recommendedMet": "Couvre les trois domaines recommandés par le score", "areas": "{present} domaines de santé sur {required}" - } + }, + "historyLink": "Évolution" }, "dailyBriefing": { "title": "Briefing du jour", @@ -4747,6 +4748,13 @@ }, "medicationsEmpty": { "coachPrefill": "Je n’ai encore ajouté aucun médicament. Que dois-je savoir avant de suivre ici l’observance ?" + }, + "scoreHistory": { + "healthTitle": "Évolution du score de santé", + "healthDescription": "Ton score de santé jour après jour, tel qu’il s’affichait chaque jour.", + "emptyDescription": "Aucun score n’a encore été enregistré sur cette période.", + "usualRange": "En grisé : ta plage habituelle, de {lo} à {hi}.", + "seam": "La ligne s’interrompt là où ce qui compte pour le score a changé." } }, "thresholds": { diff --git a/messages/it.json b/messages/it.json index dfbd9bd6a..71b7106c9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2765,7 +2765,8 @@ "coverage": { "recommendedMet": "Copre i tre ambiti consigliati dal punteggio", "areas": "{present} ambiti di salute su {required}" - } + }, + "historyLink": "Andamento" }, "dailyBriefing": { "title": "Briefing del giorno", @@ -4747,6 +4748,13 @@ }, "medicationsEmpty": { "coachPrefill": "Non ho ancora aggiunto farmaci: cosa dovrei sapere prima di iniziare a monitorare qui l’aderenza?" + }, + "scoreHistory": { + "healthTitle": "Andamento del punteggio di salute", + "healthDescription": "Il tuo punteggio di salute giorno per giorno, come veniva mostrato ogni giorno.", + "emptyDescription": "In questo periodo non è ancora stato registrato alcun punteggio.", + "usualRange": "In ombra: il tuo intervallo abituale, da {lo} a {hi}.", + "seam": "La linea si interrompe dove è cambiato ciò che conta per il punteggio." } }, "thresholds": { diff --git a/messages/ko.json b/messages/ko.json index 3b0fa4cff..65dfaff04 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -2765,7 +2765,8 @@ "coverage": { "recommendedMet": "점수가 권장하는 세 영역을 모두 채웠어요", "areas": "건강 영역 {required}개 중 {present}개" - } + }, + "historyLink": "추이" }, "dailyBriefing": { "title": "데일리 브리핑", @@ -4747,6 +4748,13 @@ }, "medicationsEmpty": { "coachPrefill": "아직 약을 추가하지 않았어요. 여기서 복약 순응도를 기록하기 전에 무엇을 알아야 하나요?" + }, + "scoreHistory": { + "healthTitle": "건강 점수 추이", + "healthDescription": "날마다 표시된 그대로의 건강 점수입니다.", + "emptyDescription": "이 기간에는 아직 기록된 점수가 없습니다.", + "usualRange": "음영: 평소 범위 {lo}에서 {hi}까지.", + "seam": "점수에 반영되는 항목이 바뀐 지점에서 선이 끊깁니다." } }, "thresholds": { diff --git a/messages/pl.json b/messages/pl.json index 71b857b83..fbe0acb2b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2765,7 +2765,8 @@ "coverage": { "recommendedMet": "Pokrywa trzy obszary zalecane przez wynik", "areas": "{present} z {required} obszarów zdrowia" - } + }, + "historyLink": "Przebieg" }, "dailyBriefing": { "title": "Briefing dnia", @@ -4747,6 +4748,13 @@ }, "medicationsEmpty": { "coachPrefill": "Nie dodałem jeszcze żadnych leków. Co powinienem wiedzieć, zanim zacznę tu śledzić przestrzeganie zaleceń?" + }, + "scoreHistory": { + "healthTitle": "Przebieg wyniku zdrowia", + "healthDescription": "Twój wynik zdrowia dzień po dniu, taki jak był pokazywany każdego dnia.", + "emptyDescription": "W tym okresie nie zapisano jeszcze żadnego wyniku.", + "usualRange": "Zacieniowane: twój zwykły zakres, od {lo} do {hi}.", + "seam": "Linia przerywa się tam, gdzie zmieniło się to, co liczy się do wyniku." } }, "thresholds": { diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 4685e0d45..b7b768b4b 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -1414,6 +1414,10 @@ const DELEGABLE_ROUTES: Record = { domain: "record", why: "Rhythm events over the record's own recordings. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", }, + "app/api/insights/score-history/route.ts": { + domain: "record", + why: "A score's daily course: the stored health-score days, the nightly readiness rows, the per-night sleep score. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, "app/api/insights/targets/route.ts": { domain: "record", why: "The record's resolved targets. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", @@ -1932,6 +1936,11 @@ const DELEGABLE_MANAGE_ROUTES: Record = { conditions: [], why: "Rhythm events over the record's own recordings.", }, + "app/api/insights/score-history/route.ts": { + domain: "record", + conditions: [], + why: "A score's daily course, read from the same rows the score pages and the day view read. Deterministic, no provider, nothing enqueued.", + }, "app/api/insights/targets/route.ts": { domain: "record", conditions: [], @@ -2080,8 +2089,12 @@ const ACTOR_ROUTES: Record = { * release (no share level was worded for them), so both modules take a bare * `requireAuth()` and drop off the record list and the manage literal. * 256 -> 252. + * + * v1.42 — the score-history read joins the record list and the manage + * literal (a score's daily course, MANAGE like the derived score itself). + * 252 -> 254. */ -const FROZEN_ENTRY_COUNT = 252; +const FROZEN_ENTRY_COUNT = 254; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/app/api/__tests__/ai-route-inventory.ts b/src/app/api/__tests__/ai-route-inventory.ts index 0e3b50a76..78d509cef 100644 --- a/src/app/api/__tests__/ai-route-inventory.ts +++ b/src/app/api/__tests__/ai-route-inventory.ts @@ -210,6 +210,8 @@ export const DATA_ROUTES: Readonly> = { "src/app/api/insights/ecg/[id]/route.ts": "One device recording's waveform.", "src/app/api/insights/rhythm-events/route.ts": "The device's own flagged events, verbatim.", + "src/app/api/insights/score-history/route.ts": + "A score's stored or computed daily values; no model text.", "src/app/api/insights/cards/route.ts": "Rule alerts from the threshold engine; `provider` is `rules`.", "src/app/api/insights/correlations/route.ts": diff --git a/src/app/api/__tests__/inline-schema-strictness-inventory.test.ts b/src/app/api/__tests__/inline-schema-strictness-inventory.test.ts index 5a3cc6b7c..80a5dc67f 100644 --- a/src/app/api/__tests__/inline-schema-strictness-inventory.test.ts +++ b/src/app/api/__tests__/inline-schema-strictness-inventory.test.ts @@ -67,6 +67,10 @@ const DELIBERATELY_OPEN: Record = { reason: "hand-built query object", }, "insights/derived/route.ts": { count: 1, reason: "hand-built query object" }, + "insights/score-history/route.ts": { + count: 1, + reason: "hand-built query object", + }, "insights/metric-status/route.ts": { count: 1, reason: "hand-built query object", diff --git a/src/app/api/insights/score-history/route.ts b/src/app/api/insights/score-history/route.ts new file mode 100644 index 000000000..68c7be4bc --- /dev/null +++ b/src/app/api/insights/score-history/route.ts @@ -0,0 +1,98 @@ +/** + * `GET /api/insights/score-history?score=&days=` — one score's daily + * course over the trailing `days`, for the history chart on its page. + * + * Serves the three scores that have no stored measurement series of their + * own to chart: the health score (its `HealthScoreRecord` rows), readiness + * (the nightly blend the recovery job persists) and the sleep score + * (computed per night). The nightly recovery, stress and strain scores are + * stored readings and chart through the measurement series route like any + * other metric. What each point means, and why the health score's line can + * break, is written at `readScoreHistory`. + * + * `days` is the chart's range tab: 7, 30, 90, or the "All" tab's ten years. + * Any whole number in between is accepted, so a client is not tied to these + * tabs; out of range is a 422, never a silent clamp. + */ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { prisma } from "@/lib/db"; +import { loadBaselineProfile } from "@/lib/insights/derived"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; +import { resolveStoredTimezone } from "@/lib/tz/resolver"; +import { readScoreHistory } from "@/lib/insights/score-history"; +import { + SCORE_HISTORY_IDS, + SCORE_HISTORY_MAX_DAYS, +} from "@/lib/insights/score-history-ids"; +import { DERIVED_MODULE } from "../derived/route"; + +export const dynamic = "force-dynamic"; + +const scoreHistoryQuerySchema = z.object({ + score: z.enum(SCORE_HISTORY_IDS), + days: z.coerce.number().int().min(1).max(SCORE_HISTORY_MAX_DAYS), +}); + +export const GET = apiHandler(async (request: NextRequest) => { + // The same standing as the score itself: the derived scores and the other + // insight reads are a MANAGE-level read over the whole record. + const { user } = await requireRecordAuth("manage", "record"); + + const rl = await checkAnalyticsReadRateLimit(user.id); + if (!rl.allowed) { + return apiError("Too many analytics requests. Please retry later.", 429); + } + + const parsed = scoreHistoryQuerySchema.safeParse({ + score: request.nextUrl.searchParams.get("score"), + days: request.nextUrl.searchParams.get("days"), + }); + if (!parsed.success) { + annotate({ + action: { name: "insights.score-history.invalid-query" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422); + } + const { score, days } = parsed.data; + + // Readiness is the recovery module's, the sleep score the sleep module's, + // through the same map the derived routes gate on. The health score is + // core and carries no gate. + const moduleKey = + score === "HEALTH_SCORE" ? undefined : DERIVED_MODULE[score]; + if (moduleKey) { + const gate = await requireModuleEnabled(user.id, moduleKey); + if (!gate.enabled) return gate.response; + } + + const history = await readScoreHistory({ + userId: user.id, + score, + days, + now: new Date(), + tz: await resolveStoredTimezone(user.timezone), + profile: () => loadBaselineProfile(prisma, user.id), + priorityJson: () => loadUserSourcePriority(user.id), + }); + + annotate({ + action: { name: "insights.score-history" }, + meta: { + score, + days, + points: history.points.length, + seams: history.points.filter((p) => p.seamBreak).length, + has_band: history.band !== null, + }, + }); + + return apiSuccess(history); +}); diff --git a/src/app/insights/health-score/__tests__/page.test.tsx b/src/app/insights/health-score/__tests__/page.test.tsx new file mode 100644 index 000000000..ae7b58fef --- /dev/null +++ b/src/app/insights/health-score/__tests__/page.test.tsx @@ -0,0 +1,90 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { DAY_SCORE_HREF } from "@/lib/day/contract"; + +/** + * v1.42 — `/insights/health-score`: the health score over time, reached from + * the score panel and from the day view's score tile. + */ + +vi.mock("@/lib/i18n/context", () => ({ + useTranslations: () => ({ t: (key: string) => key }), +})); + +vi.mock("@/components/insights/sub-page-shell", () => ({ + SubPageShell: ({ + title, + backLink, + children, + }: { + title: string; + backLink: React.ReactNode; + children: React.ReactNode; + }) => ( +
+ {backLink} + {children} +
+ ), +})); + +vi.mock("@/components/ui/back-link", () => ({ + BackLink: ({ href }: { href: string }) => ( + + ), +})); + +vi.mock("@/components/insights/derived/score-history", () => ({ + ScoreTrendChartDynamic: ({ + score, + chartKey, + label, + }: { + score: string; + chartKey: string; + label: string; + }) => ( +
+ ), +})); + +import HealthScoreHistoryPage from "../page"; + +describe("/insights/health-score", () => { + it("charts the health score's history under its own range slot", () => { + const html = renderToStaticMarkup(); + expect(html).toContain('data-title="insights.scoreHistory.healthTitle"'); + expect(html).toContain( + 'data-slot="trend-probe" data-score="HEALTH_SCORE" data-chart-key="scoreHealth" data-label="insights.healthScore.label"', + ); + expect(html).toContain('href="/insights"'); + }); + + it("is where the day view's health-score tile opens", () => { + expect(DAY_SCORE_HREF.healthScore).toBe("/insights/health-score"); + }); + + it("every score tile in the day view opens a page that exists", () => { + const app = join(__dirname, "..", "..", ".."); + for (const href of Object.values(DAY_SCORE_HREF)) { + const segments = href.split("/").filter(Boolean); + const direct = join(app, ...segments, "page.tsx"); + const dynamic = join( + app, + ...segments.slice(0, -1), + "[metric]", + "page.tsx", + ); + expect(existsSync(direct) || existsSync(dynamic), href).toBe(true); + } + }); +}); diff --git a/src/app/insights/health-score/page.tsx b/src/app/insights/health-score/page.tsx new file mode 100644 index 000000000..a67eaf21e --- /dev/null +++ b/src/app/insights/health-score/page.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useTranslations } from "@/lib/i18n/context"; +import { BackLink } from "@/components/ui/back-link"; +import { SubPageShell } from "@/components/insights/sub-page-shell"; +import { ScoreTrendChartDynamic } from "@/components/insights/derived/score-history"; + +/** + * v1.42 — `/insights/health-score`, the health score over time. + * + * The score itself, with its pillars, lives in the Insights overview's hero + * band; this page is where it can be followed day by day. Each point is the + * score as it was shown that day (its stored day row), and opens that day. + * Reached from the score panel's history link and from the score tile in the + * day view. + */ +export default function HealthScoreHistoryPage() { + const { t } = useTranslations(); + const title = t("insights.healthScore.label"); + + return ( + + } + > + + + ); +} diff --git a/src/components/charts/__tests__/health-chart-mini.test.tsx b/src/components/charts/__tests__/health-chart-mini.test.tsx index fa8e3a027..dbb65db6a 100644 --- a/src/components/charts/__tests__/health-chart-mini.test.tsx +++ b/src/components/charts/__tests__/health-chart-mini.test.tsx @@ -101,6 +101,20 @@ describe("", () => { expect(html).toMatch(/data-slot="chart-range-tab"/); }); + it("keeps the range tabs but drops the overlay dropdown with overlayControls off", () => { + // v1.42 — the score histories bind a key to remember their range only. + const html = render( + , + ); + expect(html.match(/data-slot="chart-range-tab"/g)).toHaveLength(4); + expect(html).not.toContain("chart-overlay-controls-trigger"); + }); + it("renders the overlay-controls trigger when chartKey is supplied", () => { const html = render( , diff --git a/src/components/charts/chart-range-tabs.tsx b/src/components/charts/chart-range-tabs.tsx new file mode 100644 index 000000000..0f3ba18b5 --- /dev/null +++ b/src/components/charts/chart-range-tabs.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import type { ChartRangePoints } from "@/lib/dashboard-layout"; +import { useTranslations } from "@/lib/i18n/context"; + +/** + * The range tabs every chart drawn in days carries: 7 / 30 / 90 days and + * All. One component, so the metric charts and the score histories offer + * the same choice in the same place and look the same doing it. + * + * The tabs select a CALENDAR-DAY window ending now — `days: 7` is "the last + * 7 days", not "the last 7 readings". The persisted preference field keeps + * its historical name `rangePoints` (it crosses the chart-overlay-prefs + * wire), but its value has always been days. `0` is "All". + */ +export const CHART_RANGE_TABS = [ + { labelKey: "charts.days7Label", days: 7, titleKey: "charts.days7Title" }, + { labelKey: "charts.days30Label", days: 30, titleKey: "charts.days30Title" }, + { labelKey: "charts.days90Label", days: 90, titleKey: "charts.days90Title" }, + { labelKey: "charts.daysAllLabel", days: 0, titleKey: "charts.daysAllTitle" }, +] as const satisfies ReadonlyArray<{ + labelKey: string; + days: ChartRangePoints; + titleKey: string; +}>; + +/** The tab a chart opens on before anything was chosen. */ +export const DEFAULT_CHART_RANGE: ChartRangePoints = 30; + +/** + * v1.19.0 — day-span the "All" tab (`0`) fetches. A generous fixed bound + * (~10 years) that is clearly larger than any other tab, so "All" means "all + * of my history" rather than a silent one-year truncation. Fixed (not per + * account) so the fetch-window cache key stays stable across the session. + */ +export const ALL_RANGE_DAYS = 3650; + +/** The day window a tab asks the server for. */ +export function rangeWindowDays(range: number): number { + return range > 0 ? range : ALL_RANGE_DAYS; +} + +export function ChartRangeTabs({ + value, + onChange, +}: { + value: number; + onChange: (days: ChartRangePoints) => void; +}) { + const { t } = useTranslations(); + return ( + <> + {CHART_RANGE_TABS.map((r) => ( + + ))} + + ); +} diff --git a/src/components/charts/chart-runtime.ts b/src/components/charts/chart-runtime.ts index 93a51dcfc..5b4cc66a2 100644 --- a/src/components/charts/chart-runtime.ts +++ b/src/components/charts/chart-runtime.ts @@ -42,6 +42,7 @@ export { HostMetricsChart } from "@/components/admin/host-metrics-chart"; export { CustomMetricChart } from "@/components/custom-metrics/custom-metric-chart"; export { BbtChart } from "@/components/cycle/bbt-chart"; export { DeltaSparkline } from "@/components/insights/derived/delta-sparkline"; +export { ScoreTrendChart } from "@/components/insights/derived/score-trend-chart"; export { MoodDimensionTrends } from "@/components/insights/mood/mood-dimension-trends"; export { MoodDistributionChart } from "@/components/insights/mood/mood-distribution-chart"; export { MoodTimeOfDayChart } from "@/components/insights/mood/mood-time-of-day-chart"; diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 82ff67e67..22fb703bf 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -41,7 +41,6 @@ import { ChartErrorState } from "./chart-error-state"; import { TileHeader } from "@/components/insights/tile-header"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; import { computePaddedYDomain } from "@/lib/insights/chart-y-domain"; -import { Button } from "@/components/ui/button"; import { useDateFormatPreference, useTranslations, @@ -85,52 +84,15 @@ import { useChartDayLinks, } from "@/components/day/chart-day-links"; import { isWholeNumberType } from "@/components/day/use-day-value-format"; +import { + ChartRangeTabs, + DEFAULT_CHART_RANGE, + rangeWindowDays, +} from "./chart-range-tabs"; -// The range tabs select a CALENDAR-DAY window ending now — `days: 7` is -// "the last 7 days", not "the last 7 readings". The labels said "points" -// until v1.37.29 while the fetch below always treated the value as a day -// window; a user who weighs in twice a week picked "7 pts" and saw two. -// The persisted preference field keeps its historical name `rangePoints` -// (it crosses the chart-overlay-prefs wire), but its value has always -// been days. -const TIME_RANGES_KEYS = [ - { - labelKey: "charts.days7Label", - days: 7, - titleKey: "charts.days7Title", - }, - { - labelKey: "charts.days30Label", - days: 30, - titleKey: "charts.days30Title", - }, - { - labelKey: "charts.days90Label", - days: 90, - titleKey: "charts.days90Title", - }, - { - labelKey: "charts.daysAllLabel", - days: 0, - titleKey: "charts.daysAllTitle", - }, -] as const; - -/** - * v1.19.0 — day-span the "All" range tab (`rangePoints === 0`) fetches. - * A generous fixed bound (~10 years) that is clearly larger than any - * other range tab, so "All" means "all of my history" rather than the - * old silent 365-day truncation. Fixed (not per-account derived) so the - * fetch-window cache key stays stable across the session. - * - * v1.19.2 — the server's daily-aggregate reader now steps UP the bucket - * tier (DAY → WEEK → MONTH) for windows wider than the DAY cap, so a - * multi-year "All" range returns whole-history coverage downsampled to - * the tier the chart's `bucketTimeSeries` would render anyway, instead of - * truncating to the most recent ~365 daily buckets. The render cost stays - * flat — the coarse tier bounds the point count. - */ -const ALL_RANGE_DAYS = 3650; +// The range tabs (7 / 30 / 90 / All, a calendar-day window ending now) and +// the "All" tab's ten-year fetch span live with the shared +// ``, so every chart drawn in days offers the same choice. interface HealthChartProps { types: string[]; @@ -191,6 +153,13 @@ interface HealthChartProps { * its toggles default to OFF (clean line). */ chartKey?: ChartOverlayKey; + /** + * v1.42 — whether a chart bound to a `chartKey` also shows the overlay + * dropdown. Off for a chart that persists only its range tab (the score + * histories), so binding a key to remember the range does not add + * controls the chart never had. Defaults to on. + */ + overlayControls?: boolean; /** * v1.4.20 phase B4 — additive storyboard annotations. * @@ -669,6 +638,7 @@ export function HealthChart({ windowOverride, compareBaseline = "none", chartKey, + overlayControls = true, annotations, verticalMarkers, userTimezone: userTimezoneProp, @@ -715,7 +685,7 @@ export function HealthChart({ // hides the range tabs, so the user can't change it. const initialRangePoints = windowOverride ? resolveMiniRangePoints(windowOverride) - : 30; + : DEFAULT_CHART_RANGE; const [rangePoints, setRangePoints] = useState(initialRangePoints); // v1.4.18 — three overlay toggles (showTrendIndicator / showTrendArrow @@ -812,7 +782,7 @@ export function HealthChart({ // cache key) and moves on by itself after midnight. const todayKey = localDayKeyFor(new Date(), userTimezone); const fetchWindow = useMemo(() => { - const windowDays = rangePoints > 0 ? rangePoints : ALL_RANGE_DAYS; + const windowDays = rangeWindowDays(rangePoints); const compareShift = effectiveCompareBaseline === "lastMonth" ? 30 @@ -1881,40 +1851,30 @@ export function HealthChart({ className="flex flex-nowrap items-center justify-end gap-1 self-end sm:self-auto" data-slot="chart-header-controls" > - {TIME_RANGES_KEYS.map((r) => ( - - ))} + { + // v1.12.8 — a range-tab change re-slices `chartData`; the + // visible-range stats memo recomputes off the new slice and + // the stat strip follows automatically. + setRangeHydrated(true); + setRangePoints(days); + // v1.30.1 M2 — persist the pick per chart, same model + // as the overlay toggles, so the range survives a + // remount instead of resetting to 30 d every visit. + if (chartKey && !windowOverride) { + overlayPrefs.setPrefs({ + ...overlayPrefs.prefs, + rangePoints: days, + }); + } + }} + /> {/* v1.4.18 — overlay-controls dropdown anchored next to the range tabs. Only painted when the chart is bound to a persistent chartKey; ad-hoc usages keep the clean-line default. */} - {chartKey ? ( + {chartKey && overlayControls ? ( disclosure", () => { }); }); +describe(" history", () => { + it("links the score to its course over time, at rest", () => { + const html = render(); + const rest = atRest(html); + const link = rest.match( + /]*data-slot="health-score-history-link"[^>]*>[\s\S]*?<\/a>/, + ); + expect(link).not.toBeNull(); + expect(link![0]).toContain('href="/insights/health-score"'); + expect(link![0]).toContain("History"); + }); +}); + describe(" pillars", () => { it("gives a row to the scored pillars only, never to a gated one", () => { const html = render(); diff --git a/src/components/insights/derived/__tests__/composite-score-anatomy-coach.test.tsx b/src/components/insights/derived/__tests__/composite-score-anatomy-coach.test.tsx index 928d734ab..9e8025330 100644 --- a/src/components/insights/derived/__tests__/composite-score-anatomy-coach.test.tsx +++ b/src/components/insights/derived/__tests__/composite-score-anatomy-coach.test.tsx @@ -46,7 +46,7 @@ vi.mock("../score-anatomy-view", () => ({ // about the assessment's hand-off, so the course is a probe here. vi.mock("../score-history", () => ({ ScoreHistoryChart: () =>
, - ScoreHistoryCard: () =>
, + ScoreTrendChartDynamic: () =>
, })); import { CompositeScoreAnatomy } from "../composite-score-anatomy"; diff --git a/src/components/insights/derived/__tests__/score-history-wiring.test.tsx b/src/components/insights/derived/__tests__/score-history-wiring.test.tsx index 6ccf806fe..daa9c2122 100644 --- a/src/components/insights/derived/__tests__/score-history-wiring.test.tsx +++ b/src/components/insights/derived/__tests__/score-history-wiring.test.tsx @@ -7,9 +7,10 @@ import { renderToStaticMarkup } from "react-dom/server"; * longer rides above the method. * * The stored nightly scores (recovery, stress, strain) get the full metric - * chart over their measurement type; the two composites draw the course their - * value carries, and nothing when it carries fewer than two points. The chart - * and the card are stubbed to probes that state what they were given. + * chart over their measurement type; the two composites chart the daily + * history the score-history route serves. Every one of them remembers its + * range tab under its own slot. The charts and the card are stubbed to probes + * that state what they were given. */ vi.mock("@/lib/i18n/context", () => ({ @@ -32,20 +33,30 @@ vi.mock("../score-anatomy-view", () => ({ })); vi.mock("../score-history", () => ({ - ScoreHistoryChart: ({ type }: { type: string }) => ( -
- ), - ScoreHistoryCard: ({ - series, - windowDays, + ScoreHistoryChart: ({ + type, + chartKey, }: { - series: number[]; - windowDays: number; + type: string; + chartKey: string; }) => (
+ ), + ScoreTrendChartDynamic: ({ + score, + chartKey, + }: { + score: string; + chartKey: string; + }) => ( +
), })); @@ -85,7 +96,7 @@ describe("score detail history", () => { expect(html).toContain( `data-slot="history-chart-probe" data-type="${metric}"`, ); - expect(html).not.toContain("history-card-probe"); + expect(html).not.toContain("trend-chart-probe"); // Under the score card, not somewhere else on the page. expect(html.indexOf("history-chart-probe")).toBeGreaterThan( html.indexOf("anatomy-probe"), @@ -93,16 +104,31 @@ describe("score detail history", () => { }, ); - it.each(["SLEEP_SCORE", "READINESS"] as const)( - "%s draws the course its value carries, over its window", - (metric) => { + it.each([ + ["SLEEP_SCORE", "scoreSleep"], + ["READINESS", "scoreReadiness"], + ] as const)( + "%s charts its daily history from the score-history route", + (metric, chartKey) => { const html = render(metric, [55, 61, 70]); - expect(html).toContain('data-points="3"'); - expect(html).toContain('data-window="30"'); + expect(html).toContain( + `data-slot="trend-chart-probe" data-score="${metric}" data-chart-key="${chartKey}"`, + ); expect(html).not.toContain("history-chart-probe"); + expect(html.indexOf("trend-chart-probe")).toBeGreaterThan( + html.indexOf("anatomy-probe"), + ); }, ); + it.each([ + ["RECOVERY_SCORE", "scoreRecovery"], + ["STRESS_SCORE", "scoreStress"], + ["STRAIN_SCORE", "scoreStrain"], + ] as const)("%s remembers its range under %s", (metric, chartKey) => { + expect(render(metric)).toContain(`data-chart-key="${chartKey}"`); + }); + it("an account without a score gets no empty chart under the insufficient card", () => { derived.current = { isLoading: false, @@ -123,9 +149,11 @@ describe("score detail history", () => { expect(html).not.toContain("history-chart-probe"); }); - it("a composite with fewer than two points draws no course", () => { - expect(render("READINESS", [70])).not.toContain("history-card-probe"); - expect(render("READINESS")).not.toContain("history-card-probe"); + it("a composite charts its history even when its value carries no series", () => { + // The course no longer rides the value: one point, or none, still gets + // the chart, whose own range decides what it shows. + expect(render("READINESS", [70])).toContain("trend-chart-probe"); + expect(render("READINESS")).toContain("trend-chart-probe"); }); it("no score page carries the caveat line above its method", () => { diff --git a/src/components/insights/derived/__tests__/score-trend-chart.test.tsx b/src/components/insights/derived/__tests__/score-trend-chart.test.tsx new file mode 100644 index 000000000..0cda9f76c --- /dev/null +++ b/src/components/insights/derived/__tests__/score-trend-chart.test.tsx @@ -0,0 +1,195 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +/** + * v1.42 — the score history chart: the shared range tabs on the remembered + * range, the day table as the keyboard way to every point's day, the usual + * range and the seam named under the chart, and the honest empty state. + * The query is seeded; the plot itself does not draw under SSR. + */ + +vi.mock("@/lib/api/api-fetch", () => ({ + apiGet: () => new Promise(() => {}), + apiPut: vi.fn(), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true, user: { id: "u1" } }), +})); + +const prefs: { + rangePoints: number | undefined; + lastKey: string | undefined; + setPrefs: ReturnType; +} = { rangePoints: undefined, lastKey: undefined, setPrefs: vi.fn() }; +vi.mock("@/hooks/use-chart-overlay-prefs", () => ({ + useChartOverlayPrefs: (key: string) => { + prefs.lastKey = key; + return { + prefs: { + showTrendIndicator: false, + showTrendArrow: false, + showTargetRange: false, + comparisonBaseline: "none", + rangePoints: prefs.rangePoints, + }, + setPrefs: prefs.setPrefs, + isSaving: false, + }; + }, +})); + +import { ScoreTrendChart, toChartRows } from "../score-trend-chart"; + +type Point = { day: string; value: number; seamBreak: boolean }; + +function render( + days: number, + data: { + points: Point[]; + band: { lo: number; hi: number; n: number } | null; + } | null, +): string { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + if (data) { + client.setQueryData(queryKeys.insightsScoreHistory("HEALTH_SCORE", days), { + score: "HEALTH_SCORE", + days, + ...data, + }); + } + return renderToStaticMarkup( + + + + + , + ); +} + +const POINTS: Point[] = [ + { day: "2026-06-01", value: 61, seamBreak: false }, + { day: "2026-06-02", value: 64, seamBreak: false }, + { day: "2026-06-03", value: 70, seamBreak: true }, +]; + +beforeEach(() => { + prefs.rangePoints = undefined; + prefs.setPrefs.mockReset(); +}); + +describe("", () => { + it("offers the four range tabs every chart offers, on 30 days by default", () => { + const html = render(30, { points: POINTS, band: null }); + const tabs = html.match(/]*data-slot="chart-range-tab"[^>]*>/g); + expect(tabs).toHaveLength(4); + expect(tabs!.map((tab) => tab.match(/data-range="(\d+)"/)![1])).toEqual([ + "7", + "30", + "90", + "0", + ]); + const pressed = tabs!.filter((tab) => tab.includes('aria-pressed="true"')); + expect(pressed).toHaveLength(1); + expect(pressed[0]).toContain('data-range="30"'); + expect(html).toContain('data-slot="score-trend-chart"'); + // The range is remembered under the page's own slot. + expect(prefs.lastKey).toBe("scoreHealth"); + }); + + it("opens on the remembered range and asks for its window", () => { + prefs.rangePoints = 90; + const html = render(90, { points: POINTS, band: null }); + const pressed = ( + html.match(/]*data-slot="chart-range-tab"[^>]*>/g) ?? [] + ).filter((tab) => tab.includes('aria-pressed="true"')); + expect(pressed).toHaveLength(1); + expect(pressed[0]).toContain('data-range="90"'); + // The seeded 90-day read is the one the chart drew from. + expect(html.match(/data-slot="chart-data-table-row"/g)).toHaveLength(3); + }); + + it("asks for ten years on the All tab", () => { + prefs.rangePoints = 0; + const html = render(3650, { points: POINTS, band: null }); + expect(html).toContain('data-slot="chart-data-table"'); + }); + + it("lists every point with a link to its day", () => { + const html = render(30, { points: POINTS, band: null }); + expect(html.match(/data-slot="chart-data-table-row"/g)).toHaveLength(3); + for (const point of POINTS) { + expect(html).toContain(point.day); + } + expect(html).toContain('data-slot="chart-plot"'); + }); + + it("names the usual range and the seam under the chart", () => { + const html = render(30, { + points: POINTS, + band: { lo: 60, hi: 68, n: 12 }, + }); + expect(html).toContain('data-slot="score-trend-band"'); + expect(html).toContain("your usual range, 60 to 68"); + expect(html).toContain('data-slot="score-trend-seam"'); + }); + + it("says nothing about a range or a seam it does not have", () => { + const html = render(30, { + points: POINTS.map((p) => ({ ...p, seamBreak: false })), + band: null, + }); + expect(html).not.toContain("score-trend-band"); + expect(html).not.toContain("score-trend-seam"); + }); + + it("paints the empty state, not an empty plot, for a window without points", () => { + const html = render(30, { points: [], band: null }); + expect(html).toContain('data-slot="chart-empty-state"'); + expect(html).not.toContain('data-slot="chart-plot"'); + expect(html).not.toContain('data-slot="chart-data-table"'); + }); + + it("holds the plot's height while the first read is in flight", () => { + const html = render(30, null); + expect(html).toContain('data-slot="skeleton"'); + }); +}); + +describe("toChartRows", () => { + it("alternates the series key at every seam, so no line crosses one", () => { + const rows = toChartRows([ + { day: "2026-06-01", value: 50, seamBreak: false }, + { day: "2026-06-02", value: 52, seamBreak: false }, + { day: "2026-06-03", value: 80, seamBreak: true }, + { day: "2026-06-05", value: 81, seamBreak: false }, + { day: "2026-06-06", value: 60, seamBreak: true }, + ]); + expect(rows.map((r) => [r.even ?? null, r.odd ?? null])).toEqual([ + [50, null], + [52, null], + [null, 80], + [null, 81], + [60, null], + ]); + expect(rows[0]!.timestamp).toBe(Date.parse("2026-06-01T12:00:00.000Z")); + }); + + it("does not treat a seam flag on the first point as a break", () => { + const [first] = toChartRows([ + { day: "2026-06-01", value: 50, seamBreak: true }, + ]); + expect(first).toMatchObject({ even: 50 }); + }); +}); diff --git a/src/components/insights/derived/composite-score-anatomy.tsx b/src/components/insights/derived/composite-score-anatomy.tsx index d62b3035a..c64258fad 100644 --- a/src/components/insights/derived/composite-score-anatomy.tsx +++ b/src/components/insights/derived/composite-score-anatomy.tsx @@ -17,10 +17,11 @@ import { ScoreAnatomyView, type AnatomyContributor, } from "./score-anatomy-view"; -import type { RingHue } from "./ring-hues"; +import { RING_GRADIENT, type RingHue } from "./ring-hues"; import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context"; import { METRIC_PROVENANCE } from "./standards"; -import { ScoreHistoryCard, ScoreHistoryChart } from "./score-history"; +import { ScoreHistoryChart, ScoreTrendChartDynamic } from "./score-history"; +import type { ChartOverlayKey } from "@/lib/dashboard-layout"; /** * v1.10.0 — the data-bound wrapper that fetches a composite/persisted derived @@ -98,6 +99,15 @@ const METRIC_COACH_SCOPE: Record = { }, }; +/** The slot each score page remembers its history chart's range tab under. */ +const METRIC_CHART_KEY: Record = { + SLEEP_SCORE: "scoreSleep", + READINESS: "scoreReadiness", + RECOVERY_SCORE: "scoreRecovery", + STRESS_SCORE: "scoreStress", + STRAIN_SCORE: "scoreStrain", +}; + const METRIC_HUE: Record = { SLEEP_SCORE: "sleep", READINESS: "readiness", @@ -284,13 +294,10 @@ export function CompositeScoreAnatomy({ // the card always renders its prose rather than the no-provider fallback. const assessment = data.assessment; - // v1.42 — the score's course, right under its card. A stored nightly score - // gets the full chart; a composite draws the course its value carries. - const series = - data.status === "ok" && data.value - ? (data.value as { series?: number[] }).series - : undefined; - // Only beside a score: an account without one gets the honest + // v1.42 — the score's course, right under its card, with the range tabs + // every chart offers. A stored nightly score charts its readings; readiness + // and the sleep score chart the daily history the score-history route + // serves. Only beside a score: an account without one gets the honest // insufficient card, not an empty chart under it. const history = data.status !== "ok" ? null : metric === "RECOVERY_SCORE" || @@ -299,14 +306,16 @@ export function CompositeScoreAnatomy({ - ) : series && series.length >= 2 ? ( - - ) : null; + ); return (
diff --git a/src/components/insights/derived/score-history.tsx b/src/components/insights/derived/score-history.tsx index d4c22a5c9..d71a22952 100644 --- a/src/components/insights/derived/score-history.tsx +++ b/src/components/insights/derived/score-history.tsx @@ -3,40 +3,46 @@ import dynamic from "next/dynamic"; import { History } from "lucide-react"; +import { ChartSkeleton } from "@/components/charts/chart-skeleton"; import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; -import { TileHeader } from "@/components/insights/tile-header"; -import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { useAuth } from "@/hooks/use-auth"; +import type { ChartOverlayKey } from "@/lib/dashboard-layout"; import { useTranslations } from "@/lib/i18n/context"; +import { importWithRetry } from "@/lib/retry-import"; import { RING_GRADIENT, type RingHue } from "./ring-hues"; /** - * v1.42 — a score's course over time on its detail page, directly under the - * score card. + * v1.42 — a score's course over time on its page, directly under the score + * card. * * The three nightly scores (recovery, stress, strain) are stored readings, so - * they get the full metric chart every measured page carries: the 7 / 30 / 90 - * / All switch, the tooltip, and each day opening its day view. The two - * composites (sleep score, readiness) are computed rather than stored, so the - * course they carry on their value is drawn as a compact line over its window. + * they get the full metric chart every measured page carries. The health + * score, readiness and the sleep score are not stored as readings; their + * daily course comes from the score-history route and draws in + * ``. Both offer the same range tabs (7 / 30 / 90 / All), + * remembered per score page, and open each day from its point. */ -const DeltaSparkline = dynamic( +/** The health score, readiness and sleep score history, loaded with Recharts. */ +export const ScoreTrendChartDynamic = dynamic( () => - import("@/components/charts/chart-runtime").then((mod) => ({ - default: mod.DeltaSparkline, - })), - { ssr: false, loading: () => null }, + importWithRetry(() => import("@/components/charts/chart-runtime")).then( + (mod) => ({ default: mod.ScoreTrendChart }), + ), + { ssr: false, loading: () => }, ); export function ScoreHistoryChart({ type, hue, + chartKey, }: { /** The stored measurement type the score is written as — or, for a strain * score served from the device, the device's own `DAY_STRAIN`. */ type: "RECOVERY_SCORE" | "STRESS_SCORE" | "STRAIN_SCORE" | "DAY_STRAIN"; hue: RingHue; + /** The slot the range tab is remembered under. */ + chartKey: ChartOverlayKey; }) { const { t } = useTranslations(); const { user } = useAuth(); @@ -49,44 +55,10 @@ export function ScoreHistoryChart({ colors={[RING_GRADIENT[hue][1]]} unit={t("insights.deviceScore.unitScore")} userTimezone={user?.timezone} + chartKey={chartKey} + overlayControls={false} dayLinks />
); } - -export function ScoreHistoryCard({ - series, - windowDays, - hue, -}: { - series: number[]; - windowDays: number; - hue: RingHue; -}) { - const { t } = useTranslations(); - return ( - - - - {t("insights.derived.scores.historyWindow", { days: windowDays })} - - } - /> - - - - - - ); -} diff --git a/src/components/insights/derived/score-trend-chart.tsx b/src/components/insights/derived/score-trend-chart.tsx new file mode 100644 index 000000000..c6a0f5464 --- /dev/null +++ b/src/components/insights/derived/score-trend-chart.tsx @@ -0,0 +1,375 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { History } from "lucide-react"; +import { + CartesianGrid, + ComposedChart, + Line, + ReferenceArea, + ReferenceLine, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +import { ChartDataTable } from "@/components/charts/chart-data-table"; +import { ChartEmptyState } from "@/components/charts/chart-empty-state"; +import { ChartErrorState } from "@/components/charts/chart-error-state"; +import { + ChartRangeTabs, + DEFAULT_CHART_RANGE, + rangeWindowDays, +} from "@/components/charts/chart-range-tabs"; +import { + RichChartTooltip, + type RichTooltipRow, +} from "@/components/charts/chart-tooltip"; +import { + ChartDayFooter, + OPEN_DAY_LINE, + dayAnchor, + useChartDayLinks, +} from "@/components/day/chart-day-links"; +import { TileHeader } from "@/components/insights/tile-header"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useAuth } from "@/hooks/use-auth"; +import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs"; +import { apiGet } from "@/lib/api/api-fetch"; +import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; +import type { ChartOverlayKey, ChartRangePoints } from "@/lib/dashboard-layout"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import type { ScoreHistoryId } from "@/lib/insights/score-history-ids"; +import { queryKeys } from "@/lib/query-keys"; + +/** + * v1.42 — a score's daily course on its page, for the scores that have no + * stored measurement series to chart: the health score, readiness and the + * sleep score (the nightly recovery, stress and strain scores are stored and + * ride ``). + * + * The same card as the metric charts: the range tabs every chart offers, + * remembered per score page like theirs, each point a door to its day (click, + * or on touch the tooltip's button), and the data table as the keyboard way + * to the same days. The person's usual range is shaded behind the line when + * the server could form one. + * + * A health-score point flagged `seamBreak` starts a new segment: the recipe + * changed there, the values either side are averages of different things, + * so no line joins them. The segments alternate between two series keys, so + * each is drawn by a line that has no value on its neighbour's days. + */ + +/** Matches the response schema at `scoreHistoryResponse`. */ +interface ScoreHistoryResponse { + score: ScoreHistoryId; + days: number; + points: Array<{ day: string; value: number; seamBreak: boolean }>; + band: { lo: number; hi: number; n: number } | null; +} + +interface ChartRow { + day: string; + timestamp: number; + value: number; + /** The value on even segments, absent on odd ones. */ + even?: number; + /** The value on odd segments, absent on even ones. */ + odd?: number; +} + +/** Points past this many draw the line alone; the dots would merge. */ +const MAX_DOTTED_POINTS = 90; +const Y_AXIS_WIDTH = 36; +const MARGIN = { top: 10, right: 8, bottom: 8, left: 0 } as const; + +const TIMEOUT_MS = 8_000; + +/** + * The points as chart rows: each on its day's noon-UTC anchor, its value on + * the series key of its segment. Segments alternate between the two keys, so + * the line of one never reaches into its neighbour. Exported for its test. + */ +export function toChartRows( + points: ReadonlyArray<{ day: string; value: number; seamBreak: boolean }>, +): ChartRow[] { + const rows: ChartRow[] = []; + let segment = 0; + for (const [index, point] of points.entries()) { + if (index > 0 && point.seamBreak) segment += 1; + rows.push({ + day: point.day, + timestamp: dayAnchor(point.day), + value: point.value, + ...(segment % 2 === 0 ? { even: point.value } : { odd: point.value }), + }); + } + return rows; +} + +export interface ScoreTrendChartProps { + score: ScoreHistoryId; + /** The slot the range tab is remembered under. */ + chartKey: ChartOverlayKey; + /** The line's colour, a CSS variable reference. */ + color: string; + /** The score's name: the table caption and the opened day's label. */ + label: string; +} + +export function ScoreTrendChart({ + score, + chartKey, + color, + label, +}: ScoreTrendChartProps) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const { isAuthenticated } = useAuth(); + + const overlayPrefs = useChartOverlayPrefs(chartKey); + const [picked, setPicked] = useState(null); + const range = picked ?? overlayPrefs.prefs.rangePoints ?? DEFAULT_CHART_RANGE; + const days = rangeWindowDays(range); + + const query = useQuery({ + queryKey: queryKeys.insightsScoreHistory(score, days), + queryFn: async (): Promise => { + const params = new URLSearchParams({ score, days: String(days) }); + return apiGet( + `/api/insights/score-history?${params.toString()}`, + { signal: AbortSignal.timeout(TIMEOUT_MS) }, + ); + }, + enabled: isAuthenticated, + staleTime: 60_000, + retry: 0, + // A range change keeps the previous series painted, dimmed and inert, + // until the new one lands, so the card does not jump. + placeholderData: keepPreviousData, + }); + + const rows = useMemo( + () => toChartRows(query.data?.points ?? []), + [query.data], + ); + const hasSeam = (query.data?.points ?? []).some((p) => p.seamBreak); + const band = query.data?.band ?? null; + + const chartDays = useChartDayLinks({ + enabled: rows.length > 0, + days: rows.map((row) => row.day), + focusFor: (index) => { + const row = rows[index]; + return row ? { label, value: String(row.value) } : null; + }, + }); + const openRow = + chartDays.openIndex !== undefined ? rows[chartDays.openIndex] : undefined; + + const animate = !prefersReducedMotion(); + const dot = rows.length <= MAX_DOTTED_POINTS ? { r: 3, fill: color } : false; + const heightClass = + "h-[var(--chart-height,240px)] md:h-[var(--chart-height-md,280px)]"; + + function selectRange(next: ChartRangePoints) { + setPicked(next); + overlayPrefs.setPrefs({ ...overlayPrefs.prefs, rangePoints: next }); + } + + return ( +
+
+ +
+ +
+
+ + {query.isLoading ? ( + + ) : query.isError ? ( + void query.refetch()} + /> + ) : rows.length === 0 ? ( + + ) : ( + <> +
+
+ + + + + fmt.dateShortSmartCalendar(new Date(ts)) + } + tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} + tickLine={false} + axisLine={false} + minTickGap={32} + padding={{ left: 10, right: 10 }} + tickMargin={10} + /> + + {band ? ( + + ) : null} + {openRow ? ( + + ) : null} + { + const payload = props.payload as + ReadonlyArray<{ payload?: ChartRow }> | undefined; + const row = payload?.[0]?.payload; + if (!props.active || !row) { + return ; + } + const tooltipRows: RichTooltipRow[] = [ + { name: label, value: String(row.value), color }, + ]; + return ( + + ); + }} + /> + {(["even", "odd"] as const).map((key) => ( + + ))} + + +
+
+ + {band || hasSeam ? ( +
+ {band ? ( +

+ {t("insights.scoreHistory.usualRange", { + lo: band.lo, + hi: band.hi, + })} +

+ ) : null} + {hasSeam ? ( +

+ {t("insights.scoreHistory.seam")} +

+ ) : null} +
+ ) : null} + ({ + date: row.day, + timestamp: row.timestamp, + value: row.value, + }))} + columns={[{ key: "value", label }]} + formatValue={(value) => String(value)} + formatDate={(date) => fmt.dateShortSmartCalendar(date)} + bucket="day" + metricLabel={label} + dayLinks + /> + + )} +
+ ); +} diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx index ebcceddd3..0cb27e7f8 100644 --- a/src/components/insights/health-score-card.tsx +++ b/src/components/insights/health-score-card.tsx @@ -2,7 +2,7 @@ import { useId, useState } from "react"; import Link from "next/link"; -import { AlertTriangle, ChevronDown, RotateCw } from "lucide-react"; +import { AlertTriangle, ChevronDown, History, RotateCw } from "lucide-react"; import { Button } from "@/components/ui/button"; import { InfoPopover } from "@/components/ui/info-popover"; @@ -431,14 +431,25 @@ export function HealthScoreCard({ > {t("insights.healthScore.label")}

- {report.delta != null && report.delta > 0 ? ( - + {report.delta != null && report.delta > 0 ? ( + + +{report.delta} + + ) : null} + {/* v1.42 — the way to the score's course over time. */} + - +{report.delta} - - ) : null} +
{/* 2 — the number. The reason the panel exists. */} diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index c37344902..4337ac001 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -328,6 +328,14 @@ export const CHART_OVERLAY_KEYS = [ "painNrs", "waistCircumference", "waistToHeight", + // v1.42 — the score pages' history charts. Each remembers only its range + // tab (the charts mount no overlay dropdown), one slot per score page. + "scoreHealth", + "scoreReadiness", + "scoreRecovery", + "scoreSleep", + "scoreStress", + "scoreStrain", ] as const; export type ChartOverlayKey = (typeof CHART_OVERLAY_KEYS)[number]; diff --git a/src/lib/day/contract.ts b/src/lib/day/contract.ts index 4586f6bf3..387b6f64f 100644 --- a/src/lib/day/contract.ts +++ b/src/lib/day/contract.ts @@ -151,7 +151,7 @@ export type DayScoreKey = (typeof DAY_SCORE_KEYS)[number]; /** The page each score opens on. */ export const DAY_SCORE_HREF: Readonly> = { - healthScore: "/insights", + healthScore: "/insights/health-score", readiness: "/insights/scores/readiness", recovery: "/insights/scores/recovery", sleepScore: "/insights/scores/sleep", diff --git a/src/lib/insights/__tests__/score-history.test.ts b/src/lib/insights/__tests__/score-history.test.ts new file mode 100644 index 000000000..57ef416d6 --- /dev/null +++ b/src/lib/insights/__tests__/score-history.test.ts @@ -0,0 +1,192 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + healthScoreRecord: { findMany: vi.fn() }, + measurement: { findMany: vi.fn() }, + }, +})); +vi.mock("@/lib/insights/derived/sleep-score", () => ({ + computeSleepScoreHistory: vi.fn(), +})); + +import { prisma } from "@/lib/db"; +import { computeSleepScoreHistory } from "@/lib/insights/derived/sleep-score"; +import { readScoreHistory } from "../score-history"; + +/** + * v1.42 — the score-history reader: the window, the seams in the health + * score's line, and the usual range behind the newest point. The rows are + * stubbed; the integration suite reads them from Postgres. + */ + +const records = prisma.healthScoreRecord.findMany as ReturnType; +const measurements = prisma.measurement.findMany as ReturnType; +const sleep = computeSleepScoreHistory as ReturnType; + +const NOW = new Date("2026-06-30T10:00:00Z"); +const TZ = "UTC"; +const day = (offset: number) => + new Date(Date.parse("2026-06-30T12:00:00Z") + offset * 86_400_000) + .toISOString() + .slice(0, 10); + +function args( + score: "HEALTH_SCORE" | "READINESS" | "SLEEP_SCORE", + days: number, +) { + return { + userId: "u1", + score, + days, + now: NOW, + tz: TZ, + profile: vi.fn().mockResolvedValue({ ageYears: 40, sex: null }), + priorityJson: vi.fn().mockResolvedValue(null), + }; +} + +function row( + offset: number, + composite: number, + configVersion: number | null = 1, + scoreVersion = 1, +) { + return { dayKey: day(offset), composite, configVersion, scoreVersion }; +} + +beforeEach(() => vi.clearAllMocks()); + +describe("readScoreHistory — health score", () => { + it("reads thirty days back on a short window and keeps only the window's days", async () => { + records.mockResolvedValue( + Array.from({ length: 31 }, (_, i) => row(i - 30, 70 + (i % 3))), + ); + const out = await readScoreHistory(args("HEALTH_SCORE", 7)); + const where = records.mock.calls[0]![0].where; + expect(where.dayKey).toEqual({ gte: day(-30), lte: day(0) }); + expect(out.points.map((p) => p.day)).toEqual( + [-6, -5, -4, -3, -2, -1, 0].map(day), + ); + expect(out.band?.n).toBe(30); + }); + + it("reads the whole window when it is longer than thirty days", async () => { + records.mockResolvedValue([]); + await readScoreHistory(args("HEALTH_SCORE", 90)); + expect(records.mock.calls[0]![0].where.dayKey.gte).toBe(day(-89)); + }); + + it("flags a seam on a recipe change, an algorithm change and an unknown recipe", async () => { + records.mockResolvedValue([ + row(-5, 60, 1), + row(-4, 61, 1), + row(-3, 70, 2), + row(-2, 71, 2, 2), + row(-1, 72, null, 2), + row(0, 73, null, 2), + ]); + const out = await readScoreHistory(args("HEALTH_SCORE", 30)); + expect(out.points.map((p) => p.seamBreak)).toEqual([ + false, + false, + true, + true, + true, + true, + ]); + }); + + it("never opens the window on a seam", async () => { + records.mockResolvedValue([row(-8, 50, 1), row(-6, 80, 2), row(-5, 80, 2)]); + const out = await readScoreHistory(args("HEALTH_SCORE", 7)); + expect(out.points[0]).toEqual({ + day: day(-6), + value: 80, + seamBreak: false, + }); + }); + + it("forms the usual range from the newest point's side of the last seam", async () => { + records.mockResolvedValue([ + ...Array.from({ length: 10 }, (_, i) => row(i - 20, 30, 1)), + ...Array.from({ length: 10 }, (_, i) => row(i - 9, 80 + (i % 2), 2)), + ]); + const out = await readScoreHistory(args("HEALTH_SCORE", 30)); + expect(out.band).not.toBeNull(); + expect(out.band!.n).toBe(9); + expect(out.band!.lo).toBeGreaterThanOrEqual(79); + }); + + it("gives no range with fewer than seven days behind the newest point", async () => { + records.mockResolvedValue( + Array.from({ length: 6 }, (_, i) => row(i - 5, 70)), + ); + expect((await readScoreHistory(args("HEALTH_SCORE", 30))).band).toBeNull(); + }); + + it("gives no range for an empty window, even with older days on record", async () => { + records.mockResolvedValue( + Array.from({ length: 20 }, (_, i) => row(i - 29, 70)), + ); + const out = await readScoreHistory(args("HEALTH_SCORE", 7)); + expect(out).toEqual({ + score: "HEALTH_SCORE", + days: 7, + points: [], + band: null, + }); + }); + + it("does not load the sleep inputs for another score", async () => { + records.mockResolvedValue([]); + const a = args("HEALTH_SCORE", 7); + await readScoreHistory(a); + expect(a.profile).not.toHaveBeenCalled(); + expect(a.priorityJson).not.toHaveBeenCalled(); + }); +}); + +describe("readScoreHistory — readiness", () => { + it("reads only the nightly blend and files it on its wake day", async () => { + measurements.mockResolvedValue([ + { value: 64.6, measuredAt: new Date(`${day(-3)}T12:00:00Z`) }, + // A same-night re-score: the later row wins. + { value: 70, measuredAt: new Date(`${day(-2)}T12:00:00Z`) }, + { value: 71.2, measuredAt: new Date(`${day(-2)}T18:00:00Z`) }, + ]); + const out = await readScoreHistory(args("READINESS", 7)); + const where = measurements.mock.calls[0]![0].where; + expect(where).toMatchObject({ + type: "RECOVERY_SCORE", + source: "COMPUTED", + deletedAt: null, + }); + expect(out.points).toEqual([ + { day: day(-2), value: 65, seamBreak: false }, + { day: day(-1), value: 71, seamBreak: false }, + ]); + }); +}); + +describe("readScoreHistory — sleep score", () => { + it("rounds each night and keeps the nights inside the window", async () => { + sleep.mockResolvedValue([ + { night: day(-40), score: 50 }, + { night: day(-1), score: 77.4 }, + { night: day(0), score: 80.5 }, + ]); + const a = args("SLEEP_SCORE", 7); + const out = await readScoreHistory(a); + expect(sleep.mock.calls[0]![2]).toMatchObject({ + fromDay: day(-30), + now: NOW, + tz: TZ, + priorityJson: null, + }); + expect(out.points).toEqual([ + { day: day(-1), value: 77, seamBreak: false }, + { day: day(0), value: 81, seamBreak: false }, + ]); + }); +}); diff --git a/src/lib/insights/derived/__tests__/sleep-score-history.test.ts b/src/lib/insights/derived/__tests__/sleep-score-history.test.ts new file mode 100644 index 000000000..b88517980 --- /dev/null +++ b/src/lib/insights/derived/__tests__/sleep-score-history.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { findMany: vi.fn() }, + user: { findUnique: vi.fn().mockResolvedValue(null) }, + }, +})); + +import { prisma } from "@/lib/db"; +import { + blendSleepSubScores, + circularMeanMinutes, + computeSleepScore, + computeSleepScoreHistory, + scoreComposition, + scoreConsistency, + scoreEfficiency, + scoreNightsAgainstTrailingWindow, + scoreSufficiency, + scoreTiming, + sleepNeedMinutes, + type NightSummary, +} from "../sleep-score"; + +/** + * v1.42 — the sleep score's history: every night scored the way the + * headline scores its latest night, against the trailing window that ends + * on it. + */ + +const findMany = prisma.measurement.findMany as ReturnType; +beforeEach(() => vi.clearAllMocks()); + +function summary(night: string, midpoint: number, asleep = 420): NightSummary { + return { + night, + asleepMinutes: asleep, + awakeMinutes: 20, + remMinutes: 90, + deepMinutes: 60, + inBedMinutes: asleep + 30, + hasStageBreakdown: true, + midpoint, + }; +} + +const NEED = sleepNeedMinutes(40); + +describe("scoreNightsAgainstTrailingWindow", () => { + it("scores each night with the window that ends on it", () => { + const nights = [ + summary("2026-06-01", 180), + summary("2026-06-02", 200), + summary("2026-06-03", 240, 300), + ]; + const out = scoreNightsAgainstTrailingWindow(nights, NEED, "2026-06-01"); + expect(out.map((n) => n.night)).toEqual([ + "2026-06-01", + "2026-06-02", + "2026-06-03", + ]); + // The third night by hand: its own sub-scores, the window's yardstick. + const mids = [180, 200, 240]; + const expected = blendSleepSubScores({ + sufficiency: scoreSufficiency(300, NEED), + efficiency: scoreEfficiency(300, 330), + consistency: scoreConsistency(mids), + timing: scoreTiming(240, circularMeanMinutes(mids), 3), + composition: scoreComposition(90, 60, 300, true), + }).score; + expect(out[2]!.score).toBe(expected); + // The first night has only itself behind it: no timing, no consistency. + const first = blendSleepSubScores({ + sufficiency: scoreSufficiency(420, NEED), + efficiency: scoreEfficiency(420, 450), + consistency: scoreConsistency([180]), + timing: scoreTiming(180, 180, 1), + composition: scoreComposition(90, 60, 420, true), + }).score; + expect(out[0]!.score).toBe(first); + }); + + it("returns only nights from fromDay, using the earlier ones as the window", () => { + const nights = [ + summary("2026-05-20", 100), + summary("2026-05-21", 400), + summary("2026-06-01", 180), + ]; + const withWindow = scoreNightsAgainstTrailingWindow( + nights, + NEED, + "2026-06-01", + ); + const alone = scoreNightsAgainstTrailingWindow( + [nights[2]!], + NEED, + "2026-06-01", + ); + expect(withWindow.map((n) => n.night)).toEqual(["2026-06-01"]); + expect(withWindow[0]!.score).not.toBe(alone[0]!.score); + }); + + it("drops a night that fell out of the window", () => { + const old = summary("2026-04-01", 600); + const recent = summary("2026-06-01", 180); + const out = scoreNightsAgainstTrailingWindow( + [old, recent], + NEED, + "2026-06-01", + ); + const alone = scoreNightsAgainstTrailingWindow( + [recent], + NEED, + "2026-06-01", + ); + expect(out[0]!.score).toBe(alone[0]!.score); + }); +}); + +describe("computeSleepScoreHistory", () => { + it("ends on the score the headline shows for the latest night", async () => { + const rows = ["2026-05-30", "2026-05-31", "2026-06-01", "2026-06-02"].map( + (day, i) => ({ + value: 400 + i * 15, + measuredAt: new Date(`${day}T06:00:00Z`), + sleepStage: "ASLEEP", + source: "APPLE_HEALTH", + deviceType: null, + }), + ); + findMany.mockResolvedValue(rows); + const now = new Date("2026-06-02T10:00:00Z"); + const profile = { ageYears: 40, sex: "MALE" as const }; + const history = await computeSleepScoreHistory("u1", profile, { + fromDay: "2026-05-27", + now, + tz: "UTC", + priorityJson: null, + }); + expect(history.map((n) => n.night)).toEqual([ + "2026-05-30", + "2026-05-31", + "2026-06-01", + "2026-06-02", + ]); + const headline = await computeSleepScore("u1", profile, { + now, + tz: "UTC", + priorityJson: null, + }); + expect(headline.status).toBe("ok"); + if (headline.status === "ok") { + expect(history.at(-1)!.score).toBe(headline.value.score); + } + }); + + it("reads from the window before fromDay", async () => { + findMany.mockResolvedValue([]); + await computeSleepScoreHistory( + "u1", + { ageYears: 40, sex: null }, + { + fromDay: "2026-06-01", + now: new Date("2026-06-02T10:00:00Z"), + tz: "UTC", + priorityJson: null, + }, + ); + const where = findMany.mock.calls[0]![0].where; + expect(where.type).toBe("SLEEP_DURATION"); + expect(where.measuredAt.gte.toISOString()).toBe("2026-05-01T00:00:00.000Z"); + }); +}); diff --git a/src/lib/insights/derived/sleep-score.ts b/src/lib/insights/derived/sleep-score.ts index 29ad1e953..6a00dd7ff 100644 --- a/src/lib/insights/derived/sleep-score.ts +++ b/src/lib/insights/derived/sleep-score.ts @@ -40,6 +40,8 @@ import type { import { prisma } from "@/lib/db"; import { wallClockInTz } from "@/lib/tz/wall-clock"; import { resolveUserTimezone } from "@/lib/tz/resolver"; +import { shiftDateKey } from "@/lib/tz/format"; +import { startOfLocalDayKey } from "@/lib/tz/local-day"; import { reconstructSleepNights, type SleepStageRow, @@ -608,3 +610,97 @@ export async function computeSleepScore( }, }); } + +// ── history ───────────────────────────────────────────────────────────── + +/** + * Each night's Sleep Score as the headline would have shown it the morning + * after: the night's own sufficiency, efficiency and composition, with + * consistency and timing read against the nights of the trailing window that + * ends on it. That is the yardstick the headline uses for its latest night, + * applied to every night in turn, so the newest point of a history equals the + * score on the card and a past point equals what the card said that day. + * + * `nights` must be ascending by wake day and scorable (asleep minutes > 0). + * Only nights on or after `fromDay` are returned; earlier ones serve as the + * window behind the first returned night. Pure. + */ +export function scoreNightsAgainstTrailingWindow( + nights: readonly NightSummary[], + needMinutes: number, + fromDay: string, + windowDays: number = DEFAULT_WINDOW_DAYS, +): Array<{ night: string; score: number }> { + const out: Array<{ night: string; score: number }> = []; + let start = 0; + for (let i = 0; i < nights.length; i += 1) { + const night = nights[i]!; + const windowStart = shiftDateKey(night.night, -(windowDays - 1)); + while (start < i && nights[start]!.night < windowStart) start += 1; + if (night.night < fromDay) continue; + const midpoints = nights + .slice(start, i + 1) + .map((n) => n.midpoint) + .filter((m): m is number => m != null); + const habitualMidpoint = circularMeanMinutes(midpoints); + out.push({ + night: night.night, + score: blendSleepSubScores({ + sufficiency: scoreSufficiency(night.asleepMinutes, needMinutes), + efficiency: scoreEfficiency(night.asleepMinutes, night.inBedMinutes), + consistency: scoreConsistency(midpoints), + timing: scoreTiming(night.midpoint, habitualMidpoint, midpoints.length), + composition: scoreComposition( + night.remMinutes, + night.deepMinutes, + night.asleepMinutes, + night.hasStageBreakdown, + ), + }).score, + }); + } + return out; +} + +/** + * The Sleep Score of every night from `fromDay` (a wake-day key) to now, one + * value per night, oldest first. Reads the sleep rows once, from a window + * before `fromDay` so the first night has its yardstick behind it. + */ +export async function computeSleepScoreHistory( + userId: string, + profile: BaselineProfile, + opts: { fromDay: string; now: Date; tz: string; priorityJson: unknown }, +): Promise> { + // The wake day of a night can sit a day after the instant its first row + // starts, so the read opens one day earlier than the window strictly needs. + const since = startOfLocalDayKey( + shiftDateKey(opts.fromDay, -(DEFAULT_WINDOW_DAYS + 1)), + opts.tz, + ); + const rows = (await prisma.measurement.findMany({ + where: { + userId, + type: "SLEEP_DURATION" satisfies MeasurementType, + deletedAt: null, + measuredAt: { gte: since, lte: opts.now }, + }, + orderBy: { measuredAt: "asc" }, + select: { + value: true, + measuredAt: true, + sleepStage: true, + source: true, + deviceType: true, + }, + })) as SleepRow[]; + if (rows.length === 0) return []; + const nights = reconstructNights(rows, opts.tz, opts.priorityJson) + .filter((n) => n.asleepMinutes > 0) + .sort((a, b) => (a.night < b.night ? -1 : a.night > b.night ? 1 : 0)); + return scoreNightsAgainstTrailingWindow( + nights, + sleepNeedMinutes(profile.ageYears), + opts.fromDay, + ); +} diff --git a/src/lib/insights/score-history-ids.ts b/src/lib/insights/score-history-ids.ts new file mode 100644 index 000000000..4e98c78d0 --- /dev/null +++ b/src/lib/insights/score-history-ids.ts @@ -0,0 +1,18 @@ +/** + * The closed set of scores `GET /api/insights/score-history` serves, and the + * widest window it accepts. Pure constants, apart from the reader, so the + * OpenAPI registry and the client can import them without the database. + */ +export const SCORE_HISTORY_IDS = [ + "HEALTH_SCORE", + "READINESS", + "SLEEP_SCORE", +] as const; +export type ScoreHistoryId = (typeof SCORE_HISTORY_IDS)[number]; + +/** + * The widest window a caller may ask for: the chart's "All" tab, the same + * ten years the metric charts fetch for it. Every score here is one value + * per day, so the response is bounded by the window, not by row density. + */ +export const SCORE_HISTORY_MAX_DAYS = 3650; diff --git a/src/lib/insights/score-history.ts b/src/lib/insights/score-history.ts new file mode 100644 index 000000000..5eeabd71a --- /dev/null +++ b/src/lib/insights/score-history.ts @@ -0,0 +1,216 @@ +/** + * A score's daily course over a chosen window, for the score pages' history + * chart: the health score, readiness and the sleep score. + * + * Each is read from where the day view reads the same score for one day + * (`src/lib/day/scores.ts`), so a point on the chart and the score tile of + * the day it opens never disagree: + * + * - Health score: the `HealthScoreRecord` row of each local day, the score + * as it was shown that day. Nothing recomputes it. + * - Readiness: the `COMPUTED` `RECOVERY_SCORE` rows the nightly job wrote, + * filed on the wake day of the night they describe (`wakeDayKeyOf`). + * - Sleep score: computed per night against the trailing window that ends + * on it, the yardstick the headline uses for its latest night. + * + * The health score's line breaks where the recipe changed (Eurostat's `b` + * flag on the first day under a new recipe): the numbers either side are + * averages of different things, so the chart draws two segments and never + * one line through the seam. The algorithm version is part of the recipe + * here, for the same reason. + * + * The usual range is the person's own, the rule the day view's score tiles + * use (`scoreBand`): median and scaled MAD over the 30 days before the + * newest point, with at least seven of them, and only from the newest + * point's side of a seam. + */ +import { prisma } from "@/lib/db"; +import { scoreBand } from "@/lib/day/scores"; +import { BAND_WINDOW_DAYS } from "@/lib/day/values"; +import type { BaselineProfile } from "@/lib/insights/derived/baseline"; +import { wakeDayKeyOf } from "@/lib/insights/derived/recovery-resolve"; +import { computeSleepScoreHistory } from "@/lib/insights/derived/sleep-score"; +import { shiftDateKey, userDayKey } from "@/lib/tz/format"; +import { startOfLocalDayKey } from "@/lib/tz/local-day"; + +import type { ScoreHistoryId } from "./score-history-ids"; + +const MS_PER_DAY = 86_400_000; +const SCORE_SCALE_MAX = 100; + +export interface ScoreHistoryPoint { + /** The local calendar day the value describes, `YYYY-MM-DD`. */ + day: string; + /** 0 to 100, whole points. */ + value: number; + /** True on the first day scored under a different recipe than the day before. */ + seamBreak: boolean; +} + +export interface ScoreHistory { + score: ScoreHistoryId; + days: number; + points: ScoreHistoryPoint[]; + band: { lo: number; hi: number; n: number } | null; +} + +export interface ScoreHistoryArgs { + userId: string; + score: ScoreHistoryId; + days: number; + now: Date; + tz: string; + /** Read only for the sleep score. */ + profile: () => Promise; + priorityJson: () => Promise; +} + +interface DayValue { + day: string; + value: number; + seamBreak: boolean; +} + +async function healthScoreDays( + userId: string, + from: string, + to: string, +): Promise { + const rows = await prisma.healthScoreRecord.findMany({ + where: { userId, dayKey: { gte: from, lte: to } }, + orderBy: { dayKey: "asc" }, + select: { + dayKey: true, + composite: true, + configVersion: true, + scoreVersion: true, + }, + }); + return rows.map((row, index) => { + const previous = index > 0 ? rows[index - 1] : null; + // A row that does not say which recipe produced it cannot support the + // claim that it shares one with its neighbour, so an unknown version on + // either side is a seam too. + const seamBreak = + previous !== null && + (previous.configVersion === null || + row.configVersion === null || + previous.configVersion !== row.configVersion || + previous.scoreVersion !== row.scoreVersion); + return { day: row.dayKey, value: row.composite, seamBreak }; + }); +} + +async function readinessDays( + userId: string, + from: string, + to: string, + tz: string, +): Promise { + // The row is stamped on the day that ended, a day before the wake day it + // is filed under, so the read opens one day early. + const rows = await prisma.measurement.findMany({ + where: { + userId, + type: "RECOVERY_SCORE", + source: "COMPUTED", + deletedAt: null, + measuredAt: { + gte: new Date(startOfLocalDayKey(from, tz).getTime() - MS_PER_DAY), + }, + }, + orderBy: { measuredAt: "asc" }, + select: { value: true, measuredAt: true }, + }); + const byDay = new Map(); + for (const row of rows) { + // A same-night re-score is the later row: ascending order lets it win. + byDay.set(wakeDayKeyOf(row.measuredAt, "COMPUTED", tz), row.value); + } + return [...byDay] + .filter(([day]) => day >= from && day <= to) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([day, value]) => ({ + day, + value: Math.round(value), + seamBreak: false, + })); +} + +async function sleepScoreDays( + args: ScoreHistoryArgs, + from: string, + to: string, +): Promise { + const nights = await computeSleepScoreHistory( + args.userId, + await args.profile(), + { + fromDay: from, + now: args.now, + tz: args.tz, + priorityJson: await args.priorityJson(), + }, + ); + return nights + .filter((night) => night.night <= to) + .map((night) => ({ + day: night.night, + value: Math.round(night.score), + seamBreak: false, + })); +} + +/** + * The usual range behind the newest point: the values of the 30 days before + * it, from its own side of the last seam. + */ +function usualBand(values: readonly DayValue[]): ScoreHistory["band"] { + const newest = values[values.length - 1]; + if (!newest) return null; + let segmentStart = 0; + for (let i = values.length - 1; i > 0; i -= 1) { + if (values[i]!.seamBreak) { + segmentStart = i; + break; + } + } + const windowFrom = shiftDateKey(newest.day, -BAND_WINDOW_DAYS); + const prior = values + .slice(segmentStart, -1) + .filter((v) => v.day >= windowFrom) + .map((v) => v.value); + return scoreBand(prior, SCORE_SCALE_MAX); +} + +export async function readScoreHistory( + args: ScoreHistoryArgs, +): Promise { + const today = userDayKey(args.now, args.tz); + const from = shiftDateKey(today, -(args.days - 1)); + // Read far enough back that the newest day has its 30 days behind it even + // on a seven-day window. + const readFrom = shiftDateKey( + today, + -Math.max(args.days - 1, BAND_WINDOW_DAYS), + ); + + const values = + args.score === "HEALTH_SCORE" + ? await healthScoreDays(args.userId, readFrom, today) + : args.score === "READINESS" + ? await readinessDays(args.userId, readFrom, today, args.tz) + : await sleepScoreDays(args, readFrom, today); + + const points = values.filter((v) => v.day >= from); + return { + score: args.score, + days: args.days, + // The first point drawn opens no seam of its own: a beginning is not a + // break. + points: points.map((point, index) => + index === 0 ? { ...point, seamBreak: false } : point, + ), + band: points.length > 0 ? usualBand(values) : null, + }; +} diff --git a/src/lib/openapi/routes/insights/paths.ts b/src/lib/openapi/routes/insights/paths.ts index ba211432d..534a96116 100644 --- a/src/lib/openapi/routes/insights/paths.ts +++ b/src/lib/openapi/routes/insights/paths.ts @@ -66,6 +66,8 @@ import { glp1TimelineResponse, intradayPulseQuery, intradayPulseResponse, + scoreHistoryQuery, + scoreHistoryResponse, } from "./schemas"; import { recommendationFeedbackRequestSchema } from "@/lib/validations/recommendation-feedback"; @@ -829,6 +831,32 @@ export const insightsPaths: NonNullable = { }, }, }, + "/api/insights/score-history": { + get: { + tags: ["Insights"], + summary: "A score's daily history", + description: + "v1.42 — the daily course of the health score, readiness or the sleep score over a trailing window, one point per local day, for the history chart on the score's page. Each point is the value the day view shows for that day: the stored health-score day, the nightly readiness blend filed on its wake day, the sleep score of the night that ended that morning. The health score's points carry `seamBreak` where the recipe changed, and the chart draws no line across it. `band` is the usual range behind the newest point. Readiness is gated on the `recovery` module and the sleep score on `sleep` (403 `module.disabled`); the health score is core. Pure reads, no AI. Delegable at MANAGE level over the whole record. Additive. Cookie or Bearer auth.", + requestParams: { + query: scoreHistoryQuery, + }, + responses: { + ...recordRefusal(), + "200": { + description: "The score's daily points and usual range.", + content: { + "application/json": { + schema: dataEnvelope( + scoreHistoryResponse, + "ScoreHistoryResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/insights/glp1-plateau": { get: { tags: ["Insights"], diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index 4191190ac..486ca50aa 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -21,6 +21,10 @@ import { ANALYTICS_RANGES } from "@/lib/analytics/range-delta"; import { providerChainPutSchema } from "@/lib/validations/ai-provider"; import { PERIOD_DAYS } from "@/lib/insights/narrative/period-narrative"; import { aiCapabilityState } from "../profile"; +import { + SCORE_HISTORY_IDS, + SCORE_HISTORY_MAX_DAYS, +} from "@/lib/insights/score-history-ids"; /** The retrospective periods the narrative route accepts, read off the engine. */ const NARRATIVE_PERIOD_VALUES = Object.keys(PERIOD_DAYS); @@ -2957,3 +2961,61 @@ export const intradayPulseResponse = z description: "One local day's intraday heart-rate shape, plus at most one cautious elevated-at-rest window. Computed from raw samples through the read-swap pattern rather than persisted as ten-minute rollups for all history. Awareness only, never a diagnosis.", }); + +export const scoreHistoryQuery = z + .object({ + score: z + .enum(SCORE_HISTORY_IDS) + .describe( + "The score to chart. `HEALTH_SCORE` reads the stored day scores, `READINESS` the nightly readiness blend, `SLEEP_SCORE` the per-night sleep score. The nightly recovery, stress and strain scores are stored measurements and chart through the measurement series instead.", + ), + days: z.coerce + .number() + .int() + .min(1) + .max(SCORE_HISTORY_MAX_DAYS) + .describe( + `Trailing window in local calendar days ending today (1–${SCORE_HISTORY_MAX_DAYS}). The web chart sends 7, 30, 90, or ${SCORE_HISTORY_MAX_DAYS} for its "All" tab. Out of range is a 422, never a silent clamp.`, + ), + }) + .meta({ id: "ScoreHistoryQuery" }); + +export const scoreHistoryResponse = z + .object({ + score: z.enum(SCORE_HISTORY_IDS), + days: z.number().int().describe("The window the request asked for."), + points: z + .array( + z.object({ + day: z + .string() + .describe( + "The local calendar day the value describes, `YYYY-MM-DD`; the day the day view opens for it.", + ), + value: z.number().int().min(0).max(100), + seamBreak: z + .boolean() + .describe( + "True on the first day scored under a different recipe (what counts, or the algorithm version) than the day before. Draw no line from the previous point to this one: the values either side are averages of different things. Only the health score has seams; never true on the first point.", + ), + }), + ) + .describe( + "One point per day that has a value, oldest first. A day without one is absent, never zero.", + ), + band: z + .object({ + lo: z.number().int(), + hi: z.number().int(), + n: z.number().int().describe("Days the range was formed from."), + }) + .nullable() + .describe( + "The person's usual range behind the newest point: median and scaled MAD of the values in the 30 days before it, from its own side of the last seam. Null with fewer than seven such days. Describes, does not grade.", + ), + }) + .meta({ + id: "ScoreHistoryResponse", + description: + "One score's daily course over a trailing window, for a history chart with a day per point.", + }); diff --git a/src/lib/query-keys/insights.ts b/src/lib/query-keys/insights.ts index 1fdf45da5..5287aa153 100644 --- a/src/lib/query-keys/insights.ts +++ b/src/lib/query-keys/insights.ts @@ -120,6 +120,13 @@ export const insightsKeys = { */ insightsDerivedBatch: (tokens: readonly string[]) => ["insights", "derived", "batch", [...tokens].sort().join(",")] as const, + /** + * v1.42 — one score's daily history (`/api/insights/score-history`), per + * score and window. Under the `["insights"]` prefix so the measurement-write + * invalidation fan-out reaches it with the derived reads. + */ + insightsScoreHistory: (score: string, days: number) => + ["insights", "score-history", score, days] as const, /** * v1.10.0 — FDR-controlled correlation discovery * (`/api/insights/correlations`). Read-only descriptive surface; the diff --git a/tests/integration/score-history.test.ts b/tests/integration/score-history.test.ts new file mode 100644 index 000000000..7049a3594 --- /dev/null +++ b/tests/integration/score-history.test.ts @@ -0,0 +1,338 @@ +/** + * v1.42 — `GET /api/insights/score-history` against real Postgres. + * + * What the unit tests cannot show: that each score's points land on the day + * the day view shows the same score for (the readiness row stamped the day + * before its wake day, the sleep score of the night that ended that morning), + * that the health score's line breaks where the recipe changed, that the + * window, the module gate, the record standing and the query limits hold. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { NextRequest } from "next/server"; + +import { userDayKey } from "@/lib/tz/format"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, switchSessionTo, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +type Handler = ( + request: NextRequest, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + context: { params: Promise }, +) => Promise; + +interface History { + score: string; + days: number; + points: Array<{ day: string; value: number; seamBreak: boolean }>; + band: { lo: number; hi: number; n: number } | null; +} + +const TZ = "Europe/Berlin"; +const shift = (key: string, days: number) => + new Date(Date.parse(`${key}T12:00:00.000Z`) + days * 86_400_000) + .toISOString() + .slice(0, 10); + +let counter = 0; + +async function makeUser(label: string, modules: Record = {}) { + const suffix = `${label}-${counter++}`; + return getPrismaClient().user.create({ + data: { + username: `sh-${suffix}`, + email: `sh-${suffix}@example.test`, + role: "USER", + timezone: TZ, + locale: "en", + modulePreferencesJson: modules, + }, + }); +} + +async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return session; +} + +async function history(query: string): Promise { + const { GET } = await import("@/app/api/insights/score-history/route"); + return (GET as Handler)( + new NextRequest(`http://localhost/api/insights/score-history?${query}`), + { params: Promise.resolve({}) }, + ); +} + +async function json(response: Response): Promise { + return ((await response.json()) as { data: T }).data; +} + +async function dayScores(date: string) { + const { GET } = await import("@/app/api/day/[date]/route"); + const response = await (GET as Handler)( + new NextRequest(`http://localhost/api/day/${date}`), + { params: Promise.resolve({ date }) }, + ); + const day = await json(response); + return Object.fromEntries(day.scores.map((s) => [s.key, s])); +} + +async function seedHealthScore( + userId: string, + key: string, + composite: number, + configVersion: number | null = 1, +) { + await getPrismaClient().healthScoreRecord.create({ + data: { + userId, + dayKey: key, + timezone: TZ, + composite, + band: "green", + scoreVersion: 1, + composition: ["sleep"], + pillarScores: { sleep: composite }, + inputFingerprint: "a".repeat(64), + configVersion, + }, + }); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +describe("GET /api/insights/score-history", () => { + it("serves the health score's stored days inside the window, oldest first", async () => { + const owner = await makeUser("health"); + const today = userDayKey(new Date(), TZ); + // Forty days, one without a row. + for (let i = 39; i >= 0; i -= 1) { + if (i === 3) continue; + await seedHealthScore(owner.id, shift(today, -i), 60 + (i % 5)); + } + await signIn(owner.id); + + const response = await history("score=HEALTH_SCORE&days=7"); + expect(response.status).toBe(200); + const body = await json(response); + expect(body.score).toBe("HEALTH_SCORE"); + expect(body.days).toBe(7); + expect(body.points.map((p) => p.day)).toEqual( + [6, 5, 4, 2, 1, 0].map((i) => shift(today, -i)), + ); + expect(body.points.at(-1)?.value).toBe(60); + expect(body.points.every((p) => !p.seamBreak)).toBe(true); + // The usual range reads the thirty days before the newest point, even on + // a seven-day window. + expect(body.band?.n).toBe(29); + + const all = await json( + await history("score=HEALTH_SCORE&days=3650"), + ); + expect(all.points).toHaveLength(39); + }); + + it("breaks the health score's line where the recipe changed, and forms the range on the newest side", async () => { + const owner = await makeUser("seam"); + const today = userDayKey(new Date(), TZ); + for (let i = 20; i >= 0; i -= 1) { + // Version 1 until nine days ago, version 2 from then on. + await seedHealthScore( + owner.id, + shift(today, -i), + i > 9 ? 40 : 80, + i > 9 ? 1 : 2, + ); + } + await signIn(owner.id); + + const body = await json( + await history("score=HEALTH_SCORE&days=30"), + ); + const seams = body.points.filter((p) => p.seamBreak).map((p) => p.day); + expect(seams).toEqual([shift(today, -9)]); + // Only the version-2 days stand behind the newest point: nine of them. + expect(body.band).toEqual({ lo: 79, hi: 81, n: 9 }); + + // A window that starts on the seam opens no seam of its own. + const short = await json( + await history("score=HEALTH_SCORE&days=10"), + ); + expect(short.points[0]).toMatchObject({ + day: shift(today, -9), + seamBreak: false, + }); + }); + + it("files readiness on the wake day, as the day view does, and leaves a device's recovery out", async () => { + const db = getPrismaClient(); + const owner = await makeUser("readiness"); + const today = userDayKey(new Date(), TZ); + for (let i = 12; i >= 0; i -= 1) { + const key = shift(today, -i); + // The nightly proxy is stamped at noon UTC of the day that ended. + await db.measurement.create({ + data: { + userId: owner.id, + type: "RECOVERY_SCORE", + value: 70 + (i % 4), + unit: "score", + source: "COMPUTED", + measuredAt: new Date(`${shift(key, -1)}T12:00:00.000Z`), + }, + }); + } + await db.measurement.create({ + data: { + userId: owner.id, + type: "RECOVERY_SCORE", + value: 12, + unit: "score", + source: "WHOOP", + measuredAt: new Date(`${shift(today, -2)}T06:30:00.000Z`), + }, + }); + await signIn(owner.id); + + const body = await json(await history("score=READINESS&days=7")); + expect(body.points.map((p) => p.day)).toEqual( + [6, 5, 4, 3, 2, 1, 0].map((i) => shift(today, -i)), + ); + expect(body.points.map((p) => p.value)).not.toContain(12); + + const target = shift(today, -2); + const scores = await dayScores(target); + expect(body.points.find((p) => p.day === target)?.value).toBe( + scores.readiness?.value, + ); + }); + + it("gives the sleep score of each night the value the day view shows for its morning", async () => { + const db = getPrismaClient(); + const owner = await makeUser("sleep"); + const today = userDayKey(new Date(), TZ); + for (let i = 14; i >= 1; i -= 1) { + const key = shift(today, -i); + await db.measurement.create({ + data: { + userId: owner.id, + type: "SLEEP_DURATION", + value: 400 + (i % 4) * 20, + unit: "min", + sleepStage: "ASLEEP", + source: "APPLE_HEALTH", + // 01:00 to about 08:00 local, ending on the morning of `key`. + measuredAt: new Date(`${key}T05:00:00.000Z`), + }, + }); + } + await signIn(owner.id); + + const body = await json( + await history("score=SLEEP_SCORE&days=30"), + ); + expect(body.points.length).toBeGreaterThanOrEqual(10); + for (const point of body.points) { + expect(point.value).toBeGreaterThan(0); + expect(point.value).toBeLessThanOrEqual(100); + } + for (const offset of [1, 5]) { + const target = shift(today, -offset); + const scores = await dayScores(target); + expect(body.points.find((p) => p.day === target)?.value).toBe( + scores.sleepScore?.value, + ); + } + }); + + it("refuses readiness and the sleep score when their module is off, never the health score", async () => { + const owner = await makeUser("modules", { recovery: false, sleep: false }); + await signIn(owner.id); + expect((await history("score=READINESS&days=30")).status).toBe(403); + expect((await history("score=SLEEP_SCORE&days=30")).status).toBe(403); + expect((await history("score=HEALTH_SCORE&days=30")).status).toBe(200); + }); + + it("answers an empty record with no points and no range", async () => { + const owner = await makeUser("empty"); + await signIn(owner.id); + const body = await json( + await history("score=HEALTH_SCORE&days=90"), + ); + expect(body).toEqual({ + score: "HEALTH_SCORE", + days: 90, + points: [], + band: null, + }); + }); + + it("rejects an unknown score and a window out of range", async () => { + const owner = await makeUser("invalid"); + await signIn(owner.id); + for (const query of [ + "score=STRESS_SCORE&days=30", + "score=HEALTH_SCORE&days=0", + "score=HEALTH_SCORE&days=3651", + "score=HEALTH_SCORE&days=7.5", + "score=HEALTH_SCORE", + ]) { + expect((await history(query)).status, query).toBe(422); + } + }); + + it("refuses a delegate holding a read grant: the history is a MANAGE read", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await seedHealthScore(owner.id, userDayKey(new Date(), TZ), 70); + const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants"); + const invited = await inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + access: "READ", + scope: null, + }); + await acceptGrant({ grantId: invited.id, granteeId: delegate.id }); + const session = await signIn(delegate.id); + await switchSessionTo(session.id, owner.id); + + expect((await history("score=HEALTH_SCORE&days=30")).status).toBe(403); + }); + + it("refuses an anonymous caller", async () => { + expect((await history("score=HEALTH_SCORE&days=30")).status).toBe(401); + }); +}); From 668f568289cc300021365419d5e6e43488d30fa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 9 Oct 2026 16:52:39 +0200 Subject: [PATCH 2/2] feat(dashboard): let the top card be switched off The layout settings gain a switch for the dashboard's top card (the Today card above the tiles). Stored server-side on the layout blob as `todayCardVisible`: missing means shown, only an explicit false hides it, the default is omitted from storage, a save that omits the field keeps the stored choice, and a layout reset shows the card again. Hidden, the card leaves no skeleton or error card behind. The flag rides the account-settings backup with the rest of the layout. The field is deliberately not `heroVisible`, the retired opt-in hero's flag that older stored layouts may still carry. An end-to-end journey on its own account switches the card off and on and walks the score histories' range tabs. --- docs/api/openapi.yaml | 15 + e2e/score-history.spec.ts | 361 ++++++++++++++++++ e2e/setup/global-setup.ts | 48 +++ messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/ko.json | 3 +- messages/pl.json | 3 +- playwright.config.ts | 5 + .../__tests__/dashboard-hero-wiring.test.ts | 11 + .../dashboard/widgets/__tests__/route.test.ts | 96 +++++ src/app/api/dashboard/widgets/route.ts | 6 + src/app/page-client.tsx | 5 +- .../dashboard-layout-section.test.tsx | 41 ++ .../settings/dashboard-layout-section.tsx | 25 +- src/lib/__tests__/dashboard-layout.test.ts | 54 +++ src/lib/dashboard-layout.ts | 15 + .../__tests__/account-settings-backup.test.ts | 13 + src/lib/openapi/routes/dashboard.ts | 8 +- 21 files changed, 714 insertions(+), 10 deletions(-) create mode 100644 e2e/score-history.spec.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index fc8300146..09264477d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -32820,6 +32820,11 @@ components: enum: - score - reminders + todayCardVisible: + description: v1.42 — whether the dashboard shows its top card (the Today card above the tiles). Missing resolves to + `true`; only an explicit `false` hides it. The stored default is omitted. A layout reset (DELETE) shows it + again. Additive. + type: boolean baseUpdatedAt: description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored @@ -76275,6 +76280,11 @@ components: enum: - score - reminders + todayCardVisible: + description: v1.42 — whether the dashboard shows its top card (the Today card above the tiles). Missing resolves to + `true`; only an explicit `false` hides it. The stored default is omitted. A layout reset (DELETE) shows it + again. Additive. + type: boolean updatedAt: description: "Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as `baseUpdatedAt` on the next write. Opaque." @@ -76532,6 +76542,11 @@ components: enum: - score - reminders + todayCardVisible: + description: v1.42 — whether the dashboard shows its top card (the Today card above the tiles). Missing resolves to + `true`; only an explicit `false` hides it. The stored default is omitted. A layout reset (DELETE) shows it + again. Additive. + type: boolean updatedAt: description: "Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as `baseUpdatedAt` on the next write. Opaque." diff --git a/e2e/score-history.spec.ts b/e2e/score-history.spec.ts new file mode 100644 index 000000000..a22b9382f --- /dev/null +++ b/e2e/score-history.spec.ts @@ -0,0 +1,361 @@ +import type { Page, Route } from "@playwright/test"; + +import { mockDay } from "./setup/day-mock"; +import { mockPopulatedInsights } from "./utils/mock-populated-insights"; +import { SCORE_HISTORY_STORAGE_STATE_PATH } from "./setup/global-setup"; +import { expect, test } from "./setup/test"; +import type { DailyDigest } from "@/lib/daily/digest"; +import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures"; + +/** + * v1.42 — the score histories and the dashboard's top card switch. + * + * - The health score's page charts its daily course with the range tabs + * every chart offers; a tab asks the server for its window, the choice + * survives a reload, and a point opens its day. + * - Readiness, recovery, the sleep score and strain carry the same four + * tabs in the same component. + * - The top card goes away when the layout settings switch it off, and + * comes back when they switch it on. + * + * The score history and the derived scores are route mocks in the + * contract's shape (the server's answers are pinned by the integration + * suite); the range preference and the layout are this account's own, saved + * for real. Stable data attributes only. + */ + +test.use({ storageState: SCORE_HISTORY_STORAGE_STATE_PATH }); +// Every test writes this one account's layout blob, a Serializable +// read-modify-write; run in parallel they would abort each other's write. +test.describe.configure({ mode: "serial" }); + +const RANGES = ["7", "30", "90", "0"]; + +function dayKey(offset: number): string { + const now = new Date(); + const local = new Date( + now.toLocaleString("en-US", { timeZone: "Europe/Berlin" }), + ); + local.setDate(local.getDate() + offset); + const y = local.getFullYear(); + const m = String(local.getMonth() + 1).padStart(2, "0"); + const d = String(local.getDate()).padStart(2, "0"); + return `${y}-${m}-${d}`; +} + +/** Answer the score history with one point per day of the window, max 40. */ +async function mockScoreHistory(page: Page): Promise { + const asked: number[] = []; + await page.route(/\/api\/insights\/score-history\?/, (route: Route) => { + const url = new URL(route.request().url()); + const days = Number(url.searchParams.get("days")); + asked.push(days); + const count = Math.min(days, 40); + const points = Array.from({ length: count }, (_, i) => ({ + day: dayKey(i - count + 1), + value: 60 + ((i * 7) % 25), + seamBreak: false, + })); + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + score: url.searchParams.get("score"), + days, + points, + band: { lo: 64, hi: 76, n: 30 }, + }, + error: null, + }), + }); + }); + return asked; +} + +/** An `ok` derived score, so the score page draws its history under it. */ +async function mockDerived(page: Page) { + await page.route(/\/api\/insights\/derived\?/, (route: Route) => { + const metric = new URL(route.request().url()).searchParams.get("metric"); + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + metric, + status: "ok", + value: { + score: 72, + band: "green", + components: [], + subScores: [], + night: dayKey(0), + asleepMinutes: 420, + inBedMinutes: 450, + windowNights: 20, + }, + coverage: { + requiredInputs: 1, + presentInputs: 1, + historyDays: 30, + missing: [], + }, + confidence: "high", + provenance: { + inputs: [], + source: "rollup", + windowDays: 30, + computedAt: new Date().toISOString(), + }, + reason: null, + assessment: null, + }, + error: null, + }), + }); + }); +} + +async function resetRange(page: Page, chartKey: string) { + const res = await page.request.put("/api/dashboard/chart-overlay-prefs", { + data: { + chartKey, + prefs: { + showTrendIndicator: false, + showTrendArrow: false, + showTargetRange: false, + comparisonBaseline: "none", + rangePoints: 30, + }, + }, + }); + expect(res.status()).toBe(200); +} + +async function expectSharedTabs(page: Page, chart: string, pressed: string) { + const tabs = page.locator(`${chart} [data-slot="chart-range-tab"]`); + await expect(tabs).toHaveCount(4); + expect( + await tabs.evaluateAll((nodes) => + nodes.map((node) => node.getAttribute("data-range")), + ), + ).toEqual(RANGES); + await expect( + page.locator(`${chart} [data-slot="chart-range-tab"][aria-pressed="true"]`), + ).toHaveAttribute("data-range", pressed); +} + +test.describe("score histories", () => { + test.beforeEach(async ({ page, context }, testInfo) => { + const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000"; + await context.addCookies([ + { name: "healthlog-locale", value: "en", url: baseURL }, + ]); + await mockDay(page, { index: "full" }); + }); + + test("the health score's range tabs switch the window and are remembered", async ({ + page, + }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await resetRange(page, "scoreHealth"); + const asked = await mockScoreHistory(page); + + await page.goto("/insights/health-score"); + const chart = page.locator('[data-slot="score-trend-chart"]'); + await expect(chart).toHaveAttribute("data-score", "HEALTH_SCORE"); + await expectSharedTabs(page, '[data-slot="score-trend-chart"]', "30"); + await expect(chart.locator('[data-slot="chart-plot"]')).toBeVisible(); + await expect(chart.locator('[data-slot="score-trend-band"]')).toBeVisible(); + expect(asked).toContain(30); + + await chart + .locator('[data-slot="chart-range-tab"][data-range="7"]') + .click(); + await expect(chart).toHaveAttribute("data-range", "7"); + await expect.poll(() => asked.at(-1)).toBe(7); + // The table lists exactly the window's days. + await chart.locator('[data-slot="chart-data-table-toggle"]').click(); + await expect( + chart.locator('[data-slot="chart-data-table-row"]'), + ).toHaveCount(7); + + // Remembered: a reload opens on seven days again. + await page.reload(); + await expect(chart).toHaveAttribute("data-range", "7"); + + await chart + .locator('[data-slot="chart-range-tab"][data-range="0"]') + .click(); + await expect(chart).toHaveAttribute("data-range", "0"); + await expect.poll(() => asked.at(-1)).toBe(3650); + + // A point opens its day beside the page. + const plot = chart.locator('[data-slot="chart-plot"]'); + await expect(plot).toHaveAttribute("data-day-links", "true"); + const box = (await plot.boundingBox())!; + await page.mouse.click(box.x + box.width * 0.8, box.y + box.height / 2); + await expect(page.locator('[data-slot="day-panel"]')).toHaveAttribute( + "data-shell", + "docked", + ); + await expect(page).toHaveURL(/[?&]day=\d{4}-\d{2}-\d{2}/); + + await resetRange(page, "scoreHealth"); + }); + + test("the score panel leads to the health score page and back", async ({ + page, + }) => { + await mockPopulatedInsights(page); + await mockScoreHistory(page); + await page.goto("/insights"); + const link = page.locator('[data-slot="health-score-history-link"]'); + await expect(link).toHaveAttribute("href", "/insights/health-score"); + await link.click(); + await expect(page).toHaveURL(/\/insights\/health-score$/); + await expect( + page.locator('[data-slot="health-score-back"]'), + ).toHaveAttribute("href", "/insights"); + }); + + test("readiness and the sleep score offer the same tabs, and switching asks for the window", async ({ + page, + }) => { + await resetRange(page, "scoreReadiness"); + await mockDerived(page); + const asked = await mockScoreHistory(page); + + await page.goto("/insights/scores/readiness"); + const chart = page.locator('[data-slot="score-trend-chart"]'); + await expect(chart).toHaveAttribute("data-score", "READINESS"); + await expectSharedTabs(page, '[data-slot="score-trend-chart"]', "30"); + await chart + .locator('[data-slot="chart-range-tab"][data-range="90"]') + .click(); + await expect(chart).toHaveAttribute("data-range", "90"); + await expect.poll(() => asked.at(-1)).toBe(90); + await resetRange(page, "scoreReadiness"); + + await page.goto("/insights/scores/sleep"); + await expect( + page.locator('[data-slot="score-trend-chart"]'), + ).toHaveAttribute("data-score", "SLEEP_SCORE"); + await expectSharedTabs(page, '[data-slot="score-trend-chart"]', "30"); + }); + + test("recovery and strain carry the same tabs on their stored-score chart", async ({ + page, + }) => { + await resetRange(page, "scoreRecovery"); + await mockDerived(page); + + await page.goto("/insights/scores/recovery"); + const chart = '[data-slot="score-history-chart"]'; + await expect(page.locator(chart)).toHaveAttribute( + "data-type", + "RECOVERY_SCORE", + ); + await expectSharedTabs(page, chart, "30"); + // A score history remembers its range, and mounts no overlay dropdown. + await expect( + page.locator(`${chart} [data-slot="chart-overlay-controls-trigger"]`), + ).toHaveCount(0); + await page + .locator(`${chart} [data-slot="chart-range-tab"][data-range="7"]`) + .click(); + await expectSharedTabs(page, chart, "7"); + await page.reload(); + await expectSharedTabs(page, chart, "7"); + await resetRange(page, "scoreRecovery"); + + await page.goto("/insights/scores/strain"); + await expectSharedTabs(page, chart, "30"); + }); +}); + +const DIGEST: DailyDigest = { + generatedAt: new Date().toISOString(), + ai: DIGEST_AI_AVAILABLE, + phase: "final", + sleepPending: false, + score: { + value: 82, + band: "green", + delta: null, + deltaReason: "first_eligibility_window", + steadyWeeks: 2, + }, + topSignal: null, + signalLine: null, + briefingLead: null, + lead: null, + today: [], + restMode: null, + line: "Your health score today is 82.", + worthALook: [], + justIn: null, + reactionLine: null, +}; + +test.describe("the dashboard's top card", () => { + test.afterEach(async ({ page }) => { + // Leave the account's layout at its defaults for the next run. + await page.request.delete("/api/dashboard/widgets"); + }); + + test("switches off and on from the layout settings", async ({ page }) => { + await page.request.delete("/api/dashboard/widgets"); + await page.route(/\/api\/daily\/digest(\?|$)/, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data: DIGEST, error: null }), + }), + ); + + const hero = page.locator('[data-slot="today-hero"]'); + await page.goto("/"); + await expect(hero.first()).toBeVisible(); + + async function flip(expected: "checked" | "unchecked") { + await page.goto("/settings/layout/dashboard"); + const toggle = page.locator('[data-slot="today-card-switch"]'); + await expect(toggle).toBeVisible(); + await toggle.click(); + await expect(toggle).toHaveAttribute("data-state", expected); + const saved = page.waitForResponse( + (res) => + res.url().includes("/api/dashboard/widgets") && + res.request().method() === "PUT", + ); + await page + .locator('[data-slot="settings-dashboard-layout-save"]') + .click(); + const response = await saved; + expect(response.status()).toBe(200); + const body = response.request().postDataJSON() as { + todayCardVisible?: boolean; + }; + expect(body.todayCardVisible).toBe(expected === "checked"); + } + + await flip("unchecked"); + await page.goto("/"); + await expect(page.locator("main").first()).toBeVisible(); + await expect(hero).toHaveCount(0); + await expect(page.locator('[data-slot="today-hero-skeleton"]')).toHaveCount( + 0, + ); + + // A phone width shows no card either. + await page.setViewportSize({ width: 390, height: 844 }); + await page.reload(); + await expect(hero).toHaveCount(0); + await page.setViewportSize({ width: 1280, height: 720 }); + + await flip("checked"); + await page.goto("/"); + await expect(hero.first()).toBeVisible(); + }); +}); diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index f3ed47952..9eeb1d669 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -270,6 +270,25 @@ export const DAY_DOORS_STORAGE_STATE_PATH = resolve( "e2e/setup/storageStateDayDoors.json", ); +/** + * The score-history journey's own account (`score-history.spec.ts`). + * + * It switches the dashboard's top card off and back on and remembers a range + * tab per score page, both per-account preferences; on a shared account a + * parallel spec would find its dashboard without the card it asserts. + */ +export const E2E_SCORE_HISTORY = { + email: "e2e-score-history@healthlog.test", + username: "e2e-score-history", + password: "Sh7!Vn2qLp9xWc4R", + role: "USER", +} as const; + +export const SCORE_HISTORY_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateScoreHistory.json", +); + export const AI_OPTIONAL_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateAiOptional.json", @@ -995,6 +1014,32 @@ export default async function globalSetup(config: FullConfig): Promise { ], ); + // The score-history journey's account. Every module on, a fresh layout: + // the journey moves its top card and range tabs, so a re-run starts from + // the defaults. + await pool.query( + `INSERT INTO users + (id, username, email, password_hash, role, created_at, updated_at, + onboarding_completed_at, onboarding_tour_completed, + module_preferences_json) + VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, NULL) + ON CONFLICT (username) DO UPDATE SET + email = EXCLUDED.email, + password_hash = EXCLUDED.password_hash, + updated_at = EXCLUDED.updated_at, + onboarding_completed_at = EXCLUDED.onboarding_completed_at, + onboarding_tour_completed = EXCLUDED.onboarding_tour_completed, + module_preferences_json = NULL, + dashboard_widgets_json = NULL`, + [ + cuid(), + E2E_SCORE_HISTORY.username, + E2E_SCORE_HISTORY.email, + await hashPassword(E2E_SCORE_HISTORY.password), + now, + ], + ); + // The notification-dispatch journey's account. Seeded like the others; // its channels, devices, ledger rows and preferences are reset by // `e2e/setup/notification-fixture.ts` before every test, because the @@ -1605,6 +1650,9 @@ export default async function globalSetup(config: FullConfig): Promise { await capture(E2E_DAY_DOORS, DAY_DOORS_STORAGE_STATE_PATH); await seedDayDoorsRecord(baseURL, DAY_DOORS_STORAGE_STATE_PATH); + // The score-history journey's jar — see `E2E_SCORE_HISTORY`. + await capture(E2E_SCORE_HISTORY, SCORE_HISTORY_STORAGE_STATE_PATH); + await capture(E2E_SCOPE_DELEGATE, SCOPE_DELEGATE_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, SCOPE_A11Y_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, SCOPE_CAPABILITIES_STORAGE_STATE_PATH); diff --git a/messages/de.json b/messages/de.json index 3776f3684..ce779efcd 100644 --- a/messages/de.json +++ b/messages/de.json @@ -702,7 +702,8 @@ "body": "Ohne eine Kopie des Schlüssels lassen sich deine Daten nach einer Neuinstallation nicht wiederherstellen. Es dauert eine Minute.", "action": "Zum Schritt" }, - "quickAddSymptom": "Symptom erfassen" + "quickAddSymptom": "Symptom erfassen", + "todayCardVisible": "Obere Karte anzeigen" }, "measurements": { "title": "Messungen", diff --git a/messages/en.json b/messages/en.json index 9bd00706f..f32159a0c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -702,7 +702,8 @@ "body": "Without a copy of the key, your data cannot be recovered after a reinstall. It takes a minute.", "action": "Open the step" }, - "quickAddSymptom": "Log symptom" + "quickAddSymptom": "Log symptom", + "todayCardVisible": "Show the top card" }, "measurements": { "title": "Measurements", diff --git a/messages/es.json b/messages/es.json index 937e152ac..8aa56b605 100644 --- a/messages/es.json +++ b/messages/es.json @@ -702,7 +702,8 @@ "body": "Sin una copia de la clave, tus datos no se pueden recuperar tras una reinstalación. Es cuestión de un minuto.", "action": "Abrir el paso" }, - "quickAddSymptom": "Registrar síntoma" + "quickAddSymptom": "Registrar síntoma", + "todayCardVisible": "Mostrar la tarjeta superior" }, "measurements": { "title": "Mediciones", diff --git a/messages/fr.json b/messages/fr.json index 620b79947..1482e0220 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -702,7 +702,8 @@ "body": "Sans copie de la clé, tes données ne peuvent pas être récupérées après une réinstallation. Ça prend une minute.", "action": "Ouvrir l'étape" }, - "quickAddSymptom": "Noter un symptôme" + "quickAddSymptom": "Noter un symptôme", + "todayCardVisible": "Afficher la carte du haut" }, "measurements": { "title": "Mesures", diff --git a/messages/it.json b/messages/it.json index 71b7106c9..f4b9918de 100644 --- a/messages/it.json +++ b/messages/it.json @@ -702,7 +702,8 @@ "body": "Senza una copia della chiave, i tuoi dati non si possono recuperare dopo una reinstallazione. Ci vuole un minuto.", "action": "Apri il passaggio" }, - "quickAddSymptom": "Registra sintomo" + "quickAddSymptom": "Registra sintomo", + "todayCardVisible": "Mostra la scheda in alto" }, "measurements": { "title": "Misurazioni", diff --git a/messages/ko.json b/messages/ko.json index 65dfaff04..3ae10251e 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -702,7 +702,8 @@ "body": "키 사본이 없으면 다시 설치한 뒤 데이터를 복구할 수 없습니다. 1분이면 됩니다.", "action": "단계 열기" }, - "quickAddSymptom": "증상 기록" + "quickAddSymptom": "증상 기록", + "todayCardVisible": "상단 카드 표시" }, "measurements": { "title": "측정", diff --git a/messages/pl.json b/messages/pl.json index fbe0acb2b..c69c7e1a1 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -702,7 +702,8 @@ "body": "Bez kopii klucza Twoich danych nie da się odzyskać po ponownej instalacji. To zajmie minutę.", "action": "Otwórz krok" }, - "quickAddSymptom": "Zapisz objaw" + "quickAddSymptom": "Zapisz objaw", + "todayCardVisible": "Pokaż górną kartę" }, "measurements": { "title": "Pomiary", diff --git a/playwright.config.ts b/playwright.config.ts index ed9e0ffb8..796f9b4fe 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -289,6 +289,11 @@ export default defineConfig({ // account under the first. They prove server state through stable // data-slots, not a mobile layout, so they run in one project. "ai-optional-account.spec.ts", + // The score-history journey moves its account's top card and range + // tabs; two projects on that one account would flip them under each + // other. It checks the phone width by resizing inside the desktop + // project. + "score-history.spec.ts", ], use: { // Pixel 5 — Chromium-based mobile profile so CI only needs diff --git a/src/app/__tests__/dashboard-hero-wiring.test.ts b/src/app/__tests__/dashboard-hero-wiring.test.ts index 5242915c0..b6c5811cb 100644 --- a/src/app/__tests__/dashboard-hero-wiring.test.ts +++ b/src/app/__tests__/dashboard-hero-wiring.test.ts @@ -42,6 +42,17 @@ describe("dashboard hero — page wiring", () => { ); }); + it("hides the top card, its skeleton and its error card only on an explicit false", () => { + // One gate in front of the whole three-way branch, so switching the card + // off leaves no reserve and no retry card where it stood. + const gate = src.indexOf("layout.todayCardVisible === false ? null :"); + expect(gate).toBeGreaterThan(-1); + const hero = src.indexOf(" { expect(src).not.toContain("DashboardHero"); expect(src).not.toContain("DashboardHeroSkeleton"); diff --git a/src/app/api/dashboard/widgets/__tests__/route.test.ts b/src/app/api/dashboard/widgets/__tests__/route.test.ts index 177950e2f..0e9ce687f 100644 --- a/src/app/api/dashboard/widgets/__tests__/route.test.ts +++ b/src/app/api/dashboard/widgets/__tests__/route.test.ts @@ -929,7 +929,9 @@ describe("DELETE /api/dashboard/widgets — web-owned reset scope", () => { selectedScoreRings: ["READINESS", "SLEEP_SCORE"], heroRingOrder: ["SLEEP_SCORE", "HEALTH_SCORE", "READINESS"], enabledHeroItemKinds: ["milestone"], + todayCardVisible: false, }); + expect(stored.todayCardVisible).toBe(false); const advanced = new Date("2026-07-24T12:00:00.000Z"); transactionUser.findUnique.mockResolvedValue({ dashboardWidgetsJson: stored, @@ -958,6 +960,10 @@ describe("DELETE /api/dashboard/widgets — web-owned reset scope", () => { expect( Object.prototype.hasOwnProperty.call(persisted, "enabledHeroItemKinds"), ).toBe(false); + // A reset brings a hidden top card back; shown is stored by omission. + expect( + Object.prototype.hasOwnProperty.call(persisted, "todayCardVisible"), + ).toBe(false); expect(persisted.comparisonBaseline).toBe("lastYear"); expect(persisted.chartOverlayPrefs).toEqual({ weight: { @@ -1076,6 +1082,96 @@ describe("dashboard widgets — hero primary content", () => { }); }); +describe("dashboard widgets — the top card switch", () => { + const weightOnly = [ + { id: "weight" as const, visible: true, tileVisible: true, order: 0 }, + ]; + + function persistedLayout() { + const updateArg = vi.mocked(prisma.user.update).mock + .calls[0]?.[0] as unknown as { + data: { dashboardWidgetsJson: { todayCardVisible?: boolean } }; + }; + return updateArg.data.dashboardWidgetsJson; + } + + it("accepts and persists todayCardVisible: false", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dashboardWidgetsJson: null, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await callPut( + makeReq({ version: 1, widgets: weightOnly, todayCardVisible: false }), + ); + expect(res.status).toBe(200); + expect(persistedLayout().todayCardVisible).toBe(false); + const body = (await res.json()) as { + data: { todayCardVisible?: boolean }; + }; + expect(body.data.todayCardVisible).toBe(false); + }); + + it("rejects a non-boolean with 422", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dashboardWidgetsJson: null, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await callPut( + makeReq({ version: 1, widgets: weightOnly, todayCardVisible: "no" }), + ); + expect(res.status).toBe(422); + expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled(); + }); + + it("keeps a hidden top card hidden when the client omits the field", async () => { + // A save from a client that predates the field must not bring it back. + const stored = serializeDashboardLayout({ + version: 1, + widgets: weightOnly, + todayCardVisible: false, + }); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dashboardWidgetsJson: stored, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await callPut(makeReq({ version: 1, widgets: weightOnly })); + expect(res.status).toBe(200); + expect(persistedLayout().todayCardVisible).toBe(false); + }); + + it("stores showing it again as the omitted default", async () => { + const stored = serializeDashboardLayout({ + version: 1, + widgets: weightOnly, + todayCardVisible: false, + }); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dashboardWidgetsJson: stored, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await callPut( + makeReq({ version: 1, widgets: weightOnly, todayCardVisible: true }), + ); + expect(res.status).toBe(200); + expect(persistedLayout()).not.toHaveProperty("todayCardVisible"); + }); + + it("GET answers true for a layout that never chose", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dashboardWidgetsJson: serializeDashboardLayout(DEFAULT_DASHBOARD_LAYOUT), + } as never); + const res = await callGet(); + const body = (await res.json()) as { + data: { todayCardVisible?: boolean }; + }; + expect(body.data.todayCardVisible).toBe(true); + }); +}); + describe("a toggle that cannot do anything is not offered", () => { // A switch that is on over a tile that can never paint, with nothing // saying why, is the defect the HRV fallback exists to remove — so the diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index 13a1c37a7..688355b0e 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -170,6 +170,10 @@ const layoutSchema = z.object({ // Same preserve-when-absent contract as `comparisonBaseline`; the // resolver drops unknown stored values back to "score" on read. hero: z.enum(HERO_PRIMARY_CONTENTS).optional(), + // v1.42 — whether the dashboard shows its top card. Same additive, + // preserve-when-absent contract as `hero`; the default (shown) is omitted + // from storage. + todayCardVisible: z.boolean().optional(), // v1.32.16 (issue #581) — the optimistic-concurrency base token used to // live here. v1.32.21 (R5a) moved it to the shared `takeBaseToken` helper, // which strips it BEFORE this parse: the token is transport, not a layout @@ -503,6 +507,8 @@ export const DELETE = apiHandler(async () => { ...existingLayout, widgets: DEFAULT_DASHBOARD_LAYOUT.widgets, enabledHeroItemKinds: DEFAULT_DASHBOARD_LAYOUT.enabledHeroItemKinds, + // A reset brings a hidden top card back. + todayCardVisible: DEFAULT_DASHBOARD_LAYOUT.todayCardVisible, }); const updated = await tx.user.update({ diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index 63c6ccf7f..6dc157f45 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -954,7 +954,10 @@ export default function DashboardPageClient({ // rehydrates from disk. The error / empty branches stay reachable only // post-mount. DO NOT add `["daily", …]` to that allowlist, or render // the hero from any client-only source, without revisiting this gate. - digestQuery.data ? ( + // v1.42 — the top card can be switched off in the layout settings; + // only an explicit `false` hides it, and with it its skeleton and + // its error card. + layout.todayCardVisible === false ? null : digestQuery.data ? ( — hero content", () => { expect(html).toContain("Notification settings are managed separately."); }); }); + +describe(" — the top card switch", () => { + function control(html: string): string { + const match = html.match(/]*data-slot="today-card-switch"[^>]*>/); + expect(match).not.toBeNull(); + return match![0]; + } + + it("offers one labelled switch, on by default", () => { + const html = render(); + expect(html.match(/data-slot="today-card-switch"/g)).toHaveLength(1); + expect(control(html)).toContain('data-state="checked"'); + const id = control(html).match(/id="([^"]+)"/)![1]; + expect(html).toContain(`for="${id}"`); + expect(html).toContain("Show the top card"); + expect(html).toContain("Using default layout"); + }); + + it("reads a layout that predates the field as shown", () => { + const legacy: DashboardLayout = { ...DEFAULT_DASHBOARD_LAYOUT }; + delete legacy.todayCardVisible; + queryState.layout = legacy; + const html = render(); + expect(control(html)).toContain('data-state="checked"'); + }); + + it("shows a hidden top card as off, and as a customised layout", () => { + queryState.layout = { + ...DEFAULT_DASHBOARD_LAYOUT, + todayCardVisible: false, + }; + const html = render(); + expect(control(html)).toContain('data-state="unchecked"'); + expect(html).toContain("Custom layout active"); + }); + + it("labels it in German", () => { + const html = render(, "de"); + expect(html).toContain("Obere Karte anzeigen"); + }); +}); diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index 8d71687fe..b7e313fca 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -207,6 +207,7 @@ export function DashboardLayoutSection({ id }: { id: string }) { const heroDescriptionId = useId(); const heroNotificationNoteId = useId(); const heroContentSelectId = useId(); + const todayCardSwitchId = useId(); // v1.34 — the "Today highlights" fieldset below is gated on `layout`, // which can be truthy on the very first client render whenever a // returning visitor's browser already has a warm, offline-persisted @@ -404,6 +405,12 @@ export function DashboardLayoutSection({ id }: { id: string }) { }); } + /** v1.42 — the server-persisted on/off switch for the dashboard's top card. */ + function setTodayCardVisible(visible: boolean) { + if (!layout) return; + setDraft({ ...layout, todayCardVisible: visible }); + } + /** * Hero primary content — the server-persisted `hero` field on the layout * blob. The value set is closed ("score" | "reminders"); anything else a @@ -483,6 +490,20 @@ export function DashboardLayoutSection({ id }: { id: string }) {

{t("dashboard.heroItemsDescription")}

+ {/* v1.42 — the top card itself can be switched off. Shown is the + default; the choices below shape the card while it is shown. */} +
+ + +
{/* Hero primary content — a single labelled select beside the highlight toggles. "score" keeps the health-score read; "reminders" promotes the highlight rail into the hero slot. */} @@ -758,6 +779,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {