diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f4a86455..47baaf25d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,51 @@ ## [Unreleased] +## [1.34.5] — 2026-07-31 + +A release about the score explaining itself, and about sentences arriving in the +language you read. + +### Added + +- The health score sits beside the greeting again, at the top of the insights page, + instead of on a separate card pinned underneath it. It has one place on the page. +- Every pillar inside the score takes its own colour. A pillar doing well was painted + in the overall score's colour, so a good result inside a weak week looked like a bad + one. +- A blood pressure score says how it came about. The pillar's detail now opens with + which of the two values set the number and how far it sat from its boundary, what + the scale means at that boundary, and that the pair it works from is a weighted + average over the window rather than a reading anyone took. + +### Fixed + +- The Coach read strip writes both of its lines in the language you use. The summary + was translated and the line directly beneath it stayed English. The translations + existed the whole time. The request simply never named a language, and the code + behind it chose English whenever nobody did. +- The cycle insights read carried the same silence and the same English line. +- Switching language reloads those lines instead of showing the previous language out + of the browser cache. +- Anything in the score that is not contributing is one quiet line instead of a block + of its own. A read that failed is named once for every pillar it took down and keeps + its retry. Safety guidance is shown in full. Anything not recorded yet is counted, + with the names one tap away. + +### Internal + +- A function that writes a sentence for a person has to be told the language, and none + of them may fall back to English on their own. A guard reads the types rather than + the parameter names, so renaming the parameter or moving the default onto the + destructuring pattern does not get past it. +- The space the score holds open while it loads is measured against the built panel + instead of estimated. The declared minimum was smaller than the blocks inside it, so + it never bound anything, and a short score was pushed further down the page than it + needed to be. +- A widening of the pillar label column was taken back out. The measurement behind it + did not hold: the longest label in five of the six languages overruns any column + this panel can afford, so the extra width would have come off the bar. + ## [1.34.4] — 2026-07-31 A release about two places in the code answering one question differently. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a9721d741..81dec2775 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.34.4 + version: 1.34.5 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/e2e/insights-hero-score-mobile.spec.ts b/e2e/insights-hero-score-mobile.spec.ts new file mode 100644 index 000000000..726024268 --- /dev/null +++ b/e2e/insights-hero-score-mobile.spec.ts @@ -0,0 +1,136 @@ +import { expect, test } from "./setup/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { mockPopulatedInsights } from "./utils/mock-populated-insights"; +import { + expectNoHorizontalOverflow, + settleForOverflowMeasurement, +} from "./utils/horizontal-overflow"; + +/** + * The Health Score panel at 360 px. + * + * The panel is the widest-content block in the hero band: a fixed label + * column, a fill bar, a value column and a popover trigger on one line, plus + * a coalesced failed-read line carrying an inline button, plus a disclosure + * whose open state adds a bordered tile and three prose paragraphs. 360 px is + * the narrowest common Android width and the one this overview has scrolled + * sideways at before. + * + * Two ways a guard like this proves nothing, both of which have bitten this + * repo: measuring only `document.scrollWidth` when `AuthShell` scrolls inside + * `
`, and measuring an empty page. The shared helper closes the first; + * the fixture and the fixture-honesty test below close the second by asserting + * the panel is actually on screen with rows in it before any width is read. + * + * Assertions are on `data-slot` attributes, never on rendered text, so a + * locale change or a copy edit cannot turn this red for the wrong reason. + */ + +const PANEL = '[data-slot="health-score-card"]'; + +test.describe("the Health Score panel at phone width", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test.beforeEach(async ({}, testInfo) => { + test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec"); + }); + + test("is on screen with its rows before anything is measured", async ({ + page, + }) => { + await mockPopulatedInsights(page); + await page.setViewportSize({ width: 360, height: 851 }); + await page.goto("/insights", { waitUntil: "domcontentloaded" }); + + const panel = page.locator(PANEL); + await expect(panel).toBeVisible(); + await expect( + panel.locator('[data-slot="health-score-card-number"]'), + ).toBeVisible(); + // Four scored pillars in the fixture; a panel with no rows would fit any + // width and prove nothing about the row geometry. + await expect(panel.locator("li[data-status='ok']")).toHaveCount(4); + // The failed read and its retry are visible without opening anything. + await expect( + panel.locator('[data-slot="health-score-pillar-error"]'), + ).toBeVisible(); + await expect( + panel.locator('[data-slot="health-score-pillar-retry"]'), + ).toBeVisible(); + await expect( + panel.locator('[data-slot="health-score-crisis"]'), + ).toBeVisible(); + await expect( + panel.locator('[data-slot="health-score-not-scored-count"]'), + ).toBeVisible(); + }); + + test("does not scroll the page sideways, closed or open", async ({ + page, + }) => { + await mockPopulatedInsights(page); + await page.setViewportSize({ width: 360, height: 851 }); + await page.goto("/insights", { waitUntil: "domcontentloaded" }); + + const panel = page.locator(PANEL); + await expect(panel).toBeVisible(); + await settleForOverflowMeasurement(page); + await expectNoHorizontalOverflow(page, "/insights score panel @360px"); + + // Opened, the disclosure adds the bordered weight-goal tile and the method + // paragraphs — the widest content the panel can hold. + await panel.locator('[data-slot="health-score-anatomy-toggle"]').click(); + await expect( + panel.locator('[data-slot="health-score-weight-goal"]'), + ).toBeVisible(); + await settleForOverflowMeasurement(page); + await expectNoHorizontalOverflow(page, "/insights score panel open @360px"); + }); + + test("keeps the panel inside the band, never wider than it", async ({ + page, + }) => { + await mockPopulatedInsights(page); + await page.setViewportSize({ width: 360, height: 851 }); + await page.goto("/insights", { waitUntil: "domcontentloaded" }); + + const panel = page.locator(PANEL); + await expect(panel).toBeVisible(); + await panel.locator('[data-slot="health-score-anatomy-toggle"]').click(); + await expect( + panel.locator('[data-slot="health-score-weight-goal"]'), + ).toBeVisible(); + + const measured = await panel.evaluate((el) => { + const band = el.closest('[data-slot="insights-hero-strip"]'); + // The widest descendant that is not contained by its own scroller. + let widest = 0; + for (const child of Array.from(el.querySelectorAll("*"))) { + const r = child.getBoundingClientRect(); + if (r.width > widest) widest = r.width; + } + return { + panelScroll: el.scrollWidth, + panelClient: el.clientWidth, + panelRight: Math.round(el.getBoundingClientRect().right), + bandRight: band ? Math.round(band.getBoundingClientRect().right) : null, + widestChild: Math.round(widest), + }; + }); + + expect( + measured.panelScroll, + `panel scrolls its own content: ${JSON.stringify(measured)}`, + ).toBeLessThanOrEqual(measured.panelClient + 1); + expect(measured.bandRight).not.toBeNull(); + expect( + measured.panelRight, + `panel extends past the band: ${JSON.stringify(measured)}`, + ).toBeLessThanOrEqual(measured.bandRight! + 1); + expect( + measured.widestChild, + `a child is wider than the panel: ${JSON.stringify(measured)}`, + ).toBeLessThanOrEqual(measured.panelClient + 1); + }); +}); diff --git a/e2e/utils/mock-populated-insights.ts b/e2e/utils/mock-populated-insights.ts index 0968f2e9b..378c60208 100644 --- a/e2e/utils/mock-populated-insights.ts +++ b/e2e/utils/mock-populated-insights.ts @@ -97,6 +97,115 @@ const COINCIDENT_FIRED = { reason: null, }; +/** + * A populated Health Score report for the hero band's score column. + * + * The panel is the widest-content block in the band: a fixed-width label + * column, a fill bar, a value column and a popover trigger all on one 20 px + * line, plus a coalesced failed-read line that carries an inline button. An + * empty score leaves the column unmounted entirely, so without this the + * phone-width guard measured a band that had no panel in it. + * + * Every state the panel can paint is present at once: scored rows, a failed + * read with its retry, safety signposting, and counted absence. + */ +function scorePillar(id: string, domain: string, score: number) { + return { + id, + domain, + result: { + status: "ok" as const, + value: { + score, + observed: { + value: 126, + unit: "mmHg", + label: "126/78 mmHg", + asOf: NOW, + sources: ["MANUAL"], + }, + reference: { + kind: "clinical-threshold" as const, + low: 120, + high: 129, + label: "120 to 129/70 to 79 mmHg", + source: "ESH 2023", + }, + noiseFloor: 1, + deltaEligible: true, + deltaIdentity: id, + }, + coverage: coverage(), + confidence: confidence(), + provenance: provenance([id]), + }, + }; +} + +function gatedPillar(id: string, domain: string, reason: string) { + return { + id, + domain, + result: { + status: "insufficient" as const, + coverage: { + requiredInputs: 4, + presentInputs: 0, + historyDays: 0, + missing: [] as string[], + }, + provenance: provenance([id]), + reason, + }, + }; +} + +const HEALTH_SCORE = { + composite: { + status: "ok" as const, + value: { + score: 74, + band: "green" as const, + bandSetter: "SLEEP", + composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP", "ADIPOSITY"], + noiseFloor: 3, + scoreVersion: 2, + }, + coverage: coverage(), + confidence: confidence(), + provenance: provenance(["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"]), + }, + pillars: [ + scorePillar("BLOOD_PRESSURE", "cardiometabolic", 56), + scorePillar("ACTIVITY", "activity", 88), + scorePillar("SLEEP", "sleep", 41), + scorePillar("ADIPOSITY", "adiposity", 72), + gatedPillar("FITNESS", "fitness", "read_failed"), + gatedPillar("LIPIDS", "cardiometabolic", "read_failed"), + gatedPillar("WELLBEING", "wellbeing", "crisis_signposting"), + gatedPillar("GLYCAEMIA", "cardiometabolic", "not_tracked"), + ], + delta: 2, + deltaReason: null, + scoreVersion: 2, + weightGoal: { + status: "ok" as const, + value: { + currentKg: 81, + target: { min: 74, max: 78 }, + distanceKg: 3, + deltaKg: 1, + asOf: NOW, + source: "MANUAL", + }, + coverage: coverage(), + confidence: confidence(), + provenance: provenance(["WEIGHT"]), + }, + algorithmNotice: null, + restMode: null, +}; + /** A deterministic sparkline series around `center`. */ function series(center: number, spread: number): number[] { return Array.from({ length: 30 }, (_, i) => @@ -253,6 +362,28 @@ export async function mockPopulatedInsights(page: Page): Promise { }), ); + // The hero band's score column. The real response is fetched and the score + // injected into it, so every OTHER field the page reads off `/api/analytics` + // stays authentic and this fixture cannot silently blank a section it was + // never meant to touch. + await page.route(/\/api\/analytics(\?|$)/, async (route) => { + const response = await route.fetch(); + let payload: { data?: Record | null; error?: unknown }; + try { + payload = await response.json(); + } catch { + payload = { data: {}, error: null }; + } + await route.fulfill({ + response, + contentType: "application/json", + body: JSON.stringify({ + ...payload, + data: { ...(payload.data ?? {}), healthScore: HEALTH_SCORE }, + }), + }); + }); + // The single-metric derived route. Only COINCIDENT_DEVIATION is stubbed — // it is the card whose heading carries the provenance sentence. Any other // metric falls through to the real handler. diff --git a/e2e/utils/settle.ts b/e2e/utils/settle.ts index 14480ab7b..de8afc088 100644 --- a/e2e/utils/settle.ts +++ b/e2e/utils/settle.ts @@ -32,6 +32,17 @@ export async function settleBeforeMeasure( } else { await expect(sentinel).toBeVisible(); } + // A shell can be visible while the data underneath it is still arriving, + // and a placeholder standing in for that data occupies height like any + // other content. Measuring then reports the geometry of a page that no + // longer exists a moment later, and blames the application for it. The + // suite marks those placeholders with a `-loading` slot, so wait for them + // to go. Never fail on the wait: a page that legitimately keeps one would + // otherwise turn every measurement on it into a timeout, which is a worse + // failure than the one being prevented. + await expect(page.locator('[data-slot$="-loading"]')) + .toHaveCount(0, { timeout: 5_000 }) + .catch(() => {}); // Fonts and late CSS change geometry after the element is visible, and // every measurement in this suite is geometric. await page.waitForLoadState("networkidle").catch(() => {}); diff --git a/knip.json b/knip.json index 764f7ec30..4e233cf9a 100644 --- a/knip.json +++ b/knip.json @@ -63,7 +63,8 @@ "testcontainers", "tw-animate-css", "@napi-rs/canvas", - "@openai/codex-security" + "@openai/codex-security", + "postcss" ], "ignoreBinaries": ["docker", "docker-compose", "pg_isready", "tsx", "cosign"], "ignoreExportsUsedInFile": true, diff --git a/messages/de.json b/messages/de.json index 62e7dfdaa..b8e7cd85c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2301,6 +2301,11 @@ "fastingObserved": "Nüchternglukose: {value} {unit}", "fastingReference": "{low} bis {high} {unit} nüchtern", "personalReference": "Dein Ziel: {value}", + "bpBasisAbove": "Ausschlaggebend: {axis}, etwa {offset} mmHg über der Obergrenze von {boundary} mmHg.", + "bpBasisBelowFloor": "Ausschlaggebend: {axis}, etwa {offset} mmHg unter der Grenze für niedrigen Blutdruck von {boundary} mmHg.", + "bpBasisInBand": "Beide Werte liegen auf oder unter ihren Referenz-Obergrenzen.", + "bpScale": "An der Obergrenze liegt der Wert bei 85; 100 bedeutet rund 12 mmHg darunter.", + "bpRepresentative": "Beruht auf einem Durchschnitt der letzten {days} Tage, der neuere Messungen stärker gewichtet, und kann deshalb von jeder einzelnen Messung abweichen.", "source": "Quelle: {source}, Stand {date}", "observedActivity": "{value} Schritte pro Tag", "observedSleep": "{value} Minuten pro Nacht", diff --git a/messages/en.json b/messages/en.json index 9c9b3db53..c47ffad22 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2301,6 +2301,11 @@ "fastingObserved": "Fasting glucose: {value} {unit}", "fastingReference": "{low} to {high} {unit} fasting", "personalReference": "Your target: {value}", + "bpBasisAbove": "{axis} sets this score: about {offset} mmHg over its ceiling of {boundary} mmHg.", + "bpBasisBelowFloor": "{axis} sets this score: about {offset} mmHg under the low-pressure floor of {boundary} mmHg.", + "bpBasisInBand": "Both values are at or below their reference ceilings.", + "bpScale": "At the ceiling the score is 85; 100 means roughly 12 mmHg below it.", + "bpRepresentative": "Based on a recency-weighted average of the last {days} days, so it may not match any single reading.", "source": "Source: {source}, as of {date}", "observedActivity": "{value} steps per day", "observedSleep": "{value} minutes per night", diff --git a/messages/es.json b/messages/es.json index b9efc2096..ef080d594 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2301,6 +2301,11 @@ "fastingObserved": "Glucosa en ayunas: {value} {unit}", "fastingReference": "{low} a {high} {unit} en ayunas", "personalReference": "Tu objetivo: {value}", + "bpBasisAbove": "Determinante: {axis}, unos {offset} mmHg por encima de su límite de {boundary} mmHg.", + "bpBasisBelowFloor": "Determinante: {axis}, unos {offset} mmHg por debajo del límite de presión baja de {boundary} mmHg.", + "bpBasisInBand": "Ambos valores están en su límite de referencia o por debajo.", + "bpScale": "En el límite la puntuación es 85; 100 significa unos 12 mmHg por debajo.", + "bpRepresentative": "Se basa en un promedio de los últimos {days} días que pondera más las mediciones recientes, por lo que puede no coincidir con ninguna medición concreta.", "source": "Fuente: {source}, a fecha de {date}", "observedActivity": "{value} pasos al día", "observedSleep": "{value} minutos por noche", diff --git a/messages/fr.json b/messages/fr.json index 69258a2dd..e229906e1 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2301,6 +2301,11 @@ "fastingObserved": "Glycémie à jeun : {value} {unit}", "fastingReference": "{low} à {high} {unit} à jeun", "personalReference": "Votre objectif : {value}", + "bpBasisAbove": "Déterminant : {axis}, environ {offset} mmHg au-dessus de son plafond de {boundary} mmHg.", + "bpBasisBelowFloor": "Déterminant : {axis}, environ {offset} mmHg sous le seuil de basse pression de {boundary} mmHg.", + "bpBasisInBand": "Les deux valeurs sont à leur plafond de référence ou en dessous.", + "bpScale": "Au plafond, le score est de 85 ; 100 correspond à environ 12 mmHg en dessous.", + "bpRepresentative": "Repose sur une moyenne des {days} derniers jours qui pèse davantage les mesures récentes, et peut donc ne correspondre à aucune mesure isolée.", "source": "Source : {source}, au {date}", "observedActivity": "{value} pas par jour", "observedSleep": "{value} minutes par nuit", diff --git a/messages/it.json b/messages/it.json index b14a7c282..3974df24a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2301,6 +2301,11 @@ "fastingObserved": "Glicemia a digiuno: {value} {unit}", "fastingReference": "Da {low} a {high} {unit} a digiuno", "personalReference": "Il tuo obiettivo: {value}", + "bpBasisAbove": "Determinante: {axis}, circa {offset} mmHg sopra il suo limite di {boundary} mmHg.", + "bpBasisBelowFloor": "Determinante: {axis}, circa {offset} mmHg sotto la soglia di pressione bassa di {boundary} mmHg.", + "bpBasisInBand": "Entrambi i valori sono pari o inferiori ai loro limiti di riferimento.", + "bpScale": "Al limite il punteggio è 85; 100 significa circa 12 mmHg più in basso.", + "bpRepresentative": "Si basa su una media degli ultimi {days} giorni che pesa di più le misurazioni recenti, quindi può non corrispondere a nessuna singola misurazione.", "source": "Fonte: {source}, al {date}", "observedActivity": "{value} passi al giorno", "observedSleep": "{value} minuti per notte", diff --git a/messages/pl.json b/messages/pl.json index 51f7d586f..c49b93879 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2301,6 +2301,11 @@ "fastingObserved": "Glukoza na czczo: {value} {unit}", "fastingReference": "Od {low} do {high} {unit} na czczo", "personalReference": "Twój cel: {value}", + "bpBasisAbove": "Decyduje: {axis}, około {offset} mmHg powyżej swojej granicy {boundary} mmHg.", + "bpBasisBelowFloor": "Decyduje: {axis}, około {offset} mmHg poniżej granicy niskiego ciśnienia {boundary} mmHg.", + "bpBasisInBand": "Obie wartości są na poziomie swoich granic referencyjnych lub poniżej.", + "bpScale": "Przy granicy wynik to 85; 100 oznacza około 12 mmHg poniżej.", + "bpRepresentative": "Opiera się na średniej z ostatnich {days} dni, która mocniej waży nowsze pomiary, więc może nie odpowiadać żadnemu pojedynczemu pomiarowi.", "source": "Źródło: {source}, stan na {date}", "observedActivity": "{value} kroków dziennie", "observedSleep": "{value} minut na noc", diff --git a/package.json b/package.json index a77845fcf..483df3683 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.34.4", + "version": "1.34.5", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 1fe34fe52..cb99cf294 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.34.4"; + /* @sw-version-fallback */ "v1.34.5"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/__tests__/helpers/narrated-locale.ts b/src/__tests__/helpers/narrated-locale.ts new file mode 100644 index 000000000..a7042029b --- /dev/null +++ b/src/__tests__/helpers/narrated-locale.ts @@ -0,0 +1,300 @@ +/** + * Detector for the "a narrated sentence's locale stays required" guard. + * + * Lives beside the guard rather than inside it so the guard's own + * counter-tests can run this exact code against disguised sources without + * re-running the guard itself. See `narrated-locale-required-guard.test.ts` + * for why the rule exists and `narrated-locale-guard-defeat.test.ts` for the + * disguises it has to see through. + */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import ts from "typescript"; + +const REPO_ROOT = resolve(__dirname, "../../.."); + +/** + * The chain. Each entry is a declaration whose output is, or becomes, prose a + * person reads. Add to it when a new one joins the chain; never remove one + * because the check became inconvenient. + */ +export const CHAIN: ReadonlyArray<{ file: string; declaration: string }> = [ + { + file: "src/lib/insights/correlation-discovery.ts", + declaration: "discoverCorrelations", + }, + { + file: "src/lib/insights/correlation-discovery.ts", + declaration: "discoverEmergingCorrelations", + }, + { + file: "src/lib/insights/correlation-discovery.ts", + declaration: "discoverLabOutcomeCorrelations", + }, + { + file: "src/lib/ai/coach/tools/correlations-read.ts", + declaration: "readCoachCorrelations", + }, + { + file: "src/lib/insights/derived/coach-read.ts", + declaration: "buildCoachReadStrip", + }, + { + file: "src/lib/insights/metric-correlation-context.ts", + declaration: "getRelevantCorrelationsForMetric", + }, + { + file: "src/lib/cycle/phase-crosstab.ts", + declaration: "discoverPhaseCorrelations", + }, + { + file: "src/lib/insights/narrative/period-narrative.ts", + declaration: "buildPeriodNarrativeContext", + }, + { + file: "src/lib/insights/narrative/period-narrative.ts", + declaration: "assemblePeriodNarrativeContext", + }, + { + file: "src/lib/ai/coach/correlations-snapshot.ts", + declaration: "buildCorrelationsSnapshotBlock", + }, +]; + +export const WHY_NO_DEFAULT = + "A locale on this chain must be REQUIRED, with no `?` and no default. " + + "These functions return finished sentences that a page prints verbatim. " + + "An optional locale lets a caller say nothing and still get a sentence — " + + "which is exactly how the metric page's Coach-read strip came to answer a " + + "German page in English, under a German first line, for months. A default " + + "cannot fail loudly; a missing argument can. Pass the reader's locale " + + "instead of restoring the fallback."; + +export interface LocaleSite { + /** Where it sits, for the failure message. */ + where: string; + optional: boolean; + hasInitializer: boolean; +} + +/** The local name `Locale` is bound to in this file (it may be aliased). */ +function localeTypeName(source: ts.SourceFile): string { + let name = "Locale"; + for (const statement of source.statements) { + if (!ts.isImportDeclaration(statement)) continue; + const bindings = statement.importClause?.namedBindings; + if (!bindings || !ts.isNamedImports(bindings)) continue; + for (const element of bindings.elements) { + if ((element.propertyName ?? element.name).text === "Locale") { + name = element.name.text; + } + } + } + return name; +} + +/** Does this type node denote the locale type (possibly a union with it)? */ +function isLocaleType(node: ts.TypeNode | undefined, alias: string): boolean { + if (!node) return false; + if (ts.isTypeReferenceNode(node)) return node.typeName.getText() === alias; + if (ts.isUnionTypeNode(node)) + return node.types.some((t) => isLocaleType(t, alias)); + return false; +} + +/** + * Does the type still let a caller say nothing? `Locale | undefined` and + * `Locale | null` carry no `?` and no default, so a check that only looked at + * the question mark read them as required — and they are the original bug + * wearing a different hat: the caller passes `undefined`, the body picks a + * language, and the sentence comes back in English again. `any` and `unknown` + * are the same hole with the door taken off. + */ +function toleratesAbsence(node: ts.TypeNode | undefined): boolean { + if (!node) return false; + if ( + node.kind === ts.SyntaxKind.UndefinedKeyword || + node.kind === ts.SyntaxKind.NullKeyword || + node.kind === ts.SyntaxKind.AnyKeyword || + node.kind === ts.SyntaxKind.UnknownKeyword || + node.kind === ts.SyntaxKind.VoidKeyword + ) { + return true; + } + if (ts.isLiteralTypeNode(node)) { + return node.literal.kind === ts.SyntaxKind.NullKeyword; + } + if (ts.isUnionTypeNode(node)) return node.types.some(toleratesAbsence); + return false; +} + +/** + * Property names given a default inside an object binding pattern — + * `function narrate({ locale = "en" }: { locale: Locale })`. The default sits + * on the binding, not on the parameter, so `parameter.initializer` is empty + * and the signature reads clean. + */ +function defaultedBindingNames( + parameter: ts.ParameterDeclaration, +): Set { + const names = new Set(); + if (!ts.isObjectBindingPattern(parameter.name)) return names; + for (const element of parameter.name.elements) { + if (element.initializer === undefined) continue; + names.add((element.propertyName ?? element.name).getText()); + } + return names; +} + +/** Find a top-level function/interface declaration by name. */ +function findDeclaration( + source: ts.SourceFile, + name: string, +): ts.FunctionDeclaration | ts.InterfaceDeclaration | undefined { + for (const statement of source.statements) { + if ( + (ts.isFunctionDeclaration(statement) || + ts.isInterfaceDeclaration(statement)) && + statement.name?.text === name + ) { + return statement; + } + } + return undefined; +} + +/** + * Every place a locale is accepted by `declaration` — as a parameter in its own + * right, as a member of an inline options object, or as a member of a + * same-file interface used as a parameter type. Keyed on the TYPE, so renaming + * the parameter changes nothing. + */ +export function localeSites( + source: ts.SourceFile, + declarationName: string, +): LocaleSite[] { + const alias = localeTypeName(source); + const declaration = findDeclaration(source, declarationName); + if (!declaration) { + throw new Error( + `${WHY_NO_DEFAULT}\n\nGuard could not find \`${declarationName}\` in ` + + `${source.fileName}. If it was renamed or moved, update the chain ` + + `list — do not delete the entry.`, + ); + } + + const sites: LocaleSite[] = []; + + const collectFromMembers = ( + members: ts.NodeArray, + owner: string, + ownerOptional: boolean, + ownerHasInitializer: boolean, + defaultedNames: Set = new Set(), + ) => { + for (const member of members) { + if (!ts.isPropertySignature(member)) continue; + if (!isLocaleType(member.type, alias)) continue; + const name = member.name.getText(); + sites.push({ + where: `${owner}.${name}`, + // The property itself may be required while the object it lives on is + // optional or defaulted — which puts the silence right back. So does a + // type that merely admits `undefined` / `null`: no question mark, and a + // caller can still hand over nothing. + optional: + member.questionToken !== undefined || + ownerOptional || + toleratesAbsence(member.type), + hasInitializer: ownerHasInitializer || defaultedNames.has(name), + }); + } + }; + + const visitParameters = ( + parameters: ts.NodeArray, + ) => { + for (const parameter of parameters) { + const owner = parameter.name.getText(); + const optional = parameter.questionToken !== undefined; + const hasInitializer = parameter.initializer !== undefined; + const defaultedNames = defaultedBindingNames(parameter); + if (isLocaleType(parameter.type, alias)) { + sites.push({ + where: owner, + optional: optional || toleratesAbsence(parameter.type), + hasInitializer, + }); + continue; + } + const type = parameter.type; + if (type && ts.isTypeLiteralNode(type)) { + collectFromMembers( + type.members, + owner, + optional, + hasInitializer, + defaultedNames, + ); + } else if (type && ts.isTypeReferenceNode(type)) { + const referenced = findDeclaration(source, type.typeName.getText()); + if (referenced && ts.isInterfaceDeclaration(referenced)) { + collectFromMembers( + referenced.members, + type.typeName.getText(), + optional, + hasInitializer, + defaultedNames, + ); + } + } + } + }; + + if (ts.isInterfaceDeclaration(declaration)) { + collectFromMembers( + declaration.members, + declaration.name.text, + false, + false, + ); + } else { + visitParameters(declaration.parameters); + } + + return sites; +} + +/** + * Every `??` / `||` in the file whose left side reads a locale. This is the + * "move the default into a variable" escape: `opts.locale ?? fallback` is the + * same bug as `opts.locale ?? defaultLocale`, and neither is allowed here. + */ +export function localeFallbacks(source: ts.SourceFile): string[] { + const found: string[] = []; + const visit = (node: ts.Node): void => { + if (ts.isBinaryExpression(node)) { + const op = node.operatorToken.kind; + if ( + op === ts.SyntaxKind.QuestionQuestionToken || + op === ts.SyntaxKind.BarBarToken + ) { + const left = node.left.getText(); + if (/locale/i.test(left)) found.push(node.getText()); + } + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(source, visit); + return found; +} + +export function parse(file: string, text?: string): ts.SourceFile { + return ts.createSourceFile( + file, + text ?? readFileSync(resolve(REPO_ROOT, file), "utf8"), + ts.ScriptTarget.Latest, + true, + ); +} diff --git a/src/__tests__/narrated-locale-guard-defeat.test.ts b/src/__tests__/narrated-locale-guard-defeat.test.ts new file mode 100644 index 000000000..cd94e11b1 --- /dev/null +++ b/src/__tests__/narrated-locale-guard-defeat.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, it } from "vitest"; + +import { localeSites, localeFallbacks, parse } from "./helpers/narrated-locale"; + +/** + * The guard checked against a guard. + * + * A structural check that cannot fail is worse than no check: it reads as + * proof and is not. So each case below is a disguise someone would plausibly + * reach for while putting the English default back — renaming the parameter, + * aliasing the type, hiding the fallback behind a variable, defaulting the + * whole options object instead of the field — and each one is asserted to be + * caught by the SAME detector the real guard runs. + * + * These sources are strings on purpose. They are the shapes that must not + * ship, so nothing here may live in a file the app can import. + */ + +const FILE = "disguise.ts"; + +describe("the locale guard cannot be talked around", () => { + it("catches the plain optional it was written for", () => { + const [site] = localeSites( + parse( + FILE, + `import type { Locale } from "@/lib/i18n/config"; + export function narrate(opts: { locale?: Locale }) { return opts; }`, + ), + "narrate", + ); + expect(site.optional).toBe(true); + }); + + it("catches a renamed parameter — it reads the type, not the name", () => { + const [site] = localeSites( + parse( + FILE, + `import type { Locale } from "@/lib/i18n/config"; + export function narrate(opts: { lang?: Locale }) { return opts; }`, + ), + "narrate", + ); + expect(site.where).toBe("opts.lang"); + expect(site.optional).toBe(true); + }); + + it("catches an aliased type import", () => { + const [site] = localeSites( + parse( + FILE, + `import type { Locale as L } from "@/lib/i18n/config"; + export function narrate(opts: { lang?: L }) { return opts; }`, + ), + "narrate", + ); + expect(site.optional).toBe(true); + }); + + it("catches a required field on a DEFAULTED options object", () => { + // The field says `locale: Locale`, so a field-only check reads as clean — + // but `= {}` means the caller can still say nothing. + const [site] = localeSites( + parse( + FILE, + `import type { Locale } from "@/lib/i18n/config"; + export function narrate(opts: { locale: Locale } = {} as never) { return opts; }`, + ), + "narrate", + ); + expect(site.hasInitializer).toBe(true); + }); + + it("catches a required field on an OPTIONAL options object", () => { + const [site] = localeSites( + parse( + FILE, + `import type { Locale } from "@/lib/i18n/config"; + export function narrate(opts?: { locale: Locale }) { return opts; }`, + ), + "narrate", + ); + expect(site.optional).toBe(true); + }); + + it("catches an optional locale hidden in a same-file interface", () => { + const [site] = localeSites( + parse( + FILE, + `import type { Locale } from "@/lib/i18n/config"; + export interface Opts { period: string; locale?: Locale } + export function narrate(opts: Opts) { return opts; }`, + ), + "narrate", + ); + expect(site.optional).toBe(true); + }); + + it("catches a union that merely tolerates undefined", () => { + const [site] = localeSites( + parse( + FILE, + `import type { Locale } from "@/lib/i18n/config"; + export function narrate(opts: { locale?: Locale | undefined }) { return opts; }`, + ), + "narrate", + ); + expect(site.optional).toBe(true); + }); + + it("catches `Locale | undefined` written WITHOUT the question mark", () => { + // The nastiest disguise, because it reads as required at a glance: no `?`, + // no default, and the field is spelled out in the signature. But the caller + // can still pass `undefined`, the body picks a language, and the sentence + // comes back in English — the original bug, one hat later. + const [site] = localeSites( + parse( + FILE, + `import type { Locale } from "@/lib/i18n/config"; + export function narrate(opts: { locale: Locale | undefined }) { + return opts.locale === undefined ? "en" : opts.locale; + }`, + ), + "narrate", + ); + expect(site.optional).toBe(true); + }); + + it("catches `Locale | null` the same way", () => { + const [site] = localeSites( + parse( + FILE, + `import type { Locale } from "@/lib/i18n/config"; + export function narrate(opts: { locale: Locale | null }) { + return opts.locale === null ? "en" : opts.locale; + }`, + ), + "narrate", + ); + expect(site.optional).toBe(true); + }); + + it("catches a locale widened to `any`", () => { + const [site] = localeSites( + parse( + FILE, + `import type { Locale } from "@/lib/i18n/config"; + export function narrate(opts: { locale: Locale | any }) { return opts; }`, + ), + "narrate", + ); + expect(site.optional).toBe(true); + }); + + it("catches a default hidden in the parameter's own binding pattern", () => { + // The field is required in the type and the parameter has no initializer, + // so both of the earlier checks read clean — the default sits on the + // destructuring instead. + const [site] = localeSites( + parse( + FILE, + `import type { Locale } from "@/lib/i18n/config"; + export function narrate({ locale = "en" }: { locale: Locale }) { return locale; }`, + ), + "narrate", + ); + expect(site.hasInitializer).toBe(true); + }); + + it("leaves a plainly required locale alone", () => { + // The shape the whole chain is supposed to have. If this ever reads as a + // violation the guard stops being a guard and starts being noise. + const [site] = localeSites( + parse( + FILE, + `import type { Locale } from "@/lib/i18n/config"; + export function narrate(opts: { locale: Locale }) { return opts.locale; }`, + ), + "narrate", + ); + expect(site.optional).toBe(false); + expect(site.hasInitializer).toBe(false); + }); + + it("refuses to pass a declaration that dropped the locale entirely", () => { + expect(() => + localeSites( + parse( + FILE, + `export function narrate(series: string[]) { return series; }`, + ), + "narrate", + ), + ).not.toThrow(); + expect( + localeSites( + parse( + FILE, + `export function narrate(series: string[]) { return series; }`, + ), + "narrate", + ), + ).toEqual([]); // the guard's own `length > 0` assertion is what fails on this + }); + + it("throws rather than quietly passing when the declaration is gone", () => { + expect(() => + localeSites(parse(FILE, `export const x = 1;`), "narrate"), + ).toThrow(/could not find/); + }); + + it("catches the default moved into a variable", () => { + expect( + localeFallbacks( + parse( + FILE, + `const fallback = "en"; + export function narrate(opts: { locale?: string }) { + return opts.locale ?? fallback; + }`, + ), + ), + ).not.toEqual([]); + }); + + it("catches the default written with || instead of ??", () => { + expect( + localeFallbacks( + parse( + FILE, + `export const l = (o: { locale?: string }) => o.locale || "en";`, + ), + ), + ).not.toEqual([]); + }); + + it("catches a renamed field's fallback when it still reads like a locale", () => { + expect( + localeFallbacks( + parse( + FILE, + `export const l = (o: { readerLocale?: string }) => o.readerLocale ?? "en";`, + ), + ), + ).not.toEqual([]); + }); + + it("does not fire on a fallback that has nothing to do with language", () => { + // A guard that flags everything gets silenced, so it has to stay quiet + // where it should. + expect( + localeFallbacks( + parse( + FILE, + `export const n = (o: { minPairs?: number }) => o.minPairs ?? 20;`, + ), + ), + ).toEqual([]); + }); +}); diff --git a/src/__tests__/narrated-locale-required-guard.test.ts b/src/__tests__/narrated-locale-required-guard.test.ts new file mode 100644 index 000000000..a9fceaee7 --- /dev/null +++ b/src/__tests__/narrated-locale-required-guard.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; + +import { queryKeys } from "@/lib/query-keys"; +import { + CHAIN, + WHY_NO_DEFAULT, + localeFallbacks, + localeSites, + parse, +} from "./helpers/narrated-locale"; + +/** + * A function that writes a sentence for a person must be told which language. + * + * The metric page's "Coach read" strip printed its own summary in German and + * the pattern line under it in English, one under the other, for months. The + * translations existed in all six languages the whole time. What broke was the + * shape of the call: the discovery engine took `locale?: Locale` and fell back + * to English, and the route that knew who was reading simply never said. Nobody + * had to write anything wrong — staying quiet was enough, and the result read + * like a finished sentence, so nothing looked broken from the code's side. + * + * A default is what made silence survivable. So on this chain there is none: + * every locale a narrated sentence depends on is a REQUIRED parameter, and a + * caller that cannot name one does not compile. This guard is what keeps that + * true, because "optional with a sensible default" reads like a kindness every + * time someone adds the next caller. + * + * It reads types, not names. Renaming the parameter, or lifting the default + * into a variable, does not get past it — see the disguises in + * `narrated-locale-guard-defeat.test.ts`, which run this same detector. + */ + +describe("a narrated sentence's locale stays required", () => { + for (const { file, declaration } of CHAIN) { + it(`${declaration} (${file}) accepts a locale, and it is required`, () => { + const sites = localeSites(parse(file), declaration); + + // Deleting the parameter must not pass as "nothing optional found". + expect( + sites.length, + `${WHY_NO_DEFAULT}\n\n\`${declaration}\` no longer accepts a locale at ` + + `all. It writes prose; it has to be told the language.`, + ).toBeGreaterThan(0); + + for (const site of sites) { + expect( + site.optional, + `${WHY_NO_DEFAULT}\n\nOptional locale at \`${declaration}\` → ${site.where}.`, + ).toBe(false); + expect( + site.hasInitializer, + `${WHY_NO_DEFAULT}\n\nDefaulted locale at \`${declaration}\` → ${site.where}.`, + ).toBe(false); + } + }); + } + + for (const file of [...new Set(CHAIN.map((entry) => entry.file))]) { + it(`${file} falls back to no language of its own`, () => { + const fallbacks = localeFallbacks(parse(file)); + expect( + fallbacks, + `${WHY_NO_DEFAULT}\n\nA locale fallback in ${file}: ` + + `${fallbacks.join(", ")}. Naming the fallback something else does ` + + `not change what it does.`, + ).toEqual([]); + }); + } + + it("keys the strip's browser cache by language", () => { + // The server-side fix is invisible to anyone who switches language rather + // than reloads if the cached entry is shared across languages. + const german = queryKeys.insightsCoachRead("WEIGHT", "de"); + const english = queryKeys.insightsCoachRead("WEIGHT", "en"); + expect(german).not.toEqual(english); + expect(german).toContain("de"); + expect(english).toContain("en"); + }); +}); diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index dc7f3586b..d200f6a9a 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -174,6 +174,11 @@ const PINNED_AFFORDANCES: Record< "src/components/insights/confidence-badge.ts": { "text-success": 1 }, "src/components/insights/daily-briefing.tsx": { "text-success": 1 }, "src/components/insights/derived/band-tokens.ts": { "text-success": 1 }, + // The green `+N` chip beside the Health Score label. It reports a week that + // went up, not an action that succeeded, and it wears the same band token + // the score's own number does — there is no outcome here to route through + // the outcome module. + "src/components/insights/health-score-card.tsx": { "text-success": 1 }, "src/components/insights/insights-edit-mode.tsx": { "toast.success": 2 }, "src/components/insights/insights-tab-strip.tsx": { "toast.success": 1 }, "src/components/insights/mood/mood-better-days.tsx": { "text-success": 1 }, diff --git a/src/app/__tests__/insights-polish.test.ts b/src/app/__tests__/insights-polish.test.ts index b225b623d..df6ed66b6 100644 --- a/src/app/__tests__/insights-polish.test.ts +++ b/src/app/__tests__/insights-polish.test.ts @@ -268,42 +268,55 @@ describe("v1.16.8 — insights overview cold paint", () => { expect(src).not.toMatch(/useDashboardDerived\([^)]*data/); }); - it("reserves the health-score card's footprint while the payload is pending", () => { + it("reserves the score column's footprint while the payload is pending", () => { const src = load(INSIGHTS_PATH); - // The pinned slot holds the collapsed card's footprint from first paint, - // so the resolved card is a swap rather than a push. + // The reserve holds the column from first paint, so the resolved panel is + // a swap rather than a push. expect(src).toMatch( - /analyticsQuery\.isPending && !analytics \? \(\s* { + it("mounts the health score once, in the hero, outside the section registry", () => { const src = load(INSIGHTS_PATH); // An id a saved layout has never seen merges in as default-INVISIBLE, so // registering the score would delete it for every customised page. It is - // also no longer inside the briefing branch, which used to take the whole + // also outside the briefing branch, which used to take the whole // breakdown down with the flag. - const hero = src.indexOf("` mentioned in the file's header + // comment — matching the comment would have made the slice below read a + // block of prose and pass on nothing. + const hero = src.search(/\n\s*", hero)); + expect(heroCall).toContain("healthScore={analytics?.healthScore ?? null}"); + expect(heroCall).not.toContain("flags.briefing"); }); - it("hero strip carries no score surface at all", () => { + it("hero strip owns the one score surface", () => { const heroSrc = readFileSync( join(ROOT, "src/components/insights/hero-strip.tsx"), "utf8", ); - expect(heroSrc).not.toContain("HealthScoreCard"); - expect(heroSrc).not.toContain("health-score-card-skeleton"); - expect(heroSrc).not.toContain("healthScorePending"); + expect(heroSrc).toContain(" { // contract (the chunked findMany must never re-appear on the default // slice critical path). }); + +/** + * v1.34.5 — the blood-pressure score's basis has to ARRIVE, not merely exist. + * + * The grader returns `{ score, basis }` and the pillar builder copies the + * basis onto the pillar value, and both of those ends have their own unit + * tests. Neither of them sees the assembly in between: the route's envelope + * hand-off, the score reader's envelope narrowing, the pillar list, the JSON + * envelope. A field can be produced correctly, consumed correctly, and still + * never reach a client, which is precisely how v1.33.1 shipped a payload + * field that nothing on the wire ever carried. + * + * So these drive the REAL route. Nothing about the score is stubbed — the + * fixture seeds raw blood-pressure rows on the same `$queryRawUnsafe` the + * live BP path issues, and the route does its own pairing, its own + * recency-weighting, its own grading and its own serialisation. The whole + * payload is read back off `res.json()`. + */ +describe("GET /api/analytics — blood-pressure score basis on the wire", () => { + const DAY_MS = 24 * 60 * 60 * 1000; + + /** + * ESH 2023 band for the fixture's date of birth (age < 65). + */ + const CLINICAL = { sysHigh: 129, diaHigh: 79 } as const; + + interface WirePillar { + id: string; + result: { + status: string; + value?: { + score: number; + scoreBasis?: { + axis: string; + relation: string; + offsetMmHg: number; + boundaryMmHg: number; + }; + observed: { label: string }; + reference: { label: string }; + personalReference?: { label: string }; + }; + }; + } + + /** + * Seed one blood-pressure pair per day over the trailing `pairs` days. + * Every pair carries the same reading, so the recency-weighted + * representative is that reading exactly and the expected score can be + * stated as a literal instead of a tolerance. + */ + function seedBpSeries(input: { sys: number; dia: number; pairs: number }) { + const seriesOf = (value: number) => + Array.from({ length: input.pairs }, (_, i) => ({ + measured_at: new Date(Date.now() - (i + 1) * DAY_MS), + value, + })); + const sysRows = seriesOf(input.sys); + const diaRows = seriesOf(input.dia); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(prisma.$queryRawUnsafe as any).mockImplementation( + async (sql: unknown) => { + if (typeof sql !== "string") return []; + if (sql.includes(`m."type" = 'BLOOD_PRESSURE_SYS'`)) return sysRows; + if (sql.includes(`m."type" = 'BLOOD_PRESSURE_DIA'`)) return diaRows; + return []; + }, + ); + } + + function signInWith(thresholdsJson: unknown) { + vi.mocked(getSession).mockResolvedValue({ + session: SESSION_OK.session, + user: { ...SESSION_USER, thresholdsJson }, + } as never); + } + + async function readBloodPressurePillar(): Promise { + const res = await ( + GET as unknown as (...args: never[]) => Promise + )(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { healthScore: { pillars: WirePillar[] } | null }; + }; + const pillar = body.data.healthScore?.pillars.find( + (p) => p.id === "BLOOD_PRESSURE", + ); + if (!pillar) throw new Error("no BLOOD_PRESSURE pillar in the payload"); + return pillar; + } + + it("carries the basis onto the pillar the client actually receives", async () => { + // 137/91 against the 129/79 band: systolic sits 8 over its ceiling and + // grades 57, diastolic sits 12 over and grades 50. The pillar takes the + // worse of the two, so diastolic is the axis that set the number. + seedBpSeries({ sys: 137, dia: 91, pairs: 14 }); + const pillar = await readBloodPressurePillar(); + + expect(pillar.result.status).toBe("ok"); + expect(pillar.result.value?.observed.label).toBe("137/91 mmHg"); + expect(pillar.result.value?.score).toBe(50); + expect(pillar.result.value?.scoreBasis).toEqual({ + axis: "diastolic", + relation: "above_ceiling", + offsetMmHg: 12, + boundaryMmHg: CLINICAL.diaHigh, + }); + }); + + it("says the reading is in band when it is", async () => { + // 126/68 clears both ceilings, so no axis is over anything and the + // relation has to say so rather than reporting a distance from a ceiling + // the reading never reached. Systolic is the nearer of the two to its + // own ceiling here, so this also puts the other axis on the wire. + seedBpSeries({ sys: 126, dia: 68, pairs: 14 }); + const pillar = await readBloodPressurePillar(); + + expect(pillar.result.value?.observed.label).toBe("126/68 mmHg"); + expect(pillar.result.value?.scoreBasis).toEqual({ + axis: "systolic", + relation: "in_band", + offsetMmHg: 3, + boundaryMmHg: CLINICAL.sysHigh, + }); + }); + + it("omits the basis entirely when the pillar has no score to explain", async () => { + // Eleven pairs is one short of the floor, so the pillar is insufficient + // and carries no value at all — the popover must fall back to the + // shipped lines rather than to an explanation of a number nobody has. + seedBpSeries({ sys: 137, dia: 91, pairs: 11 }); + const pillar = await readBloodPressurePillar(); + + expect(pillar.result.status).toBe("insufficient"); + expect(pillar.result.value).toBeUndefined(); + }); + + /** + * The coupling the two-ended tests cannot see. + * + * A user with a personal target gets the whole BP helper run twice: once + * against their own band for the in-target percentages on the dashboard, + * once against the clinical band for the score. The score comes from the + * second run. So does the basis, and it has to, because the two runs + * disagree on every field of it — different binding axis, different + * ceiling, different distance. Picking the score off one run and the + * basis off the other reads as correct in review and produces a popover + * that explains a number the user is not looking at. + * + * The fixture is chosen so the two runs share NOTHING: 137/91 against the + * personal 135/95 band binds on systolic 2 over 135 and scores 77; against + * the clinical 129/79 band it binds on diastolic 12 over 79 and scores 50. + */ + it("takes the basis from the same run as the score when a personal target differs", async () => { + signInWith({ + BLOOD_PRESSURE_SYS: { min: 110, max: 135 }, + BLOOD_PRESSURE_DIA: { min: 65, max: 95 }, + }); + seedBpSeries({ sys: 137, dia: 91, pairs: 14 }); + const pillar = await readBloodPressurePillar(); + + // The personal band is genuinely in play — this account really does take + // the route's second-run branch, so the assertions below are about the + // coupling and not about a user who has no personal target at all. + expect(pillar.result.value?.personalReference?.label).toBe( + "110–135/65–95 mmHg", + ); + + expect(pillar.result.value?.score).toBe(50); + expect(pillar.result.value?.scoreBasis).toEqual({ + axis: "diastolic", + relation: "above_ceiling", + offsetMmHg: 12, + boundaryMmHg: CLINICAL.diaHigh, + }); + // Stated negatively as well: nothing from the personal run may leak in. + expect(pillar.result.value?.scoreBasis?.boundaryMmHg).not.toBe(135); + }); +}); diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index aebd44534..ca9b9c5ad 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -360,6 +360,17 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) { }), ], ); + // The pillar is graded against the CLINICAL band even when the user + // keeps a personal target (that band is the number's identity; the + // personal one is displayed beside it and scores nothing). So when the + // two differ we re-run the whole helper against the clinical targets + // and the score comes from that second run. + // + // v1.34.5 — the "why this number" basis rides the same envelope, so it + // is the same run, the same representative pair and the same ceilings + // by construction. Assign envelopes WHOLE here; picking `graded.score` + // off one run and a basis off the other would let the popover explain + // a number nobody is looking at, and it would look correct in review. if (bpTargetsEqual(bpTargets, clinicalBpTargets)) { bpEnvelope = windows; bpEnvelopePriorWeek = windowsPriorWeek; diff --git a/src/app/api/cycle/insights/__tests__/route-locale.test.ts b/src/app/api/cycle/insights/__tests__/route-locale.test.ts new file mode 100644 index 000000000..a55be82e6 --- /dev/null +++ b/src/app/api/cycle/insights/__tests__/route-locale.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +/** + * The cycle insights route has to say who is reading, too. + * + * Each surviving lagged row carries a finished `interpretation` sentence that + * this route serves straight to the client. The engine underneath can write it + * in any of the six languages, and it only writes the right one if the route + * resolves the reader's language and hands it over — the same handoff the + * metric page's "Coach read" strip was missing when it answered a German page + * in English. + * + * So what is pinned here is the handoff itself: the resolved locale arriving at + * `discoverPhaseCorrelations`, from the cookie a language switch sets and from + * the stored preference when there is no cookie. Without this the route could + * go back to naming one language outright and nothing in the suite would + * notice. + */ + +/** Stands in for the discovery engine so the ARGUMENTS are what is inspected. */ +const discoverPhaseCorrelations = vi.fn< + (args: { locale?: unknown }) => { + discovered: never[]; + pairsTested: number; + fdrQ: number; + minPairs: number; + } +>(() => ({ + discovered: [], + pairsTested: 0, + fdrQ: 0.1, + minPairs: 20, +})); +vi.mock("@/lib/cycle/phase-crosstab", async (importOriginal) => ({ + ...(await importOriginal()), + discoverPhaseCorrelations: (args: { locale?: unknown }) => + discoverPhaseCorrelations(args), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + auditLog: { create: vi.fn() }, + appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, + cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) }, + measurement: { findMany: vi.fn().mockResolvedValue([]) }, + moodEntry: { findMany: vi.fn().mockResolvedValue([]) }, + user: { + findUnique: vi.fn().mockResolvedValue({ sourcePriorityJson: null }), + }, + }, +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/cycle/gate", () => ({ + requireCycleEnabled: vi.fn(async () => ({ + enabled: true, + profile: { lutealPhaseLength: 14, averageCycleLength: 28 }, + })), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(async () => ({ allowed: true })), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const localeCookie = vi.fn<() => string | undefined>(() => undefined); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: (name: string) => + name === "healthlog-locale" && localeCookie() !== undefined + ? { value: localeCookie() } + : undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { getSession } from "@/lib/auth/session"; + +function sessionWithLocale(locale: string | null) { + return { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "testuser", + role: "USER" as const, + gender: "FEMALE" as const, + timezone: "Europe/Berlin", + locale, + }, + }; +} + +beforeEach(() => { + vi.mocked(getSession).mockResolvedValue( + sessionWithLocale(null) as unknown as Awaited< + ReturnType + >, + ); + discoverPhaseCorrelations.mockClear(); + localeCookie.mockReturnValue(undefined); +}); + +/** The locale the route handed the discovery engine on the last call. */ +function passedLocale(): unknown { + return discoverPhaseCorrelations.mock.calls.at(-1)?.[0]?.locale; +} + +describe("GET /api/cycle/insights — the reader's language", () => { + it("passes the language the switcher's cookie names", async () => { + localeCookie.mockReturnValue("de"); + + const res = await GET(); + + expect(res.status).toBe(200); + expect(passedLocale()).toBe("de"); + }); + + it("falls back to the stored preference when there is no cookie", async () => { + vi.mocked(getSession).mockResolvedValue( + sessionWithLocale("pl") as unknown as Awaited< + ReturnType + >, + ); + + await GET(); + + expect(passedLocale()).toBe("pl"); + }); + + it("never hard-codes a language of its own", async () => { + // Two readers, two languages, from the same handler. A route that named + // one outright would pass this only by accident. + localeCookie.mockReturnValue("it"); + await GET(); + const first = passedLocale(); + localeCookie.mockReturnValue("fr"); + await GET(); + + expect([first, passedLocale()]).toEqual(["it", "fr"]); + }); +}); diff --git a/src/app/api/cycle/insights/route.ts b/src/app/api/cycle/insights/route.ts index 3f16a02ed..7a9d5fdfc 100644 --- a/src/app/api/cycle/insights/route.ts +++ b/src/app/api/cycle/insights/route.ts @@ -20,6 +20,7 @@ import { annotate } from "@/lib/logging/context"; import { apiSuccess, apiError } from "@/lib/api-response"; import { requireCycleEnabled } from "@/lib/cycle/gate"; import { checkRateLimit } from "@/lib/rate-limit"; +import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { predictCycle, type NightlyTempInput } from "@/lib/cycle"; import { LUTEAL_DEFAULT } from "@/lib/cycle/types"; import { @@ -208,10 +209,16 @@ export const GET = apiHandler(async () => { userPriorityJson, }); const headline = selectHeadlinePhaseRow(rows); + // The lagged rows are served with their narrated `interpretation` intact, so + // the reader's language is resolved here the same way the correlations route + // resolves it. This surface had the same silent-English gap the metric-page + // strip had. + const locale = await resolveServerLocale({ userLocale: user.locale ?? null }); const lagged = discoverPhaseCorrelations({ phaseByDay, measurements, userPriorityJson, + locale, }); // Cycle-NATIVE insight: where each logged symptom clusters across the phases. diff --git a/src/app/api/insights/coach-read/__tests__/route.test.ts b/src/app/api/insights/coach-read/__tests__/route.test.ts new file mode 100644 index 000000000..13d083137 --- /dev/null +++ b/src/app/api/insights/coach-read/__tests__/route.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * The route has to say who is reading. + * + * The strip's second line is a finished sentence written server-side. The + * builder can write it in any of the six languages; for months it wrote + * English on a German page because this route never passed a language and the + * engine underneath quietly defaulted. So what is pinned here is the handoff + * itself: the resolved locale reaching `buildCoachReadStrip`, from the cookie a + * language switch sets and from the stored preference when there is no cookie. + */ + +const buildCoachReadStrip = vi.fn(); +vi.mock("@/lib/insights/derived/coach-read", () => ({ + buildCoachReadStrip: (userId: string, metric: string, locale: string) => + buildCoachReadStrip(userId, metric, locale), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + auditLog: { create: vi.fn() }, + appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + }, +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/modules/gate", async (importOriginal) => ({ + ...(await importOriginal()), + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), + resolveModuleMap: vi.fn().mockResolvedValue({}), +})); +vi.mock("@/lib/feature-flags", () => ({ + requireAssistantSurface: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkAnalyticsReadRateLimit: vi.fn(), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const localeCookie = vi.fn<() => string | undefined>(() => undefined); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: (name: string) => + name === "healthlog-locale" && localeCookie() !== undefined + ? { value: localeCookie() } + : undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; + +function sessionWithLocale(locale: string | null) { + return { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "testuser", + role: "USER" as const, + locale, + }, + }; +} + +const REQUEST = () => + new NextRequest("http://localhost/api/insights/coach-read?metric=WEIGHT"); + +beforeEach(() => { + vi.mocked(getSession).mockResolvedValue( + sessionWithLocale(null) as unknown as Awaited< + ReturnType + >, + ); + vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({ + allowed: true, + } as unknown as Awaited>); + buildCoachReadStrip.mockReset().mockResolvedValue({ + baseline: null, + learning: true, + driver: null, + }); + localeCookie.mockReturnValue(undefined); +}); + +describe("GET /api/insights/coach-read — the reader's language", () => { + it("passes the language the switcher's cookie names", async () => { + localeCookie.mockReturnValue("de"); + + const res = await GET(REQUEST()); + + expect(res.status).toBe(200); + expect(buildCoachReadStrip).toHaveBeenCalledWith("user-1", "WEIGHT", "de"); + }); + + it("falls back to the stored preference when there is no cookie", async () => { + vi.mocked(getSession).mockResolvedValue( + sessionWithLocale("fr") as unknown as Awaited< + ReturnType + >, + ); + + await GET(REQUEST()); + + expect(buildCoachReadStrip).toHaveBeenCalledWith("user-1", "WEIGHT", "fr"); + }); + + it("never hard-codes a language of its own", async () => { + // Two readers, two languages, from the same handler. A route that pinned + // one would pass this only by accident. + localeCookie.mockReturnValue("es"); + await GET(REQUEST()); + localeCookie.mockReturnValue("de"); + await GET(REQUEST()); + + expect(buildCoachReadStrip.mock.calls.map((call) => call[2])).toEqual([ + "es", + "de", + ]); + }); +}); diff --git a/src/app/api/insights/coach-read/route.ts b/src/app/api/insights/coach-read/route.ts index 58db34f5c..443d1597b 100644 --- a/src/app/api/insights/coach-read/route.ts +++ b/src/app/api/insights/coach-read/route.ts @@ -23,6 +23,7 @@ import { requireModuleEnabled } from "@/lib/modules/gate"; import { requireAssistantSurface } from "@/lib/feature-flags"; import { measurementTypeEnum } from "@/lib/validations/measurement"; import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read"; +import { resolveServerLocale } from "@/lib/i18n/server-locale"; import type { MeasurementType } from "@/generated/prisma/client"; export const dynamic = "force-dynamic"; @@ -60,12 +61,20 @@ export const GET = apiHandler(async (request: NextRequest) => { } const metric = parsed.data.metric as MeasurementType; - const strip = await buildCoachReadStrip(user.id, metric); + // Line 2 of the strip is a finished sentence the clients print verbatim, so + // the reader's language is decided here — same resolution the correlations + // and analytics routes use (cookie, then the stored preference, then the + // Accept-Language header). Without it the strip answered a German page in + // English, one line under the other. + const locale = await resolveServerLocale({ userLocale: user.locale ?? null }); + + const strip = await buildCoachReadStrip(user.id, metric, locale); annotate({ action: { name: "insights.coach-read" }, meta: { metric, + locale, has_baseline: strip.baseline !== null, learning: strip.learning, has_driver: strip.driver !== null, diff --git a/src/app/insights/page-client.tsx b/src/app/insights/page-client.tsx index 769efcc54..7d7d1dc9e 100644 --- a/src/app/insights/page-client.tsx +++ b/src/app/insights/page-client.tsx @@ -26,10 +26,6 @@ import { QueryErrorCard } from "@/components/ui/query-error-card"; import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; import { HeroStrip } from "@/components/insights/hero-strip"; -import { - HealthScoreCard, - HealthScoreCardSkeleton, -} from "@/components/insights/health-score-card"; import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor"; import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; @@ -561,33 +557,27 @@ export default function InsightsPageClient() { // " line with a discreet connect-provider hint, matching // the briefing card footer. Only when a briefing is actually shown. noProviderStale={briefingPayload !== null && !advisor.hasProvider} + // The Health Score lives in the band's right column and NOWHERE else + // on this page. It is deliberately not a registry section: an id a + // saved layout has never seen merges in as default-INVISIBLE + // (`orderedVisibleSectionIds`), so registering it would silently + // delete the score for every account that has ever customised this + // page. It is also outside the daily-briefing branch, so an instance + // with the briefing switched off keeps the whole breakdown. + healthScore={analytics?.healthScore ?? null} + // `isPending` settles false on success AND error, so a no-score + // account drops the reserve exactly once. + scorePending={analyticsQuery.isPending && !analytics} + // v1.21.2 (A5 / A6) — server-resolved Tension Verdict + return-to- + // baseline off the dashboard snapshot. The panel localises the keys; + // null leaves it quiet. + tension={heroTension} + returnToBand={heroReturnToBand} + // A pillar whose read FAILED is a named line inside the panel, and the + // retry there refetches the payload that carries every pillar. + onScoreRetry={() => void analyticsQuery.refetch()} /> - {/* The Health Score is PINNED here, between the greeting and the - customizable region, and is deliberately NOT a registry section: an - id a saved layout has never seen merges in as default-INVISIBLE - (`orderedVisibleSectionIds`), so registering it would silently delete - the score for every account that has ever customised this page. It - also used to render inside the daily-briefing branch, which meant an - instance with the briefing switched off lost the whole breakdown. - `isPending` settles false on success AND error, so a no-score account - drops the reserve exactly once. */} - {analyticsQuery.isPending && !analytics ? ( - - ) : analytics?.healthScore ? ( - void analyticsQuery.refetch()} - /> - ) : null} - {/* v1.15.18 — the inline "Anpassen" toggle was removed. Customising the overview (section show/hide + order) and sorting the nav pills now live ONLY in Settings → Insights, reached via the top-right cog on the diff --git a/src/components/cycle/use-cycle.ts b/src/components/cycle/use-cycle.ts index 591971cfd..323a7ee19 100644 --- a/src/components/cycle/use-cycle.ts +++ b/src/components/cycle/use-cycle.ts @@ -204,8 +204,12 @@ export function useCycleProfile() { * insights tab. */ export function useCycleInsights() { + // The lagged rows come back with a narrated `interpretation` in the reader's + // language, so the cache cell belongs to one language — otherwise a switch + // would keep serving the sentence written for the previous one. + const { locale } = useTranslations(); return useQuery({ - queryKey: queryKeys.cycleInsights(), + queryKey: queryKeys.cycleInsights(locale), queryFn: () => apiGet("/api/cycle/insights"), staleTime: 5 * 60_000, }); diff --git a/src/components/insights/__tests__/health-score-card-geometry.test.tsx b/src/components/insights/__tests__/health-score-card-geometry.test.tsx new file mode 100644 index 000000000..9912a2c8c --- /dev/null +++ b/src/components/insights/__tests__/health-score-card-geometry.test.tsx @@ -0,0 +1,488 @@ +import { createRequire } from "node:module"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { Browser, Page } from "@playwright/test"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { locales, type Locale } from "@/lib/i18n/config"; +import type { + HealthScoreReport, + ScorePillarResult, +} from "@/lib/analytics/score/types"; +import { HeroStrip } from "../hero-strip"; + +/** + * The hero band's geometry, measured rather than asserted from source. + * + * Two numbers in `health-score-card.tsx` cannot be checked by reading the + * file: the height the loading reserve holds, and the width of the row's + * label column. Both were carried for a while as docblock claims with no way + * to reproduce them. This file is that way: it renders the REAL band with the + * REAL compiled stylesheet, lays it out in a browser at the real column + * width, and reads the boxes back. + * + * What it does NOT do is pretend to know the shipped typeface. The app loads + * Inter through `next/font/google` at build time; no font file is committed, + * so a headless browser here resolves the `font-sans` fallback chain instead. + * Every assertion below is therefore written to be font-independent — a + * relative comparison inside one run, or a structural property (does the row + * push, does the label ellipsise, is the bar still there) that holds in any + * typeface. The absolute pixel numbers this file prints are true of the run + * that printed them and are reported as such, never baked into a comment as + * if they were a property of the design. + * + * It needs a Chromium build. `pnpm test` on a machine that has one (any + * machine that has run `pnpm e2e`) measures for real; a machine without one + * skips loudly rather than passing quietly. The unit CI job installs no + * browsers, so treat this as a check for the person editing the panel and for + * the release audit, not as the gate. + */ + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ user: { unitPreference: "metric", glucoseUnit: "mg/dL" } }), +})); + +/** The `md` column: `md:basis-[22rem]`, i.e. 352 CSS px. */ +const COLUMN_PX = 352; +/** Wide enough for the two-column split, narrow enough to stay below `xl`. */ +const VIEWPORT_PX = 1024; +/** One `text-xs` row is a 16 px line; two lines would exceed this. */ +const ONE_LINE_PX = 20; +/** + * The row's info trigger carries `-mx-2` so its 44 px tap target does not + * inflate a 20 px row: it reaches 8 px into the card's own padding on each + * side, which is deliberate and stays inside the panel. Anything past that is + * a real push, so the row check allows exactly this much and no more. + */ +const TAP_TARGET_OVERHANG_PX = 8; + +const require = createRequire(import.meta.url); + +/** + * Compile `globals.css` the way the app does. + * + * `postcss` is not a direct dependency — it arrives under the Tailwind + * PostCSS plugin, which is. Resolving it from that plugin's own location is + * what keeps this working under pnpm's strict layout without adding a + * dependency for a check. + */ +async function compileStylesheet(): Promise { + const pluginEntry = require.resolve("@tailwindcss/postcss"); + const postcss = require(require.resolve("postcss", { paths: [pluginEntry] })); + const tailwind = require(pluginEntry); + const fs = await import("node:fs/promises"); + const source = await fs.readFile("src/app/globals.css", "utf8"); + const plugin = tailwind.default ? tailwind.default() : tailwind(); + const result = await postcss([plugin]).process(source, { + from: "src/app/globals.css", + }); + return result.css; +} + +const provenance = { + inputs: ["BLOOD_PRESSURE"], + source: "live" as const, + windowDays: 90, + computedAt: "2026-07-28T12:00:00.000Z", +}; + +const coverage = { + requiredInputs: 3, + presentInputs: 3, + historyDays: 28, + missing: [] as string[], +}; + +function scored(id: string, score: number): ScorePillarResult { + return { + id, + domain: "cardiometabolic", + result: { + status: "ok", + value: { + score, + observed: { + value: 126, + unit: "mmHg", + label: "126/78 mmHg", + asOf: "2026-07-27T08:00:00.000Z", + sources: ["MANUAL"], + }, + reference: { + kind: "clinical-threshold", + low: 120, + high: 129, + label: "120 to 129/70 to 79 mmHg", + source: "ESH 2023", + }, + noiseFloor: 1, + deltaEligible: true, + deltaIdentity: `pillar_${id}`, + }, + coverage, + confidence: { score: 100, band: "high" }, + provenance, + }, + } as ScorePillarResult; +} + +/** Every pillar id, so each locale is measured on its own longest label. */ +const ALL_PILLARS = [ + "BLOOD_PRESSURE", + "GLYCAEMIA", + "ACTIVITY", + "SLEEP", + "ADIPOSITY", + "WELLBEING", + "FITNESS", + "LIPIDS", +] as const; + +function reportWith(pillars: ScorePillarResult[]): HealthScoreReport { + return { + composite: { + status: "ok", + value: { + score: 74, + band: "green", + bandSetter: null, + composition: pillars.map((p) => p.id), + noiseFloor: 3, + scoreVersion: 2, + }, + coverage, + confidence: { score: 100, band: "high" }, + provenance, + }, + pillars, + delta: 2, + deltaReason: null, + scoreVersion: 2, + weightGoal: { + status: "insufficient", + coverage: { + requiredInputs: 2, + presentInputs: 0, + historyDays: 0, + missing: [], + }, + provenance: { ...provenance, inputs: ["WEIGHT"] }, + reason: "no_personal_goal", + }, + algorithmNotice: null, + } as HealthScoreReport; +} + +/** N scored rows, the shape the report actually produces (3 to 6, per §5). */ +function reportWithRows(rows: number): HealthScoreReport { + return reportWith( + ALL_PILLARS.slice(0, rows).map((id, i) => scored(id, 80 - i * 4)), + ); +} + +function band(node: React.ReactNode, locale: Locale = "en"): string { + return renderToStaticMarkup( + {node}, + ); +} + +let browser: Browser | null = null; +let page: Page | null = null; +let stylesheet = ""; +let skipReason: string | null = null; + +beforeAll(async () => { + try { + const { chromium } = await import("@playwright/test"); + browser = await chromium.launch(); + } catch (error) { + skipReason = `no Chromium build available (${String(error).slice(0, 120)})`; + // Loud on purpose: a geometry check that quietly skips is the same thing + // as no geometry check, and this repo has shipped that before. + console.warn(`[hero geometry] SKIPPED — ${skipReason}`); + return; + } + stylesheet = await compileStylesheet(); + page = await browser.newPage({ + viewport: { width: VIEWPORT_PX, height: 1200 }, + }); +}, 60_000); + +afterAll(async () => { + await browser?.close(); +}); + +/** Lay the rendered band out and hand the page back for measuring. */ +async function layout(html: string, width = VIEWPORT_PX): Promise { + const target = page; + if (!target) throw new Error("browser page was never created"); + await target.setContent( + ` +
${html}
+ `, + ); + await target.addStyleTag({ content: stylesheet }); + return target; +} + +const PANEL = '[data-slot="health-score-card"]'; +const RESERVE = '[data-slot="health-score-card-skeleton"]'; + +describe("hero band geometry", () => { + it("holds a reserve that sits inside the range the real report produces", async () => { + if (skipReason) return expect(skipReason).toBeTruthy(); + + const heights: Record = {}; + for (const rows of [3, 4, 5, 6]) { + const view = await layout( + band( + , + ), + ); + heights[`rows${rows}`] = await view + .locator(PANEL) + .evaluate((el) => Math.round(el.getBoundingClientRect().height)); + const width = await view + .locator(PANEL) + .evaluate((el) => Math.round(el.getBoundingClientRect().width)); + expect(width, "the panel is not on the md column width").toBe(COLUMN_PX); + } + + const view = await layout( + band( + , + ), + ); + // Measured twice: as it renders, and with the declared minimum removed. + // If the two agree, the constant is decoration and the reserve is really + // whatever its blocks happen to add up to — which is how a reserve drifts + // without anyone touching the number that claims to own it. + const { reserve, natural } = await view.locator(RESERVE).evaluate((el) => { + const rendered = Math.round(el.getBoundingClientRect().height); + const declared = el.style.minHeight; + (el as HTMLElement).style.minHeight = "0px"; + const content = Math.round(el.getBoundingClientRect().height); + (el as HTMLElement).style.minHeight = declared; + return { reserve: rendered, natural: content }; + }); + + console.info( + `[hero geometry] column ${COLUMN_PX}px · panel ${JSON.stringify(heights)} · reserve ${reserve}px (blocks alone ${natural}px)`, + ); + + expect( + natural, + `the reserve's own blocks are ${natural}px, so the declared minimum never binds and the reserve is whatever the blocks happen to be`, + ).toBeLessThan(reserve); + + // The reserve cannot match every report, so it must not be one-sided: a + // three-row account may not be pushed down, a six-row account may not be + // left with a gap that collapses. Between the two is the whole rule. + expect( + reserve, + `reserve ${reserve}px is below a three-row panel (${heights.rows3}px): a short report gets pushed`, + ).toBeGreaterThanOrEqual(heights.rows3); + expect( + reserve, + `reserve ${reserve}px is above a six-row panel (${heights.rows6}px): a long report leaves a collapsing gap`, + ).toBeLessThanOrEqual(heights.rows6); + }, 60_000); + + it("keeps every row on one line in all six locales", async () => { + if (skipReason) return expect(skipReason).toBeTruthy(); + + const widest: Record = {}; + + for (const locale of locales) { + const view = await layout( + band( + scored(id, 80 - i * 4)), + )} + />, + locale, + ), + ); + + const rows = await view.locator(`${PANEL} li[data-status="ok"]`).all(); + expect(rows.length, `${locale}: no rows to measure`).toBe( + ALL_PILLARS.length, + ); + + let localeWidest = { label: "", natural: 0 }; + for (const row of rows) { + const m = await row.evaluate((el) => { + const label = el.querySelector("span"); + const bar = el.querySelector( + '[data-slot="health-score-pillar-bar"]', + )?.parentElement; + const value = el.querySelectorAll("span")[1]; + // The natural width the label would take if nothing constrained it. + const probe = document.createElement("span"); + probe.textContent = label?.textContent ?? ""; + probe.style.cssText = + "position:absolute;visibility:hidden;white-space:nowrap;width:max-content"; + probe.className = label?.className ?? ""; + document.body.appendChild(probe); + const natural = Math.ceil(probe.getBoundingClientRect().width); + probe.remove(); + return { + id: el.getAttribute("data-pillar") ?? "", + text: label?.textContent ?? "", + natural, + labelWidth: Math.round(label?.getBoundingClientRect().width ?? 0), + labelHeight: Math.round(label?.getBoundingClientRect().height ?? 0), + labelOverflow: label + ? getComputedStyle(label).textOverflow + : "none", + barWidth: Math.round(bar?.getBoundingClientRect().width ?? 0), + valueScroll: value?.scrollWidth ?? 0, + valueClient: value?.clientWidth ?? 0, + rowScroll: el.scrollWidth, + rowClient: el.clientWidth, + }; + }); + + // The row is the invariant, not the label: a long name may ellipsise, + // but it may never widen the row or wrap it onto a second line. Drop + // `min-w-0` or `truncate` from the label and the first grid track + // grows to the longest word instead, which is exactly this. + expect( + m.rowScroll, + `${locale}/${m.id}: the row is pushed wider than its column by "${m.text}"`, + ).toBeLessThanOrEqual(m.rowClient + TAP_TARGET_OVERHANG_PX); + expect( + m.labelWidth, + `${locale}/${m.id}: the label track grew past 7rem for "${m.text}"`, + ).toBeLessThanOrEqual(112 + 1); + expect( + m.labelHeight, + `${locale}/${m.id}: "${m.text}" wrapped onto a second line`, + ).toBeLessThanOrEqual(ONE_LINE_PX); + expect( + m.labelOverflow, + `${locale}/${m.id}: "${m.text}" is cut without an ellipsis`, + ).toBe("ellipsis"); + // The label column may not eat the two things the row exists to show. + expect( + m.barWidth, + `${locale}/${m.id}: the fill bar was squeezed out by the label column`, + ).toBeGreaterThan(24); + expect( + m.valueScroll, + `${locale}/${m.id}: the score itself is truncated`, + ).toBeLessThanOrEqual(m.valueClient + 1); + + if (m.natural > localeWidest.natural) { + localeWidest = { label: m.text, natural: m.natural }; + } + } + widest[locale] = localeWidest; + } + + // Reported, never asserted: these widths are the measuring browser's + // fallback typeface, not the shipped Inter, so they say how much room the + // longest name wants in THIS run — not a pixel budget the design owns. + console.info( + `[hero geometry] longest label per locale against a 112px column: ${Object.entries( + widest, + ) + .map(([l, w]) => `${l} "${w.label}" ${w.natural}px`) + .join(" · ")}`, + ); + }, 60_000); + + it.each([ + { width: VIEWPORT_PX, where: "the md column" }, + { width: 360, where: "a 360px phone, stacked under the greeting" }, + ])( + "never scrolls sideways in $where", + async ({ width }) => { + if (skipReason) return expect(skipReason).toBeTruthy(); + + const target = page!; + await target.setViewportSize({ width, height: 1200 }); + // French carries the longest pillar names of the six, so it is the + // width-worst case rather than an arbitrary pick. + const view = await layout( + band( + scored(id, 80 - i * 4)), + )} + />, + "fr", + ), + width, + ); + + const m = await view.locator(PANEL).evaluate((el) => { + let widest = 0; + let culprit = ""; + for (const child of Array.from(el.querySelectorAll("*"))) { + const w = child.getBoundingClientRect().width; + if (w > widest) { + widest = w; + culprit = child.getAttribute("data-slot") ?? child.className; + } + } + const strip = el.closest('[data-slot="insights-hero-strip"]')!; + const stripBox = getComputedStyle(strip); + return { + scroll: el.scrollWidth, + client: el.clientWidth, + width: Math.round(el.getBoundingClientRect().width), + widest: Math.round(widest), + culprit: String(culprit).slice(0, 60), + // The width the band actually offers, padding taken off. + available: Math.round( + strip.getBoundingClientRect().width - + parseFloat(stripBox.paddingLeft) - + parseFloat(stripBox.paddingRight), + ), + docScroll: document.documentElement.scrollWidth, + docClient: document.documentElement.clientWidth, + }; + }); + + expect( + m.scroll, + `the panel scrolls its own content: ${JSON.stringify(m)}`, + ).toBeLessThanOrEqual(m.client + 1); + expect( + m.widest, + `a child is wider than the panel: ${JSON.stringify(m)}`, + ).toBeLessThanOrEqual(m.client + 1); + // The panel against the room the band gives it. This is the assertion + // that has to carry the phone case: the band wears `overflow-hidden` + // for its glow, so a panel wider than the band is silently CLIPPED + // rather than scrolled, and the document check below stays green while + // a person loses the right-hand end of every row. + expect( + m.width, + `the panel is wider than the band gives it: ${JSON.stringify(m)}`, + ).toBeLessThanOrEqual(m.available + 1); + // Kept anyway, for anything that escapes the band's clip. + expect( + m.docScroll, + `the page scrolls sideways: ${JSON.stringify(m)}`, + ).toBeLessThanOrEqual(m.docClient + 1); + + await target.setViewportSize({ width: VIEWPORT_PX, height: 1200 }); + }, + 60_000, + ); +}); diff --git a/src/components/insights/__tests__/health-score-card.test.tsx b/src/components/insights/__tests__/health-score-card.test.tsx index 0316245bd..b879bdcf3 100644 --- a/src/components/insights/__tests__/health-score-card.test.tsx +++ b/src/components/insights/__tests__/health-score-card.test.tsx @@ -165,6 +165,20 @@ const FASTING_GLUCOSE: ScorePillarResult = { }, }; +/** A scored pillar at an arbitrary score, for the partition + tint tests. */ +function scoredAt( + id: ScorePillarResult["id"], + score: number, +): ScorePillarResult { + const base = BLOOD_PRESSURE.result; + if (base.status !== "ok") throw new Error("fixture is not scored"); + return { + ...BLOOD_PRESSURE, + id, + result: { ...base, value: { ...base.value, score } }, + }; +} + function gatedPillar( id: ScorePillarResult["id"], reason: string, @@ -253,6 +267,35 @@ function anatomyRegion(html: string): string { return html.slice(start); } +/** Everything visible without opening the disclosure. */ +function atRest(html: string): string { + const end = html.indexOf('data-slot="health-score-anatomy-region"'); + expect(end).toBeGreaterThan(-1); + return html.slice(0, end); +} + +/** The named not-scored list itself, not merely the region containing it. */ +function notScoredList(html: string): string { + const start = html.indexOf('data-slot="health-score-not-scored"'); + expect(start).toBeGreaterThan(-1); + const end = html.indexOf("", start); + expect(end).toBeGreaterThan(start); + return html.slice(start, end); +} + +/** Every `data-pillar="X"` in document order — the plural attribute never matches. */ +function pillarAttrs(html: string): string[] { + return [...html.matchAll(/data-pillar="([^"]+)"/g)].map((m) => m[1]); +} + +/** The ids a coalesced line declares it speaks for. */ +function pillarsAttr(html: string, slot: string): string[] { + const m = html.match( + new RegExp(`data-slot="${slot}"\\s+data-pillars="([^"]*)"`), + ); + return m && m[1] ? m[1].split(",") : []; +} + beforeEach(() => { mockAuthUser = { unitPreference: "metric", glucoseUnit: "mg/dL" }; }); @@ -268,42 +311,107 @@ describe(" disclosure", () => { const controls = html.match(/aria-controls="([^"]+)"/); expect(controls).not.toBeNull(); expect(html).toContain(`id="${controls![1]}" hidden=""`); - expect(anatomyRegion(html)).toContain('data-slot="health-score-pillars"'); + expect(anatomyRegion(html)).toContain('data-slot="health-score-method"'); }); - it("keeps the score face outside the disclosure", () => { + it("keeps the number and the contributor rows out of the disclosure", () => { const html = render(); - const face = html.slice( - 0, - html.indexOf('data-slot="health-score-anatomy-toggle"'), - ); - expect(face).toContain('data-slot="health-score-band"'); - expect(face).toContain('data-slot="health-score-delta"'); - expect(face).toContain(">86<"); - // The pillar grid and the method footer belong to the opened region only. - expect(face).not.toContain('data-slot="health-score-pillars"'); - expect(face).not.toContain('data-slot="health-score-method"'); + const rest = atRest(html); + // The number, the bar, the band sentence and the delta are the panel's + // face; the rows sit directly under them, not behind a toggle. + expect(rest).toContain('data-slot="health-score-card-number"'); + expect(rest).toContain('data-slot="health-score-card-progress"'); + expect(rest).toContain('data-slot="health-score-band"'); + expect(rest).toContain('data-slot="health-score-delta"'); + expect(rest).toContain(">86<"); + expect(rest).toContain('data-slot="health-score-pillars"'); + // The named reasons, the weight goal and the method footer are what the + // disclosure is for. + expect(rest).not.toContain('data-slot="health-score-method"'); + expect(rest).not.toContain('data-slot="health-score-weight-goal"'); + expect(rest).not.toContain('data-slot="health-score-not-scored"'); + }); + + it("wears the hero column's width and stretches to the band", () => { + const html = render(); + // The panel owns its own column geometry, so the band's `items-stretch` + // can equalise it against the greeting. + expect(html).toContain("md:basis-[22rem]"); + expect(html).toContain("xl:basis-[26rem]"); + expect(html).toContain("md:shrink-0"); + expect(html).toContain("flex h-full flex-col"); }); }); describe(" pillars", () => { - it("grids the scored pillars only and never draws a gated one as a cell", () => { + it("gives a row to the scored pillars only, never to a gated one", () => { const html = render(); - const region = anatomyRegion(html); - expect(region).toContain("sm:grid-cols-2"); - expect(region).toContain("lg:grid-cols-3"); - expect(region).toContain('data-pillar="BLOOD_PRESSURE" data-status="ok"'); - expect(region).not.toContain('data-pillar="ACTIVITY" data-status="ok"'); - // Blood pressure scores 82, so its impact bar is band-green at 82%. - expect(region).toContain("width:82%"); - expect(region).toContain("bg-success"); + const rest = atRest(html); + expect(rest).toContain('data-pillar="BLOOD_PRESSURE" data-status="ok"'); + expect(rest).not.toContain('data-pillar="ACTIVITY" data-status="ok"'); + // One line per row: a truncating label column, a thin bar, the score. + expect(rest).toContain("grid-cols-[minmax(0,7rem)_1fr_2rem_auto]"); + expect(rest).toContain("width:82%"); }); - it("names every gated pillar with its reason instead of scoring it zero", () => { + it("tints each row with its OWN band, not the composite's", () => { + // The composite is red and the two pillars sit either side of the green + // threshold. The old rows took the composite's colour and painted the + // strong pillar red. + const html = render( + , + ); + const rows = atRest(html).split('data-pillar="'); + const barClass = (id: string) => { + const row = rows.find((r) => r.startsWith(id)); + expect(row, `${id} has no row`).toBeDefined(); + const m = row!.match( + /data-slot="health-score-pillar-bar" class="([^"]*)"/, + ); + expect(m, `${id} has no bar`).not.toBeNull(); + return m![1]; + }; + expect(barClass("BLOOD_PRESSURE")).toContain("bg-success"); + expect(barClass("GLYCAEMIA")).toContain("bg-destructive"); + expect(barClass("SLEEP")).toContain("bg-warning"); + // The composite is red, so a composite-tinted set would be all-red. + expect(barClass("BLOOD_PRESSURE")).not.toContain("bg-destructive"); + }); + + it("counts absence at rest and names it behind the disclosure", () => { const html = render(); + // At rest: a count, never a name drawn as a zero. + expect(atRest(html)).toContain("Not scored yet (1)"); + expect(atRest(html)).toContain('data-count="1"'); const region = anatomyRegion(html); expect(region).toContain('data-slot="health-score-not-scored"'); - expect(region).toContain("Not scored yet (1)"); expect(region).toContain('data-reason="below_day_floor_or_stale"'); expect(region).toContain("Not enough recent eligible data"); expect(region).not.toContain("below_day_floor_or_stale<"); @@ -323,17 +431,52 @@ describe(" pillars", () => { onRetry={retry} />, ); - const region = anatomyRegion(html); - expect(region).toContain('data-slot="health-score-pillar-error"'); - expect(region).toContain('role="alert"'); - expect(region).toContain('data-slot="health-score-pillar-retry"'); - expect(region).toContain("Data could not be loaded"); + // A failure may be quiet, but it may not hide: the line and its retry are + // visible without opening anything. + const rest = atRest(html); + expect(rest).toContain('data-slot="health-score-pillar-error"'); + expect(rest).toContain('role="alert"'); + expect(rest).toContain('data-slot="health-score-pillar-retry"'); + expect(rest).toContain("Data could not be loaded"); + expect(rest).toContain("Not scored yet (1)"); + // A failed read is NOT absence, so it stays out of the "not scored" list. - expect(region).toContain("Not scored yet (1)"); + const region = anatomyRegion(html); expect(region).not.toContain('data-reason="read_failed"'); + expect(notScoredList(html)).not.toContain('data-reason="read_failed"'); + expect(notScoredList(html)).not.toContain('data-pillar="SLEEP"'); }); - it("leads the gated list with safety signposting", () => { + it("coalesces every failed read into one line with one retry", () => { + // Lipids and HbA1c come off one labs read, so a single failure gates two + // pillars. One cause, one line, one retry — and the retry refetches the + // whole payload anyway. + const html = render( + , + ); + expect( + html.match(/data-slot="health-score-pillar-error"/g) ?? [], + ).toHaveLength(1); + expect( + html.match(/data-slot="health-score-pillar-retry"/g) ?? [], + ).toHaveLength(1); + expect(pillarsAttr(html, "health-score-pillar-error")).toEqual([ + "LIPIDS", + "GLYCAEMIA", + ]); + expect(atRest(html)).toContain("Lipids, Glycaemia: Data could not be"); + }); + + it("names safety signposting at rest instead of counting it", () => { const html = render( pillars", () => { })} />, ); - const region = anatomyRegion(html); - expect(region).toContain("Not scored yet (2)"); - const crisis = region.indexOf('data-reason="crisis_signposting"'); - const activity = region.indexOf('data-reason="below_day_floor_or_stale"'); - expect(crisis).toBeGreaterThan(-1); - expect(crisis).toBeLessThan(activity); - expect(region).toContain("Safety guidance is shown instead of a score"); + // Safety copy is content: it is named in full on the surface, not folded + // into a count and not hidden behind the toggle. + const rest = atRest(html); + expect(rest).toContain('data-slot="health-score-crisis"'); + expect(rest).toContain("Wellbeing: Safety guidance is shown instead of"); + // …and therefore it is not also one of the anonymous counted absences. + expect(rest).toContain("Not scored yet (1)"); + expect(rest).toContain('data-count="1"'); + expect(anatomyRegion(html)).not.toContain( + 'data-reason="crisis_signposting"', + ); + }); +}); + +describe(" one pillar, one home", () => { + /** Four scored, one failed read, one crisis, two plain absences. */ + function mixedReport() { + return scoredReport({ + pillars: [ + scoredAt("BLOOD_PRESSURE", 82), + scoredAt("GLYCAEMIA", 74), + scoredAt("ACTIVITY", 61), + scoredAt("SLEEP", 45), + gatedPillar("FITNESS", "read_failed"), + gatedPillar("WELLBEING", "crisis_signposting"), + gatedPillar("ADIPOSITY", "missing_height"), + gatedPillar("LIPIDS", "not_tracked"), + ], + }); + } + + it("puts every pillar in exactly one of the four homes", () => { + const html = render(); + const rest = atRest(html); + + const rowIds = [ + ...rest.matchAll(/data-pillar="([^"]+)" data-status="ok"/g), + ].map((m) => m[1]); + const failedIds = pillarsAttr(html, "health-score-pillar-error"); + const crisisIds = pillarsAttr(html, "health-score-crisis"); + const counted = Number(rest.match(/data-count="(\d+)"/)?.[1] ?? 0); + + expect(rowIds).toEqual([ + "BLOOD_PRESSURE", + "GLYCAEMIA", + "ACTIVITY", + "SLEEP", + ]); + expect(failedIds).toEqual(["FITNESS"]); + expect(crisisIds).toEqual(["WELLBEING"]); + expect(counted).toBe(2); + + // The four homes partition the eight pillars: disjoint, and complete. + const named = [...rowIds, ...failedIds, ...crisisIds]; + expect(new Set(named).size).toBe(named.length); + expect(named.length + counted).toBe(8); + }); + + it("never renders one pillar id twice anywhere in the panel", () => { + const html = render(); + // The scored rows and the named not-scored list both carry `data-pillar`; + // the coalesced lines carry the plural `data-pillars` and are not matched + // here. Seeing an id twice is exactly the complaint this panel answers. + const ids = pillarAttrs(html); + expect(new Set(ids).size).toBe(ids.length); + expect(ids.sort()).toEqual( + [ + "ACTIVITY", + "ADIPOSITY", + "BLOOD_PRESSURE", + "GLYCAEMIA", + "LIPIDS", + "SLEEP", + ].sort(), + ); + }); + + it("never renders one pillar's name twice anywhere in the panel", () => { + const html = render(); + // The method footer's "Overall band set by …" sentence names a pillar as + // prose rather than listing it, so it is not part of this count. + const methodAt = html.indexOf('data-slot="health-score-method"'); + expect(methodAt).toBeGreaterThan(-1); + const listed = html.slice(0, methodAt); + for (const label of [ + "Blood pressure", + "Glycaemia", + "Activity", + "Sleep", + "Adiposity", + "Wellbeing", + "Measured fitness", + "Lipids", + ]) { + const hits = listed.split(label).length - 1; + expect(hits, `${label} appears ${hits} times`).toBeLessThanOrEqual(1); + } }); }); describe(" composite states", () => { - it("shows a provisional ring and the learning gate when the composite is gated", () => { + it("shows a dash and the learning line when the composite is gated", () => { const html = render(); expect(html).toMatch( /data-slot="health-score-card"[^>]*data-status="insufficient"/, @@ -365,8 +598,11 @@ describe(" composite states", () => { expect(html).toContain( "The score needs at least three, including a measured physiological pillar.", ); - // No band sentence and no fabricated number in the face. + // No band sentence and no fabricated number: a dash, and no bar either. expect(html).not.toContain('data-slot="health-score-band"'); + expect(html).not.toContain('data-slot="health-score-card-progress"'); + expect(html).toContain("—"); + expect(html).not.toContain("/ 100"); expect(html).toContain("Method version 2"); }); @@ -389,17 +625,30 @@ describe(" composite states", () => { returnToBand={{ metricType: "RESTING_HEART_RATE", daysInside: 6 }} />, ); - const face = html.slice( - 0, - html.indexOf('data-slot="health-score-anatomy-toggle"'), - ); - expect(face).toContain("Rest Mode active since 2026-07-20"); - expect(face).toContain('data-slot="health-score-tension"'); - expect(face).toContain('data-slot="health-score-return-to-band"'); + const rest = atRest(html); + expect(rest).toContain("Rest Mode active since 2026-07-20"); + expect(rest).toContain('data-slot="health-score-tension"'); + expect(rest).toContain('data-slot="health-score-return-to-band"'); // The contributor key and the metric type are localised, never raw. - expect(face).toContain("Resting heart rate"); - expect(face).not.toContain("RESTING_HEART_RATE"); - expect(face).not.toContain(">rhr<"); + expect(rest).toContain("Resting heart rate"); + expect(rest).not.toContain("RESTING_HEART_RATE"); + expect(rest).not.toContain(">rhr<"); + }); + + it("shows the weekly gain as a chip beside the label", () => { + const html = render(); + expect(html).toContain('data-slot="health-score-card-label"'); + expect(html).toContain('data-slot="health-score-card-delta-chip"'); + expect(html).toMatch(/data-slot="health-score-card-delta-chip"[^>]*>\+2 { + const html = render( + , + ); + expect(html).not.toContain('data-slot="health-score-card-delta-chip"'); + // The line still states it — only the green chip is reserved for a gain. + expect(html).toContain('data-slot="health-score-delta"'); }); it("keeps a one-sided tension quiet", () => { @@ -497,9 +746,17 @@ describe("pillar detail formatting", () => { const html = render( , ); - // The card mounts with mmol/L accounts too; the visible observed line is - // the one always on screen. - expect(html).toContain("Observed: Fasting glucose: 100 mg/dL"); + // The observed value moved into the row's popover, which Radix mounts + // only once opened — so the panel shows the row, and the value itself is + // asserted through the same pure formatter the popover is fed. + expect(html).toContain('data-pillar="GLYCAEMIA" data-status="ok"'); + expect(html).toContain('data-slot="info-popover-trigger"'); + expect( + pillarObservedText(FASTING_GLUCOSE, { + t: translator(), + glucoseUnit: "mg/dL", + }), + ).toBe("Fasting glucose: 100 mg/dL"); const mmol = pillarObservedText(FASTING_GLUCOSE, { t: translator(), @@ -575,12 +832,38 @@ describe("pillar detail formatting", () => { }); describe("", () => { - it("reserves the collapsed footprint and announces nothing", () => { + it("reserves the column's footprint and announces nothing", () => { const html = render(); expect(html).toContain('data-slot="health-score-card-skeleton"'); expect(html).toContain('aria-hidden="true"'); - // The `md` ring is a fixed 168 px box; the reserve matches it exactly. - expect(html).toContain("size-[168px]"); + // A reserve that is not the panel's width is not a reserve. + expect(html).toContain("md:basis-[22rem]"); + expect(html).toContain("xl:basis-[26rem]"); + // That it declares a floor at all is what this file can honestly check; + // whether the floor is the RIGHT height is a browser question, and + // `health-score-card-geometry.test.tsx` answers it by measuring the panel + // and the reserve side by side. Pinning the literal here would only mean + // the two places have to be edited together. + expect(html).toMatch(/min-h-\[[\d.]+rem\]/); + }); + + it("holds the same column geometry the resolved panel does", () => { + const skeleton = render(); + const panel = render(); + // Any drift between the two is a jump when the payload lands. + for (const cls of [ + "w-full", + "md:shrink-0", + "md:grow-0", + "md:basis-[22rem]", + "xl:basis-[26rem]", + "flex h-full flex-col", + "rounded-xl border p-4", + "md:p-6", + ]) { + expect(skeleton, cls).toContain(cls); + expect(panel, cls).toContain(cls); + } }); }); diff --git a/src/components/insights/__tests__/health-score-pillar-detail-bp-basis.test.tsx b/src/components/insights/__tests__/health-score-pillar-detail-bp-basis.test.tsx new file mode 100644 index 000000000..6c952ca25 --- /dev/null +++ b/src/components/insights/__tests__/health-score-pillar-detail-bp-basis.test.tsx @@ -0,0 +1,208 @@ +/** + * v1.34.5 — the route from the blood-pressure score to its reason. + * + * A pillar reading 56 gave no way to find out why: the popover held the + * reference band, the personal target and the source, and nothing said + * which of the two axes had set the number, how far over its ceiling that + * axis sat, or that a reading exactly at the ceiling reads 85 rather than + * 100. These pin the three lines that answer it, in the two locales the + * maintainer reads, against the real bundles. + * + * The lines live behind a Radix popover, which only mounts once opened, + * so they are asserted through the pure formatter the card feeds it — + * the same strings the card shows, not a second copy of the copy. + */ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider, useTranslations } from "@/lib/i18n/context"; +import type { Locale } from "@/lib/i18n/config"; +import type { BpScoreBasis } from "@/lib/analytics/bp-grade"; +import type { ScorePillarResult } from "@/lib/analytics/score/types"; +import { + pillarDetailLines, + type Translate, +} from "../health-score-pillar-detail"; + +function translator(locale: Locale = "en"): Translate { + let captured: Translate | null = null; + function Probe() { + captured = useTranslations().t; + return null; + } + renderToStaticMarkup( + + + , + ); + if (!captured) throw new Error("translator probe did not run"); + return captured; +} + +function bloodPressure(scoreBasis?: BpScoreBasis): ScorePillarResult { + return { + id: "BLOOD_PRESSURE", + domain: "cardiometabolic", + result: { + status: "ok", + value: { + score: 56, + ...(scoreBasis ? { scoreBasis } : {}), + observed: { + value: 137, + unit: "mmHg", + label: "137/87 mmHg", + asOf: "2026-07-30T08:00:00.000Z", + sources: ["MANUAL"], + }, + reference: { + kind: "clinical-threshold", + low: 120, + high: 129, + label: "120 to 129/70 to 79 mmHg", + source: "ESH 2023", + }, + noiseFloor: 1, + deltaEligible: true, + deltaIdentity: "graded_bp_pair_series", + }, + coverage: { + requiredInputs: 12, + presentInputs: 12, + historyDays: 60, + missing: [], + }, + confidence: { score: 100, band: "high" }, + provenance: { + inputs: ["BLOOD_PRESSURE"], + source: "live", + windowDays: 90, + computedAt: "2026-07-30T12:00:00.000Z", + }, + }, + }; +} + +const ABOVE_CEILING: BpScoreBasis = { + axis: "systolic", + relation: "above_ceiling", + offsetMmHg: 8, + boundaryMmHg: 129, +}; + +describe("blood-pressure score basis in the popover", () => { + it("leads with the binding axis, its distance and its ceiling", () => { + const lines = pillarDetailLines(bloodPressure(ABOVE_CEILING), { + t: translator(), + glucoseUnit: "mg/dL", + }); + expect(lines[0]).toBe( + "Systolic sets this score: about 8 mmHg over its ceiling of 129 mmHg.", + ); + }); + + it("names the diastolic axis when that is the one that bound", () => { + const lines = pillarDetailLines( + bloodPressure({ + axis: "diastolic", + relation: "above_ceiling", + offsetMmHg: 9, + boundaryMmHg: 79, + }), + { t: translator(), glucoseUnit: "mg/dL" }, + ); + expect(lines[0]).toBe( + "Diastolic sets this score: about 9 mmHg over its ceiling of 79 mmHg.", + ); + }); + + it("measures a hypotensive reading from the low-pressure floor", () => { + const lines = pillarDetailLines( + bloodPressure({ + axis: "systolic", + relation: "below_floor", + offsetMmHg: 4, + boundaryMmHg: 90, + }), + { t: translator(), glucoseUnit: "mg/dL" }, + ); + expect(lines[0]).toBe( + "Systolic sets this score: about 4 mmHg under the low-pressure floor of 90 mmHg.", + ); + }); + + it("says both axes are in band without naming a distance", () => { + const lines = pillarDetailLines( + bloodPressure({ + axis: "systolic", + relation: "in_band", + offsetMmHg: 5, + boundaryMmHg: 129, + }), + { t: translator(), glucoseUnit: "mg/dL" }, + ); + expect(lines[0]).toBe( + "Both values are at or below their reference ceilings.", + ); + }); + + it("explains the scale and the averaged reading, then the shipped lines", () => { + const lines = pillarDetailLines(bloodPressure(ABOVE_CEILING), { + t: translator(), + glucoseUnit: "mg/dL", + }); + expect(lines[1]).toBe( + "At the ceiling the score is 85; 100 means roughly 12 mmHg below it.", + ); + // The window comes off the pillar's own provenance, so the sentence + // cannot drift from the window the score was actually read over. + expect(lines[2]).toBe( + "Based on a recency-weighted average of the last 90 days, so it may not match any single reading.", + ); + expect(lines).toHaveLength(5); + expect(lines[3]).toContain("ESH 2023"); + expect(lines[4]).toContain("Source: MANUAL"); + }); + + it("reads in German too", () => { + const lines = pillarDetailLines(bloodPressure(ABOVE_CEILING), { + t: translator("de"), + glucoseUnit: "mg/dL", + }); + expect(lines[0]).toBe( + "Ausschlaggebend: Systolisch, etwa 8 mmHg über der Obergrenze von 129 mmHg.", + ); + expect(lines[1]).toBe( + "An der Obergrenze liegt der Wert bei 85; 100 bedeutet rund 12 mmHg darunter.", + ); + expect(lines[2]).toBe( + "Beruht auf einem Durchschnitt der letzten 90 Tage, der neuere Messungen stärker gewichtet, und kann deshalb von jeder einzelnen Messung abweichen.", + ); + }); + + it("says nothing at all when the server published no basis", () => { + // The field is optional and blood-pressure-only: an older payload, or + // a pillar that has nothing of the kind to say, must fall back to the + // shipped lines rather than to an empty or invented sentence. + const lines = pillarDetailLines(bloodPressure(), { + t: translator(), + glucoseUnit: "mg/dL", + }); + expect(lines).toHaveLength(2); + expect(lines[0]).toContain("ESH 2023"); + expect(lines.join("\n")).not.toContain("mmHg over its ceiling"); + }); + + it("leaves other pillars untouched", () => { + const glycaemia: ScorePillarResult = { + ...bloodPressure(ABOVE_CEILING), + id: "GLYCAEMIA", + }; + const lines = pillarDetailLines(glycaemia, { + t: translator(), + glucoseUnit: "mg/dL", + }); + expect(lines.join("\n")).not.toContain("sets this score"); + expect(lines.join("\n")).not.toContain("At the ceiling"); + }); +}); diff --git a/src/components/insights/__tests__/hero-strip.test.tsx b/src/components/insights/__tests__/hero-strip.test.tsx index 65e3dc1e8..1785b8175 100644 --- a/src/components/insights/__tests__/hero-strip.test.tsx +++ b/src/components/insights/__tests__/hero-strip.test.tsx @@ -3,6 +3,11 @@ import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; import { HeroStrip } from "../hero-strip"; import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema"; +import type { + HealthScoreReport, + ScorePillarId, + ScorePillarResult, +} from "@/lib/analytics/score/types"; // The "generated" freshness line reads the profile timezone via `useAuth`; stub // it so the SSR render does not reach for a QueryClient the test omits. @@ -37,6 +42,120 @@ const sampleBriefing: DailyBriefingPayload = { // (the mocked `Europe/Berlin`), not the host's — so pin explicit UTC // instants and translate them to the intended Berlin wall-clock hour. // May 10 2026 is CEST (UTC+2), so Berlin = UTC + 2h. +const provenance = { + inputs: ["BLOOD_PRESSURE"], + source: "live" as const, + windowDays: 90, + computedAt: "2026-07-28T12:00:00.000Z", +}; + +function scored(id: ScorePillarId, score: number): ScorePillarResult { + return { + id, + domain: "cardiometabolic", + result: { + status: "ok", + value: { + score, + observed: { + value: 126, + unit: "mmHg", + label: "126/78 mmHg", + asOf: "2026-07-27T08:00:00.000Z", + sources: ["MANUAL"], + }, + reference: { + kind: "clinical-threshold", + low: 120, + high: 129, + label: "120 to 129 mmHg", + source: "ESH 2023", + }, + noiseFloor: 1, + deltaEligible: true, + deltaIdentity: id, + }, + coverage: { + requiredInputs: 12, + presentInputs: 12, + historyDays: 28, + missing: [], + }, + confidence: { score: 100, band: "high" }, + provenance, + }, + }; +} + +function gated(id: ScorePillarId, reason: string): ScorePillarResult { + return { + id, + domain: "wellbeing", + result: { + status: "insufficient", + coverage: { + requiredInputs: 1, + presentInputs: 0, + historyDays: 0, + missing: [], + }, + provenance: { ...provenance, inputs: [id] }, + reason, + }, + }; +} + +function scoreReport(pillars: ScorePillarResult[]): HealthScoreReport { + return { + composite: { + status: "ok", + value: { + score: 74, + band: "green", + bandSetter: null, + composition: ["BLOOD_PRESSURE"], + noiseFloor: 3, + scoreVersion: 2, + }, + coverage: { + requiredInputs: 3, + presentInputs: 3, + historyDays: 28, + missing: [], + }, + confidence: { score: 100, band: "high" }, + provenance, + }, + pillars, + delta: 2, + deltaReason: null, + scoreVersion: 2, + weightGoal: { + status: "insufficient", + coverage: { + requiredInputs: 2, + presentInputs: 0, + historyDays: 0, + missing: [], + }, + provenance: { ...provenance, inputs: ["WEIGHT"] }, + reason: "no_personal_goal", + }, + algorithmNotice: null, + }; +} + +const SCORE = scoreReport([ + scored("BLOOD_PRESSURE", 82), + scored("ACTIVITY", 61), + gated("LIPIDS", "not_tracked"), +]); + +const SCORE_WITH_FAILED_READ = scoreReport([ + scored("BLOOD_PRESSURE", 82), + gated("LIPIDS", "read_failed"), +]); + const morningLocal = new Date("2026-05-10T07:00:00Z"); // 09:00 Berlin const afternoonLocal = new Date("2026-05-10T12:00:00Z"); // 14:00 Berlin const eveningLocal = new Date("2026-05-10T18:00:00Z"); // 20:00 Berlin @@ -238,27 +357,95 @@ describe("", () => { expect(html).not.toContain('data-slot="insights-hero-strip-weekly-banner"'); }); - // ── B5 — the Health Score left the hero band ────────────────────── - // The score used to mount twice: a compact face in this band and the full - // breakdown below it. The breakdown is now one pinned card under the band - // (`page-client.tsx`), so the hero carries no score surface at all — no - // panel, no reserved column, no skeleton. - it("never renders a Health Score surface in the band", () => { + // ── The Health Score column ──────────────────────────────────────── + // The score is back in the band's right column, and this band is its only + // mount on the overview. + it("keeps the greeting full width when there is no score", () => { + // An account without a score keeps the single-column band it always had; + // the split must not appear around an empty slot. const html = render(); + expect(html).not.toContain("md:flex-row"); + expect(html).not.toContain("md:items-stretch"); expect(html).not.toContain('data-slot="health-score-card"'); - expect(html).not.toContain('data-slot="health-score-card-compact"'); expect(html).not.toContain('data-slot="health-score-card-skeleton"'); }); - it("keeps the greeting on the full width with no reserved second column", () => { - // With the panel gone the band is single-column on every breakpoint; the - // `md:`/`lg:` split + stretch contract it carried for the score column - // must not come back, or the greeting reflows around an empty slot. - const html = render(); - expect(html).not.toContain("md:flex-row"); - expect(html).not.toContain("lg:flex-row"); - expect(html).not.toContain("md:items-stretch"); - expect(html).not.toContain("md:basis-[22rem]"); + it("splits into two columns and paints the panel when a score is present", () => { + const html = render( + , + ); + expect(html).toContain("md:flex-row"); + expect(html).toContain("md:items-stretch"); + expect(html).toContain('data-slot="health-score-card"'); + // The greeting keeps its own column and can shrink inside it. + expect(html).toContain("min-w-0 flex-1"); + expect(html).toContain('data-slot="insights-hero-strip-greeting"'); + }); + + it("reserves the column while the payload is in flight", () => { + const html = render( + , + ); + // The split is already there, so the greeting does not re-wrap when the + // score lands — that reflow is why the reserve exists at all. + expect(html).toContain("md:flex-row"); + expect(html).toContain('data-slot="health-score-card-skeleton"'); + expect(html).toContain("md:basis-[22rem]"); + expect(html).not.toContain('data-slot="health-score-card"'); + }); + + it("prefers the resolved panel over the reserve", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="health-score-card"'); + expect(html).not.toContain('data-slot="health-score-card-skeleton"'); + }); + + it("renders the full panel with no briefing at all", () => { + // The score used to live inside the daily-briefing branch, so an instance + // with the briefing switched off lost the entire breakdown. The band + // renders unconditionally and carries the panel whole. + const html = render( + , + ); + expect(html).toContain("A daily read of your trends"); // the no-briefing subtitle + for (const slot of [ + "health-score-card", + "health-score-card-label", + "health-score-card-number", + "health-score-card-progress", + "health-score-band", + "health-score-pillars", + "health-score-not-scored-count", + "health-score-anatomy-toggle", + "health-score-anatomy-region", + "health-score-not-scored", + "health-score-weight-goal", + "health-score-method", + ]) { + expect(html, slot).toContain(`data-slot="${slot}"`); + } + expect(html).toContain(">74<"); + expect(html).toContain('data-pillar="BLOOD_PRESSURE" data-status="ok"'); + }); + + it("forwards the retry so a failed read can be retried from the band", () => { + const html = render( + {}} + />, + ); + expect(html).toContain('data-slot="health-score-pillar-error"'); + expect(html).toContain('data-slot="health-score-pillar-retry"'); }); it("does not render any Ask-the-Coach affordance in the hero band", () => { diff --git a/src/components/insights/derived/__tests__/coach-read-strip-locale-cache.test.tsx b/src/components/insights/derived/__tests__/coach-read-strip-locale-cache.test.tsx new file mode 100644 index 000000000..d52646886 --- /dev/null +++ b/src/components/insights/derived/__tests__/coach-read-strip-locale-cache.test.tsx @@ -0,0 +1,108 @@ +import { describe, it, expect, 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"; +import type { CoachReadStripData } from "@/lib/insights/derived/coach-read-shape"; + +/** + * A cached strip belongs to ONE language. + * + * The strip's second line is prose the server writes in the reader's language. + * The browser cache used to key it by metric alone, so after a language switch + * the page re-used the sentence written for the previous language and the + * server-side fix would have been invisible to anyone who switched rather than + * reloaded. `/api/analytics` carries the same warning in its own cache key. + * + * Both entries are seeded, so a key that has stopped separating them shows up + * as the WRONG sentence rather than as an empty page — a test that only proved + * "the other locale misses" would also pass if the key stopped matching + * anything at all. + */ + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { timezone: "UTC", dateOfBirth: null, gender: null }, + isAuthenticated: true, + }), +})); + +// The strip paints nothing until it is client-mounted (React #418). +vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true })); + +import { CoachReadStrip } from "../coach-read-strip"; + +const GERMAN_NOTE = + "Wenn die Symptomstärke höher liegt, scheint die Schlafdauer am Folgetag " + + "nach bisheriger Datenlage eher niedriger auszufallen — ein Muster, das " + + "Beachtung verdient, keine Ursache."; +const ENGLISH_NOTE = + "Higher symptom severity looks like it goes with lower next-day sleep " + + "duration in your data, on the evidence so far — a pattern worth watching, " + + "not a cause."; + +function payload(note: string): CoachReadStripData { + return { + baseline: null, + learning: true, + driver: { note, behaviour: "symptom severity", outcome: "sleep duration" }, + }; +} + +const METRIC = "SLEEP_DURATION"; + +/** + * One client holding BOTH languages' entries — the state a person is in the + * instant after switching language on a page they had already loaded. + */ +function seededClient(): QueryClient { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + client.setQueryData( + queryKeys.insightsCoachRead(METRIC, "de"), + payload(GERMAN_NOTE), + ); + client.setQueryData( + queryKeys.insightsCoachRead(METRIC, "en"), + payload(ENGLISH_NOTE), + ); + return client; +} + +function renderStrip(client: QueryClient, locale: "de" | "en"): string { + return renderToStaticMarkup( + + + + + , + ); +} + +describe("CoachReadStrip — a cached strip is not served to another language", () => { + it("reads the German entry for a German reader", () => { + const markup = renderStrip(seededClient(), "de"); + expect(markup).toContain(GERMAN_NOTE); + expect(markup).not.toContain(ENGLISH_NOTE); + }); + + it("reads the English entry for an English reader", () => { + const markup = renderStrip(seededClient(), "en"); + expect(markup).toContain(ENGLISH_NOTE); + expect(markup).not.toContain(GERMAN_NOTE); + }); + + it("does not carry one language's cached sentence into the other on a switch", () => { + // One client, both renders — exactly what a language switch does to a page + // that is already warm. + const client = seededClient(); + const german = renderStrip(client, "de"); + const english = renderStrip(client, "en"); + + expect(german).toContain(GERMAN_NOTE); + expect(english).toContain(ENGLISH_NOTE); + expect(english).not.toContain(GERMAN_NOTE); + }); +}); diff --git a/src/components/insights/derived/coach-read-strip.tsx b/src/components/insights/derived/coach-read-strip.tsx index 8dfad52fe..1558ecbbd 100644 --- a/src/components/insights/derived/coach-read-strip.tsx +++ b/src/components/insights/derived/coach-read-strip.tsx @@ -77,7 +77,7 @@ export function CoachReadStrip({ const resolvedUnit = transformed ? unitDisplay.unitFor(metricType) : unit; const { data } = useQuery({ - queryKey: queryKeys.insightsCoachRead(metricType), + queryKey: queryKeys.insightsCoachRead(metricType, locale), queryFn: () => apiGet( `/api/insights/coach-read?metric=${encodeURIComponent(metricType)}`, @@ -120,6 +120,10 @@ export function CoachReadStrip({ }); })(); + // Only the wrapper is translated here. `note` is a finished sentence the + // server wrote in the reader's language, so it goes in as it arrived — which + // is exactly why the route has to know the language, and why this query is + // keyed by locale. const driverLine = data.driver ? t("insights.coach.readStrip.driver", { note: data.driver.note }) : null; diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx index 587056d8a..59be655b7 100644 --- a/src/components/insights/health-score-card.tsx +++ b/src/components/insights/health-score-card.tsx @@ -1,22 +1,21 @@ "use client"; import { useId, useState } from "react"; -import { ChevronDown } from "lucide-react"; +import { AlertTriangle, ChevronDown, RotateCw } from "lucide-react"; -import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; import { InfoPopover } from "@/components/ui/info-popover"; import { LearningGate } from "@/components/ui/learning-gate"; -import { QueryErrorRow } from "@/components/ui/query-error-row"; import { Skeleton } from "@/components/ui/skeleton"; -import { TileHeader } from "@/components/insights/tile-header"; import { + BAND_BORDER_CLASS, + BAND_NUMBER_CLASS, BAND_PROGRESS_CLASS, bandForScore, clampScore, } from "@/components/insights/derived/band-tokens"; import { CoverageMeter } from "@/components/insights/derived/coverage-meter"; import { ProvenanceExplainer } from "@/components/insights/derived/provenance-explainer"; -import { ScoreRing } from "@/components/insights/derived/score-ring"; import { METRIC_PROVENANCE } from "@/components/insights/derived/standards"; import { pillarDetailLines, @@ -36,23 +35,33 @@ import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; /** - * The Health Score surface: one card, one score, one place. + * The Health Score panel: the hero band's right-hand column. * - * Pre-rework the score had two mounts — a compact face in the hero band and a - * nine-block stack further down the overview that only rendered while the - * daily-briefing flag was on. The stack is now a single progressive-disclosure - * card pinned directly under the hero, and the hero band is the full-width - * greeting again. + * The score belongs beside the greeting, not under it. It sat there for + * eleven weeks — big number at the top of a narrow column, thin contributor + * rows directly beneath it inside the same card — and three arrangements in + * four days moved it away, ending in a full-width card pinned below the + * greeting that spent most of its height on things that do not count toward + * the number. This is the original arrangement, restored, with the eight + * pillars the composite now carries instead of the four it had then. * - * Collapsed reads as the score face: the ring, the band sentence, the weekly - * delta (or the honest reason there is none), Rest Mode, the two ambient - * context lines, and the coverage meter. Expanded adds the pillar grid, the - * named list of pillars that are not scored yet, the personal weight goal, and - * the method footer with its cited standard. + * The rules the intervening work established are all kept: * - * Absence stays absence: a pillar the server gated as `insufficient` is listed - * by name with its reason instead of being drawn as a zero, and a pillar whose - * read FAILED is a visible error row with a retry, never silence. + * - One mount. This panel is the only score surface on the overview. + * - It renders whether or not the daily briefing is switched on. + * - It is not a customisable section, so a saved layout cannot hide it. + * - The column reserves its own height while the payload is in flight, + * so the band does not reflow when the score lands. + * + * Only a pillar that carries a score gets a row. A pillar that contributes + * nothing is one quiet line: a failed read is named with a retry, safety + * signposting is named in full, and everything else that is simply not + * recorded yet is counted, with the names and reasons one tap away in the + * foot disclosure. Each pillar has exactly one home — a row, the failed-read + * line, the safety line, or the count — and never two. + * + * Each contributor row is tinted by ITS OWN band. The old rows took the + * composite's colour, which painted a pillar scoring 92 red on a red day. */ const PILLAR_LABEL_KEY: Record = { @@ -82,6 +91,39 @@ const REASON_KEY: Record = { stale: "insufficientHistory", }; +/** + * The column geometry, shared by the panel and by its loading reserve so the + * two can never drift apart. `basis` rather than a width so the column flexes + * inside the hero band's `md:flex-row` split (~36 % at md, ~40 % at xl), and + * `w-full` below `md` where the column stacks under the greeting. + */ +const PANEL_COLUMN_CLASS = + "w-full md:shrink-0 md:grow-0 md:basis-[22rem] xl:basis-[26rem]"; + +/** The card chrome the panel wears inside the gradient band. */ +const PANEL_CHROME_CLASS = + "bg-card/65 rounded-xl border p-4 shadow-sm backdrop-blur-sm md:p-6"; + +/** + * Height the reserve holds while the payload is in flight, so the band does + * not jump when the score lands. + * + * The rule, not a remembered number: the reserve sits between the shortest + * and the tallest panel the report actually produces (three to six scored + * rows), so a short report is not pushed down and a long one does not leave a + * gap that collapses. It is deliberately near the middle of that range, which + * is the best a single number can do when the panel's height depends on how + * many pillars an account scores. + * + * `health-score-card-geometry.test.tsx` measures all of it in a browser + * against the compiled stylesheet and fails if this value leaves that range — + * and also if the value stops binding at all. It did stop: the blocks below + * added up to more than the minimum declared here, so the reserve was + * whatever the blocks happened to be and this constant was decoration. The + * blocks were trimmed to sit under it again. + */ +const PANEL_RESERVE_CLASS = "min-h-[28rem]"; + /** * v1.21.2 (A5) — readiness contributor keys the Tension Verdict surfaces. * Kept as a local string union so the client never imports the server @@ -202,24 +244,34 @@ export function HealthScoreCard({ return t(`insights.healthScore.reason.${key}`); } + function labelsOf(pillars: ScorePillarResult[]): string { + return pillars.map((p) => t(PILLAR_LABEL_KEY[p.id])).join(", "); + } + const composite = report.composite; + // Four homes, one pillar each. A pillar that scores gets a row; a pillar + // whose read failed joins the one coalesced failure line; a pillar gated + // for safety signposting is named in full at rest; everything else gated + // is counted, and named a tap away. The sets are disjoint by construction + // and each pillar falls into exactly one of them. const scored = report.pillars.filter((p) => p.result.status === "ok"); const readFailed = report.pillars.filter( (p) => gateReason(p) === "read_failed", ); + const crisis = report.pillars.filter( + (p) => gateReason(p) === "crisis_signposting", + ); // A failed read is not absence, so it never joins the "not scored yet" list; - // it gets its own error row above. Safety copy leads the list — signposting - // is content, never buried meta. - const notScored = report.pillars - .filter((p) => { - const reason = gateReason(p); - return reason !== null && reason !== "read_failed"; - }) - .sort( - (a, b) => - Number(gateReason(b) === "crisis_signposting") - - Number(gateReason(a) === "crisis_signposting"), + // it gets its own line above. Safety copy is content, so it is named at rest + // rather than counted — which is also why it is not in this list twice. + const notScored = report.pillars.filter((p) => { + const reason = gateReason(p); + return ( + reason !== null && + reason !== "read_failed" && + reason !== "crisis_signposting" ); + }); // Only forwarded when a real disagreement is present on BOTH sides; the // server resolver already enforces that, but the guard keeps the card from @@ -245,122 +297,252 @@ export function HealthScoreCard({ }) : null; + const compositeScore = + composite.status === "ok" ? clampScore(composite.value.score) : null; + const compositeBand = composite.status === "ok" ? composite.value.band : null; + return ( - - - - - - {/* The score face. The ring keeps its own identity block; everything - that qualifies the number sits beside it, so a person reads the - score and the reason for it in one pass. */} -
-
- -
-
- {composite.status === "ok" ? ( -

- {t(`insights.healthScore.band.${composite.value.band}`)} -

- ) : null} - {report.delta != null ? ( -

- {t("insights.healthScore.delta", { - delta: report.delta > 0 ? `+${report.delta}` : report.delta, - })} -

- ) : report.deltaReason ? ( -

- {t(`insights.healthScore.deltaReason.${report.deltaReason}`)} -

- ) : null} - {report.restMode?.active ? ( -

- {t("insights.healthScore.restMode", { - since: - report.restMode.since ?? t("insights.healthScore.none"), - })} -

- ) : null} - {tensionLine ? ( -

- {tensionLine} -

- ) : null} - {returnToBandLine ? ( -

- {returnToBandLine} -

- ) : null} -
-
+ {/* 1 — the label, and the weekly gain when there is one. */} +
+

+ {t("insights.healthScore.label")} +

+ {report.delta != null && report.delta > 0 ? ( + + +{report.delta} + + ) : null} +
- {composite.status !== "ok" ? ( + {/* 2 — the number. The reason the panel exists. */} +
+ + {compositeScore == null ? "—" : Math.round(compositeScore)} + + {compositeScore == null ? null : ( + + )} +
+ + {/* 3 — the composite bar. */} + {compositeScore != null && compositeBand ? ( +
+
+
+ ) : null} + + {/* 4 — the lines that qualify the number, one each, only when true. */} +
+ {compositeBand ? ( +

+ {t(`insights.healthScore.band.${compositeBand}`)} +

+ ) : ( + )} + {report.delta != null ? ( +

+ {t("insights.healthScore.delta", { + delta: report.delta > 0 ? `+${report.delta}` : report.delta, + })} +

+ ) : report.deltaReason ? ( +

+ {t(`insights.healthScore.deltaReason.${report.deltaReason}`)} +

) : null} + {report.restMode?.active ? ( +

+ {t("insights.healthScore.restMode", { + since: report.restMode.since ?? t("insights.healthScore.none"), + })} +

+ ) : null} + {tensionLine ? ( +

+ {tensionLine} +

+ ) : null} + {returnToBandLine ? ( +

+ {returnToBandLine} +

+ ) : null} +
+ {/* 5 — the contributing factors, always visible, directly under the + number. Server registry order; no re-ranking. */} + {scored.length > 0 ? ( +
    + {scored.map((pillar) => ( + + ))} +
+ ) : null} + + {/* 6 — what is not contributing. One weight class, two faces: the + failure carries the glyph and the only retry on the surface, the + absence is muted and actionless. */} + {readFailed.length > 0 || crisis.length > 0 || notScored.length > 0 ? ( +
+ {readFailed.length > 0 ? ( +
p.id).join(",")} + role="alert" + className="text-foreground flex flex-wrap items-center gap-x-2 gap-y-1 text-xs" + > +
+ ) : null} + + {crisis.length > 0 ? ( +

p.id).join(",")} + className="text-foreground text-xs" + > + {labelsOf(crisis)} + {": "} + {reasonText("crisis_signposting")} +

+ ) : null} + + {notScored.length > 0 ? ( +

+ {t("insights.healthScore.notScored", { + count: notScored.length, + })} +

+ ) : null} +
+ ) : null} + + {/* 7 — the foot. `mt-auto` collects the slack here rather than under the + number, so a short report and a long one both end on the same line. */} +
{/* Progressive disclosure. No Collapsible primitive exists in the UI kit, so this is the same accessible button + region shape the glucose panel ships: `aria-expanded` on the trigger, `aria-controls` + `hidden` on the region. */} -
+
- - +
+ ); } /** - * One scored pillar: name + score, a band-tinted impact bar, and the observed - * line with its detail popover and coverage meter. The reference band, the - * personal target and the source live in the popover — the same strings as - * before, one tap away instead of four permanent lines per pillar. + * One scored pillar, one line: a truncating name, a thin fill bar, the score, + * and the (i) that opens everything else. The bar takes the pillar's OWN band + * colour — a pillar at 92 reads green even when the composite is red, which + * is the point of showing the contributors at all. + * + * The observed value, the reference band, the personal target, the source and + * the per-pillar confidence all live in the popover. That is one tap more + * than the pinned card charged for the observed line, and it is what buys the + * column back. */ -function PillarCell({ +function PillarRow({ pillar, detail, }: { @@ -494,25 +638,28 @@ function PillarCell({ if (pillar.result.status !== "ok") return null; const score = clampScore(pillar.result.value.score); const band = bandForScore(score); + const observed = pillarObservedText(pillar, detail); const lines = pillarDetailLines(pillar, detail); return (
  • -
    - - {t(PILLAR_LABEL_KEY[pillar.id])} - - - {Math.round(score)} - -
    + {/* The label truncates; the row never grows. `minmax(0,…)` plus + `min-w-0 truncate` is what makes that structural rather than a + promise — drop either and the track grows to the longest word and + pushes the row. Widening the column does not rescue the long names: + the longest label in five of the six locales overruns any column + this panel can afford, so the extra width would come off the fill + bar and buy a few more characters of one language. */} + + {t(PILLAR_LABEL_KEY[pillar.id])} +
  • ); } /** * The personal weight goal: an inner tile inside the disclosure, deliberately - * NOT a pillar cell, because it is context the user set for themselves and it - * never moves the score. + * NOT a contributor row, because it is context the user set for themselves and + * it never moves the score. That is also why it left the at-rest column. */ function WeightGoalRow({ report }: { report: HealthScoreReport }) { const { t } = useTranslations(); @@ -610,38 +762,49 @@ function WeightGoalRow({ report }: { report: HealthScoreReport }) { } /** - * The pinned slot's reserve while the analytics payload is in flight. Mirrors - * the COLLAPSED footprint — header, ring, face lines, coverage meter, trigger - * row — so first paint lands at the card's real height and the resolved card - * is a swap rather than a push. Decorative: the card announces nothing while - * it loads. + * The hero column's reserve while the analytics payload is in flight. Wears + * the panel's own column classes and chrome and holds a measured minimum + * height, so the greeting beside it does not reflow when the score lands — + * the reason this reserve was added in the first place. */ export function HealthScoreCardSkeleton({ className }: { className?: string }) { return ( -