Merge pull request #671 from MBombeck/release/v1.34.5

Release v1.34.5
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-31 13:02:20 +02:00
commit 63b54587a8
77 files changed
+4572 -610

No files matched your search

+45
View File
@@ -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.
+1 -1
View File
@@ -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.
+136
View File
@@ -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
* `<main>`, 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);
});
});
+131
View File
@@ -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<void> {
}),
);
// 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<string, unknown> | 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.
+11
View File
@@ -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(() => {});
+2 -1
View File
@@ -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,
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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/*`
+300
View File
@@ -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<string> {
const names = new Set<string>();
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<ts.TypeElement>,
owner: string,
ownerOptional: boolean,
ownerHasInitializer: boolean,
defaultedNames: Set<string> = 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<ts.ParameterDeclaration>,
) => {
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,
);
}
@@ -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([]);
});
});
@@ -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");
});
});
@@ -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 },
+29 -16
View File
@@ -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*<HealthScoreCardSkeleton/,
/scorePending=\{analyticsQuery\.isPending && !analytics\}/,
);
});
it("pins the health score under the hero, outside the section registry", () => {
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("<HeroStrip");
const score = src.indexOf("<HealthScoreCard");
const region = src.indexOf("orderedSectionIds.map");
// The JSX mount, not the `<HeroStrip>` 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*<HeroStrip\n/);
expect(hero).toBeGreaterThan(-1);
expect(score).toBeGreaterThan(hero);
expect(score).toBeLessThan(region);
expect(src).not.toMatch(/"health-score":/);
// Exactly one score surface on the page, and it is the hero's prop.
expect(src.match(/healthScore=\{/g) ?? []).toHaveLength(1);
expect(src).not.toContain("<HealthScoreCard");
expect(src).not.toContain("<HealthScoreCardSkeleton");
const briefingBranch = src.slice(
src.indexOf('"daily-briefing": flags.briefing'),
src.indexOf("vitals: <VitalsDashboard"),
);
expect(briefingBranch).not.toContain("HealthScoreCard");
expect(briefingBranch).not.toContain("healthScore");
// The band itself is not gated on the briefing flag either.
const heroCall = src.slice(hero, src.indexOf("/>", 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("<HealthScoreCard");
expect(heroSrc).toContain("<HealthScoreCardSkeleton");
// The split appears only when the column has something in it.
expect(heroSrc).toContain("md:flex-row md:items-stretch");
expect(heroSrc).toMatch(
/\(healthScore \|\| scorePending\) &&\s*"md:flex-row/,
);
});
});
@@ -450,3 +450,183 @@ describe("GET /api/analytics", () => {
// 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<WirePillar> {
const res = await (
GET as unknown as (...args: never[]) => Promise<Response>
)();
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);
});
});
+11
View File
@@ -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;
@@ -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<typeof import("@/lib/cycle/phase-crosstab")>()),
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<typeof getSession>
>,
);
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<typeof getSession>
>,
);
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"]);
});
});
+7
View File
@@ -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.
@@ -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<typeof import("@/lib/modules/gate")>()),
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<typeof getSession>
>,
);
vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({
allowed: true,
} as unknown as Awaited<ReturnType<typeof checkAnalyticsReadRateLimit>>);
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<typeof getSession>
>,
);
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",
]);
});
});
+10 -1
View File
@@ -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,
+19 -29
View File
@@ -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() {
// <relative>" 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 ? (
<HealthScoreCardSkeleton />
) : analytics?.healthScore ? (
<HealthScoreCard
report={analytics.healthScore}
// v1.21.2 (A5 / A6) — server-resolved Tension Verdict + return-to-
// baseline off the dashboard snapshot. The card localises the keys;
// null leaves it quiet.
tension={heroTension}
returnToBand={heroReturnToBand}
// A pillar whose read FAILED is an error row inside the card, and a
// retry there refetches the payload that carries every pillar.
onRetry={() => 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
+5 -1
View File
@@ -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<CycleInsightsResponse>("/api/cycle/insights"),
staleTime: 5 * 60_000,
});
@@ -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<string> {
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(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
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<Page> {
const target = page;
if (!target) throw new Error("browser page was never created");
await target.setContent(
`<!doctype html><html class="dark"><body class="font-sans bg-background" style="margin:0">
<div style="width:${width}px">${html}</div>
</body></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<string, number> = {};
for (const rows of [3, 4, 5, 6]) {
const view = await layout(
band(
<HeroStrip
briefing={null}
now={new Date("2026-05-10T07:00:00Z")}
healthScore={reportWithRows(rows)}
/>,
),
);
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(
<HeroStrip
briefing={null}
now={new Date("2026-05-10T07:00:00Z")}
scorePending
/>,
),
);
// 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<string, { label: string; natural: number }> = {};
for (const locale of locales) {
const view = await layout(
band(
<HeroStrip
briefing={null}
now={new Date("2026-05-10T07:00:00Z")}
healthScore={reportWith(
ALL_PILLARS.map((id, i) => 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(
<HeroStrip
briefing={null}
now={new Date("2026-05-10T07:00:00Z")}
healthScore={reportWith(
ALL_PILLARS.map((id, i) => 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,
);
});
@@ -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("</ul>", 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("<HealthScoreCard> 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(<HealthScoreCard report={scoredReport()} />);
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(<HealthScoreCard report={scoredReport()} />);
// 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("<HealthScoreCard> 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(<HealthScoreCard report={scoredReport()} />);
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(
<HealthScoreCard
report={scoredReport({
composite: {
status: "ok",
value: {
score: 31,
band: "red",
bandSetter: null,
composition: ["BLOOD_PRESSURE", "GLYCAEMIA"],
noiseFloor: 3,
scoreVersion: 2,
},
coverage: {
requiredInputs: 3,
presentInputs: 3,
historyDays: 28,
missing: [],
},
confidence: { score: 100, band: "high" },
provenance,
},
pillars: [
scoredAt("BLOOD_PRESSURE", 92),
scoredAt("GLYCAEMIA", 22),
scoredAt("SLEEP", 55),
],
})}
/>,
);
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(<HealthScoreCard report={scoredReport()} />);
// 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("<HealthScoreCard> 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(
<HealthScoreCard
report={scoredReport({
pillars: [
BLOOD_PRESSURE,
gatedPillar("LIPIDS", "read_failed"),
gatedPillar("GLYCAEMIA", "read_failed"),
],
})}
onRetry={vi.fn()}
/>,
);
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(
<HealthScoreCard
report={scoredReport({
@@ -345,18 +488,108 @@ describe("<HealthScoreCard> 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("<HealthScoreCard> 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(<HealthScoreCard report={mixedReport()} />);
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(<HealthScoreCard report={mixedReport()} />);
// 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(<HealthScoreCard report={mixedReport()} />);
// 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("<HealthScoreCard> 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(<HealthScoreCard report={report()} />);
expect(html).toMatch(
/data-slot="health-score-card"[^>]*data-status="insufficient"/,
@@ -365,8 +598,11 @@ describe("<HealthScoreCard> 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("<HealthScoreCard> 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(<HealthScoreCard report={scoredReport()} />);
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</);
});
it("keeps the chip away from a flat or falling week", () => {
const html = render(
<HealthScoreCard report={scoredReport({ delta: -3 })} />,
);
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(
<HealthScoreCard report={scoredReport({ pillars: [FASTING_GLUCOSE] })} />,
);
// 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("<HealthScoreCardSkeleton>", () => {
it("reserves the collapsed footprint and announces nothing", () => {
it("reserves the column's footprint and announces nothing", () => {
const html = render(<HealthScoreCardSkeleton />);
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(<HealthScoreCardSkeleton />);
const panel = render(<HealthScoreCard report={scoredReport()} />);
// 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);
}
});
});
@@ -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(
<I18nProvider initialLocale={locale}>
<Probe />
</I18nProvider>,
);
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");
});
});
@@ -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("<HeroStrip>", () => {
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(<HeroStrip briefing={null} now={morningLocal} />);
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(<HeroStrip briefing={null} now={morningLocal} />);
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(
<HeroStrip briefing={null} now={morningLocal} healthScore={SCORE} />,
);
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(
<HeroStrip briefing={null} now={morningLocal} scorePending />,
);
// 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(
<HeroStrip
briefing={null}
now={morningLocal}
healthScore={SCORE}
scorePending
/>,
);
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(
<HeroStrip briefing={null} now={morningLocal} healthScore={SCORE} />,
);
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(
<HeroStrip
briefing={null}
now={morningLocal}
healthScore={SCORE_WITH_FAILED_READ}
onScoreRetry={() => {}}
/>,
);
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", () => {
@@ -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(
<QueryClientProvider client={client}>
<I18nProvider initialLocale={locale}>
<CoachReadStrip metricType={METRIC} unit="h" />
</I18nProvider>
</QueryClientProvider>,
);
}
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);
});
});
@@ -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<CoachReadStripData>(
`/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;
+401 -238
View File
@@ -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<ScorePillarId, string> = {
@@ -82,6 +91,39 @@ const REASON_KEY: Record<string, string> = {
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 (
<Card
<div
data-slot="health-score-card"
data-status={composite.status}
className={cn("animate-insight-in", className)}
data-band={compositeBand ?? "none"}
className={cn(
"animate-insight-in",
PANEL_CHROME_CLASS,
compositeBand ? BAND_BORDER_CLASS[compositeBand] : null,
PANEL_COLUMN_CLASS,
// `h-full` lets the hero band's `items-stretch` equalise the column
// against the greeting; `mt-auto` on the foot collects the slack.
"flex h-full flex-col gap-3.5",
className,
)}
>
<CardHeader>
<TileHeader title={t("insights.healthScore.label")} titleAs="h2" />
</CardHeader>
<CardContent className="space-y-4">
{/* 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. */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
<div className="flex justify-center sm:justify-start">
<ScoreRing
score={composite.status === "ok" ? composite.value.score : null}
band={
composite.status === "ok" ? composite.value.band : undefined
}
size="md"
label={t("insights.derived.anatomy.outOf")}
/>
</div>
<div className="min-w-0 flex-1 space-y-1">
{composite.status === "ok" ? (
<p
data-slot="health-score-band"
className="text-foreground text-sm"
>
{t(`insights.healthScore.band.${composite.value.band}`)}
</p>
) : null}
{report.delta != null ? (
<p
data-slot="health-score-delta"
className="text-muted-foreground text-xs tabular-nums"
>
{t("insights.healthScore.delta", {
delta: report.delta > 0 ? `+${report.delta}` : report.delta,
})}
</p>
) : report.deltaReason ? (
<p
data-slot="health-score-delta-reason"
className="text-muted-foreground text-xs"
>
{t(`insights.healthScore.deltaReason.${report.deltaReason}`)}
</p>
) : null}
{report.restMode?.active ? (
<p
data-slot="health-score-rest-mode"
className="text-muted-foreground text-xs"
>
{t("insights.healthScore.restMode", {
since:
report.restMode.since ?? t("insights.healthScore.none"),
})}
</p>
) : null}
{tensionLine ? (
<p
data-slot="health-score-tension"
className="text-muted-foreground text-xs"
>
{tensionLine}
</p>
) : null}
{returnToBandLine ? (
<p
data-slot="health-score-return-to-band"
className="text-muted-foreground text-xs"
>
{returnToBandLine}
</p>
) : null}
</div>
</div>
{/* 1 — the label, and the weekly gain when there is one. */}
<div className="flex items-center justify-between gap-2">
<p
data-slot="health-score-card-label"
className="text-muted-foreground text-xs font-semibold tracking-[0.18em] uppercase"
>
{t("insights.healthScore.label")}
</p>
{report.delta != null && report.delta > 0 ? (
<span
data-slot="health-score-card-delta-chip"
className="bg-success/15 text-success shrink-0 rounded-full px-2 py-0.5 text-xs font-semibold tabular-nums"
>
+{report.delta}
</span>
) : null}
</div>
{composite.status !== "ok" ? (
{/* 2 — the number. The reason the panel exists. */}
<div className="flex items-baseline gap-1">
<span
data-slot="health-score-card-number"
className={cn(
"text-5xl leading-none font-semibold tabular-nums sm:text-6xl",
compositeBand
? BAND_NUMBER_CLASS[compositeBand]
: "text-muted-foreground",
)}
>
{compositeScore == null ? "—" : Math.round(compositeScore)}
</span>
{compositeScore == null ? null : (
<span
aria-hidden="true"
className="text-muted-foreground text-sm tabular-nums"
>
/ 100
</span>
)}
</div>
{/* 3 — the composite bar. */}
{compositeScore != null && compositeBand ? (
<div
data-slot="health-score-card-progress"
role="progressbar"
aria-label={t("insights.healthScore.label")}
aria-valuenow={Math.round(compositeScore)}
aria-valuemin={0}
aria-valuemax={100}
className="bg-muted/50 h-2 w-full overflow-hidden rounded-full"
>
<div
className={cn("h-full", BAND_PROGRESS_CLASS[compositeBand])}
style={{ width: `${compositeScore}%` }}
/>
</div>
) : null}
{/* 4 — the lines that qualify the number, one each, only when true. */}
<div className="space-y-1">
{compositeBand ? (
<p data-slot="health-score-band" className="text-foreground text-xs">
{t(`insights.healthScore.band.${compositeBand}`)}
</p>
) : (
<LearningGate
variant="bordered"
compact
bodySlot="health-score-insufficient"
message={t("insights.healthScore.insufficient", {
count: composite.coverage.presentInputs,
})}
/>
)}
{report.delta != null ? (
<p
data-slot="health-score-delta"
className="text-muted-foreground text-xs tabular-nums"
>
{t("insights.healthScore.delta", {
delta: report.delta > 0 ? `+${report.delta}` : report.delta,
})}
</p>
) : report.deltaReason ? (
<p
data-slot="health-score-delta-reason"
className="text-muted-foreground text-xs"
>
{t(`insights.healthScore.deltaReason.${report.deltaReason}`)}
</p>
) : null}
{report.restMode?.active ? (
<p
data-slot="health-score-rest-mode"
className="text-muted-foreground text-xs"
>
{t("insights.healthScore.restMode", {
since: report.restMode.since ?? t("insights.healthScore.none"),
})}
</p>
) : null}
{tensionLine ? (
<p
data-slot="health-score-tension"
className="text-muted-foreground text-xs"
>
{tensionLine}
</p>
) : null}
{returnToBandLine ? (
<p
data-slot="health-score-return-to-band"
className="text-muted-foreground text-xs"
>
{returnToBandLine}
</p>
) : null}
</div>
{/* 5 — the contributing factors, always visible, directly under the
number. Server registry order; no re-ranking. */}
{scored.length > 0 ? (
<ul
data-slot="health-score-pillars"
className="border-border/60 space-y-2 border-t pt-3"
>
{scored.map((pillar) => (
<PillarRow key={pillar.id} pillar={pillar} detail={detail} />
))}
</ul>
) : 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 ? (
<div className="space-y-1.5">
{readFailed.length > 0 ? (
<div
data-slot="health-score-pillar-error"
data-pillars={readFailed.map((p) => p.id).join(",")}
role="alert"
className="text-foreground flex flex-wrap items-center gap-x-2 gap-y-1 text-xs"
>
<AlertTriangle
aria-hidden="true"
className="text-muted-foreground size-3.5 shrink-0"
/>
<span className="min-w-0">
{labelsOf(readFailed)}
{": "}
{reasonText("read_failed")}
</span>
{onRetry ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={onRetry}
data-slot="health-score-pillar-retry"
className="h-7 gap-1.5 px-2 text-xs"
>
<RotateCw className="size-3.5" aria-hidden="true" />
<span>{t("common.retry")}</span>
</Button>
) : null}
</div>
) : null}
{crisis.length > 0 ? (
<p
data-slot="health-score-crisis"
data-pillars={crisis.map((p) => p.id).join(",")}
className="text-foreground text-xs"
>
{labelsOf(crisis)}
{": "}
{reasonText("crisis_signposting")}
</p>
) : null}
{notScored.length > 0 ? (
<p
data-slot="health-score-not-scored-count"
data-count={notScored.length}
className="text-muted-foreground text-xs"
>
{t("insights.healthScore.notScored", {
count: notScored.length,
})}
</p>
) : null}
</div>
) : 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. */}
<div className="mt-auto space-y-3 pt-1">
<CoverageMeter
coverage={composite.coverage}
confidence={
composite.status === "ok" ? composite.confidence : undefined
}
size="md"
size="sm"
/>
{/* 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. */}
<div className="border-border border-t pt-3">
<div className="border-border/60 border-t pt-1">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={regionId}
data-slot="health-score-anatomy-toggle"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 flex min-h-11 w-full items-center justify-between gap-2 rounded-sm text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 flex min-h-11 w-full items-center justify-between gap-2 rounded-sm text-left text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<span>{t("insights.healthScore.anatomyToggle")}</span>
<span className="min-w-0">
{t("insights.healthScore.anatomyToggle")}
</span>
<ChevronDown
aria-hidden="true"
className={cn(
"h-4 w-4 transition-transform",
"size-4 shrink-0 transition-transform",
open && "rotate-180",
)}
/>
@@ -370,71 +552,28 @@ export function HealthScoreCard({
id={regionId}
hidden={!open}
data-slot="health-score-anatomy-region"
className="space-y-4 pt-3"
className="space-y-3 pt-1 pb-1"
>
{scored.length > 0 ? (
<ul
data-slot="health-score-pillars"
className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"
>
{scored.map((pillar) => (
<PillarCell key={pillar.id} pillar={pillar} detail={detail} />
))}
</ul>
) : null}
{readFailed.map((pillar) => (
<QueryErrorRow
key={pillar.id}
slot="health-score-pillar-error"
retrySlot="health-score-pillar-retry"
message={
<>
{t(PILLAR_LABEL_KEY[pillar.id])}
{": "}
{reasonText("read_failed")}
</>
}
onRetry={onRetry}
/>
))}
{notScored.length > 0 ? (
<div data-slot="health-score-not-scored" className="space-y-2">
<p className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">
{t("insights.healthScore.notScored", {
count: notScored.length,
})}
</p>
<ul className="space-y-1">
{notScored.map((pillar) => {
const reason = gateReason(pillar) ?? "";
const crisis = reason === "crisis_signposting";
return (
<li
key={pillar.id}
data-pillar={pillar.id}
data-reason={reason}
className="flex flex-col gap-1 sm:flex-row sm:items-baseline sm:gap-2"
>
<span className="text-foreground text-xs font-medium">
{t(PILLAR_LABEL_KEY[pillar.id])}
</span>
<span
className={cn(
"text-xs",
crisis
? "text-foreground"
: "text-muted-foreground",
)}
>
{reasonText(reason)}
</span>
</li>
);
})}
</ul>
</div>
<ul data-slot="health-score-not-scored" className="space-y-1">
{notScored.map((pillar) => {
const reason = gateReason(pillar) ?? "";
return (
<li
key={pillar.id}
data-pillar={pillar.id}
data-reason={reason}
className="text-muted-foreground text-xs"
>
<span className="text-foreground font-medium">
{t(PILLAR_LABEL_KEY[pillar.id])}
</span>
{": "}
{reasonText(reason)}
</li>
);
})}
</ul>
) : null}
<WeightGoalRow report={report} />
@@ -472,18 +611,23 @@ export function HealthScoreCard({
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
/**
* 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 (
<li
data-pillar={pillar.id}
data-status="ok"
className="border-border/70 space-y-2 rounded-lg border p-3"
className="grid grid-cols-[minmax(0,7rem)_1fr_2rem_auto] items-center gap-2 text-xs"
>
<div className="flex items-baseline justify-between gap-2">
<span className="text-foreground min-w-0 text-sm font-medium">
{t(PILLAR_LABEL_KEY[pillar.id])}
</span>
<span className="text-foreground text-lg font-semibold tabular-nums">
{Math.round(score)}
</span>
</div>
{/* 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. */}
<span className="text-muted-foreground min-w-0 truncate">
{t(PILLAR_LABEL_KEY[pillar.id])}
</span>
<div
className="bg-muted/40 h-1.5 w-full overflow-hidden rounded-full"
role="presentation"
className="bg-muted/50 h-1.5 min-w-0 overflow-hidden rounded-full"
aria-hidden="true"
>
<div
data-slot="health-score-pillar-bar"
@@ -520,36 +667,41 @@ function PillarCell({
style={{ width: `${score}%` }}
/>
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground min-w-0 text-xs">
{t("insights.healthScore.observed", {
value: pillarObservedText(pillar, detail),
})}
</span>
<span className="flex shrink-0 items-center gap-2">
<InfoPopover
content={lines.map((line) => (
<span className="text-foreground text-right tabular-nums">
{Math.round(score)}
</span>
<InfoPopover
content={
<>
{observed ? (
<span className="block">
{t("insights.healthScore.observed", { value: observed })}
</span>
) : null}
{lines.map((line) => (
<span key={line} className="block">
{line}
</span>
))}
bodyDataSlot="health-score-pillar-detail"
/>
<CoverageMeter
coverage={pillar.result.coverage}
confidence={pillar.result.confidence}
size="sm"
/>
</span>
</div>
<span className="mt-1.5 block">
<CoverageMeter
coverage={pillar.result.coverage}
confidence={pillar.result.confidence}
size="sm"
/>
</span>
</>
}
bodyDataSlot="health-score-pillar-detail"
/>
</li>
);
}
/**
* 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 (
<Card
<div
data-slot="health-score-card-skeleton"
aria-hidden="true"
className={className}
className={cn(
PANEL_CHROME_CLASS,
"border-border/60",
PANEL_COLUMN_CLASS,
"flex h-full flex-col gap-3.5",
PANEL_RESERVE_CLASS,
className,
)}
>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
{/* The `md` ring paints at a fixed 168 px box; the reserve matches it
exactly so the swap moves nothing. */}
<Skeleton className="size-[168px] shrink-0 self-center rounded-full sm:self-auto" />
<div className="min-w-0 flex-1 space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-40" />
<Skeleton className="h-3 w-32" />
</div>
</div>
<Skeleton className="h-3 w-28" />
<div className="border-border border-t pt-3">
{/* Block for block, the panel above: label, number, bar, two qualifier
lines, three contributor rows, one not-contributing line, and the
foot with its meter and toggle. Three rows rather than five is what
keeps these blocks under the minimum above, so the minimum is the
thing that decides the height. */}
<Skeleton className="h-4 w-24" />
<Skeleton className="h-15 w-28" />
<Skeleton className="h-2 w-full rounded-full" />
<div className="space-y-1">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-4 w-32" />
</div>
<div className="border-border/60 space-y-2 border-t pt-3">
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-full" />
</div>
<Skeleton className="h-4 w-36" />
<div className="mt-auto space-y-3 pt-1">
<Skeleton className="h-11 w-28 rounded-sm" />
<div className="border-border/60 border-t pt-1">
<Skeleton className="h-11 w-full rounded-sm" />
</div>
</CardContent>
</Card>
</div>
</div>
);
}
@@ -152,10 +152,59 @@ function pillarReferenceText(
}
/**
* The lines behind the cell's info popover: the scored reference, the
* user-authored target when one exists, and the source with its as-of date.
* Ordered as they render; an absent personal target contributes no line
* rather than an empty one.
* Blood pressure only: how the number came about.
*
* The BP pillar scores the WORSE of the two axes against a reference
* ceiling, so the score on its own cannot say which axis it came from,
* how far over the ceiling that axis sat, or why a reading sitting
* exactly at the ceiling reads 85 rather than 100. All three were
* invisible, which is what made a defensible number feel arbitrary.
*
* The basis is resolved on the server and travels with the score, so
* nothing here recomputes anything: this reads `scoreBasis` and writes
* sentences. A pillar without a basis contributes no lines at all.
*/
function bpBasisLines(
pillar: ScorePillarResult,
{ t }: PillarDetailContext,
): string[] {
if (pillar.id !== "BLOOD_PRESSURE" || pillar.result.status !== "ok") {
return [];
}
const basis = pillar.result.value.scoreBasis;
if (!basis) return [];
// `charts.systolic` / `charts.diastolic` ship in all six locales and are
// already the words the BP chart uses for these axes.
const axis =
basis.axis === "systolic" ? t("charts.systolic") : t("charts.diastolic");
const params = {
axis,
offset: basis.offsetMmHg,
boundary: basis.boundaryMmHg,
};
const basisLine =
basis.relation === "above_ceiling"
? t("insights.healthScore.bpBasisAbove", params)
: basis.relation === "below_floor"
? t("insights.healthScore.bpBasisBelowFloor", params)
: t("insights.healthScore.bpBasisInBand");
return [
basisLine,
t("insights.healthScore.bpScale"),
// The observed pair is a recency-weighted mean over the pillar's own
// window, not a reading anyone ever took. Saying so is what keeps the
// observed line from reading as "I never had that reading".
t("insights.healthScore.bpRepresentative", {
days: pillar.result.provenance.windowDays,
}),
];
}
/**
* The lines behind the cell's info popover: for blood pressure the basis
* of the score, then the scored reference, the user-authored target when
* one exists, and the source with its as-of date. Ordered as they render;
* an absent personal target contributes no line rather than an empty one.
*/
export function pillarDetailLines(
pillar: ScorePillarResult,
@@ -165,6 +214,7 @@ export function pillarDetailLines(
const { t } = ctx;
const value = pillar.result.value;
const lines = [
...bpBasisLines(pillar, ctx),
t("insights.healthScore.reference", {
value: pillarReferenceText(pillar, ctx),
}),
+61 -13
View File
@@ -11,8 +11,14 @@ import { formatUpdatedLabel } from "@/lib/i18n/relative-time";
import { useAuth } from "@/hooks/use-auth";
import { hourInTz, DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { ProseBlocks } from "@/components/insights/prose-blocks";
import {
HealthScoreCard,
HealthScoreCardSkeleton,
type ReadinessContributorKey,
} from "@/components/insights/health-score-card";
import { cn } from "@/lib/utils";
import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema";
import type { HealthScoreReport, ScoreBand } from "@/lib/analytics/score/types";
/**
* Insights redesign hero strip.
@@ -33,14 +39,14 @@ import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema";
* overview now leads with the present-focused daily briefing, not a
* coach prompt.
*
* The right-side Health Score panel is gone too. The score had two mounts,
* a compact face here and the full breakdown below; the breakdown is now one
* progressive-disclosure card pinned directly under this band, which needs
* the full page width when it opens. The hero is back to the full-width
* greeting + subtitle + baseline meta — the shape it already had for an
* account without a score.
* The Health Score is back in the band's right column, where it lived for
* eleven weeks. It is the score's ONLY mount on the overview — there is no
* second card below the greeting any more. The band splits into two columns
* from `md` up, and only when there is a score (or one on its way): an
* account without a score keeps the full-width greeting exactly as it is.
* Below `md` the panel stacks under the greeting at full width.
*
* Pure presentational — the page owns the briefing data.
* Pure presentational — the page owns the briefing and the score data.
*/
interface HeroStripProps {
@@ -68,6 +74,24 @@ interface HeroStripProps {
* footer hint below.
*/
noProviderStale?: boolean;
/**
* The Health Score report. Non-null paints the right column; null with
* `scorePending` paints the reserve; null without it keeps the band
* full-width, which is the shape an account without a score already had.
*/
healthScore?: HealthScoreReport | null;
/** True while the analytics payload is in flight and nothing is cached. */
scorePending?: boolean;
/** Server-resolved Tension Verdict, forwarded to the panel. */
tension?: {
band: ScoreBand;
positive: ReadinessContributorKey[];
negative: ReadinessContributorKey[];
} | null;
/** Server-resolved return-to-baseline, forwarded to the panel. */
returnToBand?: { metricType: string; daysInside: number } | null;
/** Retry for a pillar whose read failed — refetches the whole payload. */
onScoreRetry?: () => void;
}
/**
@@ -94,6 +118,11 @@ export function HeroStrip({
userName,
now,
noProviderStale = false,
healthScore = null,
scorePending = false,
tension = null,
returnToBand = null,
onScoreRetry,
}: HeroStripProps) {
const { t } = useTranslations();
const fmt = useFormatters();
@@ -134,12 +163,17 @@ export function HeroStrip({
"relative isolate overflow-hidden rounded-xl px-4 py-4 md:px-6 md:py-6",
)}
>
{/* One column. The band carried a right-hand score panel from v1.4.20
until the score moved to its own pinned card below; with the panel
gone the greeting owns the full width on every breakpoint, which is
the shape this band already had for an account without a score. */}
<div className="flex min-w-0 flex-col gap-4">
<div className="flex flex-col gap-3">
{/* Two columns from `md` up, and only when the score column has
something to show. Without a score the band stays one column, which
is the shape it already had for an account that has none. */}
<div
className={cn(
"flex min-w-0 flex-col gap-4",
(healthScore || scorePending) &&
"md:flex-row md:items-stretch md:gap-6",
)}
>
<div className="flex min-w-0 flex-1 flex-col gap-3">
<div className="flex items-center gap-2">
{/* Header glyph stays in the foreground tier — UI-STANDARDS §1
reserves primary/accent for actions, not decorative header
@@ -214,6 +248,20 @@ export function HeroStrip({
* untouched — only the hero entry points are gone, so the band
* is now greeting + subtitle + baseline meta.
*/}
{/* The score column. The reserve holds the column's footprint while
the payload is in flight so the greeting beside it does not shift
when the score lands. Both wear the same width classes. */}
{healthScore ? (
<HealthScoreCard
report={healthScore}
tension={tension}
returnToBand={returnToBand}
onRetry={onScoreRetry}
/>
) : scorePending ? (
<HealthScoreCardSkeleton />
) : null}
</div>
</div>
);
@@ -45,14 +45,14 @@ beforeEach(() => {
describe("readCoachCorrelations — insights module gate", () => {
it("gates on `insights`, not on a per-domain key", async () => {
await readCoachCorrelations(USER);
await readCoachCorrelations(USER, "en");
expect(isModuleEnabled).toHaveBeenCalledWith(USER, "insights");
});
it("omits with the module OFF, and reads nothing at all", async () => {
vi.mocked(isModuleEnabled).mockImplementation(async () => false);
const res = await readCoachCorrelations(USER);
const res = await readCoachCorrelations(USER, "en");
expect(res.present).toBe(false);
// Distinct from `no_data`: the assistant is told the domain is switched
@@ -67,7 +67,7 @@ describe("readCoachCorrelations — insights module gate", () => {
});
it("proceeds to the engine with the module ON", async () => {
const res = await readCoachCorrelations(USER);
const res = await readCoachCorrelations(USER, "en");
// The synthetic fixture has no paired data, so the honest answer is a
// miss — but it must be the ENGINE's miss, reached after the real reads
// ran, not the gate's.
@@ -85,7 +85,7 @@ describe("readCoachCorrelations — insights module gate", () => {
async (_u: string, key: string) => key !== "insights",
);
const res = await readCoachCorrelations(USER);
const res = await readCoachCorrelations(USER, "en");
expect(res.present).toBe(false);
expect(res.reason).toBe("module_disabled");
@@ -99,6 +99,8 @@ describe("readCoachCorrelations — insights module gate", () => {
// loud, not silently indistinguishable from "no pattern found".
vi.mocked(isModuleEnabled).mockRejectedValue(new Error("gate down"));
await expect(readCoachCorrelations(USER)).rejects.toThrow("gate down");
await expect(readCoachCorrelations(USER, "en")).rejects.toThrow(
"gate down",
);
});
});
@@ -91,7 +91,7 @@ describe("readCoachCorrelations", () => {
fdrQ: 0.1,
minPairs: 20,
});
const result = await readCoachCorrelations("u1");
const result = await readCoachCorrelations("u1", "en");
expect(result.present).toBe(true);
expect(result.drivers).toHaveLength(1);
expect(result.drivers?.[0]).toMatchObject({
@@ -111,7 +111,7 @@ describe("readCoachCorrelations", () => {
fdrQ: 0.1,
minPairs: 20,
});
const result = await readCoachCorrelations("u1");
const result = await readCoachCorrelations("u1", "en");
expect(result.present).toBe(false);
expect(result.reason).toBe("no_significant_pattern");
});
@@ -149,7 +149,7 @@ describe("readCoachCorrelations", () => {
computedAt: "x",
},
});
const result = await readCoachCorrelations("u1");
const result = await readCoachCorrelations("u1", "en");
expect(result.present).toBe(true);
expect(result.coincident?.fired).toBe(true);
expect(result.coincident?.contributing).toEqual([
@@ -160,7 +160,7 @@ describe("readCoachCorrelations", () => {
it("degrades to present:false on a read failure", async () => {
loadBaselineProfile.mockRejectedValue(new Error("db down"));
const result = await readCoachCorrelations("u1");
const result = await readCoachCorrelations("u1", "en");
expect(result.present).toBe(false);
expect(result.reason).toBe("retrieval_failed");
});
@@ -175,7 +175,7 @@ describe("readCoachCorrelations", () => {
fdrQ: 0.1,
minPairs: 20,
});
await readCoachCorrelations("u1");
await readCoachCorrelations("u1", "en");
const where = measurementFindMany.mock.calls[0]?.[0]?.where as {
type: { in: string[] };
};
@@ -198,6 +198,7 @@ describe("readCoachCorrelations", () => {
discoverCorrelations.mockImplementation((series) =>
real.discoverCorrelations(
series as Parameters<typeof real.discoverCorrelations>[0],
{ locale: "en" },
),
);
@@ -248,7 +249,7 @@ describe("readCoachCorrelations", () => {
]);
illnessDayLogFindMany.mockResolvedValue(dayLogs);
const result = await readCoachCorrelations("u1");
const result = await readCoachCorrelations("u1", "en");
expect(result.present).toBe(true);
const symptomDriver = result.drivers?.find(
@@ -47,7 +47,7 @@ describe("buildCorrelationsSnapshotBlock (D5-5)", () => {
pairsTested: 16,
windowDays: 180,
});
const block = await buildCorrelationsSnapshotBlock("user-1");
const block = await buildCorrelationsSnapshotBlock("user-1", "en");
expect(block).not.toBeNull();
expect(block!.drivers).toHaveLength(2);
expect(block!.drivers[0].behaviour).toBe("time in daylight");
@@ -65,7 +65,7 @@ describe("buildCorrelationsSnapshotBlock (D5-5)", () => {
pairsTested: 30,
windowDays: 180,
});
const block = await buildCorrelationsSnapshotBlock("user-1");
const block = await buildCorrelationsSnapshotBlock("user-1", "en");
expect(block!.drivers).toHaveLength(SNAPSHOT_DRIVER_CAP);
// Order preserved from the already-ranked tool output (highest effect first).
expect(block!.drivers[0].behaviour).toBe("behaviour 0");
@@ -76,7 +76,7 @@ describe("buildCorrelationsSnapshotBlock (D5-5)", () => {
present: false,
reason: "no_significant_pattern",
});
expect(await buildCorrelationsSnapshotBlock("user-1")).toBeNull();
expect(await buildCorrelationsSnapshotBlock("user-1", "en")).toBeNull();
});
it("attaches nothing when present but the driver list is empty (coincident-only)", async () => {
@@ -89,6 +89,6 @@ describe("buildCorrelationsSnapshotBlock (D5-5)", () => {
pairsTested: 12,
windowDays: 180,
});
expect(await buildCorrelationsSnapshotBlock("user-1")).toBeNull();
expect(await buildCorrelationsSnapshotBlock("user-1", "en")).toBeNull();
});
});
@@ -56,7 +56,15 @@ function emptyAggregate() {
const readCoachCorrelations = vi.fn();
vi.mock("@/lib/ai/coach/tools/correlations-read", () => ({
readCoachCorrelations: (userId: string) => readCoachCorrelations(userId),
readCoachCorrelations: (userId: string, locale: string) =>
readCoachCorrelations(userId, locale),
}));
// The executor asks for the account's language before it asks for a narrated
// correlation, so the sentences it hands the model are not silently English.
const resolveLocaleForUser = vi.fn(async (_userId: string) => "de");
vi.mock("@/lib/i18n/user-locale", () => ({
resolveLocaleForUser: (userId: string) => resolveLocaleForUser(userId),
}));
// v1.21.0 (NEW-B B-2) — the illness-recovery tool augments its snapshot blocks
@@ -357,6 +365,9 @@ describe("executeCoachTool", () => {
drivers: [{ behaviour: "time in daylight", n: 42 }],
pairsTested: 18,
});
// The account's language reached the reader — the notes are finished
// sentences, and an MCP client shows some of them as they stand.
expect(readCoachCorrelations).toHaveBeenCalledWith("u1", "de");
});
it("returns a clean { present: false } for get_correlations on no pattern", async () => {
+9 -1
View File
@@ -18,6 +18,7 @@
* Server-only — `readCoachCorrelations` reads `@/lib/db`. Fail-soft: a null
* return (no surviving driver / read hiccup) attaches nothing.
*/
import type { Locale } from "@/lib/i18n/config";
import {
readCoachCorrelations,
type CoachCorrelationDriver,
@@ -45,8 +46,15 @@ export interface CorrelationsSnapshotBlock {
*/
export async function buildCorrelationsSnapshotBlock(
userId: string,
/**
* The Coach's resolved UI locale. The driver notes go into the prompt as
* finished sentences, and a model handed English prose for a German
* conversation echoes English phrasings back. Required — the snapshot knows
* the locale, so it says it.
*/
locale: Locale,
): Promise<CorrelationsSnapshotBlock | null> {
const result = await readCoachCorrelations(userId);
const result = await readCoachCorrelations(userId, locale);
if (!result.present || !result.drivers || result.drivers.length === 0) {
return null;
}
+1
View File
@@ -168,6 +168,7 @@ export async function buildCoachMemoryBlock(
const ctx = await buildPeriodNarrativeContext(userId, {
period: MEMORY_PERIOD,
now,
locale,
});
if (ctx.status === "ready") {
for (const transition of ctx.bandTransitions) {
+1 -1
View File
@@ -843,7 +843,7 @@ async function buildCoachSnapshotImpl(
// instead of getting only the coincident flag. Gated on `derivedActive` (it
// is the recovery / cross-metric layer) and fail-soft to null.
const correlationsBlockPromise = derivedActive
? buildCorrelationsSnapshotBlock(userId)
? buildCorrelationsSnapshotBlock(userId, coachLocale)
: null;
const memoryBlockPromise = buildCoachMemoryBlock(
userId,
+15 -2
View File
@@ -24,6 +24,7 @@
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import type { Locale } from "@/lib/i18n/config";
import { annotate } from "@/lib/logging/context";
import { isModuleEnabled } from "@/lib/modules/gate";
import { wallClockInTz } from "@/lib/tz/wall-clock";
@@ -151,6 +152,15 @@ function humanise(key: string): string {
*/
export async function readCoachCorrelations(
userId: string,
/**
* The reader's locale. Every `note` this returns is a finished sentence that
* some surface prints verbatim — the metric page's "Coach read" strip does
* exactly that — so the language has to be decided by whoever knows who is
* reading. No default: a caller that cannot name a locale is a caller that
* does not know, and guessing English is how the strip came to answer a
* German page in English.
*/
locale: Locale,
): Promise<CoachCorrelationsResult> {
// v1.30.22 — the `insights` gate lives HERE, at the read, not at each
// caller. This reader is reached from four places (the Coach
@@ -271,7 +281,7 @@ export async function readCoachCorrelations(
},
});
const discovery = discoverCorrelations(series);
const discovery = discoverCorrelations(series, { locale });
const drivers: CoachCorrelationDriver[] = discovery.discovered.map((d) => ({
behaviour: humanise(d.behaviour),
outcome: humanise(d.outcome),
@@ -292,6 +302,7 @@ export async function readCoachCorrelations(
);
const emergingResult = discoverEmergingCorrelations(series, discovery, {
recentFromDayKey,
locale,
});
const emerging: CoachEmergingDriver[] = emergingResult.emerging.map(
(d) => ({
@@ -307,7 +318,9 @@ export async function readCoachCorrelations(
);
// v1.22 — labs ↔ outcome pass (point-vs-window over sparse draws).
const labResult = discoverLabOutcomeCorrelations(labDraws, series);
const labResult = discoverLabOutcomeCorrelations(labDraws, series, {
locale,
});
const labDrivers: CoachLabCorrelation[] = labResult.discovered.map((d) => ({
lab: d.lab.startsWith("LAB:") ? d.lab.slice("LAB:".length) : d.lab,
outcome: humanise(d.outcome),
+7 -1
View File
@@ -62,6 +62,7 @@ import {
METRIC_SERIES_EXCLUDED_SOURCES,
} from "./source-keys";
import { readCoachCorrelations } from "./correlations-read";
import { resolveLocaleForUser } from "@/lib/i18n/user-locale";
import {
resolveEmptyRead,
subjectForTool,
@@ -599,7 +600,12 @@ async function getCorrelations(
// days, and the reader already reports WHY nothing survived (its own reason
// code). "No pattern survived the correction" is an honest absence of a
// finding, not an absence of data, and nothing older would change it.
const result = await readCoachCorrelations(userId);
// The reader's language, from their stored preference — the executor is
// reached from the chat loop and from MCP, neither of which carries a request
// of its own. The notes below are finished sentences; some MCP clients show
// them to the person verbatim rather than letting the model re-word them.
const locale = await resolveLocaleForUser(userId);
const result = await readCoachCorrelations(userId, locale);
if (!result.present) {
return { present: false, reason: result.reason ?? "no_pattern" };
}
+332 -29
View File
@@ -7,6 +7,8 @@
* (low-to-mid 50s), not in the catastrophic band.
*/
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
gradeBpScore,
gradeBpScoreFromSeries,
@@ -22,49 +24,58 @@ const UNDER65: BpTargets = {
diaHigh: 79,
};
/**
* The score alone, for the arithmetic pins below. `gradeBpScore` returns
* the score together with the basis that produced it; the basis has its
* own describe block further down.
*/
function bpScore(input: { sys: number; dia: number; target: BpTargets }) {
return gradeBpScore(input).score;
}
describe("gradeBpScore", () => {
it("grades the maintainer's 134/87 into the borderline band [45,60]", () => {
const score = gradeBpScore({ sys: 134, dia: 87, target: UNDER65 });
const score = bpScore({ sys: 134, dia: 87, target: UNDER65 });
expect(score).toBeGreaterThanOrEqual(45);
expect(score).toBeLessThanOrEqual(60);
});
it("grades a well-controlled 120/78 at or above 85", () => {
expect(
gradeBpScore({ sys: 120, dia: 78, target: UNDER65 }),
bpScore({ sys: 120, dia: 78, target: UNDER65 }),
).toBeGreaterThanOrEqual(85);
});
it("grades a textbook-normal 118/76 very high (≈100)", () => {
expect(
gradeBpScore({ sys: 118, dia: 76, target: UNDER65 }),
bpScore({ sys: 118, dia: 76, target: UNDER65 }),
).toBeGreaterThanOrEqual(88);
});
it("grades an uncontrolled 160/100 at or below 30", () => {
expect(
gradeBpScore({ sys: 160, dia: 100, target: UNDER65 }),
bpScore({ sys: 160, dia: 100, target: UNDER65 }),
).toBeLessThanOrEqual(30);
});
it("takes the WORSE axis — a single high diastolic drags the score down", () => {
// sys perfect (118), dia far over (95): the worst axis should win.
const sysOk = gradeBpScore({ sys: 118, dia: 78, target: UNDER65 });
const diaHigh = gradeBpScore({ sys: 118, dia: 95, target: UNDER65 });
const sysOk = bpScore({ sys: 118, dia: 78, target: UNDER65 });
const diaHigh = bpScore({ sys: 118, dia: 95, target: UNDER65 });
expect(diaHigh).toBeLessThan(sysOk);
});
it("is monotonic — higher BP never scores better", () => {
const a = gradeBpScore({ sys: 130, dia: 82, target: UNDER65 });
const b = gradeBpScore({ sys: 140, dia: 88, target: UNDER65 });
const c = gradeBpScore({ sys: 150, dia: 95, target: UNDER65 });
const a = bpScore({ sys: 130, dia: 82, target: UNDER65 });
const b = bpScore({ sys: 140, dia: 88, target: UNDER65 });
const c = bpScore({ sys: 150, dia: 95, target: UNDER65 });
expect(a).toBeGreaterThan(b);
expect(b).toBeGreaterThan(c);
});
it("has no cliff — a one-mmHg change moves the score by a small amount", () => {
const at = gradeBpScore({ sys: 129, dia: 79, target: UNDER65 });
const justOver = gradeBpScore({ sys: 130, dia: 79, target: UNDER65 });
const at = bpScore({ sys: 129, dia: 79, target: UNDER65 });
const justOver = bpScore({ sys: 130, dia: 79, target: UNDER65 });
expect(at - justOver).toBeLessThanOrEqual(5);
expect(at).toBeGreaterThanOrEqual(justOver);
});
@@ -73,11 +84,11 @@ describe("gradeBpScore", () => {
// 85/45 is 5 mmHg below both floors (sys 90 / dia 50). The continuous
// hypo curve gives a gentle penalty near the floor (5 below ≈ 85),
// clearly below the optimal plateau of 100.
const low = gradeBpScore({ sys: 85, dia: 45, target: UNDER65 });
const low = bpScore({ sys: 85, dia: 45, target: UNDER65 });
expect(low).toBeLessThan(100);
expect(low).toBeGreaterThanOrEqual(80);
// A markedly low reading is penalised much harder.
const veryLow = gradeBpScore({ sys: 70, dia: 40, target: UNDER65 });
const veryLow = bpScore({ sys: 70, dia: 40, target: UNDER65 });
expect(veryLow).toBeLessThan(low);
expect(veryLow).toBeLessThanOrEqual(45);
});
@@ -90,7 +101,7 @@ describe("gradeBpScore", () => {
// dia 66 sits on the optimal plateau (offset −13 → 100) so the
// systolic axis is the worst-of(sys,dia) winner across the walk.
const sysWalk = [92, 91, 90, 89, 88].map((sys) =>
gradeBpScore({ sys, dia: 66, target: UNDER65 }),
bpScore({ sys, dia: 66, target: UNDER65 }),
);
// AT and ABOVE the floor sit on the plateau (100).
expect(sysWalk[2]).toBe(100); // sys 90
@@ -109,7 +120,7 @@ describe("gradeBpScore", () => {
// sys 117 sits on the optimal plateau (offset −12 → 100) so the
// diastolic axis is the worst-of(sys,dia) winner across the walk.
const diaWalk = [52, 51, 50, 49, 48].map((dia) =>
gradeBpScore({ sys: 117, dia, target: UNDER65 }),
bpScore({ sys: 117, dia, target: UNDER65 }),
);
expect(diaWalk[2]).toBe(100); // dia 50
expect(diaWalk[1]).toBe(100); // dia 51
@@ -124,19 +135,19 @@ describe("gradeBpScore", () => {
it("descends smoothly further below the floor (mirrors over-target steepness)", () => {
// floor → 100, floor−10 → ~70, floor−20 → ~45, floor−30 → ~20.
// dia 66 stays on the plateau so the systolic axis is the winner.
expect(gradeBpScore({ sys: 90, dia: 66, target: UNDER65 })).toBe(100);
expect(gradeBpScore({ sys: 80, dia: 66, target: UNDER65 })).toBe(70);
expect(gradeBpScore({ sys: 70, dia: 66, target: UNDER65 })).toBe(45);
expect(gradeBpScore({ sys: 60, dia: 66, target: UNDER65 })).toBe(20);
expect(bpScore({ sys: 90, dia: 66, target: UNDER65 })).toBe(100);
expect(bpScore({ sys: 80, dia: 66, target: UNDER65 })).toBe(70);
expect(bpScore({ sys: 70, dia: 66, target: UNDER65 })).toBe(45);
expect(bpScore({ sys: 60, dia: 66, target: UNDER65 })).toBe(20);
// Never negative on an extreme low.
expect(
gradeBpScore({ sys: 30, dia: 20, target: UNDER65 }),
bpScore({ sys: 30, dia: 20, target: UNDER65 }),
).toBeGreaterThanOrEqual(0);
});
it("preserves the audit fairness anchors (134/87 → 57, 165/105 → 22)", () => {
expect(gradeBpScore({ sys: 134, dia: 87, target: UNDER65 })).toBe(57);
expect(gradeBpScore({ sys: 165, dia: 105, target: UNDER65 })).toBe(22);
expect(bpScore({ sys: 134, dia: 87, target: UNDER65 })).toBe(57);
expect(bpScore({ sys: 165, dia: 105, target: UNDER65 })).toBe(22);
});
});
@@ -169,7 +180,7 @@ describe("gradeBpScoreFromSeries", () => {
// recency-weighted representative should read close to the recent
// well-controlled cluster.
expect(recencyWeighted).not.toBeNull();
expect(recencyWeighted as number).toBeGreaterThanOrEqual(80);
expect(recencyWeighted!.score).toBeGreaterThanOrEqual(80);
});
it("matches the single-reading grade when every pair is today", () => {
@@ -177,9 +188,9 @@ describe("gradeBpScoreFromSeries", () => {
{ at: NOW, sys: 134, dia: 87 },
{ at: NOW, sys: 134, dia: 87 },
];
expect(gradeBpScoreFromSeries({ pairs, target: UNDER65, now: NOW })).toBe(
gradeBpScore({ sys: 134, dia: 87, target: UNDER65 }),
);
expect(
gradeBpScoreFromSeries({ pairs, target: UNDER65, now: NOW }),
).toEqual(gradeBpScore({ sys: 134, dia: 87, target: UNDER65 }));
});
it("rollup (per-day-mean + count) and live (per-event) agree on the same data", () => {
@@ -218,8 +229,300 @@ describe("gradeBpScoreFromSeries", () => {
});
expect(live).not.toBeNull();
expect(rollup).not.toBeNull();
expect(Math.abs((live as number) - (rollup as number))).toBeLessThanOrEqual(
1,
);
expect(Math.abs(live!.score - rollup!.score)).toBeLessThanOrEqual(1);
});
});
/**
* v1.34.5 — the basis published with the score. The pillar scores the
* WORSE of the two axes; these pin which axis that was, what it was
* measured against, and that the three relations the popover can render
* are the only three that exist.
*/
describe("gradeBpScore — the basis", () => {
// Hypotension floors, mirrored from `bp-in-target` so the sweep below
// can reconstruct the boundary the grader must have used.
const SYS_FLOOR = 90;
const DIA_FLOOR = 50;
it("names the systolic axis when it is the worse one", () => {
// 137/84: sys 8 over its ceiling, dia 5 over its own — systolic worse.
const { score, basis } = gradeBpScore({
sys: 137,
dia: 84,
target: UNDER65,
});
expect(basis).toEqual({
axis: "systolic",
relation: "above_ceiling",
offsetMmHg: 8,
boundaryMmHg: 129,
});
expect(score).toBe(57);
});
it("names the diastolic axis when it is the worse one", () => {
// 132/88: sys 3 over, dia 9 over — diastolic worse, and the basis
// must say so rather than defaulting to the axis people read first.
const { score, basis } = gradeBpScore({
sys: 132,
dia: 88,
target: UNDER65,
});
expect(basis).toEqual({
axis: "diastolic",
relation: "above_ceiling",
offsetMmHg: 9,
boundaryMmHg: 79,
});
expect(score).toBe(55);
});
it("breaks a tie toward systolic", () => {
// Both axes sit exactly 5 mmHg over their own ceiling, so both grade
// 65 and the score cannot say which one it came from. The documented
// rule picks systolic.
const { basis } = gradeBpScore({ sys: 134, dia: 84, target: UNDER65 });
expect(basis.axis).toBe("systolic");
expect(basis.boundaryMmHg).toBe(129);
// …and at the ceiling itself, where both axes grade 85.
expect(gradeBpScore({ sys: 129, dia: 79, target: UNDER65 }).basis).toEqual({
axis: "systolic",
relation: "in_band",
offsetMmHg: 0,
boundaryMmHg: 129,
});
});
it("reports a reading inside the band as in_band, measured from the ceiling", () => {
const { score, basis } = gradeBpScore({
sys: 124,
dia: 74,
target: UNDER65,
});
expect(basis.relation).toBe("in_band");
expect(basis.boundaryMmHg).toBe(129);
expect(basis.offsetMmHg).toBe(5); // five below the ceiling
expect(score).toBeGreaterThanOrEqual(85);
});
it("reports a hypotensive reading as below_floor, measured from the floor", () => {
// 86/70: systolic 4 under the 90 floor; diastolic comfortably in band.
const { basis } = gradeBpScore({ sys: 86, dia: 70, target: UNDER65 });
expect(basis).toEqual({
axis: "systolic",
relation: "below_floor",
offsetMmHg: 4,
boundaryMmHg: SYS_FLOOR,
});
});
it("keeps below_floor even when the score is still high — the curve is gentle near the floor", () => {
// Worth pinning because it is easy to assume "below the floor" implies
// a poor score: two mmHg under reads 94, comfortably green. The
// relation is decided by where the value sits, never by the score.
const { score, basis } = gradeBpScore({
sys: 88,
dia: 70,
target: UNDER65,
});
expect(basis.relation).toBe("below_floor");
expect(score).toBeGreaterThanOrEqual(85);
});
it("keeps the relation with the score when the rounded offset reads zero", () => {
// 129.4 is above the ceiling and scores below 85, but rounds to a
// 0 mmHg offset. The relation follows the unrounded value, so the
// sentence can never claim "in band" about a sub-85 number.
const { score, basis } = gradeBpScore({
sys: 129.4,
dia: 70,
target: UNDER65,
});
expect(basis.relation).toBe("above_ceiling");
expect(basis.offsetMmHg).toBe(0);
expect(score).toBeLessThan(85);
});
it("reproduces the worked 137.5/87 example — systolic, ~9 over, 56", () => {
const { score, basis } = gradeBpScore({
sys: 137.5,
dia: 87,
target: UNDER65,
});
expect(score).toBe(56);
expect(basis).toEqual({
axis: "systolic",
relation: "above_ceiling",
offsetMmHg: 9,
boundaryMmHg: 129,
});
});
it("uses the targets it was handed, not a fixed band", () => {
const over65: BpTargets = {
sysLow: 130,
sysHigh: 139,
diaLow: 70,
diaHigh: 79,
};
const { basis } = gradeBpScore({ sys: 137, dia: 70, target: over65 });
expect(basis).toEqual({
axis: "systolic",
relation: "in_band",
offsetMmHg: 2,
boundaryMmHg: 139,
});
});
it("has exactly three relations, and each one reconstructs its own reading", () => {
// The design asserts the three relations are exhaustive; this pins it
// against the anchors instead of trusting the prose. Every integer
// reading across the whole plausible range must land in exactly one
// relation, and axis + relation + boundary + offset together must
// reconstruct the value that produced the score.
const seen = new Set<string>();
for (let sys = 60; sys <= 220; sys += 1) {
for (let dia = 30; dia <= 140; dia += 1) {
const { score, basis } = gradeBpScore({ sys, dia, target: UNDER65 });
seen.add(basis.relation);
const value = basis.axis === "systolic" ? sys : dia;
const floor = basis.axis === "systolic" ? SYS_FLOOR : DIA_FLOOR;
const ceiling =
basis.axis === "systolic" ? UNDER65.sysHigh : UNDER65.diaHigh;
if (basis.relation === "below_floor") {
expect(value).toBeLessThan(floor);
expect(basis.boundaryMmHg).toBe(floor);
expect(floor - value).toBe(basis.offsetMmHg);
} else {
expect(value).toBeGreaterThanOrEqual(floor);
expect(basis.boundaryMmHg).toBe(ceiling);
expect(Math.abs(value - ceiling)).toBe(basis.offsetMmHg);
if (basis.relation === "in_band") {
expect(value).toBeLessThanOrEqual(ceiling);
// The scale line only makes sense if in_band never reads red.
expect(score).toBeGreaterThanOrEqual(85);
} else {
expect(basis.relation).toBe("above_ceiling");
expect(value).toBeGreaterThan(ceiling);
expect(score).toBeLessThan(85);
}
}
// The named axis alone carries the score: replace the other axis
// with an optimal value and the number must not move.
const optimalOther = gradeBpScore(
basis.axis === "systolic"
? { sys, dia: UNDER65.diaHigh - 12, target: UNDER65 }
: { sys: UNDER65.sysHigh - 12, dia, target: UNDER65 },
);
expect(optimalOther.score).toBe(score);
}
}
expect([...seen].sort()).toEqual([
"above_ceiling",
"below_floor",
"in_band",
]);
});
});
describe("gradeBpScoreFromSeries — the basis travels with the score", () => {
const NOW = new Date("2026-06-07T12:00:00.000Z");
it("returns the basis of the representative it graded, not of any single pair", () => {
// Two readings, one at each end. The representative is their mean
// (both same-day, so equal weight): 130/85. The basis must describe
// that mean, which matches neither reading.
const graded = gradeBpScoreFromSeries({
pairs: [
{ at: NOW, sys: 120, dia: 80 },
{ at: NOW, sys: 140, dia: 90 },
],
target: UNDER65,
now: NOW,
});
expect(graded).not.toBeNull();
expect(graded!.basis).toEqual({
axis: "diastolic",
relation: "above_ceiling",
offsetMmHg: 6,
boundaryMmHg: 79,
});
expect(graded!.score).toBe(
gradeBpScore({ sys: 130, dia: 85, target: UNDER65 }).score,
);
});
it("grades the basis against the targets it was given", () => {
// The analytics route re-runs this helper with the CLINICAL targets
// when the user keeps a personal one. Same series, two bands: both
// the score and the basis must move together.
const pairs: BpPairPoint[] = [{ at: NOW, sys: 132, dia: 88 }];
const clinical = gradeBpScoreFromSeries({
pairs,
target: UNDER65,
now: NOW,
})!;
const personal = gradeBpScoreFromSeries({
pairs,
target: { sysLow: 120, sysHigh: 135, diaLow: 70, diaHigh: 90 },
now: NOW,
})!;
expect(clinical.basis.boundaryMmHg).toBe(79);
expect(clinical.basis.relation).toBe("above_ceiling");
expect(personal.basis.relation).toBe("in_band");
expect(personal.score).toBeGreaterThan(clinical.score);
});
});
/**
* The popover's scale line quotes two numbers out of this curve ("at the
* ceiling the score is 85; 100 means roughly 12 mmHg below it") because
* the sentence has to read naturally in six languages. That makes the
* copy and the anchors two places that must agree, so this pins them to
* each other: re-anchoring the curve turns this red and the six strings
* have to follow.
*/
describe("the scale the popover quotes", () => {
const AT_CEILING_SCORE = 85;
const OPTIMAL_OFFSET_MMHG = 12;
it("still scores 85 at the ceiling and 100 at twelve below it", () => {
expect(
bpScore({ sys: UNDER65.sysHigh, dia: UNDER65.diaHigh, target: UNDER65 }),
).toBe(AT_CEILING_SCORE);
expect(
bpScore({
sys: UNDER65.sysHigh - OPTIMAL_OFFSET_MMHG,
dia: UNDER65.diaHigh - OPTIMAL_OFFSET_MMHG,
target: UNDER65,
}),
).toBe(100);
// Twelve is the SMALLEST distance that reaches 100 — one less does not.
expect(
bpScore({
sys: UNDER65.sysHigh - (OPTIMAL_OFFSET_MMHG - 1),
dia: UNDER65.diaHigh - (OPTIMAL_OFFSET_MMHG - 1),
target: UNDER65,
}),
).toBeLessThan(100);
});
it.each(["en", "de", "fr", "es", "it", "pl"])(
"quotes those same two numbers in %s",
(locale) => {
const bundle = JSON.parse(
readFileSync(
join(__dirname, "../../../../messages", `${locale}.json`),
"utf8",
),
) as { insights: { healthScore: Record<string, string> } };
const line = bundle.insights.healthScore.bpScale;
expect(line).toContain(String(AT_CEILING_SCORE));
expect(line).toContain(String(OPTIMAL_OFFSET_MMHG));
expect(line).toContain("100");
},
);
});
+96 -18
View File
@@ -84,6 +84,49 @@ const HYPO_ANCHORS: ReadonlyArray<readonly [below: number, score: number]> = [
[40, 5],
] as const;
/**
* Which axis set the score, measured against which boundary, and by how
* far. Published with the score (never beside it) so the surface can
* answer "why this number" without recomputing anything client-side.
*
* `relation` is a trichotomy on the binding axis' own value:
*
* value < floor → `below_floor` (boundary = floor)
* value <= ceiling → `in_band` (boundary = ceiling)
* otherwise → `above_ceiling` (boundary = ceiling)
*
* so the three relations are exhaustive and mutually exclusive by
* construction, whatever the anchors say. `offsetMmHg` is the distance
* from that boundary as a NON-NEGATIVE integer (rounded); the relation
* carries the direction, so the number never needs a sign. The relation
* is decided on the unrounded value, so it always agrees with the score
* even when the rounded offset reads 0.
*/
export type BpScoreAxis = "systolic" | "diastolic";
export type BpScoreRelation = "above_ceiling" | "below_floor" | "in_band";
export interface BpScoreBasis {
/** The axis whose sub-score the pillar took — the worse of the two. */
axis: BpScoreAxis;
relation: BpScoreRelation;
/** Rounded, non-negative distance from `boundaryMmHg`. */
offsetMmHg: number;
/** The ceiling the axis was graded against, or its hypotension floor. */
boundaryMmHg: number;
}
/**
* A graded BP reading: the score AND the basis that produced it, as one
* value. They are returned together, and carried together through every
* envelope down to the pillar, precisely so no caller can ever pair a
* score from one run with a basis from another (different representative
* pair, or — the live trap — different targets).
*/
export interface BpGrade {
score: number;
basis: BpScoreBasis;
}
/**
* Generic piecewise-linear interpolation over a monotonic-x anchor table.
* Clamps to the first / last anchor outside the table range.
@@ -127,38 +170,63 @@ function interpolateAxisScore(offset: number): number {
* the over-target branch holds at the floor, then descends smoothly —
* no boundary cliff.
*/
function gradeAxis(value: number, ceiling: number, floor: number): number {
function gradeAxis(
value: number,
ceiling: number,
floor: number,
): { score: number; relation: BpScoreRelation; boundary: number } {
if (value < floor) {
return interpolateAnchors(HYPO_ANCHORS, floor - value);
return {
score: interpolateAnchors(HYPO_ANCHORS, floor - value),
relation: "below_floor",
boundary: floor,
};
}
return interpolateAxisScore(value - ceiling);
const offset = value - ceiling;
return {
score: interpolateAxisScore(offset),
relation: offset > 0 ? "above_ceiling" : "in_band",
boundary: ceiling,
};
}
/**
* Map a representative blood-pressure reading to a smooth 0-100 score.
* Map a representative blood-pressure reading to a smooth 0-100 score,
* together with the basis that produced it.
*
* Takes the WORSE of the systolic and diastolic axis sub-scores so a
* high diastolic counts against the score (it should) without
* binary-zeroing the result the way the all-time in-target rate did.
*
* @returns integer 0-100.
* **Tie rule**: when both axes grade identically, the basis names the
* SYSTOLIC axis. The two are interchangeable for the number, so the
* choice is arbitrary in arithmetic but must not be arbitrary in the
* text; systolic is the axis clinicians lead with and the one the app
* shows first everywhere else.
*
* @returns integer score 0-100 plus the binding axis, its relation to
* its boundary, and the distance to it.
*/
export function gradeBpScore(input: {
sys: number;
dia: number;
target: BpTargets;
}): number {
const sysScore = gradeAxis(
input.sys,
input.target.sysHigh,
SYS_HYPOTENSION_FLOOR,
);
const diaScore = gradeAxis(
input.dia,
input.target.diaHigh,
DIA_HYPOTENSION_FLOOR,
);
return Math.round(Math.max(0, Math.min(100, Math.min(sysScore, diaScore))));
}): BpGrade {
const sys = gradeAxis(input.sys, input.target.sysHigh, SYS_HYPOTENSION_FLOOR);
const dia = gradeAxis(input.dia, input.target.diaHigh, DIA_HYPOTENSION_FLOOR);
// `<=` puts a tie on systolic — see the tie rule above.
const bindingIsSystolic = sys.score <= dia.score;
const binding = bindingIsSystolic ? sys : dia;
const bindingValue = bindingIsSystolic ? input.sys : input.dia;
return {
score: Math.round(Math.max(0, Math.min(100, binding.score))),
basis: {
axis: bindingIsSystolic ? "systolic" : "diastolic",
relation: binding.relation,
offsetMmHg: Math.round(Math.abs(bindingValue - binding.boundary)),
boundaryMmHg: binding.boundary,
},
};
}
/**
@@ -228,11 +296,21 @@ export function representativeBpFromSeries(input: {
};
}
/**
* Grade the recency-weighted representative of a pair series.
*
* Returns the score and its basis as ONE value. Callers must carry that
* value on, never split it: a score stored beside a basis that came from
* a second evaluation (or from a second set of targets — the analytics
* route deliberately re-runs this with the CLINICAL targets when the user
* has a personal one) would let the surface explain a number that is not
* the number on screen.
*/
export function gradeBpScoreFromSeries(input: {
pairs: ReadonlyArray<BpPairPoint>;
target: BpTargets;
now: Date;
}): number | null {
}): BpGrade | null {
const representative = representativeBpFromSeries(input);
return representative
? gradeBpScore({ ...representative, target: input.target })
+18 -7
View File
@@ -69,7 +69,11 @@ import {
isBpReadingInTarget,
type BpReading,
} from "./bp-in-target";
import { gradeBpScoreFromSeries, representativeBpFromSeries } from "./bp-grade";
import {
gradeBpScoreFromSeries,
representativeBpFromSeries,
type BpGrade,
} from "./bp-grade";
import type { BpTargets } from "./bp-targets";
/**
@@ -109,9 +113,16 @@ export interface BpInTargetEnvelope {
* recency-weighted representative reading over the trailing window.
* Feeds the Health-Score BP pillar value, replacing the binary
* in-target rate AS THE SCORE. `null` when no pairs formed.
*
* v1.34.5 — the score and the basis that explains it travel as ONE
* object, from the one `gradeBpScoreFromSeries` call that produced
* both. The route re-runs this whole helper against the clinical
* targets when the user has a personal target; carrying the pair
* together is what makes it impossible for the explanation to belong
* to a different run than the number.
*/
gradedScore: number | null;
/** Same recency-weighted pair that `gradedScore` grades. */
graded: BpGrade | null;
/** Same recency-weighted pair that `graded` grades. */
representative: { sys: number; dia: number } | null;
/** Freshest paired reading in the trailing 90-day score window. */
last90LatestAt: Date | null;
@@ -377,7 +388,7 @@ async function computeFromRollups(
dia: pair.dia,
count: pair.perDayPairCount,
}));
const gradedScore = gradeBpScoreFromSeries({
const graded = gradeBpScoreFromSeries({
pairs: scorePairs,
target: targets,
now,
@@ -398,7 +409,7 @@ async function computeFromRollups(
priorYear,
path: "rollup",
rowCount,
gradedScore,
graded,
representative,
};
}
@@ -545,7 +556,7 @@ async function computeFromLive(
const scorePairs = collectBpPairs(sysData, diaData, userTz).filter(
(pair) => pair.at >= new Date(now.getTime() - 90 * DAY_MS) && pair.at < now,
);
const gradedScore = gradeBpScoreFromSeries({
const graded = gradeBpScoreFromSeries({
pairs: scorePairs,
target: targets,
now,
@@ -568,7 +579,7 @@ async function computeFromLive(
priorYear: windows.priorYear,
path: "live",
rowCount,
gradedScore,
graded,
representative,
};
}
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { gradeBpScore } from "@/lib/analytics/bp-grade";
import { computeActivityPillar } from "../activity";
import { computeAdiposityPillar } from "../adiposity";
import { computeBloodPressurePillar } from "../blood-pressure";
@@ -30,7 +31,11 @@ describe("reference-score pillars", () => {
const below = computeBloodPressurePillar({
...live,
pairCount: 11,
gradedScore: 85,
graded: gradeBpScore({
sys: 129,
dia: 79,
target: { sysLow: 120, sysHigh: 129, diaLow: 70, diaHigh: 79 },
}),
representative: { sys: 129, dia: 79 },
oldestAt: new Date("2026-07-01T08:00:00Z"),
latestAt: new Date("2026-07-27T08:00:00Z"),
@@ -44,7 +49,11 @@ describe("reference-score pillars", () => {
const eligible = computeBloodPressurePillar({
...live,
pairCount: 12,
gradedScore: 85,
graded: gradeBpScore({
sys: 129,
dia: 79,
target: { sysLow: 120, sysHigh: 129, diaLow: 70, diaHigh: 79 },
}),
representative: { sys: 129, dia: 79 },
oldestAt: new Date("2026-07-01T08:00:00Z"),
latestAt: new Date("2026-07-27T08:00:00Z"),
@@ -63,7 +72,11 @@ describe("reference-score pillars", () => {
const result = computeBloodPressurePillar({
...live,
pairCount: 12,
gradedScore: 85,
graded: gradeBpScore({
sys: 129,
dia: 79,
target: { sysLow: 120, sysHigh: 129, diaLow: 70, diaHigh: 79 },
}),
representative: { sys: 129, dia: 79 },
oldestAt: new Date("2026-07-01T08:00:00Z"),
latestAt: new Date("2026-07-27T08:00:00Z"),
+5 -2
View File
@@ -48,7 +48,7 @@ export function computeBloodPressurePillar(
if (
presentPairs < BLOOD_PRESSURE_MIN_PAIRS ||
input.gradedScore == null ||
input.graded == null ||
input.representative == null ||
input.latestAt == null
) {
@@ -81,7 +81,10 @@ export function computeBloodPressurePillar(
historyDays,
}),
value: {
score: input.gradedScore,
score: input.graded.score,
// Same object the score came out of, so the popover can only ever
// explain the number beside it.
scoreBasis: input.graded.basis,
observed: {
value: representativeSys,
unit: "mmHg",
+11 -5
View File
@@ -11,6 +11,7 @@ import { collectBpPairs } from "@/lib/analytics/bp-in-target";
import {
gradeBpScoreFromSeries,
representativeBpFromSeries,
type BpGrade,
} from "@/lib/analytics/bp-grade";
import type { BpTargets } from "@/lib/analytics/bp-targets";
import { resolveWeightTargetOverride } from "@/lib/analytics/effective-range";
@@ -86,7 +87,12 @@ interface ActivityRead {
interface ScoreBpEnvelope {
path: "rollup" | "live" | undefined;
last90Days: { pairs: number } | null;
gradedScore: number | null;
/**
* v1.34.5 — score AND basis as one value, straight off the envelope
* the route built. Never re-graded here: a second evaluation is how
* the popover would end up explaining a different number.
*/
graded: BpGrade | null;
representative: { sys: number; dia: number } | null;
last90EarliestAt: Date | null;
last90LatestAt: Date | null;
@@ -329,7 +335,7 @@ function scoreBpEnvelope(args: {
envelope: {
path: args.base.path,
last90Days: args.base.last90Days,
gradedScore: args.base.gradedScore,
graded: args.base.graded,
representative: args.base.representative,
last90EarliestAt: args.base.last90EarliestAt,
last90LatestAt: args.base.last90LatestAt,
@@ -352,7 +358,7 @@ function scoreBpEnvelope(args: {
envelope: {
path: undefined,
last90Days: null,
gradedScore: null,
graded: null,
representative: null,
last90EarliestAt: null,
last90LatestAt: null,
@@ -385,7 +391,7 @@ function scoreBpEnvelope(args: {
envelope: {
path: "live",
last90Days: { pairs: pairs.length },
gradedScore: target
graded: target
? gradeBpScoreFromSeries({
pairs,
target,
@@ -492,7 +498,7 @@ function scoreInputsFor(args: {
readFailed: args.bp.readFailed,
asOf,
pairCount: args.bp.last90Days?.pairs ?? 0,
gradedScore: args.bp.gradedScore,
graded: args.bp.graded,
representative: args.bp.representative,
oldestAt: args.bp.last90EarliestAt,
latestAt: args.bp.last90LatestAt,
+14 -1
View File
@@ -1,4 +1,5 @@
import type { BpTargets } from "@/lib/analytics/bp-targets";
import type { BpGrade, BpScoreBasis } from "@/lib/analytics/bp-grade";
import type {
Derived,
DerivedProvenanceSource,
@@ -56,6 +57,14 @@ export interface PillarValue {
reference: PillarReference;
/** Optional user-authored yardstick, displayed beside the scored reference. */
personalReference?: PillarReference;
/**
* Which input set this score and by how much, resolved on the server so
* no client ever re-derives it. Blood pressure only for now: it is the
* one pillar that scores the WORSE of two axes, so the number alone
* cannot say which one it came from. Optional by design — a pillar that
* has nothing of the kind to say says nothing.
*/
scoreBasis?: BpScoreBasis;
/** Score points that a weekly move must clear before it is narrated. */
noiseFloor: number;
/** Slow markers contribute to level but never to the weekly delta. */
@@ -125,7 +134,11 @@ export interface DomainReadState {
export interface BloodPressurePillarInput extends DomainReadState {
asOf: Date;
pairCount: number;
gradedScore: number | null;
/**
* The graded score together with the basis that produced it — one
* value, from one grading run against one set of targets.
*/
graded: BpGrade | null;
representative: { sys: number; dia: number } | null;
oldestAt: Date | null;
latestAt: Date | null;
+10 -2
View File
@@ -286,7 +286,11 @@ describe("discoverPhaseCorrelations", () => {
),
);
}
const result = discoverPhaseCorrelations({ phaseByDay, measurements });
const result = discoverPhaseCorrelations({
phaseByDay,
measurements,
locale: "en",
});
const pair = result.discovered.find(
(p) => p.behaviour === CYCLE_PHASE_CHANNEL_KEY,
);
@@ -315,7 +319,11 @@ describe("discoverPhaseCorrelations", () => {
phaseByDay.set(key, phases[i % phases.length]);
measurements.push(m("RESTING_HEART_RATE", key, 60)); // constant → no variance
}
const result = discoverPhaseCorrelations({ phaseByDay, measurements });
const result = discoverPhaseCorrelations({
phaseByDay,
measurements,
locale: "en",
});
expect(result.discovered).toEqual([]);
});
});
+10 -2
View File
@@ -51,6 +51,7 @@ import {
import { round } from "@/lib/insights/status-shared";
import type { MeasurementType } from "@/generated/prisma/enums";
import type { CyclePhase } from "@/lib/cycle/types";
import type { Locale } from "@/lib/i18n/config";
/** Stable channel key for the continuous CYCLE_PHASE discovery series. */
export const CYCLE_PHASE_CHANNEL_KEY = "CYCLE_PHASE";
@@ -292,8 +293,15 @@ export function discoverPhaseCorrelations(args: {
phaseByDay: Map<string, CyclePhase>;
measurements: CrossMetricMeasurement[];
userPriorityJson?: unknown;
/**
* The reader's locale. Each surviving row carries a finished `interpretation`
* sentence that the cycle route serves straight to the client, so it has to
* be written in the reader's language. Required — no default, because a
* default is what let the metric page's driver line ship in English.
*/
locale: Locale;
}): CorrelationDiscoveryResult {
const { phaseByDay, measurements } = args;
const { phaseByDay, measurements, locale } = args;
const userPriorityJson = args.userPriorityJson ?? null;
const phaseSeries = buildPhaseDiscoverySeries(phaseByDay);
@@ -307,7 +315,7 @@ export function discoverPhaseCorrelations(args: {
outcomeSeries.push({ key: cfg.type, role: "outcome", points });
}
return discoverCorrelations([phaseSeries, ...outcomeSeries]);
return discoverCorrelations([phaseSeries, ...outcomeSeries], { locale });
}
/**
+26
View File
@@ -0,0 +1,26 @@
/**
* The reader's locale for a server-internal caller that only has a user id.
*
* Route handlers resolve the locale from the request (cookie first, then the
* stored preference, then Accept-Language) via `resolveServerLocale`. A few
* server-internal fan-outs reach a prose-writing engine without a request of
* their own — the Coach tool executor and the MCP rich reads both take a user
* id and nothing else. This looks their stored preference up and hands it to
* the SAME resolver, so there is one place that decides what language a
* sentence is written in, not two.
*
* Deliberately its own module rather than a second export on `server-locale`:
* that file is imported by page components, and it must not start pulling
* Prisma along with it.
*/
import { prisma } from "@/lib/db";
import type { Locale } from "./config";
import { resolveServerLocale } from "./server-locale";
export async function resolveLocaleForUser(userId: string): Promise<Locale> {
const row = await prisma.user.findUnique({
where: { id: userId },
select: { locale: true },
});
return resolveServerLocale({ userLocale: row?.locale ?? null });
}
@@ -56,7 +56,9 @@ describe("discoverCorrelations", () => {
points: series([400, 410, 420]),
},
];
const result = discoverCorrelations([...behaviours, ...outcomes]);
const result = discoverCorrelations([...behaviours, ...outcomes], {
locale: "en",
});
expect(result.discovered).toHaveLength(0);
expect(result.pairsTested).toBe(0);
});
@@ -79,7 +81,7 @@ describe("discoverCorrelations", () => {
},
{ key: "SLEEP_DURATION", role: "outcome", points: series(outcomeVals) },
],
{ fdrQ: 0.1 },
{ fdrQ: 0.1, locale: "en" },
);
expect(result.pairsTested).toBe(1);
expect(result.discovered).toHaveLength(1);
@@ -100,10 +102,13 @@ describe("discoverCorrelations", () => {
{ length: 30 },
(_, i) => Math.cos(i * 1.7) * 10 + 50,
);
const result = discoverCorrelations([
{ key: "BLOOD_GLUCOSE", role: "behaviour", points: series(noiseA) },
{ key: "WEIGHT", role: "outcome", points: series(noiseB) },
]);
const result = discoverCorrelations(
[
{ key: "BLOOD_GLUCOSE", role: "behaviour", points: series(noiseA) },
{ key: "WEIGHT", role: "outcome", points: series(noiseB) },
],
{ locale: "en" },
);
// It is tested (n ≥ 20) but should not survive p < 0.05 + FDR.
expect(result.pairsTested).toBe(1);
expect(result.discovered).toHaveLength(0);
@@ -122,7 +127,7 @@ describe("discoverCorrelations", () => {
{ key: "FACTOR:work", role: "behaviour", points: series(factorVals) },
{ key: "SLEEP_DURATION", role: "outcome", points: series(sleepVals) },
],
{ fdrQ: 0.1 },
{ fdrQ: 0.1, locale: "en" },
);
expect(result.discovered).toHaveLength(1);
const pair = result.discovered[0];
@@ -153,10 +158,17 @@ describe("MOOD as outcome (F3)", () => {
const driver = i === 0 ? 60 : 60 + ((i - 1) % 10) * 5;
return 1 + (driver - 60) / 45 + (i % 2 === 0 ? 0.02 : -0.02);
});
const result = discoverCorrelations([
{ key: "TIME_IN_DAYLIGHT", role: "behaviour", points: series(daylight) },
{ key: "MOOD", role: "outcome", points: series(mood) },
]);
const result = discoverCorrelations(
[
{
key: "TIME_IN_DAYLIGHT",
role: "behaviour",
points: series(daylight),
},
{ key: "MOOD", role: "outcome", points: series(mood) },
],
{ locale: "en" },
);
const pair = result.discovered.find(
(p) => p.behaviour === "TIME_IN_DAYLIGHT" && p.outcome === "MOOD",
);
@@ -167,10 +179,13 @@ describe("MOOD as outcome (F3)", () => {
it("never tests the MOOD → MOOD self-pair", () => {
const n = 30;
const mood = Array.from({ length: n }, (_, i) => 3 + (i % 3));
const result = discoverCorrelations([
{ key: "MOOD", role: "behaviour", points: series(mood) },
{ key: "MOOD", role: "outcome", points: series(mood) },
]);
const result = discoverCorrelations(
[
{ key: "MOOD", role: "behaviour", points: series(mood) },
{ key: "MOOD", role: "outcome", points: series(mood) },
],
{ locale: "en" },
);
expect(
result.discovered.find(
(p) => p.behaviour === "MOOD" && p.outcome === "MOOD",
@@ -222,10 +237,13 @@ describe("D2-1 — same-family lagged pairs are excluded from discovery", () =>
const n = 40;
const factor = Array.from({ length: n }, (_, i) => 1 + (i % 5));
const mood = [3, ...factor.slice(0, n - 1).map((v) => v)];
const result = discoverCorrelations([
{ key: "FACTOR:work", role: "behaviour", points: longSeries(factor) },
{ key: "MOOD", role: "outcome", points: longSeries(mood) },
]);
const result = discoverCorrelations(
[
{ key: "FACTOR:work", role: "behaviour", points: longSeries(factor) },
{ key: "MOOD", role: "outcome", points: longSeries(mood) },
],
{ locale: "en" },
);
expect(result.pairsTested).toBe(0);
expect(result.discovered).toHaveLength(0);
});
@@ -283,14 +301,17 @@ describe("D2-2 — effect-size floor on discovered drivers", () => {
.slice(0, n - 1)
.map((v, i) => v * 0.12 + Math.cos(i * 1.3) * 40 + 100),
];
const result = discoverCorrelations([
{
key: "TIME_IN_DAYLIGHT",
role: "behaviour",
points: longSeries(behaviour),
},
{ key: "SLEEP_DURATION", role: "outcome", points: longSeries(outcome) },
]);
const result = discoverCorrelations(
[
{
key: "TIME_IN_DAYLIGHT",
role: "behaviour",
points: longSeries(behaviour),
},
{ key: "SLEEP_DURATION", role: "outcome", points: longSeries(outcome) },
],
{ locale: "en" },
);
// Either it failed significance, or it was floored out — either way no
// confident driver row reaches the Coach.
const surfaced = result.discovered.find(
@@ -309,14 +330,17 @@ describe("D2-2 — effect-size floor on discovered drivers", () => {
const n = 90;
const behaviour = Array.from({ length: n }, (_, i) => i + (i % 4));
const outcome = [0, ...behaviour.slice(0, n - 1).map((v) => v * 2 + 5)];
const result = discoverCorrelations([
{
key: "TIME_IN_DAYLIGHT",
role: "behaviour",
points: longSeries(behaviour),
},
{ key: "SLEEP_DURATION", role: "outcome", points: longSeries(outcome) },
]);
const result = discoverCorrelations(
[
{
key: "TIME_IN_DAYLIGHT",
role: "behaviour",
points: longSeries(behaviour),
},
{ key: "SLEEP_DURATION", role: "outcome", points: longSeries(outcome) },
],
{ locale: "en" },
);
const pair = result.discovered.find(
(p) =>
p.behaviour === "TIME_IN_DAYLIGHT" && p.outcome === "SLEEP_DURATION",
@@ -343,14 +367,17 @@ describe("D2-2 — effect-size floor on discovered drivers", () => {
.slice(0, n - 1)
.map((v, i) => v * 0.35 + Math.cos(i * 2.1) * 9 + 100),
];
const result = discoverCorrelations([
{
key: "TIME_IN_DAYLIGHT",
role: "behaviour",
points: longSeries(behaviour),
},
{ key: "SLEEP_DURATION", role: "outcome", points: longSeries(outcome) },
]);
const result = discoverCorrelations(
[
{
key: "TIME_IN_DAYLIGHT",
role: "behaviour",
points: longSeries(behaviour),
},
{ key: "SLEEP_DURATION", role: "outcome", points: longSeries(outcome) },
],
{ locale: "en" },
);
const pair = result.discovered.find(
(p) =>
p.behaviour === "TIME_IN_DAYLIGHT" && p.outcome === "SLEEP_DURATION",
@@ -462,18 +489,21 @@ describe("compliance + symptom channels (FDREXTEND)", () => {
// pair is not a perfect line (a real-world-shaped strong link).
.map((a, i) => ((100 - a) / 20) * (i % 2 === 0 ? 1.0 : 0.98)),
];
const result = discoverCorrelations([
{
key: MEDICATION_COMPLIANCE_CHANNEL_KEY,
role: "behaviour",
points: longSeries(adherence),
},
{
key: SYMPTOM_SEVERITY_CHANNEL_KEY,
role: "outcome",
points: longSeries(symptom),
},
]);
const result = discoverCorrelations(
[
{
key: MEDICATION_COMPLIANCE_CHANNEL_KEY,
role: "behaviour",
points: longSeries(adherence),
},
{
key: SYMPTOM_SEVERITY_CHANNEL_KEY,
role: "outcome",
points: longSeries(symptom),
},
],
{ locale: "en" },
);
const pair = result.discovered.find(
(p) =>
p.behaviour === MEDICATION_COMPLIANCE_CHANNEL_KEY &&
@@ -500,18 +530,21 @@ describe("compliance + symptom channels (FDREXTEND)", () => {
0,
...adherence.slice(0, n - 1).map((a) => (100 - a) / 30),
];
const result = discoverCorrelations([
{
key: MEDICATION_COMPLIANCE_CHANNEL_KEY,
role: "behaviour",
points: longSeries(adherence),
},
{
key: SYMPTOM_SEVERITY_CHANNEL_KEY,
role: "outcome",
points: longSeries(symptom),
},
]);
const result = discoverCorrelations(
[
{
key: MEDICATION_COMPLIANCE_CHANNEL_KEY,
role: "behaviour",
points: longSeries(adherence),
},
{
key: SYMPTOM_SEVERITY_CHANNEL_KEY,
role: "outcome",
points: longSeries(symptom),
},
],
{ locale: "en" },
);
// Below the floor the pair is never even tested.
expect(result.pairsTested).toBe(0);
expect(result.discovered).toHaveLength(0);
@@ -520,18 +553,21 @@ describe("compliance + symptom channels (FDREXTEND)", () => {
it("never tests the symptom → symptom self-lag", () => {
const n = 40;
const symptom = Array.from({ length: n }, (_, i) => (i % 4) / 2);
const result = discoverCorrelations([
{
key: SYMPTOM_SEVERITY_CHANNEL_KEY,
role: "behaviour",
points: longSeries(symptom),
},
{
key: SYMPTOM_SEVERITY_CHANNEL_KEY,
role: "outcome",
points: longSeries(symptom),
},
]);
const result = discoverCorrelations(
[
{
key: SYMPTOM_SEVERITY_CHANNEL_KEY,
role: "behaviour",
points: longSeries(symptom),
},
{
key: SYMPTOM_SEVERITY_CHANNEL_KEY,
role: "outcome",
points: longSeries(symptom),
},
],
{ locale: "en" },
);
expect(
result.discovered.find(
(p) =>
@@ -555,18 +591,21 @@ describe("compliance + symptom channels (FDREXTEND)", () => {
.slice(0, n - 1)
.map((a, i) => 55 + (100 - a) / 6 + (i % 2 === 0 ? 0.1 : -0.1)),
];
const result = discoverCorrelations([
{
key: MEDICATION_COMPLIANCE_CHANNEL_KEY,
role: "behaviour",
points: longSeries(adherence),
},
{
key: "RESTING_HEART_RATE",
role: "outcome",
points: longSeries(rhr),
},
]);
const result = discoverCorrelations(
[
{
key: MEDICATION_COMPLIANCE_CHANNEL_KEY,
role: "behaviour",
points: longSeries(adherence),
},
{
key: "RESTING_HEART_RATE",
role: "outcome",
points: longSeries(rhr),
},
],
{ locale: "en" },
);
const pair = result.discovered.find(
(p) =>
p.behaviour === MEDICATION_COMPLIANCE_CHANNEL_KEY &&
@@ -621,17 +660,24 @@ describe("v1.22 — new curated linkages", () => {
.slice(0, n - 1)
.map((a, i) => 160 - a / 4 + (i % 2 ? 0.2 : -0.2)),
];
const result = discoverCorrelations([
{
key: MEDICATION_COMPLIANCE_CHANNEL_KEY,
role: "behaviour",
points: longSeries(adherence),
},
// BP is both a behaviour and an outcome in the real matrix; the
// same-family guard must keep BP_SYS(behaviour)→BP_SYS(outcome) out.
{ key: "BLOOD_PRESSURE_SYS", role: "behaviour", points: longSeries(sys) },
{ key: "BLOOD_PRESSURE_SYS", role: "outcome", points: longSeries(sys) },
]);
const result = discoverCorrelations(
[
{
key: MEDICATION_COMPLIANCE_CHANNEL_KEY,
role: "behaviour",
points: longSeries(adherence),
},
// BP is both a behaviour and an outcome in the real matrix; the
// same-family guard must keep BP_SYS(behaviour)→BP_SYS(outcome) out.
{
key: "BLOOD_PRESSURE_SYS",
role: "behaviour",
points: longSeries(sys),
},
{ key: "BLOOD_PRESSURE_SYS", role: "outcome", points: longSeries(sys) },
],
{ locale: "en" },
);
expect(
result.discovered.find(
(p) =>
@@ -681,12 +727,13 @@ describe("discoverEmergingCorrelations (early detection)", () => {
points: longSeries(out),
},
];
const retrospective = discoverCorrelations(series);
const retrospective = discoverCorrelations(series, { locale: "en" });
expect(retrospective.discovered).toHaveLength(0); // n too low for the floor
const cutoff = longSeries(beh)[0].day; // whole (short) window
const { emerging } = discoverEmergingCorrelations(series, retrospective, {
recentFromDayKey: cutoff,
locale: "en",
minPairs: 10,
});
const pair = emerging.find(
@@ -711,7 +758,7 @@ describe("discoverEmergingCorrelations (early detection)", () => {
points: longSeries(out),
},
];
const retrospective = discoverCorrelations(series);
const retrospective = discoverCorrelations(series, { locale: "en" });
expect(
retrospective.discovered.some(
(p) => p.behaviour === "MOOD" && p.outcome === "HEART_RATE_VARIABILITY",
@@ -724,6 +771,7 @@ describe("discoverEmergingCorrelations (early detection)", () => {
.slice(0, 10);
const { emerging } = discoverEmergingCorrelations(series, retrospective, {
recentFromDayKey: cutoff,
locale: "en",
minPairs: 10,
});
expect(
@@ -745,10 +793,11 @@ describe("discoverEmergingCorrelations (early detection)", () => {
points: longSeries(out),
},
];
const retrospective = discoverCorrelations(series);
const retrospective = discoverCorrelations(series, { locale: "en" });
const cutoff = longSeries(beh)[Math.max(0, n - EARLY_WINDOW_DAYS)].day;
const { emerging } = discoverEmergingCorrelations(series, retrospective, {
recentFromDayKey: cutoff,
locale: "en",
minPairs: 10,
});
expect(emerging).toHaveLength(0);
@@ -778,6 +827,7 @@ describe("discoverLabOutcomeCorrelations (labs ↔ outcomes)", () => {
draws.push({ key: "LAB:HbA1c", day, value: v });
}
const result = discoverLabOutcomeCorrelations(draws, [weight], {
locale: "en",
minDraws: 5,
minWindowPoints: 5,
});
@@ -799,6 +849,7 @@ describe("discoverLabOutcomeCorrelations (labs ↔ outcomes)", () => {
value: 5 + i,
}));
const result = discoverLabOutcomeCorrelations(draws, [weight], {
locale: "en",
minDraws: 5,
});
expect(result.discovered).toHaveLength(0);
@@ -820,6 +871,7 @@ describe("discoverLabOutcomeCorrelations (labs ↔ outcomes)", () => {
value: 5 + i,
}));
const result = discoverLabOutcomeCorrelations(draws, [hrv], {
locale: "en",
minDraws: 5,
});
// HRV is not a curated lab-outcome target → no pair tested.
@@ -185,7 +185,9 @@ describe("integration — built series flow through the discovery gates", () =>
role: "outcome",
});
const result = discoverCorrelations([compliance, symptom]);
const result = discoverCorrelations([compliance, symptom], {
locale: "en",
});
expect(result.pairsTested).toBe(0);
expect(result.discovered).toHaveLength(0);
});
@@ -222,7 +224,9 @@ describe("integration — built series flow through the discovery gates", () =>
role: "outcome",
});
const result = discoverCorrelations([compliance, symptom]);
const result = discoverCorrelations([compliance, symptom], {
locale: "en",
});
const pair = result.discovered.find(
(p) =>
p.behaviour === MEDICATION_COMPLIANCE_CHANNEL_KEY &&
@@ -256,7 +260,7 @@ describe("integration — built series flow through the discovery gates", () =>
value: 55 + (i > 0 && (i - 1) % 4 === 0 ? 8 : 0) + (i % 2) * 0.1,
})),
});
const result = discoverCorrelations(series);
const result = discoverCorrelations(series, { locale: "en" });
for (const p of result.discovered) {
expect(p.interpretation).toMatch(/not a cause|never a cause/);
}
@@ -42,7 +42,7 @@ beforeEach(() => {
describe("getRelevantCorrelationsForMetric", () => {
it("returns [] without any DB read for a non-discovery metric", async () => {
// VO2_MAX is not part of the curated discovery matrix.
const out = await getRelevantCorrelationsForMetric("u-1", "VO2_MAX");
const out = await getRelevantCorrelationsForMetric("u-1", "VO2_MAX", "en");
expect(out).toEqual([]);
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
});
@@ -70,6 +70,7 @@ describe("getRelevantCorrelationsForMetric", () => {
const out = await getRelevantCorrelationsForMetric(
"u-1",
"RESTING_HEART_RATE",
"en",
);
expect(out.length).toBeGreaterThan(0);
// Every surfaced relation mentions resting heart rate (the metric's channel).
@@ -87,6 +88,7 @@ describe("getRelevantCorrelationsForMetric", () => {
const out = await getRelevantCorrelationsForMetric(
"u-1",
"RESTING_HEART_RATE",
"en",
);
expect(out).toEqual([]);
});
@@ -411,6 +411,7 @@ describe("generateMetricStatus — generation path", () => {
expect(getRelevantCorrelationsForMetric).toHaveBeenCalledWith(
"user-1",
"RESTING_HEART_RATE",
"en",
);
});
@@ -678,6 +678,7 @@ export async function prepareBloodPressureStatusForUser(
const relations = await getRelevantCorrelationsForMetric(
userId,
"BLOOD_PRESSURE_SYS",
locale,
);
const assessmentContextBlock = buildAssessmentContextBlock(
{
+27 -15
View File
@@ -39,7 +39,7 @@
* link. The series builders live in `correlation-series-builders.ts`.
*/
import { pearson, MIN_PAIRED_N } from "@/lib/insights/correlations";
import { defaultLocale, type Locale } from "@/lib/i18n/config";
import { type Locale } from "@/lib/i18n/config";
import { getServerTranslator } from "@/lib/i18n/server-translator";
/**
@@ -447,21 +447,25 @@ export function discoverCorrelations(
minPairs?: number;
fdrQ?: number;
/**
* Reader's locale for the narrated `interpretation`. Defaults to English
* so the many server callers that feed the string into an LLM prompt (which
* re-localises) keep their existing behaviour; the user-facing correlations
* route passes the resolved locale so the card renders in the reader's
* language.
* Reader's locale for the narrated `interpretation`.
*
* REQUIRED, and it must stay required. It used to be optional with an
* English default, and the default is what shipped the bug: a caller could
* say nothing and still get a finished sentence back, so the "Coach read"
* strip on a German metric page printed its second line in English under a
* German first line for months. Nobody had to write anything wrong for that
* to happen — silence was enough. With no default, a caller that forgets
* cannot compile, which is the only version of this that stays fixed.
*/
locale?: Locale;
} = {},
locale: Locale;
},
): CorrelationDiscoveryResult {
const lagDays = opts.lagDays ?? 1;
const minPairs = opts.minPairs ?? MIN_PAIRED_N;
const fdrQ = opts.fdrQ ?? FDR_Q;
// Named `translate` (not `t`) — the pair-mapping callbacks below bind `t` to
// the RawPair element, so the translator must not shadow-collide with them.
const { t: translate } = getServerTranslator(opts.locale ?? defaultLocale);
const { t: translate } = getServerTranslator(opts.locale);
const behaviours = series.filter((s) => s.role === "behaviour");
const outcomes = series.filter((s) => s.role === "outcome");
@@ -717,8 +721,12 @@ export function discoverEmergingCorrelations(
lagDays?: number;
minPairs?: number;
fdrQ?: number;
/** Reader's locale, threaded to the inner scan's narration. */
locale?: Locale;
/**
* Reader's locale, threaded to the inner scan's narration. Required for the
* same reason it is required on {@link discoverCorrelations} — an optional
* locale here would put the English default straight back, one call deeper.
*/
locale: Locale;
},
): EmergingCorrelationResult {
const windowDays = opts.windowDays ?? EARLY_WINDOW_DAYS;
@@ -880,16 +888,20 @@ export function discoverLabOutcomeCorrelations(
minDraws?: number;
minWindowPoints?: number;
fdrQ?: number;
/** Reader's locale for the narrated `interpretation`. Defaults to English. */
locale?: Locale;
} = {},
/**
* Reader's locale for the narrated `interpretation`. Required — see
* {@link discoverCorrelations}; a lab sentence leaks English just as
* happily as a daily one.
*/
locale: Locale;
},
): LabCorrelationResult {
const windowDays = opts.windowDays ?? LAB_OUTCOME_WINDOW_DAYS;
const minDraws = opts.minDraws ?? LAB_MIN_DRAWS;
const minWindowPoints = opts.minWindowPoints ?? LAB_MIN_WINDOW_POINTS;
const fdrQ = opts.fdrQ ?? FDR_Q;
// Named `translate` — the pair-mapping callback below binds `t` to its element.
const { t: translate } = getServerTranslator(opts.locale ?? defaultLocale);
const { t: translate } = getServerTranslator(opts.locale);
// Group draws by biomarker key (newest-irrelevant; Pearson is order-free).
const drawsByLab = new Map<string, LabDrawPoint[]>();
@@ -0,0 +1,173 @@
/**
* The "Coach read" strip's second line has to arrive in the reader's language.
*
* On a German metric page the strip used to print its own summary in German
* and the pattern line under it in English, one under the other. Nothing was
* mistranslated: the pattern line is a finished sentence the server writes,
* the route never said who was reading, and the engine's optional locale
* quietly answered in English.
*
* So this drives the REAL pipe — `buildCoachReadStrip` → `readCoachCorrelations`
* → `discoverCorrelations` — with only the database reads faked, and asserts the
* German sentence the German templates produce. Testing the engine alone would
* have stayed green through the whole outage: the engine could always speak
* German, it was simply never asked.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
const measurementFindMany = vi.fn();
const measurementFindFirst = vi.fn();
const moodFindMany = vi.fn();
const userFindUnique = vi.fn();
const medicationFindMany = vi.fn();
const intakeEventFindMany = vi.fn();
const illnessEpisodeFindMany = vi.fn();
const illnessDayLogFindMany = vi.fn();
const labResultFindMany = vi.fn();
vi.mock("@/lib/modules/gate", () => ({
isModuleEnabled: vi.fn(async () => true),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/db", () => ({
prisma: {
measurement: {
findMany: (a: unknown) => measurementFindMany(a),
findFirst: (a: unknown) => measurementFindFirst(a),
},
moodEntry: { findMany: (a: unknown) => moodFindMany(a) },
user: { findUnique: (a: unknown) => userFindUnique(a) },
medication: { findMany: (a: unknown) => medicationFindMany(a) },
medicationIntakeEvent: { findMany: (a: unknown) => intakeEventFindMany(a) },
illnessEpisode: { findMany: (a: unknown) => illnessEpisodeFindMany(a) },
illnessDayLog: { findMany: (a: unknown) => illnessDayLogFindMany(a) },
labResult: { findMany: (a: unknown) => labResultFindMany(a) },
},
}));
// The band engine is `baseline.test.ts`'s subject; the strip's FIRST line is
// not what is under test here. Only the second line is, so the band degrades to
// "still learning" and the driver line stands alone.
const PROFILE = { ageYears: 40, sex: "MALE", heightCm: 180 };
vi.mock("@/lib/insights/derived", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/insights/derived")>();
return {
...actual,
loadBaselineProfile: async () => PROFILE,
computeCoincidentDeviation: async () => null,
};
});
vi.mock("@/lib/insights/derived/baseline", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/insights/derived/baseline")>();
return {
...actual,
loadBaselineProfile: async () => PROFILE,
computeVitalsBaseline: async () => ({
status: "insufficient" as const,
reason: "not under test",
}),
};
});
import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read";
const DAYS = 45;
const BASE = Date.UTC(2026, 4, 1, 12, 0, 0); // midday → a tz-stable day key
function dayKey(offset: number): string {
const d = new Date(BASE + offset * 86_400_000);
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
}
/**
* 45 days where today's symptom burden runs against tomorrow's sleep, strongly
* enough to clear n ≥ 20, p < 0.05, the FDR control and the effect-size floor.
* Same fixture shape the correlations reader's own real-engine test uses.
*/
function seedSymptomDrivesSleep() {
const dayLogs: Array<{
date: string;
functionalImpact: number;
symptomLinks: Array<{ severity: number }>;
}> = [];
const measurements: Array<{ type: string; value: number; measuredAt: Date }> =
[];
for (let i = 0; i < DAYS; i++) {
const burden = i % 4;
dayLogs.push({
date: dayKey(i),
functionalImpact: burden,
symptomLinks: [],
});
measurements.push({
type: "SLEEP_DURATION",
value: 8 - burden,
measuredAt: new Date(BASE + (i + 1) * 86_400_000),
});
}
measurementFindMany.mockResolvedValue(measurements);
illnessEpisodeFindMany.mockResolvedValue([
{
id: "ep1",
onsetAt: new Date(BASE),
resolvedAt: new Date(BASE + DAYS * 86_400_000),
},
]);
illnessDayLogFindMany.mockResolvedValue(dayLogs);
}
describe("buildCoachReadStrip — the pattern line speaks the reader's language", () => {
beforeEach(() => {
measurementFindMany.mockReset().mockResolvedValue([]);
measurementFindFirst.mockReset().mockResolvedValue({ value: 7 });
moodFindMany.mockReset().mockResolvedValue([]);
userFindUnique.mockReset().mockResolvedValue({ timezone: "Europe/Berlin" });
medicationFindMany.mockReset().mockResolvedValue([]);
intakeEventFindMany.mockReset().mockResolvedValue([]);
illnessEpisodeFindMany.mockReset().mockResolvedValue([]);
illnessDayLogFindMany.mockReset().mockResolvedValue([]);
labResultFindMany.mockReset().mockResolvedValue([]);
seedSymptomDrivesSleep();
});
it("writes the driver note in German for a German reader", async () => {
const strip = await buildCoachReadStrip("u1", "SLEEP_DURATION", "de");
expect(strip.driver).not.toBeNull();
const note = strip.driver!.note;
// The exact German sentence, not merely "different from the English one".
// A sentence that is different but wrong is still a broken page.
expect(note).toBe(
"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.",
);
});
it("writes the same driver note in English for an English reader", async () => {
const strip = await buildCoachReadStrip("u1", "SLEEP_DURATION", "en");
expect(strip.driver).not.toBeNull();
expect(strip.driver!.note).toBe(
"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.",
);
});
it("keeps the driver's matching keys language-independent", async () => {
// Only the sentence is localised. `outcome` is how the strip finds the
// driver that belongs to THIS metric, so it has to stay the same string in
// every language or a German page would find no driver at all.
const de = await buildCoachReadStrip("u1", "SLEEP_DURATION", "de");
const en = await buildCoachReadStrip("u1", "SLEEP_DURATION", "en");
expect(de.driver!.outcome).toBe("sleep duration");
expect(en.driver!.outcome).toBe("sleep duration");
expect(de.driver!.behaviour).toBe(en.driver!.behaviour);
expect(de.driver!.note).not.toBe(en.driver!.note);
});
});
+13 -1
View File
@@ -23,6 +23,7 @@
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import type { Locale } from "@/lib/i18n/config";
import {
loadBaselineProfile,
computeVitalsBaseline,
@@ -71,6 +72,15 @@ async function readLatestValue(
export async function buildCoachReadStrip(
userId: string,
type: MeasurementType,
/**
* The reader's locale. Line 2 arrives as a finished sentence and is printed
* verbatim by both clients, so it has to be written in the reader's language
* HERE — the strip component only translates the wrapper around it. Required
* on purpose: while this parameter did not exist, the route said nothing and
* the sentence came back in English, which is how a German weight page ended
* up with a German first line and an English second one.
*/
locale: Locale,
): Promise<CoachReadStripData> {
const profile = await loadBaselineProfile(prisma, userId);
@@ -78,7 +88,9 @@ export async function buildCoachReadStrip(
computeVitalsBaseline(userId, profile, { type }),
readLatestValue(userId, type),
// Line 2 is best-effort: a correlation failure must never sink the band.
readCoachCorrelations(userId).catch(() => ({ present: false }) as const),
readCoachCorrelations(userId, locale).catch(
() => ({ present: false }) as const,
),
]);
let baseline: CoachReadBaseline | null = null;
@@ -22,6 +22,7 @@
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import type { Locale } from "@/lib/i18n/config";
import {
discoverCorrelations,
discoveryMeasurementTypes,
@@ -82,6 +83,13 @@ function channelKeyForType(type: MeasurementType): string | null {
export async function getRelevantCorrelationsForMetric(
userId: string,
measurementType: MeasurementType,
/**
* The reader's locale. These interpretations are finished sentences that go
* into the assessment prompt as grounded context; handing a German card's
* prompt English prose invites the model to echo it. Required — every caller
* is a status builder that already knows the reader's language.
*/
locale: Locale,
): Promise<RelevantCorrelation[]> {
const channel = channelKeyForType(measurementType);
if (!channel) return [];
@@ -162,7 +170,7 @@ export async function getRelevantCorrelationsForMetric(
}
series.push(...environmentSeries, ...customMetricSeries);
const result = discoverCorrelations(series);
const result = discoverCorrelations(series, { locale });
const decisions = await syncAcceptedPatterns({
userId,
family: PATTERN_FAMILIES.discoveryRetrospective,
+1
View File
@@ -454,6 +454,7 @@ export async function generateMetricStatus(args: {
const relations = await getRelevantCorrelationsForMetric(
args.userId,
meta.measurementType,
locale,
);
const contextBlock = buildAssessmentContextBlock(
{
@@ -103,6 +103,7 @@ describe("buildPeriodNarrativeContext — grain (QA F3)", () => {
const result = await buildPeriodNarrativeContext("u1", {
period: "week",
now,
locale: "en",
});
expect(result.status).toBe("ready");
@@ -122,7 +123,11 @@ describe("buildPeriodNarrativeContext — grain (QA F3)", () => {
const now = new Date("2026-06-30T12:00:00.000Z");
measurementFindMany.mockResolvedValue([]);
await buildPeriodNarrativeContext("u1", { period: "week", now });
await buildPeriodNarrativeContext("u1", {
period: "week",
now,
locale: "en",
});
expect(measurementFindMany).toHaveBeenCalledWith(
expect.objectContaining({
@@ -40,6 +40,7 @@ function monthInput(
},
seriesByMetric,
discoverySeries,
locale: "en",
computedAt: "2026-04-30T12:00:00.000Z",
};
}
@@ -329,7 +329,7 @@ export async function generatePeriodNarrative(
}
}
const context = await buildContext(userId, { period, now });
const context = await buildContext(userId, { period, now, locale });
if (context.status === "insufficient") {
annotate({
action: { name: "insights.narrative.insufficient" },
+18 -1
View File
@@ -31,6 +31,7 @@
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import type { Locale } from "@/lib/i18n/config";
import { annotate } from "@/lib/logging/context";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import {
@@ -286,6 +287,12 @@ export interface AssembleInput {
seriesByMetric: Map<string, DailySeriesPoint[]>;
/** Named series feeding the discovery matrix (same window). */
discoverySeries: NamedSeries[];
/**
* The reader's locale for the drivers' narrated `interpretation`. Required —
* the assembler writes sentences, so it has to be told which language they
* are in rather than assuming one.
*/
locale: Locale;
/** Compute time (ISO). */
computedAt: string;
}
@@ -305,6 +312,7 @@ export function assemblePeriodNarrativeContext(
window,
seriesByMetric,
discoverySeries,
locale,
computedAt,
} = input;
@@ -380,7 +388,7 @@ export function assemblePeriodNarrativeContext(
}
// ── drivers (FDR-surviving correlations, descriptive-only) ──────────────
const discovery = discoverCorrelations(discoverySeries);
const discovery = discoverCorrelations(discoverySeries, { locale });
const drivers: NarrativeDriver[] = discovery.discovered.map((d) => ({
behaviour: d.behaviour,
outcome: d.outcome,
@@ -486,6 +494,14 @@ export interface BuildPeriodNarrativeContextOpts {
period: NarrativePeriod;
/** Injected clock for deterministic behaviour; defaults to now. */
now?: Date;
/**
* The reader's locale, for the drivers' narrated `interpretation`. Required:
* those are finished sentences that reach the prompt and the deterministic
* fallback, and both callers already know which language the narrative is
* being written in. An optional locale here would mean an English driver line
* inside a German summary whenever a caller stayed quiet.
*/
locale: Locale;
}
/**
@@ -673,6 +689,7 @@ export async function buildPeriodNarrativeContext(
window: { from: since.toISOString(), to: now.toISOString() },
seriesByMetric,
discoverySeries,
locale: opts.locale,
computedAt: now.toISOString(),
});
}
+5 -1
View File
@@ -427,7 +427,11 @@ export async function preparePulseStatusForUser(
// v1.12.7 — diversity / anti-repetition block (see blood-pressure-status).
const varietyLead = pickVarietyLead(userId, "pulse", todayKey);
const steadyRun = computeSteadyRun(pulseGraded.weekly, pulseGraded.monthly);
const relations = await getRelevantCorrelationsForMetric(userId, "PULSE");
const relations = await getRelevantCorrelationsForMetric(
userId,
"PULSE",
locale,
);
const assessmentContextBlock = buildAssessmentContextBlock(
{
varietyLead,
+5 -1
View File
@@ -586,7 +586,11 @@ export async function prepareWeightStatusForUser(
// v1.12.7 — diversity / anti-repetition block (see blood-pressure-status).
const varietyLead = pickVarietyLead(userId, "weight", todayKey);
const steadyRun = computeSteadyRun(weightGraded.weekly, weightGraded.monthly);
const relations = await getRelevantCorrelationsForMetric(userId, "WEIGHT");
const relations = await getRelevantCorrelationsForMetric(
userId,
"WEIGHT",
locale,
);
const assessmentContextBlock = buildAssessmentContextBlock(
{
varietyLead,
@@ -32,11 +32,14 @@ vi.mock("@/lib/rollups/measurement-read-wmy", async (importOriginal) => {
await importOriginal<typeof import("@/lib/rollups/measurement-read-wmy")>();
return { ...actual, readBestGranularityRollups: vi.fn() };
});
const { labResult, measurement } = vi.hoisted(() => ({
const { labResult, measurement, user } = vi.hoisted(() => ({
labResult: { findMany: vi.fn() },
measurement: { count: vi.fn(async () => 0), groupBy: vi.fn(async () => []) },
// The rich reads resolve the account's language before asking for a
// narrated sentence, which reads the stored preference.
user: { findUnique: vi.fn(async () => ({ locale: "de" })) },
}));
vi.mock("@/lib/db", () => ({ prisma: { labResult, measurement } }));
vi.mock("@/lib/db", () => ({ prisma: { labResult, measurement, user } }));
import {
compareMetric,
+11 -3
View File
@@ -18,13 +18,16 @@ vi.mock("@/lib/rollups/measurement-read-wmy", async (importOriginal) => {
await importOriginal<typeof import("@/lib/rollups/measurement-read-wmy")>();
return { ...actual, readBestGranularityRollups: vi.fn() };
});
const { labResult, measurement } = vi.hoisted(() => ({
const { labResult, measurement, user } = vi.hoisted(() => ({
labResult: { findMany: vi.fn() },
// `withHrvFallback`'s presence probe (`count`) + the discovery presence
// query (`groupBy`). Unused by metrics that don't exercise them.
measurement: { count: vi.fn(), groupBy: vi.fn(async () => []) },
// The rich reads resolve the account's language before asking for a
// narrated sentence, which reads the stored preference.
user: { findUnique: vi.fn(async () => ({ locale: "de" })) },
}));
vi.mock("@/lib/db", () => ({ prisma: { labResult, measurement } }));
vi.mock("@/lib/db", () => ({ prisma: { labResult, measurement, user } }));
import {
getCorrelation,
@@ -215,6 +218,10 @@ describe("get_correlation", () => {
expect(res.pair?.r).toBe(-0.42);
expect(res.association).toBe("descriptive");
expect(res.windowDays).toBe(180);
// The `note` on the pair is a finished sentence an MCP client shows as it
// stands, so the account's language has to reach the reader. Nothing else
// in this file would notice if the read went back to asking in English.
expect(readCoachCorrelations).toHaveBeenCalledWith(USER, "de");
});
it("honest-null when the pair has no surviving association", async () => {
@@ -396,7 +403,7 @@ describe("get_metric_baseline", () => {
const res = await getMetricBaseline(USER, { metric: "hrv" });
expect(res.present).toBe(true);
expect(buildCoachReadStrip).toHaveBeenCalledWith(USER, "HRV_RMSSD");
expect(buildCoachReadStrip).toHaveBeenCalledWith(USER, "HRV_RMSSD", "de");
});
it("stays on SDNN when the user has any HEART_RATE_VARIABILITY rows", async () => {
@@ -419,6 +426,7 @@ describe("get_metric_baseline", () => {
expect(buildCoachReadStrip).toHaveBeenCalledWith(
USER,
"HEART_RATE_VARIABILITY",
"de",
);
});
});
+12 -2
View File
@@ -36,6 +36,7 @@ import { isModuleEnabled } from "@/lib/modules/gate";
import { moduleForMeasurementType } from "@/lib/modules/measurement-scope";
import { readCoachCorrelations } from "@/lib/ai/coach/tools/correlations-read";
import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read";
import { resolveLocaleForUser } from "@/lib/i18n/user-locale";
import {
readBestGranularityRollups,
aggregateWmyBuckets,
@@ -668,7 +669,12 @@ export async function getCorrelation(
): Promise<CorrelationResult> {
const a = norm(args.metricA);
const b = norm(args.metricB);
const result = await readCoachCorrelations(userId);
// The `note` on each driver is a finished sentence the MCP client shows as
// it stands, so it is written in the account's language, not English.
const result = await readCoachCorrelations(
userId,
await resolveLocaleForUser(userId),
);
if (!result.present || !result.drivers || result.drivers.length === 0) {
annotate({
@@ -973,7 +979,11 @@ export async function getMetricBaseline(
}
const metric = resolved.metric;
const strip = await buildCoachReadStrip(userId, metric.measurementType);
const strip = await buildCoachReadStrip(
userId,
metric.measurementType,
await resolveLocaleForUser(userId),
);
if (!strip.baseline) {
annotate({
+8 -1
View File
@@ -22,7 +22,14 @@ export const cycleKeys = {
* on-ramp reads this so a non-FEMALE account can opt in before the gated
* cycle page is reachable. */
cyclePrefs: () => ["cycle", "prefs"] as const,
cycleInsights: () => ["cycle", "insights"] as const,
/**
* The FDR-guarded phase-correlation read (`/api/cycle/insights`). Keyed by
* locale: each surviving lagged row now carries an `interpretation` the
* server writes in the reader's language, so a cached payload belongs to one
* language only. `["cycle"]` stays the prefix, so every cycle write still
* evicts it through `cycleDependentKeys`.
*/
cycleInsights: (locale: string) => ["cycle", "insights", locale] as const,
cycleDayLog: (date: string) => ["cycle", "day-log", date] as const,
/** The caller's own custom symptoms (decrypted labels) the log-day sheet
* merges into the seeded chip grid. */
+9 -2
View File
@@ -172,9 +172,16 @@ export const insightsKeys = {
* MeasurementType. Pure compute over the baseline + correlation engines —
* the `["insights"]` prefix keeps it in the standard invalidation fan-out
* (a measurement write busts it so the placement refreshes).
*
* Keyed by locale as well: the strip's second line is prose the server
* writes in the reader's language, so a cache entry belongs to one language
* only. Without the segment, switching language re-used the German page's
* cached English sentence — the fix on the server would have been invisible
* to anyone who switched rather than reloaded. Same reason
* `insightsMetricStatus` carries it.
*/
insightsCoachRead: (metric: string) =>
["insights", "coach-read", metric] as const,
insightsCoachRead: (metric: string, locale: string) =>
["insights", "coach-read", metric, locale] as const,
/**
* v1.25 — baseline-drift read (`/api/insights/health-status`). Combines the
@@ -58,7 +58,17 @@ interface PillarEnvelope {
result: {
status: "ok" | "insufficient";
reason?: string;
value?: { score: number; observed: { value: number } };
value?: {
score: number;
observed: { value: number };
scoreBasis?: {
axis: string;
relation: string;
offsetMmHg: number;
boundaryMmHg: number;
};
personalReference?: { label: string };
};
};
}
@@ -260,6 +270,84 @@ describe("GET /api/analytics — Health Score (v1.34 reference composite)", () =
expect(hs.composite.value).toBeUndefined();
});
it("delivers the BP score basis to the client from the same run that produced the score", async () => {
// v1.34.5 — the popover explains "why 56" from a server-resolved basis.
// The basis and the score must come from ONE grading run: the route
// deliberately re-runs the BP helper against the CLINICAL band when the
// user keeps a personal target, and a basis taken from the other run
// would explain a number nobody is looking at.
//
// This asserts through the REAL route and the REAL JSON response, not a
// hand-built payload, because the assembly between the grader and the
// client is exactly where a correct field goes missing.
const user = await seedSession("hs-bp-basis");
const prisma = getPrismaClient();
const now = Date.now();
const DAY = 24 * 60 * 60 * 1000;
// A personal band that is WIDER than the clinical one on both axes, so
// the two runs disagree about everything the basis carries:
// clinical 129/79 → diastolic binds, 9 mmHg over its 79 ceiling → 55
// personal 135/90 → both axes in band → 88
await prisma.user.update({
where: { id: user.id },
data: {
thresholdsJson: {
BLOOD_PRESSURE_SYS: { min: 120, max: 135 },
BLOOD_PRESSURE_DIA: { min: 70, max: 90 },
},
},
});
for (let i = 0; i < 20; i++) {
const at = new Date(now - i * DAY);
await prisma.measurement.create({
data: {
userId: user.id,
type: "BLOOD_PRESSURE_SYS",
value: 132,
unit: "mmHg",
measuredAt: at,
},
});
await prisma.measurement.create({
data: {
userId: user.id,
type: "BLOOD_PRESSURE_DIA",
value: 88,
unit: "mmHg",
measuredAt: at,
},
});
}
const { GET } = await import("@/app/api/analytics/route");
const res = await (GET as (req: Request) => Promise<Response>)(
new Request("http://localhost/api/analytics"),
);
expect(res.status).toBe(200);
const env = (await res.json()) as AnalyticsEnvelope;
const bp = env.data!.healthScore!.pillars.find(
(p) => p.id === "BLOOD_PRESSURE",
)!;
expect(bp.result.status).toBe("ok");
// The personal band reached the payload, so the route really did take
// the branch that re-grades against the clinical one.
expect(bp.result.value!.personalReference!.label).toContain("135");
// Score AND basis, both from the clinical run: the diastolic axis, nine
// over ITS clinical ceiling of 79. Under the personal band the same
// readings sit inside it and score 88 — so a basis sourced from that
// second run cannot pass here.
expect(bp.result.value!.score).toBe(55);
expect(bp.result.value!.scoreBasis).toEqual({
axis: "diastolic",
relation: "above_ceiling",
offsetMmHg: 9,
boundaryMmHg: 79,
});
});
it("stays insufficient with only two eligible domains — the three-domain floor", async () => {
// The composite requires at least three eligible domains, including a
// measured physiological one, before it grades at all (CORE_MIN_ELIGIBLE