Merge work/bp-score-basis

This commit is contained in:
Marc-André Bombeck committed 2026-07-31 11:48:21 +02:00
commit 9982f87dc9
18 files changed
+1064 -70

No files matched your search

+5
View File
@@ -2301,6 +2301,11 @@
"fastingObserved": "Nüchternglukose: {value} {unit}",
"fastingReference": "{low} bis {high} {unit} nüchtern",
"personalReference": "Dein Ziel: {value}",
"bpBasisAbove": "Ausschlaggebend: {axis}, etwa {offset} mmHg über der Obergrenze von {boundary} mmHg.",
"bpBasisBelowFloor": "Ausschlaggebend: {axis}, etwa {offset} mmHg unter der Grenze für niedrigen Blutdruck von {boundary} mmHg.",
"bpBasisInBand": "Beide Werte liegen auf oder unter ihren Referenz-Obergrenzen.",
"bpScale": "An der Obergrenze liegt der Wert bei 85; 100 bedeutet rund 12 mmHg darunter.",
"bpRepresentative": "Beruht auf einem Durchschnitt der letzten {days} Tage, der neuere Messungen stärker gewichtet, und kann deshalb von jeder einzelnen Messung abweichen.",
"source": "Quelle: {source}, Stand {date}",
"observedActivity": "{value} Schritte pro Tag",
"observedSleep": "{value} Minuten pro Nacht",
+5
View File
@@ -2301,6 +2301,11 @@
"fastingObserved": "Fasting glucose: {value} {unit}",
"fastingReference": "{low} to {high} {unit} fasting",
"personalReference": "Your target: {value}",
"bpBasisAbove": "{axis} sets this score: about {offset} mmHg over its ceiling of {boundary} mmHg.",
"bpBasisBelowFloor": "{axis} sets this score: about {offset} mmHg under the low-pressure floor of {boundary} mmHg.",
"bpBasisInBand": "Both values are at or below their reference ceilings.",
"bpScale": "At the ceiling the score is 85; 100 means roughly 12 mmHg below it.",
"bpRepresentative": "Based on a recency-weighted average of the last {days} days, so it may not match any single reading.",
"source": "Source: {source}, as of {date}",
"observedActivity": "{value} steps per day",
"observedSleep": "{value} minutes per night",
+5
View File
@@ -2301,6 +2301,11 @@
"fastingObserved": "Glucosa en ayunas: {value} {unit}",
"fastingReference": "{low} a {high} {unit} en ayunas",
"personalReference": "Tu objetivo: {value}",
"bpBasisAbove": "Determinante: {axis}, unos {offset} mmHg por encima de su límite de {boundary} mmHg.",
"bpBasisBelowFloor": "Determinante: {axis}, unos {offset} mmHg por debajo del límite de presión baja de {boundary} mmHg.",
"bpBasisInBand": "Ambos valores están en su límite de referencia o por debajo.",
"bpScale": "En el límite la puntuación es 85; 100 significa unos 12 mmHg por debajo.",
"bpRepresentative": "Se basa en un promedio de los últimos {days} días que pondera más las mediciones recientes, por lo que puede no coincidir con ninguna medición concreta.",
"source": "Fuente: {source}, a fecha de {date}",
"observedActivity": "{value} pasos al día",
"observedSleep": "{value} minutos por noche",
+5
View File
@@ -2301,6 +2301,11 @@
"fastingObserved": "Glycémie à jeun : {value} {unit}",
"fastingReference": "{low} à {high} {unit} à jeun",
"personalReference": "Votre objectif : {value}",
"bpBasisAbove": "Déterminant : {axis}, environ {offset} mmHg au-dessus de son plafond de {boundary} mmHg.",
"bpBasisBelowFloor": "Déterminant : {axis}, environ {offset} mmHg sous le seuil de basse pression de {boundary} mmHg.",
"bpBasisInBand": "Les deux valeurs sont à leur plafond de référence ou en dessous.",
"bpScale": "Au plafond, le score est de 85 ; 100 correspond à environ 12 mmHg en dessous.",
"bpRepresentative": "Repose sur une moyenne des {days} derniers jours qui pèse davantage les mesures récentes, et peut donc ne correspondre à aucune mesure isolée.",
"source": "Source : {source}, au {date}",
"observedActivity": "{value} pas par jour",
"observedSleep": "{value} minutes par nuit",
+5
View File
@@ -2301,6 +2301,11 @@
"fastingObserved": "Glicemia a digiuno: {value} {unit}",
"fastingReference": "Da {low} a {high} {unit} a digiuno",
"personalReference": "Il tuo obiettivo: {value}",
"bpBasisAbove": "Determinante: {axis}, circa {offset} mmHg sopra il suo limite di {boundary} mmHg.",
"bpBasisBelowFloor": "Determinante: {axis}, circa {offset} mmHg sotto la soglia di pressione bassa di {boundary} mmHg.",
"bpBasisInBand": "Entrambi i valori sono pari o inferiori ai loro limiti di riferimento.",
"bpScale": "Al limite il punteggio è 85; 100 significa circa 12 mmHg più in basso.",
"bpRepresentative": "Si basa su una media degli ultimi {days} giorni che pesa di più le misurazioni recenti, quindi può non corrispondere a nessuna singola misurazione.",
"source": "Fonte: {source}, al {date}",
"observedActivity": "{value} passi al giorno",
"observedSleep": "{value} minuti per notte",
+5
View File
@@ -2301,6 +2301,11 @@
"fastingObserved": "Glukoza na czczo: {value} {unit}",
"fastingReference": "Od {low} do {high} {unit} na czczo",
"personalReference": "Twój cel: {value}",
"bpBasisAbove": "Decyduje: {axis}, około {offset} mmHg powyżej swojej granicy {boundary} mmHg.",
"bpBasisBelowFloor": "Decyduje: {axis}, około {offset} mmHg poniżej granicy niskiego ciśnienia {boundary} mmHg.",
"bpBasisInBand": "Obie wartości są na poziomie swoich granic referencyjnych lub poniżej.",
"bpScale": "Przy granicy wynik to 85; 100 oznacza około 12 mmHg poniżej.",
"bpRepresentative": "Opiera się na średniej z ostatnich {days} dni, która mocniej waży nowsze pomiary, więc może nie odpowiadać żadnemu pojedynczemu pomiarowi.",
"source": "Źródło: {source}, stan na {date}",
"observedActivity": "{value} kroków dziennie",
"observedSleep": "{value} minut na noc",
@@ -450,3 +450,183 @@ describe("GET /api/analytics", () => {
// contract (the chunked findMany must never re-appear on the default
// slice critical path).
});
/**
* v1.34.5 — the blood-pressure score's basis has to ARRIVE, not merely exist.
*
* The grader returns `{ score, basis }` and the pillar builder copies the
* basis onto the pillar value, and both of those ends have their own unit
* tests. Neither of them sees the assembly in between: the route's envelope
* hand-off, the score reader's envelope narrowing, the pillar list, the JSON
* envelope. A field can be produced correctly, consumed correctly, and still
* never reach a client, which is precisely how v1.33.1 shipped a payload
* field that nothing on the wire ever carried.
*
* So these drive the REAL route. Nothing about the score is stubbed — the
* fixture seeds raw blood-pressure rows on the same `$queryRawUnsafe` the
* live BP path issues, and the route does its own pairing, its own
* recency-weighting, its own grading and its own serialisation. The whole
* payload is read back off `res.json()`.
*/
describe("GET /api/analytics — blood-pressure score basis on the wire", () => {
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* ESH 2023 band for the fixture's date of birth (age < 65).
*/
const CLINICAL = { sysHigh: 129, diaHigh: 79 } as const;
interface WirePillar {
id: string;
result: {
status: string;
value?: {
score: number;
scoreBasis?: {
axis: string;
relation: string;
offsetMmHg: number;
boundaryMmHg: number;
};
observed: { label: string };
reference: { label: string };
personalReference?: { label: string };
};
};
}
/**
* Seed one blood-pressure pair per day over the trailing `pairs` days.
* Every pair carries the same reading, so the recency-weighted
* representative is that reading exactly and the expected score can be
* stated as a literal instead of a tolerance.
*/
function seedBpSeries(input: { sys: number; dia: number; pairs: number }) {
const seriesOf = (value: number) =>
Array.from({ length: input.pairs }, (_, i) => ({
measured_at: new Date(Date.now() - (i + 1) * DAY_MS),
value,
}));
const sysRows = seriesOf(input.sys);
const diaRows = seriesOf(input.dia);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(prisma.$queryRawUnsafe as any).mockImplementation(
async (sql: unknown) => {
if (typeof sql !== "string") return [];
if (sql.includes(`m."type" = 'BLOOD_PRESSURE_SYS'`)) return sysRows;
if (sql.includes(`m."type" = 'BLOOD_PRESSURE_DIA'`)) return diaRows;
return [];
},
);
}
function signInWith(thresholdsJson: unknown) {
vi.mocked(getSession).mockResolvedValue({
session: SESSION_OK.session,
user: { ...SESSION_USER, thresholdsJson },
} as never);
}
async function readBloodPressurePillar(): Promise<WirePillar> {
const res = await (
GET as unknown as (...args: never[]) => Promise<Response>
)();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { healthScore: { pillars: WirePillar[] } | null };
};
const pillar = body.data.healthScore?.pillars.find(
(p) => p.id === "BLOOD_PRESSURE",
);
if (!pillar) throw new Error("no BLOOD_PRESSURE pillar in the payload");
return pillar;
}
it("carries the basis onto the pillar the client actually receives", async () => {
// 137/91 against the 129/79 band: systolic sits 8 over its ceiling and
// grades 57, diastolic sits 12 over and grades 50. The pillar takes the
// worse of the two, so diastolic is the axis that set the number.
seedBpSeries({ sys: 137, dia: 91, pairs: 14 });
const pillar = await readBloodPressurePillar();
expect(pillar.result.status).toBe("ok");
expect(pillar.result.value?.observed.label).toBe("137/91 mmHg");
expect(pillar.result.value?.score).toBe(50);
expect(pillar.result.value?.scoreBasis).toEqual({
axis: "diastolic",
relation: "above_ceiling",
offsetMmHg: 12,
boundaryMmHg: CLINICAL.diaHigh,
});
});
it("says the reading is in band when it is", async () => {
// 126/68 clears both ceilings, so no axis is over anything and the
// relation has to say so rather than reporting a distance from a ceiling
// the reading never reached. Systolic is the nearer of the two to its
// own ceiling here, so this also puts the other axis on the wire.
seedBpSeries({ sys: 126, dia: 68, pairs: 14 });
const pillar = await readBloodPressurePillar();
expect(pillar.result.value?.observed.label).toBe("126/68 mmHg");
expect(pillar.result.value?.scoreBasis).toEqual({
axis: "systolic",
relation: "in_band",
offsetMmHg: 3,
boundaryMmHg: CLINICAL.sysHigh,
});
});
it("omits the basis entirely when the pillar has no score to explain", async () => {
// Eleven pairs is one short of the floor, so the pillar is insufficient
// and carries no value at all — the popover must fall back to the
// shipped lines rather than to an explanation of a number nobody has.
seedBpSeries({ sys: 137, dia: 91, pairs: 11 });
const pillar = await readBloodPressurePillar();
expect(pillar.result.status).toBe("insufficient");
expect(pillar.result.value).toBeUndefined();
});
/**
* The coupling the two-ended tests cannot see.
*
* A user with a personal target gets the whole BP helper run twice: once
* against their own band for the in-target percentages on the dashboard,
* once against the clinical band for the score. The score comes from the
* second run. So does the basis, and it has to, because the two runs
* disagree on every field of it — different binding axis, different
* ceiling, different distance. Picking the score off one run and the
* basis off the other reads as correct in review and produces a popover
* that explains a number the user is not looking at.
*
* The fixture is chosen so the two runs share NOTHING: 137/91 against the
* personal 135/95 band binds on systolic 2 over 135 and scores 77; against
* the clinical 129/79 band it binds on diastolic 12 over 79 and scores 50.
*/
it("takes the basis from the same run as the score when a personal target differs", async () => {
signInWith({
BLOOD_PRESSURE_SYS: { min: 110, max: 135 },
BLOOD_PRESSURE_DIA: { min: 65, max: 95 },
});
seedBpSeries({ sys: 137, dia: 91, pairs: 14 });
const pillar = await readBloodPressurePillar();
// The personal band is genuinely in play — this account really does take
// the route's second-run branch, so the assertions below are about the
// coupling and not about a user who has no personal target at all.
expect(pillar.result.value?.personalReference?.label).toBe(
"110–135/65–95 mmHg",
);
expect(pillar.result.value?.score).toBe(50);
expect(pillar.result.value?.scoreBasis).toEqual({
axis: "diastolic",
relation: "above_ceiling",
offsetMmHg: 12,
boundaryMmHg: CLINICAL.diaHigh,
});
// Stated negatively as well: nothing from the personal run may leak in.
expect(pillar.result.value?.scoreBasis?.boundaryMmHg).not.toBe(135);
});
});
+11
View File
@@ -360,6 +360,17 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
}),
],
);
// The pillar is graded against the CLINICAL band even when the user
// keeps a personal target (that band is the number's identity; the
// personal one is displayed beside it and scores nothing). So when the
// two differ we re-run the whole helper against the clinical targets
// and the score comes from that second run.
//
// v1.34.5 — the "why this number" basis rides the same envelope, so it
// is the same run, the same representative pair and the same ceilings
// by construction. Assign envelopes WHOLE here; picking `graded.score`
// off one run and a basis off the other would let the popover explain
// a number nobody is looking at, and it would look correct in review.
if (bpTargetsEqual(bpTargets, clinicalBpTargets)) {
bpEnvelope = windows;
bpEnvelopePriorWeek = windowsPriorWeek;
@@ -0,0 +1,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");
});
});
@@ -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),
}),
+332 -29
View File
@@ -7,6 +7,8 @@
* (low-to-mid 50s), not in the catastrophic band.
*/
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
gradeBpScore,
gradeBpScoreFromSeries,
@@ -22,49 +24,58 @@ const UNDER65: BpTargets = {
diaHigh: 79,
};
/**
* The score alone, for the arithmetic pins below. `gradeBpScore` returns
* the score together with the basis that produced it; the basis has its
* own describe block further down.
*/
function bpScore(input: { sys: number; dia: number; target: BpTargets }) {
return gradeBpScore(input).score;
}
describe("gradeBpScore", () => {
it("grades the maintainer's 134/87 into the borderline band [45,60]", () => {
const score = gradeBpScore({ sys: 134, dia: 87, target: UNDER65 });
const score = bpScore({ sys: 134, dia: 87, target: UNDER65 });
expect(score).toBeGreaterThanOrEqual(45);
expect(score).toBeLessThanOrEqual(60);
});
it("grades a well-controlled 120/78 at or above 85", () => {
expect(
gradeBpScore({ sys: 120, dia: 78, target: UNDER65 }),
bpScore({ sys: 120, dia: 78, target: UNDER65 }),
).toBeGreaterThanOrEqual(85);
});
it("grades a textbook-normal 118/76 very high (≈100)", () => {
expect(
gradeBpScore({ sys: 118, dia: 76, target: UNDER65 }),
bpScore({ sys: 118, dia: 76, target: UNDER65 }),
).toBeGreaterThanOrEqual(88);
});
it("grades an uncontrolled 160/100 at or below 30", () => {
expect(
gradeBpScore({ sys: 160, dia: 100, target: UNDER65 }),
bpScore({ sys: 160, dia: 100, target: UNDER65 }),
).toBeLessThanOrEqual(30);
});
it("takes the WORSE axis — a single high diastolic drags the score down", () => {
// sys perfect (118), dia far over (95): the worst axis should win.
const sysOk = gradeBpScore({ sys: 118, dia: 78, target: UNDER65 });
const diaHigh = gradeBpScore({ sys: 118, dia: 95, target: UNDER65 });
const sysOk = bpScore({ sys: 118, dia: 78, target: UNDER65 });
const diaHigh = bpScore({ sys: 118, dia: 95, target: UNDER65 });
expect(diaHigh).toBeLessThan(sysOk);
});
it("is monotonic — higher BP never scores better", () => {
const a = gradeBpScore({ sys: 130, dia: 82, target: UNDER65 });
const b = gradeBpScore({ sys: 140, dia: 88, target: UNDER65 });
const c = gradeBpScore({ sys: 150, dia: 95, target: UNDER65 });
const a = bpScore({ sys: 130, dia: 82, target: UNDER65 });
const b = bpScore({ sys: 140, dia: 88, target: UNDER65 });
const c = bpScore({ sys: 150, dia: 95, target: UNDER65 });
expect(a).toBeGreaterThan(b);
expect(b).toBeGreaterThan(c);
});
it("has no cliff — a one-mmHg change moves the score by a small amount", () => {
const at = gradeBpScore({ sys: 129, dia: 79, target: UNDER65 });
const justOver = gradeBpScore({ sys: 130, dia: 79, target: UNDER65 });
const at = bpScore({ sys: 129, dia: 79, target: UNDER65 });
const justOver = bpScore({ sys: 130, dia: 79, target: UNDER65 });
expect(at - justOver).toBeLessThanOrEqual(5);
expect(at).toBeGreaterThanOrEqual(justOver);
});
@@ -73,11 +84,11 @@ describe("gradeBpScore", () => {
// 85/45 is 5 mmHg below both floors (sys 90 / dia 50). The continuous
// hypo curve gives a gentle penalty near the floor (5 below ≈ 85),
// clearly below the optimal plateau of 100.
const low = gradeBpScore({ sys: 85, dia: 45, target: UNDER65 });
const low = bpScore({ sys: 85, dia: 45, target: UNDER65 });
expect(low).toBeLessThan(100);
expect(low).toBeGreaterThanOrEqual(80);
// A markedly low reading is penalised much harder.
const veryLow = gradeBpScore({ sys: 70, dia: 40, target: UNDER65 });
const veryLow = bpScore({ sys: 70, dia: 40, target: UNDER65 });
expect(veryLow).toBeLessThan(low);
expect(veryLow).toBeLessThanOrEqual(45);
});
@@ -90,7 +101,7 @@ describe("gradeBpScore", () => {
// dia 66 sits on the optimal plateau (offset −13 → 100) so the
// systolic axis is the worst-of(sys,dia) winner across the walk.
const sysWalk = [92, 91, 90, 89, 88].map((sys) =>
gradeBpScore({ sys, dia: 66, target: UNDER65 }),
bpScore({ sys, dia: 66, target: UNDER65 }),
);
// AT and ABOVE the floor sit on the plateau (100).
expect(sysWalk[2]).toBe(100); // sys 90
@@ -109,7 +120,7 @@ describe("gradeBpScore", () => {
// sys 117 sits on the optimal plateau (offset −12 → 100) so the
// diastolic axis is the worst-of(sys,dia) winner across the walk.
const diaWalk = [52, 51, 50, 49, 48].map((dia) =>
gradeBpScore({ sys: 117, dia, target: UNDER65 }),
bpScore({ sys: 117, dia, target: UNDER65 }),
);
expect(diaWalk[2]).toBe(100); // dia 50
expect(diaWalk[1]).toBe(100); // dia 51
@@ -124,19 +135,19 @@ describe("gradeBpScore", () => {
it("descends smoothly further below the floor (mirrors over-target steepness)", () => {
// floor → 100, floor−10 → ~70, floor−20 → ~45, floor−30 → ~20.
// dia 66 stays on the plateau so the systolic axis is the winner.
expect(gradeBpScore({ sys: 90, dia: 66, target: UNDER65 })).toBe(100);
expect(gradeBpScore({ sys: 80, dia: 66, target: UNDER65 })).toBe(70);
expect(gradeBpScore({ sys: 70, dia: 66, target: UNDER65 })).toBe(45);
expect(gradeBpScore({ sys: 60, dia: 66, target: UNDER65 })).toBe(20);
expect(bpScore({ sys: 90, dia: 66, target: UNDER65 })).toBe(100);
expect(bpScore({ sys: 80, dia: 66, target: UNDER65 })).toBe(70);
expect(bpScore({ sys: 70, dia: 66, target: UNDER65 })).toBe(45);
expect(bpScore({ sys: 60, dia: 66, target: UNDER65 })).toBe(20);
// Never negative on an extreme low.
expect(
gradeBpScore({ sys: 30, dia: 20, target: UNDER65 }),
bpScore({ sys: 30, dia: 20, target: UNDER65 }),
).toBeGreaterThanOrEqual(0);
});
it("preserves the audit fairness anchors (134/87 → 57, 165/105 → 22)", () => {
expect(gradeBpScore({ sys: 134, dia: 87, target: UNDER65 })).toBe(57);
expect(gradeBpScore({ sys: 165, dia: 105, target: UNDER65 })).toBe(22);
expect(bpScore({ sys: 134, dia: 87, target: UNDER65 })).toBe(57);
expect(bpScore({ sys: 165, dia: 105, target: UNDER65 })).toBe(22);
});
});
@@ -169,7 +180,7 @@ describe("gradeBpScoreFromSeries", () => {
// recency-weighted representative should read close to the recent
// well-controlled cluster.
expect(recencyWeighted).not.toBeNull();
expect(recencyWeighted as number).toBeGreaterThanOrEqual(80);
expect(recencyWeighted!.score).toBeGreaterThanOrEqual(80);
});
it("matches the single-reading grade when every pair is today", () => {
@@ -177,9 +188,9 @@ describe("gradeBpScoreFromSeries", () => {
{ at: NOW, sys: 134, dia: 87 },
{ at: NOW, sys: 134, dia: 87 },
];
expect(gradeBpScoreFromSeries({ pairs, target: UNDER65, now: NOW })).toBe(
gradeBpScore({ sys: 134, dia: 87, target: UNDER65 }),
);
expect(
gradeBpScoreFromSeries({ pairs, target: UNDER65, now: NOW }),
).toEqual(gradeBpScore({ sys: 134, dia: 87, target: UNDER65 }));
});
it("rollup (per-day-mean + count) and live (per-event) agree on the same data", () => {
@@ -218,8 +229,300 @@ describe("gradeBpScoreFromSeries", () => {
});
expect(live).not.toBeNull();
expect(rollup).not.toBeNull();
expect(Math.abs((live as number) - (rollup as number))).toBeLessThanOrEqual(
1,
);
expect(Math.abs(live!.score - rollup!.score)).toBeLessThanOrEqual(1);
});
});
/**
* v1.34.5 — the basis published with the score. The pillar scores the
* WORSE of the two axes; these pin which axis that was, what it was
* measured against, and that the three relations the popover can render
* are the only three that exist.
*/
describe("gradeBpScore — the basis", () => {
// Hypotension floors, mirrored from `bp-in-target` so the sweep below
// can reconstruct the boundary the grader must have used.
const SYS_FLOOR = 90;
const DIA_FLOOR = 50;
it("names the systolic axis when it is the worse one", () => {
// 137/84: sys 8 over its ceiling, dia 5 over its own — systolic worse.
const { score, basis } = gradeBpScore({
sys: 137,
dia: 84,
target: UNDER65,
});
expect(basis).toEqual({
axis: "systolic",
relation: "above_ceiling",
offsetMmHg: 8,
boundaryMmHg: 129,
});
expect(score).toBe(57);
});
it("names the diastolic axis when it is the worse one", () => {
// 132/88: sys 3 over, dia 9 over — diastolic worse, and the basis
// must say so rather than defaulting to the axis people read first.
const { score, basis } = gradeBpScore({
sys: 132,
dia: 88,
target: UNDER65,
});
expect(basis).toEqual({
axis: "diastolic",
relation: "above_ceiling",
offsetMmHg: 9,
boundaryMmHg: 79,
});
expect(score).toBe(55);
});
it("breaks a tie toward systolic", () => {
// Both axes sit exactly 5 mmHg over their own ceiling, so both grade
// 65 and the score cannot say which one it came from. The documented
// rule picks systolic.
const { basis } = gradeBpScore({ sys: 134, dia: 84, target: UNDER65 });
expect(basis.axis).toBe("systolic");
expect(basis.boundaryMmHg).toBe(129);
// …and at the ceiling itself, where both axes grade 85.
expect(gradeBpScore({ sys: 129, dia: 79, target: UNDER65 }).basis).toEqual({
axis: "systolic",
relation: "in_band",
offsetMmHg: 0,
boundaryMmHg: 129,
});
});
it("reports a reading inside the band as in_band, measured from the ceiling", () => {
const { score, basis } = gradeBpScore({
sys: 124,
dia: 74,
target: UNDER65,
});
expect(basis.relation).toBe("in_band");
expect(basis.boundaryMmHg).toBe(129);
expect(basis.offsetMmHg).toBe(5); // five below the ceiling
expect(score).toBeGreaterThanOrEqual(85);
});
it("reports a hypotensive reading as below_floor, measured from the floor", () => {
// 86/70: systolic 4 under the 90 floor; diastolic comfortably in band.
const { basis } = gradeBpScore({ sys: 86, dia: 70, target: UNDER65 });
expect(basis).toEqual({
axis: "systolic",
relation: "below_floor",
offsetMmHg: 4,
boundaryMmHg: SYS_FLOOR,
});
});
it("keeps below_floor even when the score is still high — the curve is gentle near the floor", () => {
// Worth pinning because it is easy to assume "below the floor" implies
// a poor score: two mmHg under reads 94, comfortably green. The
// relation is decided by where the value sits, never by the score.
const { score, basis } = gradeBpScore({
sys: 88,
dia: 70,
target: UNDER65,
});
expect(basis.relation).toBe("below_floor");
expect(score).toBeGreaterThanOrEqual(85);
});
it("keeps the relation with the score when the rounded offset reads zero", () => {
// 129.4 is above the ceiling and scores below 85, but rounds to a
// 0 mmHg offset. The relation follows the unrounded value, so the
// sentence can never claim "in band" about a sub-85 number.
const { score, basis } = gradeBpScore({
sys: 129.4,
dia: 70,
target: UNDER65,
});
expect(basis.relation).toBe("above_ceiling");
expect(basis.offsetMmHg).toBe(0);
expect(score).toBeLessThan(85);
});
it("reproduces the worked 137.5/87 example — systolic, ~9 over, 56", () => {
const { score, basis } = gradeBpScore({
sys: 137.5,
dia: 87,
target: UNDER65,
});
expect(score).toBe(56);
expect(basis).toEqual({
axis: "systolic",
relation: "above_ceiling",
offsetMmHg: 9,
boundaryMmHg: 129,
});
});
it("uses the targets it was handed, not a fixed band", () => {
const over65: BpTargets = {
sysLow: 130,
sysHigh: 139,
diaLow: 70,
diaHigh: 79,
};
const { basis } = gradeBpScore({ sys: 137, dia: 70, target: over65 });
expect(basis).toEqual({
axis: "systolic",
relation: "in_band",
offsetMmHg: 2,
boundaryMmHg: 139,
});
});
it("has exactly three relations, and each one reconstructs its own reading", () => {
// The design asserts the three relations are exhaustive; this pins it
// against the anchors instead of trusting the prose. Every integer
// reading across the whole plausible range must land in exactly one
// relation, and axis + relation + boundary + offset together must
// reconstruct the value that produced the score.
const seen = new Set<string>();
for (let sys = 60; sys <= 220; sys += 1) {
for (let dia = 30; dia <= 140; dia += 1) {
const { score, basis } = gradeBpScore({ sys, dia, target: UNDER65 });
seen.add(basis.relation);
const value = basis.axis === "systolic" ? sys : dia;
const floor = basis.axis === "systolic" ? SYS_FLOOR : DIA_FLOOR;
const ceiling =
basis.axis === "systolic" ? UNDER65.sysHigh : UNDER65.diaHigh;
if (basis.relation === "below_floor") {
expect(value).toBeLessThan(floor);
expect(basis.boundaryMmHg).toBe(floor);
expect(floor - value).toBe(basis.offsetMmHg);
} else {
expect(value).toBeGreaterThanOrEqual(floor);
expect(basis.boundaryMmHg).toBe(ceiling);
expect(Math.abs(value - ceiling)).toBe(basis.offsetMmHg);
if (basis.relation === "in_band") {
expect(value).toBeLessThanOrEqual(ceiling);
// The scale line only makes sense if in_band never reads red.
expect(score).toBeGreaterThanOrEqual(85);
} else {
expect(basis.relation).toBe("above_ceiling");
expect(value).toBeGreaterThan(ceiling);
expect(score).toBeLessThan(85);
}
}
// The named axis alone carries the score: replace the other axis
// with an optimal value and the number must not move.
const optimalOther = gradeBpScore(
basis.axis === "systolic"
? { sys, dia: UNDER65.diaHigh - 12, target: UNDER65 }
: { sys: UNDER65.sysHigh - 12, dia, target: UNDER65 },
);
expect(optimalOther.score).toBe(score);
}
}
expect([...seen].sort()).toEqual([
"above_ceiling",
"below_floor",
"in_band",
]);
});
});
describe("gradeBpScoreFromSeries — the basis travels with the score", () => {
const NOW = new Date("2026-06-07T12:00:00.000Z");
it("returns the basis of the representative it graded, not of any single pair", () => {
// Two readings, one at each end. The representative is their mean
// (both same-day, so equal weight): 130/85. The basis must describe
// that mean, which matches neither reading.
const graded = gradeBpScoreFromSeries({
pairs: [
{ at: NOW, sys: 120, dia: 80 },
{ at: NOW, sys: 140, dia: 90 },
],
target: UNDER65,
now: NOW,
});
expect(graded).not.toBeNull();
expect(graded!.basis).toEqual({
axis: "diastolic",
relation: "above_ceiling",
offsetMmHg: 6,
boundaryMmHg: 79,
});
expect(graded!.score).toBe(
gradeBpScore({ sys: 130, dia: 85, target: UNDER65 }).score,
);
});
it("grades the basis against the targets it was given", () => {
// The analytics route re-runs this helper with the CLINICAL targets
// when the user keeps a personal one. Same series, two bands: both
// the score and the basis must move together.
const pairs: BpPairPoint[] = [{ at: NOW, sys: 132, dia: 88 }];
const clinical = gradeBpScoreFromSeries({
pairs,
target: UNDER65,
now: NOW,
})!;
const personal = gradeBpScoreFromSeries({
pairs,
target: { sysLow: 120, sysHigh: 135, diaLow: 70, diaHigh: 90 },
now: NOW,
})!;
expect(clinical.basis.boundaryMmHg).toBe(79);
expect(clinical.basis.relation).toBe("above_ceiling");
expect(personal.basis.relation).toBe("in_band");
expect(personal.score).toBeGreaterThan(clinical.score);
});
});
/**
* The popover's scale line quotes two numbers out of this curve ("at the
* ceiling the score is 85; 100 means roughly 12 mmHg below it") because
* the sentence has to read naturally in six languages. That makes the
* copy and the anchors two places that must agree, so this pins them to
* each other: re-anchoring the curve turns this red and the six strings
* have to follow.
*/
describe("the scale the popover quotes", () => {
const AT_CEILING_SCORE = 85;
const OPTIMAL_OFFSET_MMHG = 12;
it("still scores 85 at the ceiling and 100 at twelve below it", () => {
expect(
bpScore({ sys: UNDER65.sysHigh, dia: UNDER65.diaHigh, target: UNDER65 }),
).toBe(AT_CEILING_SCORE);
expect(
bpScore({
sys: UNDER65.sysHigh - OPTIMAL_OFFSET_MMHG,
dia: UNDER65.diaHigh - OPTIMAL_OFFSET_MMHG,
target: UNDER65,
}),
).toBe(100);
// Twelve is the SMALLEST distance that reaches 100 — one less does not.
expect(
bpScore({
sys: UNDER65.sysHigh - (OPTIMAL_OFFSET_MMHG - 1),
dia: UNDER65.diaHigh - (OPTIMAL_OFFSET_MMHG - 1),
target: UNDER65,
}),
).toBeLessThan(100);
});
it.each(["en", "de", "fr", "es", "it", "pl"])(
"quotes those same two numbers in %s",
(locale) => {
const bundle = JSON.parse(
readFileSync(
join(__dirname, "../../../../messages", `${locale}.json`),
"utf8",
),
) as { insights: { healthScore: Record<string, string> } };
const line = bundle.insights.healthScore.bpScale;
expect(line).toContain(String(AT_CEILING_SCORE));
expect(line).toContain(String(OPTIMAL_OFFSET_MMHG));
expect(line).toContain("100");
},
);
});
+96 -18
View File
@@ -84,6 +84,49 @@ const HYPO_ANCHORS: ReadonlyArray<readonly [below: number, score: number]> = [
[40, 5],
] as const;
/**
* Which axis set the score, measured against which boundary, and by how
* far. Published with the score (never beside it) so the surface can
* answer "why this number" without recomputing anything client-side.
*
* `relation` is a trichotomy on the binding axis' own value:
*
* value < floor → `below_floor` (boundary = floor)
* value <= ceiling → `in_band` (boundary = ceiling)
* otherwise → `above_ceiling` (boundary = ceiling)
*
* so the three relations are exhaustive and mutually exclusive by
* construction, whatever the anchors say. `offsetMmHg` is the distance
* from that boundary as a NON-NEGATIVE integer (rounded); the relation
* carries the direction, so the number never needs a sign. The relation
* is decided on the unrounded value, so it always agrees with the score
* even when the rounded offset reads 0.
*/
export type BpScoreAxis = "systolic" | "diastolic";
export type BpScoreRelation = "above_ceiling" | "below_floor" | "in_band";
export interface BpScoreBasis {
/** The axis whose sub-score the pillar took — the worse of the two. */
axis: BpScoreAxis;
relation: BpScoreRelation;
/** Rounded, non-negative distance from `boundaryMmHg`. */
offsetMmHg: number;
/** The ceiling the axis was graded against, or its hypotension floor. */
boundaryMmHg: number;
}
/**
* A graded BP reading: the score AND the basis that produced it, as one
* value. They are returned together, and carried together through every
* envelope down to the pillar, precisely so no caller can ever pair a
* score from one run with a basis from another (different representative
* pair, or — the live trap — different targets).
*/
export interface BpGrade {
score: number;
basis: BpScoreBasis;
}
/**
* Generic piecewise-linear interpolation over a monotonic-x anchor table.
* Clamps to the first / last anchor outside the table range.
@@ -127,38 +170,63 @@ function interpolateAxisScore(offset: number): number {
* the over-target branch holds at the floor, then descends smoothly —
* no boundary cliff.
*/
function gradeAxis(value: number, ceiling: number, floor: number): number {
function gradeAxis(
value: number,
ceiling: number,
floor: number,
): { score: number; relation: BpScoreRelation; boundary: number } {
if (value < floor) {
return interpolateAnchors(HYPO_ANCHORS, floor - value);
return {
score: interpolateAnchors(HYPO_ANCHORS, floor - value),
relation: "below_floor",
boundary: floor,
};
}
return interpolateAxisScore(value - ceiling);
const offset = value - ceiling;
return {
score: interpolateAxisScore(offset),
relation: offset > 0 ? "above_ceiling" : "in_band",
boundary: ceiling,
};
}
/**
* Map a representative blood-pressure reading to a smooth 0-100 score.
* Map a representative blood-pressure reading to a smooth 0-100 score,
* together with the basis that produced it.
*
* Takes the WORSE of the systolic and diastolic axis sub-scores so a
* high diastolic counts against the score (it should) without
* binary-zeroing the result the way the all-time in-target rate did.
*
* @returns integer 0-100.
* **Tie rule**: when both axes grade identically, the basis names the
* SYSTOLIC axis. The two are interchangeable for the number, so the
* choice is arbitrary in arithmetic but must not be arbitrary in the
* text; systolic is the axis clinicians lead with and the one the app
* shows first everywhere else.
*
* @returns integer score 0-100 plus the binding axis, its relation to
* its boundary, and the distance to it.
*/
export function gradeBpScore(input: {
sys: number;
dia: number;
target: BpTargets;
}): number {
const sysScore = gradeAxis(
input.sys,
input.target.sysHigh,
SYS_HYPOTENSION_FLOOR,
);
const diaScore = gradeAxis(
input.dia,
input.target.diaHigh,
DIA_HYPOTENSION_FLOOR,
);
return Math.round(Math.max(0, Math.min(100, Math.min(sysScore, diaScore))));
}): BpGrade {
const sys = gradeAxis(input.sys, input.target.sysHigh, SYS_HYPOTENSION_FLOOR);
const dia = gradeAxis(input.dia, input.target.diaHigh, DIA_HYPOTENSION_FLOOR);
// `<=` puts a tie on systolic — see the tie rule above.
const bindingIsSystolic = sys.score <= dia.score;
const binding = bindingIsSystolic ? sys : dia;
const bindingValue = bindingIsSystolic ? input.sys : input.dia;
return {
score: Math.round(Math.max(0, Math.min(100, binding.score))),
basis: {
axis: bindingIsSystolic ? "systolic" : "diastolic",
relation: binding.relation,
offsetMmHg: Math.round(Math.abs(bindingValue - binding.boundary)),
boundaryMmHg: binding.boundary,
},
};
}
/**
@@ -228,11 +296,21 @@ export function representativeBpFromSeries(input: {
};
}
/**
* Grade the recency-weighted representative of a pair series.
*
* Returns the score and its basis as ONE value. Callers must carry that
* value on, never split it: a score stored beside a basis that came from
* a second evaluation (or from a second set of targets — the analytics
* route deliberately re-runs this with the CLINICAL targets when the user
* has a personal one) would let the surface explain a number that is not
* the number on screen.
*/
export function gradeBpScoreFromSeries(input: {
pairs: ReadonlyArray<BpPairPoint>;
target: BpTargets;
now: Date;
}): number | null {
}): BpGrade | null {
const representative = representativeBpFromSeries(input);
return representative
? gradeBpScore({ ...representative, target: input.target })
+18 -7
View File
@@ -69,7 +69,11 @@ import {
isBpReadingInTarget,
type BpReading,
} from "./bp-in-target";
import { gradeBpScoreFromSeries, representativeBpFromSeries } from "./bp-grade";
import {
gradeBpScoreFromSeries,
representativeBpFromSeries,
type BpGrade,
} from "./bp-grade";
import type { BpTargets } from "./bp-targets";
/**
@@ -109,9 +113,16 @@ export interface BpInTargetEnvelope {
* recency-weighted representative reading over the trailing window.
* Feeds the Health-Score BP pillar value, replacing the binary
* in-target rate AS THE SCORE. `null` when no pairs formed.
*
* v1.34.5 — the score and the basis that explains it travel as ONE
* object, from the one `gradeBpScoreFromSeries` call that produced
* both. The route re-runs this whole helper against the clinical
* targets when the user has a personal target; carrying the pair
* together is what makes it impossible for the explanation to belong
* to a different run than the number.
*/
gradedScore: number | null;
/** Same recency-weighted pair that `gradedScore` grades. */
graded: BpGrade | null;
/** Same recency-weighted pair that `graded` grades. */
representative: { sys: number; dia: number } | null;
/** Freshest paired reading in the trailing 90-day score window. */
last90LatestAt: Date | null;
@@ -377,7 +388,7 @@ async function computeFromRollups(
dia: pair.dia,
count: pair.perDayPairCount,
}));
const gradedScore = gradeBpScoreFromSeries({
const graded = gradeBpScoreFromSeries({
pairs: scorePairs,
target: targets,
now,
@@ -398,7 +409,7 @@ async function computeFromRollups(
priorYear,
path: "rollup",
rowCount,
gradedScore,
graded,
representative,
};
}
@@ -545,7 +556,7 @@ async function computeFromLive(
const scorePairs = collectBpPairs(sysData, diaData, userTz).filter(
(pair) => pair.at >= new Date(now.getTime() - 90 * DAY_MS) && pair.at < now,
);
const gradedScore = gradeBpScoreFromSeries({
const graded = gradeBpScoreFromSeries({
pairs: scorePairs,
target: targets,
now,
@@ -568,7 +579,7 @@ async function computeFromLive(
priorYear: windows.priorYear,
path: "live",
rowCount,
gradedScore,
graded,
representative,
};
}
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { gradeBpScore } from "@/lib/analytics/bp-grade";
import { computeActivityPillar } from "../activity";
import { computeAdiposityPillar } from "../adiposity";
import { computeBloodPressurePillar } from "../blood-pressure";
@@ -30,7 +31,11 @@ describe("reference-score pillars", () => {
const below = computeBloodPressurePillar({
...live,
pairCount: 11,
gradedScore: 85,
graded: gradeBpScore({
sys: 129,
dia: 79,
target: { sysLow: 120, sysHigh: 129, diaLow: 70, diaHigh: 79 },
}),
representative: { sys: 129, dia: 79 },
oldestAt: new Date("2026-07-01T08:00:00Z"),
latestAt: new Date("2026-07-27T08:00:00Z"),
@@ -44,7 +49,11 @@ describe("reference-score pillars", () => {
const eligible = computeBloodPressurePillar({
...live,
pairCount: 12,
gradedScore: 85,
graded: gradeBpScore({
sys: 129,
dia: 79,
target: { sysLow: 120, sysHigh: 129, diaLow: 70, diaHigh: 79 },
}),
representative: { sys: 129, dia: 79 },
oldestAt: new Date("2026-07-01T08:00:00Z"),
latestAt: new Date("2026-07-27T08:00:00Z"),
@@ -63,7 +72,11 @@ describe("reference-score pillars", () => {
const result = computeBloodPressurePillar({
...live,
pairCount: 12,
gradedScore: 85,
graded: gradeBpScore({
sys: 129,
dia: 79,
target: { sysLow: 120, sysHigh: 129, diaLow: 70, diaHigh: 79 },
}),
representative: { sys: 129, dia: 79 },
oldestAt: new Date("2026-07-01T08:00:00Z"),
latestAt: new Date("2026-07-27T08:00:00Z"),
+5 -2
View File
@@ -48,7 +48,7 @@ export function computeBloodPressurePillar(
if (
presentPairs < BLOOD_PRESSURE_MIN_PAIRS ||
input.gradedScore == null ||
input.graded == null ||
input.representative == null ||
input.latestAt == null
) {
@@ -81,7 +81,10 @@ export function computeBloodPressurePillar(
historyDays,
}),
value: {
score: input.gradedScore,
score: input.graded.score,
// Same object the score came out of, so the popover can only ever
// explain the number beside it.
scoreBasis: input.graded.basis,
observed: {
value: representativeSys,
unit: "mmHg",
+11 -5
View File
@@ -11,6 +11,7 @@ import { collectBpPairs } from "@/lib/analytics/bp-in-target";
import {
gradeBpScoreFromSeries,
representativeBpFromSeries,
type BpGrade,
} from "@/lib/analytics/bp-grade";
import type { BpTargets } from "@/lib/analytics/bp-targets";
import { resolveWeightTargetOverride } from "@/lib/analytics/effective-range";
@@ -86,7 +87,12 @@ interface ActivityRead {
interface ScoreBpEnvelope {
path: "rollup" | "live" | undefined;
last90Days: { pairs: number } | null;
gradedScore: number | null;
/**
* v1.34.5 — score AND basis as one value, straight off the envelope
* the route built. Never re-graded here: a second evaluation is how
* the popover would end up explaining a different number.
*/
graded: BpGrade | null;
representative: { sys: number; dia: number } | null;
last90EarliestAt: Date | null;
last90LatestAt: Date | null;
@@ -329,7 +335,7 @@ function scoreBpEnvelope(args: {
envelope: {
path: args.base.path,
last90Days: args.base.last90Days,
gradedScore: args.base.gradedScore,
graded: args.base.graded,
representative: args.base.representative,
last90EarliestAt: args.base.last90EarliestAt,
last90LatestAt: args.base.last90LatestAt,
@@ -352,7 +358,7 @@ function scoreBpEnvelope(args: {
envelope: {
path: undefined,
last90Days: null,
gradedScore: null,
graded: null,
representative: null,
last90EarliestAt: null,
last90LatestAt: null,
@@ -385,7 +391,7 @@ function scoreBpEnvelope(args: {
envelope: {
path: "live",
last90Days: { pairs: pairs.length },
gradedScore: target
graded: target
? gradeBpScoreFromSeries({
pairs,
target,
@@ -492,7 +498,7 @@ function scoreInputsFor(args: {
readFailed: args.bp.readFailed,
asOf,
pairCount: args.bp.last90Days?.pairs ?? 0,
gradedScore: args.bp.gradedScore,
graded: args.bp.graded,
representative: args.bp.representative,
oldestAt: args.bp.last90EarliestAt,
latestAt: args.bp.last90LatestAt,
+14 -1
View File
@@ -1,4 +1,5 @@
import type { BpTargets } from "@/lib/analytics/bp-targets";
import type { BpGrade, BpScoreBasis } from "@/lib/analytics/bp-grade";
import type {
Derived,
DerivedProvenanceSource,
@@ -56,6 +57,14 @@ export interface PillarValue {
reference: PillarReference;
/** Optional user-authored yardstick, displayed beside the scored reference. */
personalReference?: PillarReference;
/**
* Which input set this score and by how much, resolved on the server so
* no client ever re-derives it. Blood pressure only for now: it is the
* one pillar that scores the WORSE of two axes, so the number alone
* cannot say which one it came from. Optional by design — a pillar that
* has nothing of the kind to say says nothing.
*/
scoreBasis?: BpScoreBasis;
/** Score points that a weekly move must clear before it is narrated. */
noiseFloor: number;
/** Slow markers contribute to level but never to the weekly delta. */
@@ -125,7 +134,11 @@ export interface DomainReadState {
export interface BloodPressurePillarInput extends DomainReadState {
asOf: Date;
pairCount: number;
gradedScore: number | null;
/**
* The graded score together with the basis that produced it — one
* value, from one grading run against one set of targets.
*/
graded: BpGrade | null;
representative: { sys: number; dia: number } | null;
oldestAt: Date | null;
latestAt: Date | null;
@@ -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