diff --git a/messages/de.json b/messages/de.json index 62e7dfdaa..b8e7cd85c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2301,6 +2301,11 @@ "fastingObserved": "Nüchternglukose: {value} {unit}", "fastingReference": "{low} bis {high} {unit} nüchtern", "personalReference": "Dein Ziel: {value}", + "bpBasisAbove": "Ausschlaggebend: {axis}, etwa {offset} mmHg über der Obergrenze von {boundary} mmHg.", + "bpBasisBelowFloor": "Ausschlaggebend: {axis}, etwa {offset} mmHg unter der Grenze für niedrigen Blutdruck von {boundary} mmHg.", + "bpBasisInBand": "Beide Werte liegen auf oder unter ihren Referenz-Obergrenzen.", + "bpScale": "An der Obergrenze liegt der Wert bei 85; 100 bedeutet rund 12 mmHg darunter.", + "bpRepresentative": "Beruht auf einem Durchschnitt der letzten {days} Tage, der neuere Messungen stärker gewichtet, und kann deshalb von jeder einzelnen Messung abweichen.", "source": "Quelle: {source}, Stand {date}", "observedActivity": "{value} Schritte pro Tag", "observedSleep": "{value} Minuten pro Nacht", diff --git a/messages/en.json b/messages/en.json index 9c9b3db53..c47ffad22 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2301,6 +2301,11 @@ "fastingObserved": "Fasting glucose: {value} {unit}", "fastingReference": "{low} to {high} {unit} fasting", "personalReference": "Your target: {value}", + "bpBasisAbove": "{axis} sets this score: about {offset} mmHg over its ceiling of {boundary} mmHg.", + "bpBasisBelowFloor": "{axis} sets this score: about {offset} mmHg under the low-pressure floor of {boundary} mmHg.", + "bpBasisInBand": "Both values are at or below their reference ceilings.", + "bpScale": "At the ceiling the score is 85; 100 means roughly 12 mmHg below it.", + "bpRepresentative": "Based on a recency-weighted average of the last {days} days, so it may not match any single reading.", "source": "Source: {source}, as of {date}", "observedActivity": "{value} steps per day", "observedSleep": "{value} minutes per night", diff --git a/messages/es.json b/messages/es.json index b9efc2096..ef080d594 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2301,6 +2301,11 @@ "fastingObserved": "Glucosa en ayunas: {value} {unit}", "fastingReference": "{low} a {high} {unit} en ayunas", "personalReference": "Tu objetivo: {value}", + "bpBasisAbove": "Determinante: {axis}, unos {offset} mmHg por encima de su límite de {boundary} mmHg.", + "bpBasisBelowFloor": "Determinante: {axis}, unos {offset} mmHg por debajo del límite de presión baja de {boundary} mmHg.", + "bpBasisInBand": "Ambos valores están en su límite de referencia o por debajo.", + "bpScale": "En el límite la puntuación es 85; 100 significa unos 12 mmHg por debajo.", + "bpRepresentative": "Se basa en un promedio de los últimos {days} días que pondera más las mediciones recientes, por lo que puede no coincidir con ninguna medición concreta.", "source": "Fuente: {source}, a fecha de {date}", "observedActivity": "{value} pasos al día", "observedSleep": "{value} minutos por noche", diff --git a/messages/fr.json b/messages/fr.json index 69258a2dd..e229906e1 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2301,6 +2301,11 @@ "fastingObserved": "Glycémie à jeun : {value} {unit}", "fastingReference": "{low} à {high} {unit} à jeun", "personalReference": "Votre objectif : {value}", + "bpBasisAbove": "Déterminant : {axis}, environ {offset} mmHg au-dessus de son plafond de {boundary} mmHg.", + "bpBasisBelowFloor": "Déterminant : {axis}, environ {offset} mmHg sous le seuil de basse pression de {boundary} mmHg.", + "bpBasisInBand": "Les deux valeurs sont à leur plafond de référence ou en dessous.", + "bpScale": "Au plafond, le score est de 85 ; 100 correspond à environ 12 mmHg en dessous.", + "bpRepresentative": "Repose sur une moyenne des {days} derniers jours qui pèse davantage les mesures récentes, et peut donc ne correspondre à aucune mesure isolée.", "source": "Source : {source}, au {date}", "observedActivity": "{value} pas par jour", "observedSleep": "{value} minutes par nuit", diff --git a/messages/it.json b/messages/it.json index b14a7c282..3974df24a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2301,6 +2301,11 @@ "fastingObserved": "Glicemia a digiuno: {value} {unit}", "fastingReference": "Da {low} a {high} {unit} a digiuno", "personalReference": "Il tuo obiettivo: {value}", + "bpBasisAbove": "Determinante: {axis}, circa {offset} mmHg sopra il suo limite di {boundary} mmHg.", + "bpBasisBelowFloor": "Determinante: {axis}, circa {offset} mmHg sotto la soglia di pressione bassa di {boundary} mmHg.", + "bpBasisInBand": "Entrambi i valori sono pari o inferiori ai loro limiti di riferimento.", + "bpScale": "Al limite il punteggio è 85; 100 significa circa 12 mmHg più in basso.", + "bpRepresentative": "Si basa su una media degli ultimi {days} giorni che pesa di più le misurazioni recenti, quindi può non corrispondere a nessuna singola misurazione.", "source": "Fonte: {source}, al {date}", "observedActivity": "{value} passi al giorno", "observedSleep": "{value} minuti per notte", diff --git a/messages/pl.json b/messages/pl.json index 51f7d586f..c49b93879 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2301,6 +2301,11 @@ "fastingObserved": "Glukoza na czczo: {value} {unit}", "fastingReference": "Od {low} do {high} {unit} na czczo", "personalReference": "Twój cel: {value}", + "bpBasisAbove": "Decyduje: {axis}, około {offset} mmHg powyżej swojej granicy {boundary} mmHg.", + "bpBasisBelowFloor": "Decyduje: {axis}, około {offset} mmHg poniżej granicy niskiego ciśnienia {boundary} mmHg.", + "bpBasisInBand": "Obie wartości są na poziomie swoich granic referencyjnych lub poniżej.", + "bpScale": "Przy granicy wynik to 85; 100 oznacza około 12 mmHg poniżej.", + "bpRepresentative": "Opiera się na średniej z ostatnich {days} dni, która mocniej waży nowsze pomiary, więc może nie odpowiadać żadnemu pojedynczemu pomiarowi.", "source": "Źródło: {source}, stan na {date}", "observedActivity": "{value} kroków dziennie", "observedSleep": "{value} minut na noc", diff --git a/src/app/api/analytics/__tests__/route.test.ts b/src/app/api/analytics/__tests__/route.test.ts index 8f7b11e3e..44a4903f7 100644 --- a/src/app/api/analytics/__tests__/route.test.ts +++ b/src/app/api/analytics/__tests__/route.test.ts @@ -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 { + const res = await ( + GET as unknown as (...args: never[]) => Promise + )(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { healthScore: { pillars: WirePillar[] } | null }; + }; + const pillar = body.data.healthScore?.pillars.find( + (p) => p.id === "BLOOD_PRESSURE", + ); + if (!pillar) throw new Error("no BLOOD_PRESSURE pillar in the payload"); + return pillar; + } + + it("carries the basis onto the pillar the client actually receives", async () => { + // 137/91 against the 129/79 band: systolic sits 8 over its ceiling and + // grades 57, diastolic sits 12 over and grades 50. The pillar takes the + // worse of the two, so diastolic is the axis that set the number. + seedBpSeries({ sys: 137, dia: 91, pairs: 14 }); + const pillar = await readBloodPressurePillar(); + + expect(pillar.result.status).toBe("ok"); + expect(pillar.result.value?.observed.label).toBe("137/91 mmHg"); + expect(pillar.result.value?.score).toBe(50); + expect(pillar.result.value?.scoreBasis).toEqual({ + axis: "diastolic", + relation: "above_ceiling", + offsetMmHg: 12, + boundaryMmHg: CLINICAL.diaHigh, + }); + }); + + it("says the reading is in band when it is", async () => { + // 126/68 clears both ceilings, so no axis is over anything and the + // relation has to say so rather than reporting a distance from a ceiling + // the reading never reached. Systolic is the nearer of the two to its + // own ceiling here, so this also puts the other axis on the wire. + seedBpSeries({ sys: 126, dia: 68, pairs: 14 }); + const pillar = await readBloodPressurePillar(); + + expect(pillar.result.value?.observed.label).toBe("126/68 mmHg"); + expect(pillar.result.value?.scoreBasis).toEqual({ + axis: "systolic", + relation: "in_band", + offsetMmHg: 3, + boundaryMmHg: CLINICAL.sysHigh, + }); + }); + + it("omits the basis entirely when the pillar has no score to explain", async () => { + // Eleven pairs is one short of the floor, so the pillar is insufficient + // and carries no value at all — the popover must fall back to the + // shipped lines rather than to an explanation of a number nobody has. + seedBpSeries({ sys: 137, dia: 91, pairs: 11 }); + const pillar = await readBloodPressurePillar(); + + expect(pillar.result.status).toBe("insufficient"); + expect(pillar.result.value).toBeUndefined(); + }); + + /** + * The coupling the two-ended tests cannot see. + * + * A user with a personal target gets the whole BP helper run twice: once + * against their own band for the in-target percentages on the dashboard, + * once against the clinical band for the score. The score comes from the + * second run. So does the basis, and it has to, because the two runs + * disagree on every field of it — different binding axis, different + * ceiling, different distance. Picking the score off one run and the + * basis off the other reads as correct in review and produces a popover + * that explains a number the user is not looking at. + * + * The fixture is chosen so the two runs share NOTHING: 137/91 against the + * personal 135/95 band binds on systolic 2 over 135 and scores 77; against + * the clinical 129/79 band it binds on diastolic 12 over 79 and scores 50. + */ + it("takes the basis from the same run as the score when a personal target differs", async () => { + signInWith({ + BLOOD_PRESSURE_SYS: { min: 110, max: 135 }, + BLOOD_PRESSURE_DIA: { min: 65, max: 95 }, + }); + seedBpSeries({ sys: 137, dia: 91, pairs: 14 }); + const pillar = await readBloodPressurePillar(); + + // The personal band is genuinely in play — this account really does take + // the route's second-run branch, so the assertions below are about the + // coupling and not about a user who has no personal target at all. + expect(pillar.result.value?.personalReference?.label).toBe( + "110–135/65–95 mmHg", + ); + + expect(pillar.result.value?.score).toBe(50); + expect(pillar.result.value?.scoreBasis).toEqual({ + axis: "diastolic", + relation: "above_ceiling", + offsetMmHg: 12, + boundaryMmHg: CLINICAL.diaHigh, + }); + // Stated negatively as well: nothing from the personal run may leak in. + expect(pillar.result.value?.scoreBasis?.boundaryMmHg).not.toBe(135); + }); +}); diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index aebd44534..ca9b9c5ad 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -360,6 +360,17 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) { }), ], ); + // The pillar is graded against the CLINICAL band even when the user + // keeps a personal target (that band is the number's identity; the + // personal one is displayed beside it and scores nothing). So when the + // two differ we re-run the whole helper against the clinical targets + // and the score comes from that second run. + // + // v1.34.5 — the "why this number" basis rides the same envelope, so it + // is the same run, the same representative pair and the same ceilings + // by construction. Assign envelopes WHOLE here; picking `graded.score` + // off one run and a basis off the other would let the popover explain + // a number nobody is looking at, and it would look correct in review. if (bpTargetsEqual(bpTargets, clinicalBpTargets)) { bpEnvelope = windows; bpEnvelopePriorWeek = windowsPriorWeek; diff --git a/src/components/insights/__tests__/health-score-pillar-detail-bp-basis.test.tsx b/src/components/insights/__tests__/health-score-pillar-detail-bp-basis.test.tsx new file mode 100644 index 000000000..6c952ca25 --- /dev/null +++ b/src/components/insights/__tests__/health-score-pillar-detail-bp-basis.test.tsx @@ -0,0 +1,208 @@ +/** + * v1.34.5 — the route from the blood-pressure score to its reason. + * + * A pillar reading 56 gave no way to find out why: the popover held the + * reference band, the personal target and the source, and nothing said + * which of the two axes had set the number, how far over its ceiling that + * axis sat, or that a reading exactly at the ceiling reads 85 rather than + * 100. These pin the three lines that answer it, in the two locales the + * maintainer reads, against the real bundles. + * + * The lines live behind a Radix popover, which only mounts once opened, + * so they are asserted through the pure formatter the card feeds it — + * the same strings the card shows, not a second copy of the copy. + */ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider, useTranslations } from "@/lib/i18n/context"; +import type { Locale } from "@/lib/i18n/config"; +import type { BpScoreBasis } from "@/lib/analytics/bp-grade"; +import type { ScorePillarResult } from "@/lib/analytics/score/types"; +import { + pillarDetailLines, + type Translate, +} from "../health-score-pillar-detail"; + +function translator(locale: Locale = "en"): Translate { + let captured: Translate | null = null; + function Probe() { + captured = useTranslations().t; + return null; + } + renderToStaticMarkup( + + + , + ); + if (!captured) throw new Error("translator probe did not run"); + return captured; +} + +function bloodPressure(scoreBasis?: BpScoreBasis): ScorePillarResult { + return { + id: "BLOOD_PRESSURE", + domain: "cardiometabolic", + result: { + status: "ok", + value: { + score: 56, + ...(scoreBasis ? { scoreBasis } : {}), + observed: { + value: 137, + unit: "mmHg", + label: "137/87 mmHg", + asOf: "2026-07-30T08:00:00.000Z", + sources: ["MANUAL"], + }, + reference: { + kind: "clinical-threshold", + low: 120, + high: 129, + label: "120 to 129/70 to 79 mmHg", + source: "ESH 2023", + }, + noiseFloor: 1, + deltaEligible: true, + deltaIdentity: "graded_bp_pair_series", + }, + coverage: { + requiredInputs: 12, + presentInputs: 12, + historyDays: 60, + missing: [], + }, + confidence: { score: 100, band: "high" }, + provenance: { + inputs: ["BLOOD_PRESSURE"], + source: "live", + windowDays: 90, + computedAt: "2026-07-30T12:00:00.000Z", + }, + }, + }; +} + +const ABOVE_CEILING: BpScoreBasis = { + axis: "systolic", + relation: "above_ceiling", + offsetMmHg: 8, + boundaryMmHg: 129, +}; + +describe("blood-pressure score basis in the popover", () => { + it("leads with the binding axis, its distance and its ceiling", () => { + const lines = pillarDetailLines(bloodPressure(ABOVE_CEILING), { + t: translator(), + glucoseUnit: "mg/dL", + }); + expect(lines[0]).toBe( + "Systolic sets this score: about 8 mmHg over its ceiling of 129 mmHg.", + ); + }); + + it("names the diastolic axis when that is the one that bound", () => { + const lines = pillarDetailLines( + bloodPressure({ + axis: "diastolic", + relation: "above_ceiling", + offsetMmHg: 9, + boundaryMmHg: 79, + }), + { t: translator(), glucoseUnit: "mg/dL" }, + ); + expect(lines[0]).toBe( + "Diastolic sets this score: about 9 mmHg over its ceiling of 79 mmHg.", + ); + }); + + it("measures a hypotensive reading from the low-pressure floor", () => { + const lines = pillarDetailLines( + bloodPressure({ + axis: "systolic", + relation: "below_floor", + offsetMmHg: 4, + boundaryMmHg: 90, + }), + { t: translator(), glucoseUnit: "mg/dL" }, + ); + expect(lines[0]).toBe( + "Systolic sets this score: about 4 mmHg under the low-pressure floor of 90 mmHg.", + ); + }); + + it("says both axes are in band without naming a distance", () => { + const lines = pillarDetailLines( + bloodPressure({ + axis: "systolic", + relation: "in_band", + offsetMmHg: 5, + boundaryMmHg: 129, + }), + { t: translator(), glucoseUnit: "mg/dL" }, + ); + expect(lines[0]).toBe( + "Both values are at or below their reference ceilings.", + ); + }); + + it("explains the scale and the averaged reading, then the shipped lines", () => { + const lines = pillarDetailLines(bloodPressure(ABOVE_CEILING), { + t: translator(), + glucoseUnit: "mg/dL", + }); + expect(lines[1]).toBe( + "At the ceiling the score is 85; 100 means roughly 12 mmHg below it.", + ); + // The window comes off the pillar's own provenance, so the sentence + // cannot drift from the window the score was actually read over. + expect(lines[2]).toBe( + "Based on a recency-weighted average of the last 90 days, so it may not match any single reading.", + ); + expect(lines).toHaveLength(5); + expect(lines[3]).toContain("ESH 2023"); + expect(lines[4]).toContain("Source: MANUAL"); + }); + + it("reads in German too", () => { + const lines = pillarDetailLines(bloodPressure(ABOVE_CEILING), { + t: translator("de"), + glucoseUnit: "mg/dL", + }); + expect(lines[0]).toBe( + "Ausschlaggebend: Systolisch, etwa 8 mmHg über der Obergrenze von 129 mmHg.", + ); + expect(lines[1]).toBe( + "An der Obergrenze liegt der Wert bei 85; 100 bedeutet rund 12 mmHg darunter.", + ); + expect(lines[2]).toBe( + "Beruht auf einem Durchschnitt der letzten 90 Tage, der neuere Messungen stärker gewichtet, und kann deshalb von jeder einzelnen Messung abweichen.", + ); + }); + + it("says nothing at all when the server published no basis", () => { + // The field is optional and blood-pressure-only: an older payload, or + // a pillar that has nothing of the kind to say, must fall back to the + // shipped lines rather than to an empty or invented sentence. + const lines = pillarDetailLines(bloodPressure(), { + t: translator(), + glucoseUnit: "mg/dL", + }); + expect(lines).toHaveLength(2); + expect(lines[0]).toContain("ESH 2023"); + expect(lines.join("\n")).not.toContain("mmHg over its ceiling"); + }); + + it("leaves other pillars untouched", () => { + const glycaemia: ScorePillarResult = { + ...bloodPressure(ABOVE_CEILING), + id: "GLYCAEMIA", + }; + const lines = pillarDetailLines(glycaemia, { + t: translator(), + glucoseUnit: "mg/dL", + }); + expect(lines.join("\n")).not.toContain("sets this score"); + expect(lines.join("\n")).not.toContain("At the ceiling"); + }); +}); diff --git a/src/components/insights/health-score-pillar-detail.ts b/src/components/insights/health-score-pillar-detail.ts index 27cb4c5fa..f947bd106 100644 --- a/src/components/insights/health-score-pillar-detail.ts +++ b/src/components/insights/health-score-pillar-detail.ts @@ -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), }), diff --git a/src/lib/analytics/__tests__/bp-grade.test.ts b/src/lib/analytics/__tests__/bp-grade.test.ts index 209e5715b..b0c6e8b6a 100644 --- a/src/lib/analytics/__tests__/bp-grade.test.ts +++ b/src/lib/analytics/__tests__/bp-grade.test.ts @@ -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(); + 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 } }; + const line = bundle.insights.healthScore.bpScale; + expect(line).toContain(String(AT_CEILING_SCORE)); + expect(line).toContain(String(OPTIMAL_OFFSET_MMHG)); + expect(line).toContain("100"); + }, + ); +}); diff --git a/src/lib/analytics/bp-grade.ts b/src/lib/analytics/bp-grade.ts index a1842eba2..bc809c859 100644 --- a/src/lib/analytics/bp-grade.ts +++ b/src/lib/analytics/bp-grade.ts @@ -84,6 +84,49 @@ const HYPO_ANCHORS: ReadonlyArray = [ [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; target: BpTargets; now: Date; -}): number | null { +}): BpGrade | null { const representative = representativeBpFromSeries(input); return representative ? gradeBpScore({ ...representative, target: input.target }) diff --git a/src/lib/analytics/bp-in-target-fast-path.ts b/src/lib/analytics/bp-in-target-fast-path.ts index d754c3626..431f7def9 100644 --- a/src/lib/analytics/bp-in-target-fast-path.ts +++ b/src/lib/analytics/bp-in-target-fast-path.ts @@ -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, }; } diff --git a/src/lib/analytics/score/__tests__/pillars.test.ts b/src/lib/analytics/score/__tests__/pillars.test.ts index 2681dd4a9..15fd2eb38 100644 --- a/src/lib/analytics/score/__tests__/pillars.test.ts +++ b/src/lib/analytics/score/__tests__/pillars.test.ts @@ -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"), diff --git a/src/lib/analytics/score/blood-pressure.ts b/src/lib/analytics/score/blood-pressure.ts index 0fc5d3534..c1445cec2 100644 --- a/src/lib/analytics/score/blood-pressure.ts +++ b/src/lib/analytics/score/blood-pressure.ts @@ -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", diff --git a/src/lib/analytics/score/reader.ts b/src/lib/analytics/score/reader.ts index f02f7a3a9..bf2e6ddfd 100644 --- a/src/lib/analytics/score/reader.ts +++ b/src/lib/analytics/score/reader.ts @@ -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, diff --git a/src/lib/analytics/score/types.ts b/src/lib/analytics/score/types.ts index 3d4e9b7ec..086749b95 100644 --- a/src/lib/analytics/score/types.ts +++ b/src/lib/analytics/score/types.ts @@ -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; diff --git a/tests/integration/analytics-health-score.test.ts b/tests/integration/analytics-health-score.test.ts index f74f69b6e..43a8c0fd3 100644 --- a/tests/integration/analytics-health-score.test.ts +++ b/tests/integration/analytics-health-score.test.ts @@ -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)( + 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