mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
Merge work/bp-score-basis
This commit is contained in:
commit
9982f87dc9
18 files changed
+1064
-70
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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,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),
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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