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;