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] 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) => (
+ onChange(r.days)}
+ title={t(r.titleKey)}
+ data-slot="chart-range-tab"
+ data-range={r.days}
+ >
+ {t(r.labelKey)}
+
+ ))}
+ >
+ );
+}
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(r.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: r.days,
- });
- }
- }}
- title={t(r.titleKey)}
- data-slot="chart-range-tab"
- >
- {t(r.labelKey)}
-
- ))}
+ {
+ // 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 })}
-
- }
- />
-
-
-
- ({ i, v }))}
- strokeVar={RING_GRADIENT[hue][1]}
- domain={[0, 100]}
- />
-
-
-
- );
-}
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}
+
+ {t("insights.healthScore.historyLink")}
+
+
{/* 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);
+ });
+});