mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
77 files changed
+4572
-610
No files matched your search
@@ -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,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.
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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/*`
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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 })
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user