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; + }) => ( +
+ {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/app/page-client.tsx b/src/app/page-client.tsx index 63c6ccf7f..6dc157f45 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -954,7 +954,10 @@ export default function DashboardPageClient({ // rehydrates from disk. The error / empty branches stay reachable only // post-mount. DO NOT add `["daily", …]` to that allowlist, or render // the hero from any client-only source, without revisiting this gate. - digestQuery.data ? ( + // v1.42 — the top card can be switched off in the layout settings; + // only an explicit `false` hides it, and with it its skeleton and + // its error card. + layout.todayCardVisible === false ? null : digestQuery.data ? ( ", () => { expect(html).toMatch(/data-slot="chart-range-tab"/); }); + it("keeps the range tabs but drops the overlay dropdown with overlayControls off", () => { + // v1.42 — the score histories bind a key to remember their range only. + const html = render( + , + ); + expect(html.match(/data-slot="chart-range-tab"/g)).toHaveLength(4); + expect(html).not.toContain("chart-overlay-controls-trigger"); + }); + it("renders the overlay-controls trigger when chartKey is supplied", () => { const html = render( , diff --git a/src/components/charts/chart-range-tabs.tsx b/src/components/charts/chart-range-tabs.tsx new file mode 100644 index 000000000..0f3ba18b5 --- /dev/null +++ b/src/components/charts/chart-range-tabs.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import type { ChartRangePoints } from "@/lib/dashboard-layout"; +import { useTranslations } from "@/lib/i18n/context"; + +/** + * The range tabs every chart drawn in days carries: 7 / 30 / 90 days and + * All. One component, so the metric charts and the score histories offer + * the same choice in the same place and look the same doing it. + * + * The tabs select a CALENDAR-DAY window ending now — `days: 7` is "the last + * 7 days", not "the last 7 readings". The persisted preference field keeps + * its historical name `rangePoints` (it crosses the chart-overlay-prefs + * wire), but its value has always been days. `0` is "All". + */ +export const CHART_RANGE_TABS = [ + { labelKey: "charts.days7Label", days: 7, titleKey: "charts.days7Title" }, + { labelKey: "charts.days30Label", days: 30, titleKey: "charts.days30Title" }, + { labelKey: "charts.days90Label", days: 90, titleKey: "charts.days90Title" }, + { labelKey: "charts.daysAllLabel", days: 0, titleKey: "charts.daysAllTitle" }, +] as const satisfies ReadonlyArray<{ + labelKey: string; + days: ChartRangePoints; + titleKey: string; +}>; + +/** The tab a chart opens on before anything was chosen. */ +export const DEFAULT_CHART_RANGE: ChartRangePoints = 30; + +/** + * v1.19.0 — day-span the "All" tab (`0`) fetches. A generous fixed bound + * (~10 years) that is clearly larger than any other tab, so "All" means "all + * of my history" rather than a silent one-year truncation. Fixed (not per + * account) so the fetch-window cache key stays stable across the session. + */ +export const ALL_RANGE_DAYS = 3650; + +/** The day window a tab asks the server for. */ +export function rangeWindowDays(range: number): number { + return range > 0 ? range : ALL_RANGE_DAYS; +} + +export function ChartRangeTabs({ + value, + onChange, +}: { + value: number; + onChange: (days: ChartRangePoints) => void; +}) { + const { t } = useTranslations(); + return ( + <> + {CHART_RANGE_TABS.map((r) => ( + + ))} + + ); +} diff --git a/src/components/charts/chart-runtime.ts b/src/components/charts/chart-runtime.ts index 93a51dcfc..5b4cc66a2 100644 --- a/src/components/charts/chart-runtime.ts +++ b/src/components/charts/chart-runtime.ts @@ -42,6 +42,7 @@ export { HostMetricsChart } from "@/components/admin/host-metrics-chart"; export { CustomMetricChart } from "@/components/custom-metrics/custom-metric-chart"; export { BbtChart } from "@/components/cycle/bbt-chart"; export { DeltaSparkline } from "@/components/insights/derived/delta-sparkline"; +export { ScoreTrendChart } from "@/components/insights/derived/score-trend-chart"; export { MoodDimensionTrends } from "@/components/insights/mood/mood-dimension-trends"; export { MoodDistributionChart } from "@/components/insights/mood/mood-distribution-chart"; export { MoodTimeOfDayChart } from "@/components/insights/mood/mood-time-of-day-chart"; diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 82ff67e67..22fb703bf 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -41,7 +41,6 @@ import { ChartErrorState } from "./chart-error-state"; import { TileHeader } from "@/components/insights/tile-header"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; import { computePaddedYDomain } from "@/lib/insights/chart-y-domain"; -import { Button } from "@/components/ui/button"; import { useDateFormatPreference, useTranslations, @@ -85,52 +84,15 @@ import { useChartDayLinks, } from "@/components/day/chart-day-links"; import { isWholeNumberType } from "@/components/day/use-day-value-format"; +import { + ChartRangeTabs, + DEFAULT_CHART_RANGE, + rangeWindowDays, +} from "./chart-range-tabs"; -// The range tabs select a CALENDAR-DAY window ending now — `days: 7` is -// "the last 7 days", not "the last 7 readings". The labels said "points" -// until v1.37.29 while the fetch below always treated the value as a day -// window; a user who weighs in twice a week picked "7 pts" and saw two. -// The persisted preference field keeps its historical name `rangePoints` -// (it crosses the chart-overlay-prefs wire), but its value has always -// been days. -const TIME_RANGES_KEYS = [ - { - labelKey: "charts.days7Label", - days: 7, - titleKey: "charts.days7Title", - }, - { - labelKey: "charts.days30Label", - days: 30, - titleKey: "charts.days30Title", - }, - { - labelKey: "charts.days90Label", - days: 90, - titleKey: "charts.days90Title", - }, - { - labelKey: "charts.daysAllLabel", - days: 0, - titleKey: "charts.daysAllTitle", - }, -] as const; - -/** - * v1.19.0 — day-span the "All" range tab (`rangePoints === 0`) fetches. - * A generous fixed bound (~10 years) that is clearly larger than any - * other range tab, so "All" means "all of my history" rather than the - * old silent 365-day truncation. Fixed (not per-account derived) so the - * fetch-window cache key stays stable across the session. - * - * v1.19.2 — the server's daily-aggregate reader now steps UP the bucket - * tier (DAY → WEEK → MONTH) for windows wider than the DAY cap, so a - * multi-year "All" range returns whole-history coverage downsampled to - * the tier the chart's `bucketTimeSeries` would render anyway, instead of - * truncating to the most recent ~365 daily buckets. The render cost stays - * flat — the coarse tier bounds the point count. - */ -const ALL_RANGE_DAYS = 3650; +// The range tabs (7 / 30 / 90 / All, a calendar-day window ending now) and +// the "All" tab's ten-year fetch span live with the shared +// ``, so every chart drawn in days offers the same choice. interface HealthChartProps { types: string[]; @@ -191,6 +153,13 @@ interface HealthChartProps { * its toggles default to OFF (clean line). */ chartKey?: ChartOverlayKey; + /** + * v1.42 — whether a chart bound to a `chartKey` also shows the overlay + * dropdown. Off for a chart that persists only its range tab (the score + * histories), so binding a key to remember the range does not add + * controls the chart never had. Defaults to on. + */ + overlayControls?: boolean; /** * v1.4.20 phase B4 — additive storyboard annotations. * @@ -669,6 +638,7 @@ export function HealthChart({ windowOverride, compareBaseline = "none", chartKey, + overlayControls = true, annotations, verticalMarkers, userTimezone: userTimezoneProp, @@ -715,7 +685,7 @@ export function HealthChart({ // hides the range tabs, so the user can't change it. const initialRangePoints = windowOverride ? resolveMiniRangePoints(windowOverride) - : 30; + : DEFAULT_CHART_RANGE; const [rangePoints, setRangePoints] = useState(initialRangePoints); // v1.4.18 — three overlay toggles (showTrendIndicator / showTrendArrow @@ -812,7 +782,7 @@ export function HealthChart({ // cache key) and moves on by itself after midnight. const todayKey = localDayKeyFor(new Date(), userTimezone); const fetchWindow = useMemo(() => { - const windowDays = rangePoints > 0 ? rangePoints : ALL_RANGE_DAYS; + const windowDays = rangeWindowDays(rangePoints); const compareShift = effectiveCompareBaseline === "lastMonth" ? 30 @@ -1881,40 +1851,30 @@ export function HealthChart({ className="flex flex-nowrap items-center justify-end gap-1 self-end sm:self-auto" data-slot="chart-header-controls" > - {TIME_RANGES_KEYS.map((r) => ( - - ))} + { + // v1.12.8 — a range-tab change re-slices `chartData`; the + // visible-range stats memo recomputes off the new slice and + // the stat strip follows automatically. + setRangeHydrated(true); + setRangePoints(days); + // v1.30.1 M2 — persist the pick per chart, same model + // as the overlay toggles, so the range survives a + // remount instead of resetting to 30 d every visit. + if (chartKey && !windowOverride) { + overlayPrefs.setPrefs({ + ...overlayPrefs.prefs, + rangePoints: days, + }); + } + }} + /> {/* v1.4.18 — overlay-controls dropdown anchored next to the range tabs. Only painted when the chart is bound to a persistent chartKey; ad-hoc usages keep the clean-line default. */} - {chartKey ? ( + {chartKey && overlayControls ? ( disclosure", () => { }); }); +describe(" history", () => { + it("links the score to its course over time, at rest", () => { + const html = render(); + const rest = atRest(html); + const link = rest.match( + /]*data-slot="health-score-history-link"[^>]*>[\s\S]*?<\/a>/, + ); + expect(link).not.toBeNull(); + expect(link![0]).toContain('href="/insights/health-score"'); + expect(link![0]).toContain("History"); + }); +}); + describe(" pillars", () => { it("gives a row to the scored pillars only, never to a gated one", () => { const html = render(); diff --git a/src/components/insights/derived/__tests__/composite-score-anatomy-coach.test.tsx b/src/components/insights/derived/__tests__/composite-score-anatomy-coach.test.tsx index 928d734ab..9e8025330 100644 --- a/src/components/insights/derived/__tests__/composite-score-anatomy-coach.test.tsx +++ b/src/components/insights/derived/__tests__/composite-score-anatomy-coach.test.tsx @@ -46,7 +46,7 @@ vi.mock("../score-anatomy-view", () => ({ // about the assessment's hand-off, so the course is a probe here. vi.mock("../score-history", () => ({ ScoreHistoryChart: () =>
, - ScoreHistoryCard: () =>
, + ScoreTrendChartDynamic: () =>
, })); import { CompositeScoreAnatomy } from "../composite-score-anatomy"; diff --git a/src/components/insights/derived/__tests__/score-history-wiring.test.tsx b/src/components/insights/derived/__tests__/score-history-wiring.test.tsx index 6ccf806fe..daa9c2122 100644 --- a/src/components/insights/derived/__tests__/score-history-wiring.test.tsx +++ b/src/components/insights/derived/__tests__/score-history-wiring.test.tsx @@ -7,9 +7,10 @@ import { renderToStaticMarkup } from "react-dom/server"; * longer rides above the method. * * The stored nightly scores (recovery, stress, strain) get the full metric - * chart over their measurement type; the two composites draw the course their - * value carries, and nothing when it carries fewer than two points. The chart - * and the card are stubbed to probes that state what they were given. + * chart over their measurement type; the two composites chart the daily + * history the score-history route serves. Every one of them remembers its + * range tab under its own slot. The charts and the card are stubbed to probes + * that state what they were given. */ vi.mock("@/lib/i18n/context", () => ({ @@ -32,20 +33,30 @@ vi.mock("../score-anatomy-view", () => ({ })); vi.mock("../score-history", () => ({ - ScoreHistoryChart: ({ type }: { type: string }) => ( -
- ), - ScoreHistoryCard: ({ - series, - windowDays, + ScoreHistoryChart: ({ + type, + chartKey, }: { - series: number[]; - windowDays: number; + type: string; + chartKey: string; }) => (
+ ), + ScoreTrendChartDynamic: ({ + score, + chartKey, + }: { + score: string; + chartKey: string; + }) => ( +
), })); @@ -85,7 +96,7 @@ describe("score detail history", () => { expect(html).toContain( `data-slot="history-chart-probe" data-type="${metric}"`, ); - expect(html).not.toContain("history-card-probe"); + expect(html).not.toContain("trend-chart-probe"); // Under the score card, not somewhere else on the page. expect(html.indexOf("history-chart-probe")).toBeGreaterThan( html.indexOf("anatomy-probe"), @@ -93,16 +104,31 @@ describe("score detail history", () => { }, ); - it.each(["SLEEP_SCORE", "READINESS"] as const)( - "%s draws the course its value carries, over its window", - (metric) => { + it.each([ + ["SLEEP_SCORE", "scoreSleep"], + ["READINESS", "scoreReadiness"], + ] as const)( + "%s charts its daily history from the score-history route", + (metric, chartKey) => { const html = render(metric, [55, 61, 70]); - expect(html).toContain('data-points="3"'); - expect(html).toContain('data-window="30"'); + expect(html).toContain( + `data-slot="trend-chart-probe" data-score="${metric}" data-chart-key="${chartKey}"`, + ); expect(html).not.toContain("history-chart-probe"); + expect(html.indexOf("trend-chart-probe")).toBeGreaterThan( + html.indexOf("anatomy-probe"), + ); }, ); + it.each([ + ["RECOVERY_SCORE", "scoreRecovery"], + ["STRESS_SCORE", "scoreStress"], + ["STRAIN_SCORE", "scoreStrain"], + ] as const)("%s remembers its range under %s", (metric, chartKey) => { + expect(render(metric)).toContain(`data-chart-key="${chartKey}"`); + }); + it("an account without a score gets no empty chart under the insufficient card", () => { derived.current = { isLoading: false, @@ -123,9 +149,11 @@ describe("score detail history", () => { expect(html).not.toContain("history-chart-probe"); }); - it("a composite with fewer than two points draws no course", () => { - expect(render("READINESS", [70])).not.toContain("history-card-probe"); - expect(render("READINESS")).not.toContain("history-card-probe"); + it("a composite charts its history even when its value carries no series", () => { + // The course no longer rides the value: one point, or none, still gets + // the chart, whose own range decides what it shows. + expect(render("READINESS", [70])).toContain("trend-chart-probe"); + expect(render("READINESS")).toContain("trend-chart-probe"); }); it("no score page carries the caveat line above its method", () => { diff --git a/src/components/insights/derived/__tests__/score-trend-chart.test.tsx b/src/components/insights/derived/__tests__/score-trend-chart.test.tsx new file mode 100644 index 000000000..0cda9f76c --- /dev/null +++ b/src/components/insights/derived/__tests__/score-trend-chart.test.tsx @@ -0,0 +1,195 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +/** + * v1.42 — the score history chart: the shared range tabs on the remembered + * range, the day table as the keyboard way to every point's day, the usual + * range and the seam named under the chart, and the honest empty state. + * The query is seeded; the plot itself does not draw under SSR. + */ + +vi.mock("@/lib/api/api-fetch", () => ({ + apiGet: () => new Promise(() => {}), + apiPut: vi.fn(), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true, user: { id: "u1" } }), +})); + +const prefs: { + rangePoints: number | undefined; + lastKey: string | undefined; + setPrefs: ReturnType; +} = { rangePoints: undefined, lastKey: undefined, setPrefs: vi.fn() }; +vi.mock("@/hooks/use-chart-overlay-prefs", () => ({ + useChartOverlayPrefs: (key: string) => { + prefs.lastKey = key; + return { + prefs: { + showTrendIndicator: false, + showTrendArrow: false, + showTargetRange: false, + comparisonBaseline: "none", + rangePoints: prefs.rangePoints, + }, + setPrefs: prefs.setPrefs, + isSaving: false, + }; + }, +})); + +import { ScoreTrendChart, toChartRows } from "../score-trend-chart"; + +type Point = { day: string; value: number; seamBreak: boolean }; + +function render( + days: number, + data: { + points: Point[]; + band: { lo: number; hi: number; n: number } | null; + } | null, +): string { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + if (data) { + client.setQueryData(queryKeys.insightsScoreHistory("HEALTH_SCORE", days), { + score: "HEALTH_SCORE", + days, + ...data, + }); + } + return renderToStaticMarkup( + + + + + , + ); +} + +const POINTS: Point[] = [ + { day: "2026-06-01", value: 61, seamBreak: false }, + { day: "2026-06-02", value: 64, seamBreak: false }, + { day: "2026-06-03", value: 70, seamBreak: true }, +]; + +beforeEach(() => { + prefs.rangePoints = undefined; + prefs.setPrefs.mockReset(); +}); + +describe("", () => { + it("offers the four range tabs every chart offers, on 30 days by default", () => { + const html = render(30, { points: POINTS, band: null }); + const tabs = html.match(/]*data-slot="chart-range-tab"[^>]*>/g); + expect(tabs).toHaveLength(4); + expect(tabs!.map((tab) => tab.match(/data-range="(\d+)"/)![1])).toEqual([ + "7", + "30", + "90", + "0", + ]); + const pressed = tabs!.filter((tab) => tab.includes('aria-pressed="true"')); + expect(pressed).toHaveLength(1); + expect(pressed[0]).toContain('data-range="30"'); + expect(html).toContain('data-slot="score-trend-chart"'); + // The range is remembered under the page's own slot. + expect(prefs.lastKey).toBe("scoreHealth"); + }); + + it("opens on the remembered range and asks for its window", () => { + prefs.rangePoints = 90; + const html = render(90, { points: POINTS, band: null }); + const pressed = ( + html.match(/]*data-slot="chart-range-tab"[^>]*>/g) ?? [] + ).filter((tab) => tab.includes('aria-pressed="true"')); + expect(pressed).toHaveLength(1); + expect(pressed[0]).toContain('data-range="90"'); + // The seeded 90-day read is the one the chart drew from. + expect(html.match(/data-slot="chart-data-table-row"/g)).toHaveLength(3); + }); + + it("asks for ten years on the All tab", () => { + prefs.rangePoints = 0; + const html = render(3650, { points: POINTS, band: null }); + expect(html).toContain('data-slot="chart-data-table"'); + }); + + it("lists every point with a link to its day", () => { + const html = render(30, { points: POINTS, band: null }); + expect(html.match(/data-slot="chart-data-table-row"/g)).toHaveLength(3); + for (const point of POINTS) { + expect(html).toContain(point.day); + } + expect(html).toContain('data-slot="chart-plot"'); + }); + + it("names the usual range and the seam under the chart", () => { + const html = render(30, { + points: POINTS, + band: { lo: 60, hi: 68, n: 12 }, + }); + expect(html).toContain('data-slot="score-trend-band"'); + expect(html).toContain("your usual range, 60 to 68"); + expect(html).toContain('data-slot="score-trend-seam"'); + }); + + it("says nothing about a range or a seam it does not have", () => { + const html = render(30, { + points: POINTS.map((p) => ({ ...p, seamBreak: false })), + band: null, + }); + expect(html).not.toContain("score-trend-band"); + expect(html).not.toContain("score-trend-seam"); + }); + + it("paints the empty state, not an empty plot, for a window without points", () => { + const html = render(30, { points: [], band: null }); + expect(html).toContain('data-slot="chart-empty-state"'); + expect(html).not.toContain('data-slot="chart-plot"'); + expect(html).not.toContain('data-slot="chart-data-table"'); + }); + + it("holds the plot's height while the first read is in flight", () => { + const html = render(30, null); + expect(html).toContain('data-slot="skeleton"'); + }); +}); + +describe("toChartRows", () => { + it("alternates the series key at every seam, so no line crosses one", () => { + const rows = toChartRows([ + { day: "2026-06-01", value: 50, seamBreak: false }, + { day: "2026-06-02", value: 52, seamBreak: false }, + { day: "2026-06-03", value: 80, seamBreak: true }, + { day: "2026-06-05", value: 81, seamBreak: false }, + { day: "2026-06-06", value: 60, seamBreak: true }, + ]); + expect(rows.map((r) => [r.even ?? null, r.odd ?? null])).toEqual([ + [50, null], + [52, null], + [null, 80], + [null, 81], + [60, null], + ]); + expect(rows[0]!.timestamp).toBe(Date.parse("2026-06-01T12:00:00.000Z")); + }); + + it("does not treat a seam flag on the first point as a break", () => { + const [first] = toChartRows([ + { day: "2026-06-01", value: 50, seamBreak: true }, + ]); + expect(first).toMatchObject({ even: 50 }); + }); +}); diff --git a/src/components/insights/derived/composite-score-anatomy.tsx b/src/components/insights/derived/composite-score-anatomy.tsx index d62b3035a..c64258fad 100644 --- a/src/components/insights/derived/composite-score-anatomy.tsx +++ b/src/components/insights/derived/composite-score-anatomy.tsx @@ -17,10 +17,11 @@ import { ScoreAnatomyView, type AnatomyContributor, } from "./score-anatomy-view"; -import type { RingHue } from "./ring-hues"; +import { RING_GRADIENT, type RingHue } from "./ring-hues"; import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context"; import { METRIC_PROVENANCE } from "./standards"; -import { ScoreHistoryCard, ScoreHistoryChart } from "./score-history"; +import { ScoreHistoryChart, ScoreTrendChartDynamic } from "./score-history"; +import type { ChartOverlayKey } from "@/lib/dashboard-layout"; /** * v1.10.0 — the data-bound wrapper that fetches a composite/persisted derived @@ -98,6 +99,15 @@ const METRIC_COACH_SCOPE: Record = { }, }; +/** The slot each score page remembers its history chart's range tab under. */ +const METRIC_CHART_KEY: Record = { + SLEEP_SCORE: "scoreSleep", + READINESS: "scoreReadiness", + RECOVERY_SCORE: "scoreRecovery", + STRESS_SCORE: "scoreStress", + STRAIN_SCORE: "scoreStrain", +}; + const METRIC_HUE: Record = { SLEEP_SCORE: "sleep", READINESS: "readiness", @@ -284,13 +294,10 @@ export function CompositeScoreAnatomy({ // the card always renders its prose rather than the no-provider fallback. const assessment = data.assessment; - // v1.42 — the score's course, right under its card. A stored nightly score - // gets the full chart; a composite draws the course its value carries. - const series = - data.status === "ok" && data.value - ? (data.value as { series?: number[] }).series - : undefined; - // Only beside a score: an account without one gets the honest + // v1.42 — the score's course, right under its card, with the range tabs + // every chart offers. A stored nightly score charts its readings; readiness + // and the sleep score chart the daily history the score-history route + // serves. Only beside a score: an account without one gets the honest // insufficient card, not an empty chart under it. const history = data.status !== "ok" ? null : metric === "RECOVERY_SCORE" || @@ -299,14 +306,16 @@ export function CompositeScoreAnatomy({ - ) : series && series.length >= 2 ? ( - - ) : null; + ); return (
diff --git a/src/components/insights/derived/score-history.tsx b/src/components/insights/derived/score-history.tsx index d4c22a5c9..d71a22952 100644 --- a/src/components/insights/derived/score-history.tsx +++ b/src/components/insights/derived/score-history.tsx @@ -3,40 +3,46 @@ import dynamic from "next/dynamic"; import { History } from "lucide-react"; +import { ChartSkeleton } from "@/components/charts/chart-skeleton"; import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; -import { TileHeader } from "@/components/insights/tile-header"; -import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { useAuth } from "@/hooks/use-auth"; +import type { ChartOverlayKey } from "@/lib/dashboard-layout"; import { useTranslations } from "@/lib/i18n/context"; +import { importWithRetry } from "@/lib/retry-import"; import { RING_GRADIENT, type RingHue } from "./ring-hues"; /** - * v1.42 — a score's course over time on its detail page, directly under the - * score card. + * v1.42 — a score's course over time on its page, directly under the score + * card. * * The three nightly scores (recovery, stress, strain) are stored readings, so - * they get the full metric chart every measured page carries: the 7 / 30 / 90 - * / All switch, the tooltip, and each day opening its day view. The two - * composites (sleep score, readiness) are computed rather than stored, so the - * course they carry on their value is drawn as a compact line over its window. + * they get the full metric chart every measured page carries. The health + * score, readiness and the sleep score are not stored as readings; their + * daily course comes from the score-history route and draws in + * ``. Both offer the same range tabs (7 / 30 / 90 / All), + * remembered per score page, and open each day from its point. */ -const DeltaSparkline = dynamic( +/** The health score, readiness and sleep score history, loaded with Recharts. */ +export const ScoreTrendChartDynamic = dynamic( () => - import("@/components/charts/chart-runtime").then((mod) => ({ - default: mod.DeltaSparkline, - })), - { ssr: false, loading: () => null }, + importWithRetry(() => import("@/components/charts/chart-runtime")).then( + (mod) => ({ default: mod.ScoreTrendChart }), + ), + { ssr: false, loading: () => }, ); export function ScoreHistoryChart({ type, hue, + chartKey, }: { /** The stored measurement type the score is written as — or, for a strain * score served from the device, the device's own `DAY_STRAIN`. */ type: "RECOVERY_SCORE" | "STRESS_SCORE" | "STRAIN_SCORE" | "DAY_STRAIN"; hue: RingHue; + /** The slot the range tab is remembered under. */ + chartKey: ChartOverlayKey; }) { const { t } = useTranslations(); const { user } = useAuth(); @@ -49,44 +55,10 @@ export function ScoreHistoryChart({ colors={[RING_GRADIENT[hue][1]]} unit={t("insights.deviceScore.unitScore")} userTimezone={user?.timezone} + chartKey={chartKey} + overlayControls={false} dayLinks />
); } - -export function ScoreHistoryCard({ - series, - windowDays, - hue, -}: { - series: number[]; - windowDays: number; - hue: RingHue; -}) { - const { t } = useTranslations(); - return ( - - - - {t("insights.derived.scores.historyWindow", { days: windowDays })} - - } - /> - - - - - - ); -} diff --git a/src/components/insights/derived/score-trend-chart.tsx b/src/components/insights/derived/score-trend-chart.tsx new file mode 100644 index 000000000..c6a0f5464 --- /dev/null +++ b/src/components/insights/derived/score-trend-chart.tsx @@ -0,0 +1,375 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { History } from "lucide-react"; +import { + CartesianGrid, + ComposedChart, + Line, + ReferenceArea, + ReferenceLine, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +import { ChartDataTable } from "@/components/charts/chart-data-table"; +import { ChartEmptyState } from "@/components/charts/chart-empty-state"; +import { ChartErrorState } from "@/components/charts/chart-error-state"; +import { + ChartRangeTabs, + DEFAULT_CHART_RANGE, + rangeWindowDays, +} from "@/components/charts/chart-range-tabs"; +import { + RichChartTooltip, + type RichTooltipRow, +} from "@/components/charts/chart-tooltip"; +import { + ChartDayFooter, + OPEN_DAY_LINE, + dayAnchor, + useChartDayLinks, +} from "@/components/day/chart-day-links"; +import { TileHeader } from "@/components/insights/tile-header"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useAuth } from "@/hooks/use-auth"; +import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs"; +import { apiGet } from "@/lib/api/api-fetch"; +import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; +import type { ChartOverlayKey, ChartRangePoints } from "@/lib/dashboard-layout"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import type { ScoreHistoryId } from "@/lib/insights/score-history-ids"; +import { queryKeys } from "@/lib/query-keys"; + +/** + * v1.42 — a score's daily course on its page, for the scores that have no + * stored measurement series to chart: the health score, readiness and the + * sleep score (the nightly recovery, stress and strain scores are stored and + * ride ``). + * + * The same card as the metric charts: the range tabs every chart offers, + * remembered per score page like theirs, each point a door to its day (click, + * or on touch the tooltip's button), and the data table as the keyboard way + * to the same days. The person's usual range is shaded behind the line when + * the server could form one. + * + * A health-score point flagged `seamBreak` starts a new segment: the recipe + * changed there, the values either side are averages of different things, + * so no line joins them. The segments alternate between two series keys, so + * each is drawn by a line that has no value on its neighbour's days. + */ + +/** Matches the response schema at `scoreHistoryResponse`. */ +interface ScoreHistoryResponse { + score: ScoreHistoryId; + days: number; + points: Array<{ day: string; value: number; seamBreak: boolean }>; + band: { lo: number; hi: number; n: number } | null; +} + +interface ChartRow { + day: string; + timestamp: number; + value: number; + /** The value on even segments, absent on odd ones. */ + even?: number; + /** The value on odd segments, absent on even ones. */ + odd?: number; +} + +/** Points past this many draw the line alone; the dots would merge. */ +const MAX_DOTTED_POINTS = 90; +const Y_AXIS_WIDTH = 36; +const MARGIN = { top: 10, right: 8, bottom: 8, left: 0 } as const; + +const TIMEOUT_MS = 8_000; + +/** + * The points as chart rows: each on its day's noon-UTC anchor, its value on + * the series key of its segment. Segments alternate between the two keys, so + * the line of one never reaches into its neighbour. Exported for its test. + */ +export function toChartRows( + points: ReadonlyArray<{ day: string; value: number; seamBreak: boolean }>, +): ChartRow[] { + const rows: ChartRow[] = []; + let segment = 0; + for (const [index, point] of points.entries()) { + if (index > 0 && point.seamBreak) segment += 1; + rows.push({ + day: point.day, + timestamp: dayAnchor(point.day), + value: point.value, + ...(segment % 2 === 0 ? { even: point.value } : { odd: point.value }), + }); + } + return rows; +} + +export interface ScoreTrendChartProps { + score: ScoreHistoryId; + /** The slot the range tab is remembered under. */ + chartKey: ChartOverlayKey; + /** The line's colour, a CSS variable reference. */ + color: string; + /** The score's name: the table caption and the opened day's label. */ + label: string; +} + +export function ScoreTrendChart({ + score, + chartKey, + color, + label, +}: ScoreTrendChartProps) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const { isAuthenticated } = useAuth(); + + const overlayPrefs = useChartOverlayPrefs(chartKey); + const [picked, setPicked] = useState(null); + const range = picked ?? overlayPrefs.prefs.rangePoints ?? DEFAULT_CHART_RANGE; + const days = rangeWindowDays(range); + + const query = useQuery({ + queryKey: queryKeys.insightsScoreHistory(score, days), + queryFn: async (): Promise => { + const params = new URLSearchParams({ score, days: String(days) }); + return apiGet( + `/api/insights/score-history?${params.toString()}`, + { signal: AbortSignal.timeout(TIMEOUT_MS) }, + ); + }, + enabled: isAuthenticated, + staleTime: 60_000, + retry: 0, + // A range change keeps the previous series painted, dimmed and inert, + // until the new one lands, so the card does not jump. + placeholderData: keepPreviousData, + }); + + const rows = useMemo( + () => toChartRows(query.data?.points ?? []), + [query.data], + ); + const hasSeam = (query.data?.points ?? []).some((p) => p.seamBreak); + const band = query.data?.band ?? null; + + const chartDays = useChartDayLinks({ + enabled: rows.length > 0, + days: rows.map((row) => row.day), + focusFor: (index) => { + const row = rows[index]; + return row ? { label, value: String(row.value) } : null; + }, + }); + const openRow = + chartDays.openIndex !== undefined ? rows[chartDays.openIndex] : undefined; + + const animate = !prefersReducedMotion(); + const dot = rows.length <= MAX_DOTTED_POINTS ? { r: 3, fill: color } : false; + const heightClass = + "h-[var(--chart-height,240px)] md:h-[var(--chart-height-md,280px)]"; + + function selectRange(next: ChartRangePoints) { + setPicked(next); + overlayPrefs.setPrefs({ ...overlayPrefs.prefs, rangePoints: next }); + } + + return ( +
+
+ +
+ +
+
+ + {query.isLoading ? ( + + ) : query.isError ? ( + void query.refetch()} + /> + ) : rows.length === 0 ? ( + + ) : ( + <> +
+
+ + + + + fmt.dateShortSmartCalendar(new Date(ts)) + } + tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} + tickLine={false} + axisLine={false} + minTickGap={32} + padding={{ left: 10, right: 10 }} + tickMargin={10} + /> + + {band ? ( + + ) : null} + {openRow ? ( + + ) : null} + { + const payload = props.payload as + ReadonlyArray<{ payload?: ChartRow }> | undefined; + const row = payload?.[0]?.payload; + if (!props.active || !row) { + return ; + } + const tooltipRows: RichTooltipRow[] = [ + { name: label, value: String(row.value), color }, + ]; + return ( + + ); + }} + /> + {(["even", "odd"] as const).map((key) => ( + + ))} + + +
+
+ + {band || hasSeam ? ( +
+ {band ? ( +

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

+ ) : null} + {hasSeam ? ( +

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

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

- {report.delta != null && report.delta > 0 ? ( - + {report.delta != null && report.delta > 0 ? ( + + +{report.delta} + + ) : null} + {/* v1.42 — the way to the score's course over time. */} + - +{report.delta} - - ) : null} +
{/* 2 — the number. The reason the panel exists. */} diff --git a/src/components/settings/__tests__/dashboard-layout-section.test.tsx b/src/components/settings/__tests__/dashboard-layout-section.test.tsx index 909026b67..99efa3397 100644 --- a/src/components/settings/__tests__/dashboard-layout-section.test.tsx +++ b/src/components/settings/__tests__/dashboard-layout-section.test.tsx @@ -621,3 +621,44 @@ describe(" — hero content", () => { expect(html).toContain("Notification settings are managed separately."); }); }); + +describe(" — the top card switch", () => { + function control(html: string): string { + const match = html.match(/]*data-slot="today-card-switch"[^>]*>/); + expect(match).not.toBeNull(); + return match![0]; + } + + it("offers one labelled switch, on by default", () => { + const html = render(); + expect(html.match(/data-slot="today-card-switch"/g)).toHaveLength(1); + expect(control(html)).toContain('data-state="checked"'); + const id = control(html).match(/id="([^"]+)"/)![1]; + expect(html).toContain(`for="${id}"`); + expect(html).toContain("Show the top card"); + expect(html).toContain("Using default layout"); + }); + + it("reads a layout that predates the field as shown", () => { + const legacy: DashboardLayout = { ...DEFAULT_DASHBOARD_LAYOUT }; + delete legacy.todayCardVisible; + queryState.layout = legacy; + const html = render(); + expect(control(html)).toContain('data-state="checked"'); + }); + + it("shows a hidden top card as off, and as a customised layout", () => { + queryState.layout = { + ...DEFAULT_DASHBOARD_LAYOUT, + todayCardVisible: false, + }; + const html = render(); + expect(control(html)).toContain('data-state="unchecked"'); + expect(html).toContain("Custom layout active"); + }); + + it("labels it in German", () => { + const html = render(, "de"); + expect(html).toContain("Obere Karte anzeigen"); + }); +}); diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index 8d71687fe..b7e313fca 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -207,6 +207,7 @@ export function DashboardLayoutSection({ id }: { id: string }) { const heroDescriptionId = useId(); const heroNotificationNoteId = useId(); const heroContentSelectId = useId(); + const todayCardSwitchId = useId(); // v1.34 — the "Today highlights" fieldset below is gated on `layout`, // which can be truthy on the very first client render whenever a // returning visitor's browser already has a warm, offline-persisted @@ -404,6 +405,12 @@ export function DashboardLayoutSection({ id }: { id: string }) { }); } + /** v1.42 — the server-persisted on/off switch for the dashboard's top card. */ + function setTodayCardVisible(visible: boolean) { + if (!layout) return; + setDraft({ ...layout, todayCardVisible: visible }); + } + /** * Hero primary content — the server-persisted `hero` field on the layout * blob. The value set is closed ("score" | "reminders"); anything else a @@ -483,6 +490,20 @@ export function DashboardLayoutSection({ id }: { id: string }) {

{t("dashboard.heroItemsDescription")}

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