From 64a92fd3929fa50a4fea7082d52c87b869d986d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 11:04:20 +0200 Subject: [PATCH 1/4] Publish which blood-pressure axis set the pillar score The score is the worse of the systolic and diastolic sub-scores, and until now nothing said which one it was or how far over its ceiling the reading sat. The grader now returns that basis together with the score, as one value, and it is carried as one value all the way to the pillar so an explanation can never belong to a different grading run than the number it explains. --- src/app/api/analytics/route.ts | 11 ++ src/lib/analytics/__tests__/bp-grade.test.ts | 65 +++++----- src/lib/analytics/bp-grade.ts | 114 +++++++++++++++--- src/lib/analytics/bp-in-target-fast-path.ts | 25 ++-- .../analytics/score/__tests__/pillars.test.ts | 19 ++- src/lib/analytics/score/blood-pressure.ts | 7 +- src/lib/analytics/score/reader.ts | 16 ++- src/lib/analytics/score/types.ts | 15 ++- 8 files changed, 207 insertions(+), 65 deletions(-) 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/lib/analytics/__tests__/bp-grade.test.ts b/src/lib/analytics/__tests__/bp-grade.test.ts index 209e5715b..7420484fd 100644 --- a/src/lib/analytics/__tests__/bp-grade.test.ts +++ b/src/lib/analytics/__tests__/bp-grade.test.ts @@ -22,49 +22,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 +82,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 +99,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 +118,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 +133,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 +178,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 +186,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 +227,6 @@ 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); }); }); 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; 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 2/4] 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); + }); +}); From cf7bb13ee8f8a170723cd865ccbb4b71cac2b055 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 11:08:54 +0200 Subject: [PATCH 3/4] Explain a blood-pressure score in its own popover Three lines now sit above the reference band: which axis set the number and how far it was from its ceiling, what the scale means at the ceiling and at 100, and that the pair shown is a recency-weighted average rather than a reading anyone took. In all six languages. --- ...alth-score-pillar-detail-bp-basis.test.tsx | 208 ++++++++++++++++++ src/lib/analytics/__tests__/bp-grade.test.ts | 52 +++++ 2 files changed, 260 insertions(+) create mode 100644 src/components/insights/__tests__/health-score-pillar-detail-bp-basis.test.tsx 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/lib/analytics/__tests__/bp-grade.test.ts b/src/lib/analytics/__tests__/bp-grade.test.ts index f3c330416..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, @@ -474,3 +476,53 @@ describe("gradeBpScoreFromSeries — the basis travels with the score", () => { 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"); + }, + ); +}); From 5784d5ec72a5fccf72a50ef86c52a89468187cc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 11:15:27 +0200 Subject: [PATCH 4/4] Prove the blood-pressure basis survives the assembly, not just both ends The grader returns the score and its basis together and the pillar builder copies both across, and each of those ends already had its own test. Neither sees what happens between them: the envelope hand-off, the reader's narrowing, the pillar list, the JSON that finally goes out. A field can be produced correctly, consumed correctly, and never reach anyone, which is exactly how a payload field shipped earlier this month and carried nothing on the wire. These tests read the assembled response and assert the basis that arrives, so a future hand-picked key list on that path fails here rather than in someone's browser. --- src/app/api/analytics/__tests__/route.test.ts | 180 ++++++++++++++++++ .../analytics-health-score.test.ts | 90 ++++++++- 2 files changed, 269 insertions(+), 1 deletion(-) 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/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