From 0ce40b09502f010354d7977e56c6fb405e00d73d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 11:07:39 +0200 Subject: [PATCH] Say why the blood-pressure pillar scores what it does The score popover now opens with three sentences before the reference line: which axis set the number and how far it sat from its boundary, what the scale means at that boundary, and that the pair behind it is a recency-weighted average rather than a single reading someone took. The lines read the basis the grader already publishes, so nothing here recomputes anything. A pillar that arrives without a basis contributes no lines at all instead of an empty one. --- messages/de.json | 5 + messages/en.json | 5 + messages/es.json | 5 + messages/fr.json | 5 + messages/it.json | 5 + messages/pl.json | 5 + .../insights/health-score-pillar-detail.ts | 58 ++++- src/lib/analytics/__tests__/bp-grade.test.ts | 244 ++++++++++++++++++ 8 files changed, 328 insertions(+), 4 deletions(-) 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/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 7420484fd..f3c330416 100644 --- a/src/lib/analytics/__tests__/bp-grade.test.ts +++ b/src/lib/analytics/__tests__/bp-grade.test.ts @@ -230,3 +230,247 @@ describe("gradeBpScoreFromSeries", () => { 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); + }); +});