diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index d1292d8de..09264477d 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:
@@ -32758,6 +32820,11 @@ components:
enum:
- score
- reminders
+ todayCardVisible:
+ description: v1.42 — whether the dashboard shows its top card (the Today card above the tiles). Missing resolves to
+ `true`; only an explicit `false` hides it. The stored default is omitted. A layout reset (DELETE) shows it
+ again. Additive.
+ type: boolean
baseUpdatedAt:
description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the
legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored
@@ -61988,6 +62055,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 +76198,12 @@ components:
- painNrs
- waistCircumference
- waistToHeight
+ - scoreHealth
+ - scoreReadiness
+ - scoreRecovery
+ - scoreSleep
+ - scoreStress
+ - scoreStrain
additionalProperties:
type: object
properties:
@@ -76120,6 +76280,11 @@ components:
enum:
- score
- reminders
+ todayCardVisible:
+ description: v1.42 — whether the dashboard shows its top card (the Today card above the tiles). Missing resolves to
+ `true`; only an explicit `false` hides it. The stored default is omitted. A layout reset (DELETE) shows it
+ again. Additive.
+ type: boolean
updatedAt:
description: "Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as
`baseUpdatedAt` on the next write. Opaque."
@@ -76295,6 +76460,12 @@ components:
- painNrs
- waistCircumference
- waistToHeight
+ - scoreHealth
+ - scoreReadiness
+ - scoreRecovery
+ - scoreSleep
+ - scoreStress
+ - scoreStrain
additionalProperties:
type: object
properties:
@@ -76371,6 +76542,11 @@ components:
enum:
- score
- reminders
+ todayCardVisible:
+ description: v1.42 — whether the dashboard shows its top card (the Today card above the tiles). Missing resolves to
+ `true`; only an explicit `false` hides it. The stored default is omitted. A layout reset (DELETE) shows it
+ again. Additive.
+ type: boolean
updatedAt:
description: "Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as
`baseUpdatedAt` on the next write. Opaque."
diff --git a/e2e/score-history.spec.ts b/e2e/score-history.spec.ts
new file mode 100644
index 000000000..a22b9382f
--- /dev/null
+++ b/e2e/score-history.spec.ts
@@ -0,0 +1,361 @@
+import type { Page, Route } from "@playwright/test";
+
+import { mockDay } from "./setup/day-mock";
+import { mockPopulatedInsights } from "./utils/mock-populated-insights";
+import { SCORE_HISTORY_STORAGE_STATE_PATH } from "./setup/global-setup";
+import { expect, test } from "./setup/test";
+import type { DailyDigest } from "@/lib/daily/digest";
+import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures";
+
+/**
+ * v1.42 — the score histories and the dashboard's top card switch.
+ *
+ * - The health score's page charts its daily course with the range tabs
+ * every chart offers; a tab asks the server for its window, the choice
+ * survives a reload, and a point opens its day.
+ * - Readiness, recovery, the sleep score and strain carry the same four
+ * tabs in the same component.
+ * - The top card goes away when the layout settings switch it off, and
+ * comes back when they switch it on.
+ *
+ * The score history and the derived scores are route mocks in the
+ * contract's shape (the server's answers are pinned by the integration
+ * suite); the range preference and the layout are this account's own, saved
+ * for real. Stable data attributes only.
+ */
+
+test.use({ storageState: SCORE_HISTORY_STORAGE_STATE_PATH });
+// Every test writes this one account's layout blob, a Serializable
+// read-modify-write; run in parallel they would abort each other's write.
+test.describe.configure({ mode: "serial" });
+
+const RANGES = ["7", "30", "90", "0"];
+
+function dayKey(offset: number): string {
+ const now = new Date();
+ const local = new Date(
+ now.toLocaleString("en-US", { timeZone: "Europe/Berlin" }),
+ );
+ local.setDate(local.getDate() + offset);
+ const y = local.getFullYear();
+ const m = String(local.getMonth() + 1).padStart(2, "0");
+ const d = String(local.getDate()).padStart(2, "0");
+ return `${y}-${m}-${d}`;
+}
+
+/** Answer the score history with one point per day of the window, max 40. */
+async function mockScoreHistory(page: Page): Promise {
+ const asked: number[] = [];
+ await page.route(/\/api\/insights\/score-history\?/, (route: Route) => {
+ const url = new URL(route.request().url());
+ const days = Number(url.searchParams.get("days"));
+ asked.push(days);
+ const count = Math.min(days, 40);
+ const points = Array.from({ length: count }, (_, i) => ({
+ day: dayKey(i - count + 1),
+ value: 60 + ((i * 7) % 25),
+ seamBreak: false,
+ }));
+ return route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ data: {
+ score: url.searchParams.get("score"),
+ days,
+ points,
+ band: { lo: 64, hi: 76, n: 30 },
+ },
+ error: null,
+ }),
+ });
+ });
+ return asked;
+}
+
+/** An `ok` derived score, so the score page draws its history under it. */
+async function mockDerived(page: Page) {
+ await page.route(/\/api\/insights\/derived\?/, (route: Route) => {
+ const metric = new URL(route.request().url()).searchParams.get("metric");
+ return route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ data: {
+ metric,
+ status: "ok",
+ value: {
+ score: 72,
+ band: "green",
+ components: [],
+ subScores: [],
+ night: dayKey(0),
+ asleepMinutes: 420,
+ inBedMinutes: 450,
+ windowNights: 20,
+ },
+ coverage: {
+ requiredInputs: 1,
+ presentInputs: 1,
+ historyDays: 30,
+ missing: [],
+ },
+ confidence: "high",
+ provenance: {
+ inputs: [],
+ source: "rollup",
+ windowDays: 30,
+ computedAt: new Date().toISOString(),
+ },
+ reason: null,
+ assessment: null,
+ },
+ error: null,
+ }),
+ });
+ });
+}
+
+async function resetRange(page: Page, chartKey: string) {
+ const res = await page.request.put("/api/dashboard/chart-overlay-prefs", {
+ data: {
+ chartKey,
+ prefs: {
+ showTrendIndicator: false,
+ showTrendArrow: false,
+ showTargetRange: false,
+ comparisonBaseline: "none",
+ rangePoints: 30,
+ },
+ },
+ });
+ expect(res.status()).toBe(200);
+}
+
+async function expectSharedTabs(page: Page, chart: string, pressed: string) {
+ const tabs = page.locator(`${chart} [data-slot="chart-range-tab"]`);
+ await expect(tabs).toHaveCount(4);
+ expect(
+ await tabs.evaluateAll((nodes) =>
+ nodes.map((node) => node.getAttribute("data-range")),
+ ),
+ ).toEqual(RANGES);
+ await expect(
+ page.locator(`${chart} [data-slot="chart-range-tab"][aria-pressed="true"]`),
+ ).toHaveAttribute("data-range", pressed);
+}
+
+test.describe("score histories", () => {
+ test.beforeEach(async ({ page, context }, testInfo) => {
+ const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000";
+ await context.addCookies([
+ { name: "healthlog-locale", value: "en", url: baseURL },
+ ]);
+ await mockDay(page, { index: "full" });
+ });
+
+ test("the health score's range tabs switch the window and are remembered", async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width: 1440, height: 900 });
+ await resetRange(page, "scoreHealth");
+ const asked = await mockScoreHistory(page);
+
+ await page.goto("/insights/health-score");
+ const chart = page.locator('[data-slot="score-trend-chart"]');
+ await expect(chart).toHaveAttribute("data-score", "HEALTH_SCORE");
+ await expectSharedTabs(page, '[data-slot="score-trend-chart"]', "30");
+ await expect(chart.locator('[data-slot="chart-plot"]')).toBeVisible();
+ await expect(chart.locator('[data-slot="score-trend-band"]')).toBeVisible();
+ expect(asked).toContain(30);
+
+ await chart
+ .locator('[data-slot="chart-range-tab"][data-range="7"]')
+ .click();
+ await expect(chart).toHaveAttribute("data-range", "7");
+ await expect.poll(() => asked.at(-1)).toBe(7);
+ // The table lists exactly the window's days.
+ await chart.locator('[data-slot="chart-data-table-toggle"]').click();
+ await expect(
+ chart.locator('[data-slot="chart-data-table-row"]'),
+ ).toHaveCount(7);
+
+ // Remembered: a reload opens on seven days again.
+ await page.reload();
+ await expect(chart).toHaveAttribute("data-range", "7");
+
+ await chart
+ .locator('[data-slot="chart-range-tab"][data-range="0"]')
+ .click();
+ await expect(chart).toHaveAttribute("data-range", "0");
+ await expect.poll(() => asked.at(-1)).toBe(3650);
+
+ // A point opens its day beside the page.
+ const plot = chart.locator('[data-slot="chart-plot"]');
+ await expect(plot).toHaveAttribute("data-day-links", "true");
+ const box = (await plot.boundingBox())!;
+ await page.mouse.click(box.x + box.width * 0.8, box.y + box.height / 2);
+ await expect(page.locator('[data-slot="day-panel"]')).toHaveAttribute(
+ "data-shell",
+ "docked",
+ );
+ await expect(page).toHaveURL(/[?&]day=\d{4}-\d{2}-\d{2}/);
+
+ await resetRange(page, "scoreHealth");
+ });
+
+ test("the score panel leads to the health score page and back", async ({
+ page,
+ }) => {
+ await mockPopulatedInsights(page);
+ await mockScoreHistory(page);
+ await page.goto("/insights");
+ const link = page.locator('[data-slot="health-score-history-link"]');
+ await expect(link).toHaveAttribute("href", "/insights/health-score");
+ await link.click();
+ await expect(page).toHaveURL(/\/insights\/health-score$/);
+ await expect(
+ page.locator('[data-slot="health-score-back"]'),
+ ).toHaveAttribute("href", "/insights");
+ });
+
+ test("readiness and the sleep score offer the same tabs, and switching asks for the window", async ({
+ page,
+ }) => {
+ await resetRange(page, "scoreReadiness");
+ await mockDerived(page);
+ const asked = await mockScoreHistory(page);
+
+ await page.goto("/insights/scores/readiness");
+ const chart = page.locator('[data-slot="score-trend-chart"]');
+ await expect(chart).toHaveAttribute("data-score", "READINESS");
+ await expectSharedTabs(page, '[data-slot="score-trend-chart"]', "30");
+ await chart
+ .locator('[data-slot="chart-range-tab"][data-range="90"]')
+ .click();
+ await expect(chart).toHaveAttribute("data-range", "90");
+ await expect.poll(() => asked.at(-1)).toBe(90);
+ await resetRange(page, "scoreReadiness");
+
+ await page.goto("/insights/scores/sleep");
+ await expect(
+ page.locator('[data-slot="score-trend-chart"]'),
+ ).toHaveAttribute("data-score", "SLEEP_SCORE");
+ await expectSharedTabs(page, '[data-slot="score-trend-chart"]', "30");
+ });
+
+ test("recovery and strain carry the same tabs on their stored-score chart", async ({
+ page,
+ }) => {
+ await resetRange(page, "scoreRecovery");
+ await mockDerived(page);
+
+ await page.goto("/insights/scores/recovery");
+ const chart = '[data-slot="score-history-chart"]';
+ await expect(page.locator(chart)).toHaveAttribute(
+ "data-type",
+ "RECOVERY_SCORE",
+ );
+ await expectSharedTabs(page, chart, "30");
+ // A score history remembers its range, and mounts no overlay dropdown.
+ await expect(
+ page.locator(`${chart} [data-slot="chart-overlay-controls-trigger"]`),
+ ).toHaveCount(0);
+ await page
+ .locator(`${chart} [data-slot="chart-range-tab"][data-range="7"]`)
+ .click();
+ await expectSharedTabs(page, chart, "7");
+ await page.reload();
+ await expectSharedTabs(page, chart, "7");
+ await resetRange(page, "scoreRecovery");
+
+ await page.goto("/insights/scores/strain");
+ await expectSharedTabs(page, chart, "30");
+ });
+});
+
+const DIGEST: DailyDigest = {
+ generatedAt: new Date().toISOString(),
+ ai: DIGEST_AI_AVAILABLE,
+ phase: "final",
+ sleepPending: false,
+ score: {
+ value: 82,
+ band: "green",
+ delta: null,
+ deltaReason: "first_eligibility_window",
+ steadyWeeks: 2,
+ },
+ topSignal: null,
+ signalLine: null,
+ briefingLead: null,
+ lead: null,
+ today: [],
+ restMode: null,
+ line: "Your health score today is 82.",
+ worthALook: [],
+ justIn: null,
+ reactionLine: null,
+};
+
+test.describe("the dashboard's top card", () => {
+ test.afterEach(async ({ page }) => {
+ // Leave the account's layout at its defaults for the next run.
+ await page.request.delete("/api/dashboard/widgets");
+ });
+
+ test("switches off and on from the layout settings", async ({ page }) => {
+ await page.request.delete("/api/dashboard/widgets");
+ await page.route(/\/api\/daily\/digest(\?|$)/, (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ data: DIGEST, error: null }),
+ }),
+ );
+
+ const hero = page.locator('[data-slot="today-hero"]');
+ await page.goto("/");
+ await expect(hero.first()).toBeVisible();
+
+ async function flip(expected: "checked" | "unchecked") {
+ await page.goto("/settings/layout/dashboard");
+ const toggle = page.locator('[data-slot="today-card-switch"]');
+ await expect(toggle).toBeVisible();
+ await toggle.click();
+ await expect(toggle).toHaveAttribute("data-state", expected);
+ const saved = page.waitForResponse(
+ (res) =>
+ res.url().includes("/api/dashboard/widgets") &&
+ res.request().method() === "PUT",
+ );
+ await page
+ .locator('[data-slot="settings-dashboard-layout-save"]')
+ .click();
+ const response = await saved;
+ expect(response.status()).toBe(200);
+ const body = response.request().postDataJSON() as {
+ todayCardVisible?: boolean;
+ };
+ expect(body.todayCardVisible).toBe(expected === "checked");
+ }
+
+ await flip("unchecked");
+ await page.goto("/");
+ await expect(page.locator("main").first()).toBeVisible();
+ await expect(hero).toHaveCount(0);
+ await expect(page.locator('[data-slot="today-hero-skeleton"]')).toHaveCount(
+ 0,
+ );
+
+ // A phone width shows no card either.
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.reload();
+ await expect(hero).toHaveCount(0);
+ await page.setViewportSize({ width: 1280, height: 720 });
+
+ await flip("checked");
+ await page.goto("/");
+ await expect(hero.first()).toBeVisible();
+ });
+});
diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts
index f3ed47952..9eeb1d669 100644
--- a/e2e/setup/global-setup.ts
+++ b/e2e/setup/global-setup.ts
@@ -270,6 +270,25 @@ export const DAY_DOORS_STORAGE_STATE_PATH = resolve(
"e2e/setup/storageStateDayDoors.json",
);
+/**
+ * The score-history journey's own account (`score-history.spec.ts`).
+ *
+ * It switches the dashboard's top card off and back on and remembers a range
+ * tab per score page, both per-account preferences; on a shared account a
+ * parallel spec would find its dashboard without the card it asserts.
+ */
+export const E2E_SCORE_HISTORY = {
+ email: "e2e-score-history@healthlog.test",
+ username: "e2e-score-history",
+ password: "Sh7!Vn2qLp9xWc4R",
+ role: "USER",
+} as const;
+
+export const SCORE_HISTORY_STORAGE_STATE_PATH = resolve(
+ process.cwd(),
+ "e2e/setup/storageStateScoreHistory.json",
+);
+
export const AI_OPTIONAL_STORAGE_STATE_PATH = resolve(
process.cwd(),
"e2e/setup/storageStateAiOptional.json",
@@ -995,6 +1014,32 @@ export default async function globalSetup(config: FullConfig): Promise {
],
);
+ // The score-history journey's account. Every module on, a fresh layout:
+ // the journey moves its top card and range tabs, so a re-run starts from
+ // the defaults.
+ await pool.query(
+ `INSERT INTO users
+ (id, username, email, password_hash, role, created_at, updated_at,
+ onboarding_completed_at, onboarding_tour_completed,
+ module_preferences_json)
+ VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, NULL)
+ ON CONFLICT (username) DO UPDATE SET
+ email = EXCLUDED.email,
+ password_hash = EXCLUDED.password_hash,
+ updated_at = EXCLUDED.updated_at,
+ onboarding_completed_at = EXCLUDED.onboarding_completed_at,
+ onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
+ module_preferences_json = NULL,
+ dashboard_widgets_json = NULL`,
+ [
+ cuid(),
+ E2E_SCORE_HISTORY.username,
+ E2E_SCORE_HISTORY.email,
+ await hashPassword(E2E_SCORE_HISTORY.password),
+ now,
+ ],
+ );
+
// The notification-dispatch journey's account. Seeded like the others;
// its channels, devices, ledger rows and preferences are reset by
// `e2e/setup/notification-fixture.ts` before every test, because the
@@ -1605,6 +1650,9 @@ export default async function globalSetup(config: FullConfig): Promise {
await capture(E2E_DAY_DOORS, DAY_DOORS_STORAGE_STATE_PATH);
await seedDayDoorsRecord(baseURL, DAY_DOORS_STORAGE_STATE_PATH);
+ // The score-history journey's jar — see `E2E_SCORE_HISTORY`.
+ await capture(E2E_SCORE_HISTORY, SCORE_HISTORY_STORAGE_STATE_PATH);
+
await capture(E2E_SCOPE_DELEGATE, SCOPE_DELEGATE_STORAGE_STATE_PATH);
await capture(E2E_SCOPE_DELEGATE, SCOPE_A11Y_STORAGE_STATE_PATH);
await capture(E2E_SCOPE_DELEGATE, SCOPE_CAPABILITIES_STORAGE_STATE_PATH);
diff --git a/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 a019152f5..f5084079e 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -702,7 +702,8 @@
"body": "Ohne eine Kopie des Schlüssels lassen sich deine Daten nach einer Neuinstallation nicht wiederherstellen. Es dauert eine Minute.",
"action": "Zum Schritt"
},
- "quickAddSymptom": "Symptom erfassen"
+ "quickAddSymptom": "Symptom erfassen",
+ "todayCardVisible": "Obere Karte anzeigen"
},
"measurements": {
"title": "Messungen",
@@ -2765,7 +2766,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 +4749,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 32aea792d..8b2c4bd08 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -702,7 +702,8 @@
"body": "Without a copy of the key, your data cannot be recovered after a reinstall. It takes a minute.",
"action": "Open the step"
},
- "quickAddSymptom": "Log symptom"
+ "quickAddSymptom": "Log symptom",
+ "todayCardVisible": "Show the top card"
},
"measurements": {
"title": "Measurements",
@@ -2765,7 +2766,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 +4749,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 0d7c8804f..0b60539b6 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -702,7 +702,8 @@
"body": "Sin una copia de la clave, tus datos no se pueden recuperar tras una reinstalación. Es cuestión de un minuto.",
"action": "Abrir el paso"
},
- "quickAddSymptom": "Registrar síntoma"
+ "quickAddSymptom": "Registrar síntoma",
+ "todayCardVisible": "Mostrar la tarjeta superior"
},
"measurements": {
"title": "Mediciones",
@@ -2765,7 +2766,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 +4749,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 c9cee17c2..f544efb27 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -702,7 +702,8 @@
"body": "Sans copie de la clé, tes données ne peuvent pas être récupérées après une réinstallation. Ça prend une minute.",
"action": "Ouvrir l'étape"
},
- "quickAddSymptom": "Noter un symptôme"
+ "quickAddSymptom": "Noter un symptôme",
+ "todayCardVisible": "Afficher la carte du haut"
},
"measurements": {
"title": "Mesures",
@@ -2765,7 +2766,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 +4749,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 e77412ded..145cded2e 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -702,7 +702,8 @@
"body": "Senza una copia della chiave, i tuoi dati non si possono recuperare dopo una reinstallazione. Ci vuole un minuto.",
"action": "Apri il passaggio"
},
- "quickAddSymptom": "Registra sintomo"
+ "quickAddSymptom": "Registra sintomo",
+ "todayCardVisible": "Mostra la scheda in alto"
},
"measurements": {
"title": "Misurazioni",
@@ -2765,7 +2766,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 +4749,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 71da2dd6c..e1693c749 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -702,7 +702,8 @@
"body": "키 사본이 없으면 다시 설치한 뒤 데이터를 복구할 수 없습니다. 1분이면 됩니다.",
"action": "단계 열기"
},
- "quickAddSymptom": "증상 기록"
+ "quickAddSymptom": "증상 기록",
+ "todayCardVisible": "상단 카드 표시"
},
"measurements": {
"title": "측정",
@@ -2765,7 +2766,8 @@
"coverage": {
"recommendedMet": "점수가 권장하는 세 영역을 모두 채웠어요",
"areas": "건강 영역 {required}개 중 {present}개"
- }
+ },
+ "historyLink": "추이"
},
"dailyBriefing": {
"title": "데일리 브리핑",
@@ -4747,6 +4749,13 @@
},
"medicationsEmpty": {
"coachPrefill": "아직 약을 추가하지 않았어요. 여기서 복약 순응도를 기록하기 전에 무엇을 알아야 하나요?"
+ },
+ "scoreHistory": {
+ "healthTitle": "건강 점수 추이",
+ "healthDescription": "날마다 표시된 그대로의 건강 점수입니다.",
+ "emptyDescription": "이 기간에는 아직 기록된 점수가 없습니다.",
+ "usualRange": "음영: 평소 범위 {lo}에서 {hi}까지.",
+ "seam": "점수에 반영되는 항목이 바뀐 지점에서 선이 끊깁니다."
}
},
"thresholds": {
diff --git a/messages/pl.json b/messages/pl.json
index 1645f0469..8758cf2af 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -702,7 +702,8 @@
"body": "Bez kopii klucza Twoich danych nie da się odzyskać po ponownej instalacji. To zajmie minutę.",
"action": "Otwórz krok"
},
- "quickAddSymptom": "Zapisz objaw"
+ "quickAddSymptom": "Zapisz objaw",
+ "todayCardVisible": "Pokaż górną kartę"
},
"measurements": {
"title": "Pomiary",
@@ -2765,7 +2766,8 @@
"coverage": {
"recommendedMet": "Pokrywa trzy obszary zalecane przez wynik",
"areas": "{present} z {required} obszarów zdrowia"
- }
+ },
+ "historyLink": "Przebieg"
},
"dailyBriefing": {
"title": "Briefing dnia",
@@ -4747,6 +4749,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/playwright.config.ts b/playwright.config.ts
index ed9e0ffb8..796f9b4fe 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -289,6 +289,11 @@ export default defineConfig({
// account under the first. They prove server state through stable
// data-slots, not a mobile layout, so they run in one project.
"ai-optional-account.spec.ts",
+ // The score-history journey moves its account's top card and range
+ // tabs; two projects on that one account would flip them under each
+ // other. It checks the phone width by resizing inside the desktop
+ // project.
+ "score-history.spec.ts",
],
use: {
// Pixel 5 — Chromium-based mobile profile so CI only needs
diff --git a/src/__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/__tests__/dashboard-hero-wiring.test.ts b/src/app/__tests__/dashboard-hero-wiring.test.ts
index 5242915c0..b6c5811cb 100644
--- a/src/app/__tests__/dashboard-hero-wiring.test.ts
+++ b/src/app/__tests__/dashboard-hero-wiring.test.ts
@@ -42,6 +42,17 @@ describe("dashboard hero — page wiring", () => {
);
});
+ it("hides the top card, its skeleton and its error card only on an explicit false", () => {
+ // One gate in front of the whole three-way branch, so switching the card
+ // off leaves no reserve and no retry card where it stood.
+ const gate = src.indexOf("layout.todayCardVisible === false ? null :");
+ expect(gate).toBeGreaterThan(-1);
+ const hero = src.indexOf(" {
expect(src).not.toContain("DashboardHero");
expect(src).not.toContain("DashboardHeroSkeleton");
diff --git a/src/app/api/__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/dashboard/widgets/__tests__/route.test.ts b/src/app/api/dashboard/widgets/__tests__/route.test.ts
index 177950e2f..0e9ce687f 100644
--- a/src/app/api/dashboard/widgets/__tests__/route.test.ts
+++ b/src/app/api/dashboard/widgets/__tests__/route.test.ts
@@ -929,7 +929,9 @@ describe("DELETE /api/dashboard/widgets — web-owned reset scope", () => {
selectedScoreRings: ["READINESS", "SLEEP_SCORE"],
heroRingOrder: ["SLEEP_SCORE", "HEALTH_SCORE", "READINESS"],
enabledHeroItemKinds: ["milestone"],
+ todayCardVisible: false,
});
+ expect(stored.todayCardVisible).toBe(false);
const advanced = new Date("2026-07-24T12:00:00.000Z");
transactionUser.findUnique.mockResolvedValue({
dashboardWidgetsJson: stored,
@@ -958,6 +960,10 @@ describe("DELETE /api/dashboard/widgets — web-owned reset scope", () => {
expect(
Object.prototype.hasOwnProperty.call(persisted, "enabledHeroItemKinds"),
).toBe(false);
+ // A reset brings a hidden top card back; shown is stored by omission.
+ expect(
+ Object.prototype.hasOwnProperty.call(persisted, "todayCardVisible"),
+ ).toBe(false);
expect(persisted.comparisonBaseline).toBe("lastYear");
expect(persisted.chartOverlayPrefs).toEqual({
weight: {
@@ -1076,6 +1082,96 @@ describe("dashboard widgets — hero primary content", () => {
});
});
+describe("dashboard widgets — the top card switch", () => {
+ const weightOnly = [
+ { id: "weight" as const, visible: true, tileVisible: true, order: 0 },
+ ];
+
+ function persistedLayout() {
+ const updateArg = vi.mocked(prisma.user.update).mock
+ .calls[0]?.[0] as unknown as {
+ data: { dashboardWidgetsJson: { todayCardVisible?: boolean } };
+ };
+ return updateArg.data.dashboardWidgetsJson;
+ }
+
+ it("accepts and persists todayCardVisible: false", async () => {
+ vi.mocked(prisma.user.findUnique).mockResolvedValue({
+ dashboardWidgetsJson: null,
+ } as never);
+ vi.mocked(prisma.user.update).mockResolvedValue({} as never);
+
+ const res = await callPut(
+ makeReq({ version: 1, widgets: weightOnly, todayCardVisible: false }),
+ );
+ expect(res.status).toBe(200);
+ expect(persistedLayout().todayCardVisible).toBe(false);
+ const body = (await res.json()) as {
+ data: { todayCardVisible?: boolean };
+ };
+ expect(body.data.todayCardVisible).toBe(false);
+ });
+
+ it("rejects a non-boolean with 422", async () => {
+ vi.mocked(prisma.user.findUnique).mockResolvedValue({
+ dashboardWidgetsJson: null,
+ } as never);
+ vi.mocked(prisma.user.update).mockResolvedValue({} as never);
+
+ const res = await callPut(
+ makeReq({ version: 1, widgets: weightOnly, todayCardVisible: "no" }),
+ );
+ expect(res.status).toBe(422);
+ expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled();
+ });
+
+ it("keeps a hidden top card hidden when the client omits the field", async () => {
+ // A save from a client that predates the field must not bring it back.
+ const stored = serializeDashboardLayout({
+ version: 1,
+ widgets: weightOnly,
+ todayCardVisible: false,
+ });
+ vi.mocked(prisma.user.findUnique).mockResolvedValue({
+ dashboardWidgetsJson: stored,
+ } as never);
+ vi.mocked(prisma.user.update).mockResolvedValue({} as never);
+
+ const res = await callPut(makeReq({ version: 1, widgets: weightOnly }));
+ expect(res.status).toBe(200);
+ expect(persistedLayout().todayCardVisible).toBe(false);
+ });
+
+ it("stores showing it again as the omitted default", async () => {
+ const stored = serializeDashboardLayout({
+ version: 1,
+ widgets: weightOnly,
+ todayCardVisible: false,
+ });
+ vi.mocked(prisma.user.findUnique).mockResolvedValue({
+ dashboardWidgetsJson: stored,
+ } as never);
+ vi.mocked(prisma.user.update).mockResolvedValue({} as never);
+
+ const res = await callPut(
+ makeReq({ version: 1, widgets: weightOnly, todayCardVisible: true }),
+ );
+ expect(res.status).toBe(200);
+ expect(persistedLayout()).not.toHaveProperty("todayCardVisible");
+ });
+
+ it("GET answers true for a layout that never chose", async () => {
+ vi.mocked(prisma.user.findUnique).mockResolvedValue({
+ dashboardWidgetsJson: serializeDashboardLayout(DEFAULT_DASHBOARD_LAYOUT),
+ } as never);
+ const res = await callGet();
+ const body = (await res.json()) as {
+ data: { todayCardVisible?: boolean };
+ };
+ expect(body.data.todayCardVisible).toBe(true);
+ });
+});
+
describe("a toggle that cannot do anything is not offered", () => {
// A switch that is on over a tile that can never paint, with nothing
// saying why, is the defect the HRV fallback exists to remove — so the
diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts
index 13a1c37a7..688355b0e 100644
--- a/src/app/api/dashboard/widgets/route.ts
+++ b/src/app/api/dashboard/widgets/route.ts
@@ -170,6 +170,10 @@ const layoutSchema = z.object({
// Same preserve-when-absent contract as `comparisonBaseline`; the
// resolver drops unknown stored values back to "score" on read.
hero: z.enum(HERO_PRIMARY_CONTENTS).optional(),
+ // v1.42 — whether the dashboard shows its top card. Same additive,
+ // preserve-when-absent contract as `hero`; the default (shown) is omitted
+ // from storage.
+ todayCardVisible: z.boolean().optional(),
// v1.32.16 (issue #581) — the optimistic-concurrency base token used to
// live here. v1.32.21 (R5a) moved it to the shared `takeBaseToken` helper,
// which strips it BEFORE this parse: the token is transport, not a layout
@@ -503,6 +507,8 @@ export const DELETE = apiHandler(async () => {
...existingLayout,
widgets: DEFAULT_DASHBOARD_LAYOUT.widgets,
enabledHeroItemKinds: DEFAULT_DASHBOARD_LAYOUT.enabledHeroItemKinds,
+ // A reset brings a hidden top card back.
+ todayCardVisible: DEFAULT_DASHBOARD_LAYOUT.todayCardVisible,
});
const updated = await tx.user.update({
diff --git a/src/app/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;
+ }) => (
+