From 60359dbcb7941bc17bd1aa5e1956deb731798d1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 7 Jun 2026 17:36:13 +0200 Subject: [PATCH] fix(health-score): grade the blood-pressure pillar by clinical proximity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The BP pillar value equalled the binary all-time in-target rate: the share of paired readings where both systolic and diastolic sat at or below the age-band ceiling. A borderline stage-1 profile (diastolic a few mmHg over the ceiling on most readings) collapsed the pillar to the teens even though the reading is "a bit high", not catastrophic — and improvement never surfaced because the rate is all-time and binary. Replace the binary rate AS THE SCORE with a smooth clinical-proximity grade. A recency-weighted representative reading (45-day half-life) is mapped per axis onto a 0-100 curve anchored on the same age-band ceiling the rest of the app uses — optimal ≈ 100, at the ceiling ≈ 85, then piecewise-linear down through the over-target bands; hypotension is penalised symmetrically below the clinical floors. The pillar takes the worse of the two axes, so an elevated diastolic counts without binary-zeroing the result. A 134/87 profile against a 129/79 ceiling now reads in the mid-50s. The in-target rate stays available as a secondary "X% of readings in range" stat. The healthScore.components.bp shape is unchanged — only the value moves. --- src/app/api/analytics/route.ts | 13 ++ src/lib/analytics/__tests__/bp-grade.test.ts | 120 ++++++++++++ .../__tests__/health-score-fast-path.test.ts | 68 +++++++ src/lib/analytics/bp-grade.ts | 184 ++++++++++++++++++ src/lib/analytics/bp-in-target-fast-path.ts | 29 +++ src/lib/analytics/bp-in-target.ts | 31 ++- src/lib/analytics/health-score-fast-path.ts | 24 +++ src/lib/analytics/health-score.ts | 38 +++- 8 files changed, 502 insertions(+), 5 deletions(-) create mode 100644 src/lib/analytics/__tests__/bp-grade.test.ts create mode 100644 src/lib/analytics/bp-grade.ts diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 3fb52d8f4..47dd2c292 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -258,6 +258,11 @@ async function buildAnalyticsResponse(user: AuthedUser) { // pillar compares the same window on both ends (the tile headline keeps // its 30-day scope; the score reads all-time per its documented contract). let windowsPriorWeekAllTime: number | null = null; + // v1.15.12 A1 — graded clinical-proximity BP score (recency-weighted + // representative reading) for the Health-Score BP pillar value, plus + // the prior-week run for the week-over-week delta. + let bpGradedScore: number | null = null; + let bpGradedScorePriorWeek: number | null = null; const bpTargets = getBpTargets(user.dateOfBirth); if (bpTargets) { const now = new Date(); @@ -309,6 +314,8 @@ async function buildAnalyticsResponse(user: AuthedUser) { bpInTargetPctPriorMonth = windows.priorMonth?.pct ?? null; bpInTargetPctPriorYear = windows.priorYear?.pct ?? null; bpInTargetPctPriorWeek = windowsPriorWeek.last30Days?.pct ?? null; + bpGradedScore = windows.gradedScore; + bpGradedScorePriorWeek = windowsPriorWeek.gradedScore; // The Health-Score BP pillar reads the all-time window (see the // `bpInTargetPctForScore` derivation below). Capture the prior-week // run's all-time pct too so the week-over-week delta compares the @@ -395,6 +402,12 @@ async function buildAnalyticsResponse(user: AuthedUser) { // same all-time window as the current snapshot so the delta is // apples-to-apples. bpInTargetPctPriorWeek: bpInTargetPctPriorWeekForScore, + // v1.15.12 A1 — graded clinical-proximity BP score drives the pillar + // VALUE; the binary in-target rate above only gates presence and is + // surfaced as a secondary stat. Closes the "borderline-stage-1 reads + // 16/100" trust bug. + bpGradedScore, + bpGradedScorePriorWeek, heightCm: user.heightCm ?? null, now: new Date(), coverage, diff --git a/src/lib/analytics/__tests__/bp-grade.test.ts b/src/lib/analytics/__tests__/bp-grade.test.ts new file mode 100644 index 000000000..19f9be680 --- /dev/null +++ b/src/lib/analytics/__tests__/bp-grade.test.ts @@ -0,0 +1,120 @@ +/** + * v1.15.12 A1 — unit pins for the graded BP pillar score. + * + * The maintainer's apps01 profile (under-65 ceiling 129/79, avg 134/87) + * is the canonical regression: the binary all-time in-target rate read + * ~10-16/100; the graded score must land in the borderline-stage-1 band + * (low-to-mid 50s), not in the catastrophic band. + */ +import { describe, expect, it } from "vitest"; +import { + gradeBpScore, + gradeBpScoreFromSeries, + type BpPairPoint, +} from "../bp-grade"; +import type { BpTargets } from "../bp-targets"; + +// Under-65 ESH band — the maintainer's age band. +const UNDER65: BpTargets = { + sysLow: 120, + sysHigh: 129, + diaLow: 70, + diaHigh: 79, +}; + +describe("gradeBpScore", () => { + it("grades the maintainer's 134/87 into the borderline band [45,60]", () => { + const score = gradeBpScore({ 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 }), + ).toBeGreaterThanOrEqual(85); + }); + + it("grades a textbook-normal 118/76 very high (≈100)", () => { + expect( + gradeBpScore({ 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 }), + ).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 }); + 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 }); + 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 }); + expect(at - justOver).toBeLessThanOrEqual(5); + expect(at).toBeGreaterThanOrEqual(justOver); + }); + + it("penalises hypotension symmetrically (below the clinical floor)", () => { + // 85/45 is below both floors (sys 90 / dia 50) — should not read ~100. + const low = gradeBpScore({ sys: 85, dia: 45, target: UNDER65 }); + expect(low).toBeLessThan(85); + }); +}); + +describe("gradeBpScoreFromSeries", () => { + const NOW = new Date("2026-06-07T12:00:00.000Z"); + const daysAgo = (n: number): Date => + new Date(NOW.getTime() - n * 24 * 60 * 60 * 1000); + + it("returns null for an empty series", () => { + expect( + gradeBpScoreFromSeries({ pairs: [], target: UNDER65, now: NOW }), + ).toBeNull(); + }); + + it("weights recent readings more — recent improvement lifts the score", () => { + // Old readings high, recent readings well-controlled. + const pairs: BpPairPoint[] = [ + { at: daysAgo(300), sys: 160, dia: 100 }, + { at: daysAgo(280), sys: 158, dia: 98 }, + { at: daysAgo(5), sys: 120, dia: 76 }, + { at: daysAgo(2), sys: 119, dia: 75 }, + { at: daysAgo(0), sys: 121, dia: 77 }, + ]; + const recencyWeighted = gradeBpScoreFromSeries({ + pairs, + target: UNDER65, + now: NOW, + }); + // A flat (unweighted) mean would be dragged toward ~145/90; the + // recency-weighted representative should read close to the recent + // well-controlled cluster. + expect(recencyWeighted).not.toBeNull(); + expect(recencyWeighted as number).toBeGreaterThanOrEqual(80); + }); + + it("matches the single-reading grade when every pair is today", () => { + const pairs: BpPairPoint[] = [ + { 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 })); + }); +}); diff --git a/src/lib/analytics/__tests__/health-score-fast-path.test.ts b/src/lib/analytics/__tests__/health-score-fast-path.test.ts index e0ff9b316..14ae10efc 100644 --- a/src/lib/analytics/__tests__/health-score-fast-path.test.ts +++ b/src/lib/analytics/__tests__/health-score-fast-path.test.ts @@ -666,4 +666,72 @@ describe("computeUserHealthScoreFastPath", () => { expect(result?.components.compliance.value).toBeGreaterThanOrEqual(90); }); }); + + /** + * v1.15.12 A1 — the graded clinical-proximity score, when supplied, + * becomes the BP pillar VALUE; the binary in-target rate only gates + * presence. A "borderline-stage-1" profile that read ~16/100 under the + * old binary contract must now read in the graded band. + */ + describe("graded BP pillar (A1)", () => { + it("uses bpGradedScore as the pillar value, not the binary rate", async () => { + const coverage = new Map(); + FULLY_COVERED.mockReturnValue(false); + PROBE.mockResolvedValue(coverage); + + const now = new Date("2026-06-07T12:00:00.000Z"); + // Live fallback — no weight/mood/med data; only BP signal present. + MEASUREMENT_FIND_MANY + .mockResolvedValueOnce([]) // weight raw read (live fallback) + .mockResolvedValueOnce([]); // BP-SYS source attribution + + const result = await computeUserHealthScoreFastPath({ + userId: "user-graded-bp", + // Binary all-time rate is catastrophic (the old pillar value)… + bpInTargetPct: 16, + // …but the graded clinical-proximity score says borderline. + bpGradedScore: 55, + heightCm: null, + now, + coverage, + }); + + expect(result).not.toBeNull(); + // Pillar reads the graded score, NOT the binary 16. + expect(result?.components.bp.value).toBe(55); + }); + + it("keeps the pillar absent when no BP readings exist (presence gate)", async () => { + const coverage = new Map(); + FULLY_COVERED.mockReturnValue(false); + PROBE.mockResolvedValue(coverage); + + const now = new Date("2026-06-07T12:00:00.000Z"); + MEASUREMENT_FIND_MANY + .mockResolvedValueOnce([]) // weight raw read + .mockResolvedValueOnce([]); // BP-SYS source attribution + // Mood present so the score is computable even without BP. + MOOD_FIND_MANY.mockResolvedValue( + Array.from({ length: 6 }, (_v, i) => ({ + score: 4, + moodLoggedAt: new Date(now.getTime() - i * 24 * 60 * 60 * 1000), + })), + ); + + const result = await computeUserHealthScoreFastPath({ + userId: "user-no-bp", + // No paired BP readings → rate is null → pillar absent even if a + // stray graded score were supplied. + bpInTargetPct: null, + bpGradedScore: 80, + heightCm: null, + now, + coverage, + }); + + expect(result).not.toBeNull(); + expect(result?.components.bp.value).toBeNull(); + expect(result?.components.bp.weight).toBe(0); + }); + }); }); diff --git a/src/lib/analytics/bp-grade.ts b/src/lib/analytics/bp-grade.ts new file mode 100644 index 000000000..4411c1b15 --- /dev/null +++ b/src/lib/analytics/bp-grade.ts @@ -0,0 +1,184 @@ +/** + * v1.15.12 A1 — graded blood-pressure pillar score. + * + * The Personal Health Score's BP pillar previously equalled + * `bpInTargetRate`: the share of paired readings where BOTH systolic and + * diastolic sat at-or-below the age-band ceiling, scored binary per + * reading over the all-time window. That metric is honest as a "% of + * readings in range" stat, but it is the wrong thing to use AS the + * pillar score: a user who averages 134/87 (borderline stage-1, every + * reading slightly over the diastolic ceiling) collapses to ~10-16/100 + * because nearly every reading fails the binary check — even though + * clinically they are "a bit high", not catastrophic. Improvement also + * never shows, because the rate is all-time and a binary cliff. + * + * This module replaces the binary rate AS THE SCORE with a smooth + * clinical-proximity grade. A representative BP (recency-weighted toward + * the last weeks) is mapped to a 0-100 score per axis, and the pillar + * takes the WORSE of the two sub-scores — so an elevated diastolic + * counts against the score (it should) without binary-zeroing it. + * + * The curve is anchored on the SAME age-band ceiling the rest of the app + * uses (`BpTargets.sysHigh` / `diaHigh` from `bp-targets.ts`). No new + * thresholds are invented; the score bands are derived as offsets from + * the configured ceiling: + * + * offset from ceiling → axis score + * ≤ −12 (well below) 100 (optimal — comfortably in band) + * 0 (at the ceiling) 85 (at goal — "well controlled") + * +5 65 + * +10 52 + * +20 40 + * +40 18 + * ≥ +60 5 (far above — uncontrolled) + * + * Piecewise-linear between anchors — no hard cliffs, so a one-mmHg + * change moves the score by a small, continuous amount. The diastolic + * and systolic axes share the same offset→score table, so each axis is + * judged against its own ceiling. + * + * Hypotension is penalised symmetrically: below the clinical floors + * (`sys ≥ 90`, `dia ≥ 50`, mirroring `isBpReadingInTarget`) the distance + * below the floor is fed through the same over-target curve, so a + * symptomatically low reading scores like an equally-distant high one + * rather than reading as "perfect". + * + * Pure & deterministic — fully unit-tested in `__tests__/bp-grade.test.ts`. + */ +import type { BpTargets } from "./bp-targets"; +import { + SYS_HYPOTENSION_FLOOR, + DIA_HYPOTENSION_FLOOR, +} from "./bp-in-target"; + +/** + * Offset-from-ceiling → axis-score anchors. Offsets are in mmHg; the + * first anchor is the "optimal" plateau (comfortably below the ceiling), + * the last is the "uncontrolled" floor. Interpolated piecewise-linearly. + */ +const AXIS_ANCHORS: ReadonlyArray = [ + [-12, 100], + [0, 85], + [5, 65], + [10, 52], + [20, 40], + [40, 18], + [60, 5], +] as const; + +/** + * Piecewise-linear interpolation of an over-/under-target `offset` + * (mmHg relative to the axis ceiling) onto the 0-100 axis score. + * Clamps to the first / last anchor outside the table range. + */ +function interpolateAxisScore(offset: number): number { + const first = AXIS_ANCHORS[0]; + const last = AXIS_ANCHORS[AXIS_ANCHORS.length - 1]; + if (offset <= first[0]) return first[1]; + if (offset >= last[0]) return last[1]; + for (let i = 0; i < AXIS_ANCHORS.length - 1; i++) { + const [o1, s1] = AXIS_ANCHORS[i]; + const [o2, s2] = AXIS_ANCHORS[i + 1]; + if (offset >= o1 && offset <= o2) { + const fraction = (offset - o1) / (o2 - o1); + return s1 + fraction * (s2 - s1); + } + } + return last[1]; +} + +/** + * Score one BP axis against its ceiling. + * + * - Above the floor: offset = `value - ceiling`. Negative offsets sit in + * the optimal plateau, positive ones climb the over-target curve. + * - Below the hypotension floor: the distance below the floor is fed + * through the same over-target curve so a low excursion is penalised + * symmetrically to an equally-distant high one. + */ +function gradeAxis(value: number, ceiling: number, floor: number): number { + if (value < floor) { + return interpolateAxisScore(floor - value); + } + return interpolateAxisScore(value - ceiling); +} + +/** + * Map a representative blood-pressure reading to a smooth 0-100 score. + * + * 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. + */ +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)))); +} + +/** + * One timestamped BP pair for the recency-weighted representative. + */ +export interface BpPairPoint { + /** Wall-clock of the reading (ms epoch ordering only — no tz math). */ + at: Date; + sys: number; + dia: number; +} + +/** + * Collapse a series of paired BP readings into a single recency-weighted + * representative `{ sys, dia }`, then grade it. + * + * Recent readings should dominate so that genuine improvement surfaces in + * the score rather than being diluted by years of older readings. Each + * pair is weighted by an exponential decay on its age relative to `now`, + * with a half-life of `HALF_LIFE_DAYS` (45 d): a reading 45 days old + * counts half as much as today's, 90 days old a quarter, etc. The + * weighted mean of sys and dia is the representative reading. + * + * Returns `null` when the series is empty (caller leaves the pillar + * absent — the redistribution helper then drops the BP weight cleanly). + */ +const HALF_LIFE_DAYS = 45; +const DAY_MS = 24 * 60 * 60 * 1000; + +export function gradeBpScoreFromSeries(input: { + pairs: ReadonlyArray; + target: BpTargets; + now: Date; +}): number | null { + const { pairs, target, now } = input; + if (pairs.length === 0) return null; + + const nowMs = now.getTime(); + let weightSum = 0; + let sysWeighted = 0; + let diaWeighted = 0; + for (const p of pairs) { + const ageDays = Math.max(0, (nowMs - p.at.getTime()) / DAY_MS); + const weight = Math.pow(0.5, ageDays / HALF_LIFE_DAYS); + weightSum += weight; + sysWeighted += weight * p.sys; + diaWeighted += weight * p.dia; + } + if (weightSum === 0) return null; + + const sys = sysWeighted / weightSum; + const dia = diaWeighted / weightSum; + return gradeBpScore({ sys, dia, target }); +} diff --git a/src/lib/analytics/bp-in-target-fast-path.ts b/src/lib/analytics/bp-in-target-fast-path.ts index cd6147477..8aa90a244 100644 --- a/src/lib/analytics/bp-in-target-fast-path.ts +++ b/src/lib/analytics/bp-in-target-fast-path.ts @@ -60,10 +60,12 @@ import { } from "@/lib/rollups/measurement-coverage"; import { isNearUtc } from "@/lib/tz/resolver"; import { + collectBpPairs, computeBpInTargetWindows, isBpReadingInTarget, type BpReading, } from "./bp-in-target"; +import { gradeBpScoreFromSeries } from "./bp-grade"; import type { BpTargets } from "./bp-targets"; /** @@ -85,6 +87,13 @@ export interface BpInTargetEnvelope { * requests to specific outlier accounts. */ rowCount: number; + /** + * v1.15.12 A1 — graded clinical-proximity BP score (0..100) from a + * 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. + */ + gradedScore: number | null; } /** @@ -304,6 +313,17 @@ async function computeFromRollups( }, }); + // v1.15.12 A1 — graded BP pillar score from a recency-weighted + // representative reading. Each per-day pair (the day's MEAN sys/dia) + // contributes weighted by its age; recent readings dominate so genuine + // improvement surfaces in the score instead of being diluted by years + // of older readings. + const gradedScore = gradeBpScoreFromSeries({ + pairs: pairsByDay.map((p) => ({ at: p.day, sys: p.sys, dia: p.dia })), + target: targets, + now, + }); + return { last7Days: last7, last30Days: last30, @@ -312,6 +332,7 @@ async function computeFromRollups( priorYear, path: "rollup", rowCount, + gradedScore, }; } @@ -410,6 +431,13 @@ async function computeFromLive( }); const windows = computeBpInTargetWindows(sysData, diaData, targets, now); + // v1.15.12 A1 — graded BP pillar score from the recency-weighted + // representative of every accepted per-event pair over the read window. + const gradedScore = gradeBpScoreFromSeries({ + pairs: collectBpPairs(sysData, diaData), + target: targets, + now, + }); return { last7Days: windows.last7Days, last30Days: windows.last30Days, @@ -418,6 +446,7 @@ async function computeFromLive( priorYear: windows.priorYear, path: "live", rowCount, + gradedScore, }; } diff --git a/src/lib/analytics/bp-in-target.ts b/src/lib/analytics/bp-in-target.ts index bfbdde9dc..12e0ceaed 100644 --- a/src/lib/analytics/bp-in-target.ts +++ b/src/lib/analytics/bp-in-target.ts @@ -57,8 +57,8 @@ export interface BpReading { * shock-onset thresholds; well below any reasonable normotensive * resting reading. */ -const SYS_HYPOTENSION_FLOOR = 90; -const DIA_HYPOTENSION_FLOOR = 50; +export const SYS_HYPOTENSION_FLOOR = 90; +export const DIA_HYPOTENSION_FLOOR = 50; import { toBerlinDayKey } from "@/lib/tz/resolver"; @@ -167,6 +167,33 @@ export function computeBpInTargetPct( }; } +/** + * v1.15.12 A1 — collect the accepted SYS/DIA pairs (same pairing rules + * as `computeBpInTargetPct`) as timestamped points, for the graded BP + * pillar score. Returns `{ at, sys, dia }` per accepted pair using the + * SYS reading's timestamp as the pair anchor — recency weighting only + * needs ordering, not sub-second precision. Empty when no pairs form. + */ +export function collectBpPairs( + sysSeries: BpReading[], + diaSeries: BpReading[], +): Array<{ at: Date; sys: number; dia: number }> { + if (sysSeries.length === 0 || diaSeries.length === 0) return []; + const SAME_SESSION_MS = 5 * 60 * 1000; + const out: Array<{ at: Date; sys: number; dia: number }> = []; + for (const sys of sysSeries) { + const match = findClosestDia(sys, diaSeries); + if (!match) continue; + const sameSession = match.deltaMs <= SAME_SESSION_MS; + const sameBerlinDay = + !sameSession && + toBerlinDayKey(sys.measuredAt) === toBerlinDayKey(match.dia.measuredAt); + if (!sameSession && !sameBerlinDay) continue; + out.push({ at: sys.measuredAt, sys: sys.value, dia: match.dia.value }); + } + return out; +} + /** * v1.4.18 A1 → v1.4.19 A1 — windowed in-target shares for the dashboard * tile. diff --git a/src/lib/analytics/health-score-fast-path.ts b/src/lib/analytics/health-score-fast-path.ts index 41ed7fca6..b20902e20 100644 --- a/src/lib/analytics/health-score-fast-path.ts +++ b/src/lib/analytics/health-score-fast-path.ts @@ -114,6 +114,20 @@ export interface HealthScoreFastPathInput { * additive. */ bpInTargetPctPriorWeek?: number | null; + /** + * v1.15.12 A1 — graded clinical-proximity BP score (0..100) from a + * recency-weighted representative reading. When supplied it becomes + * the BP pillar VALUE; the binary `bpInTargetPct` only decides pillar + * presence and is surfaced as a secondary stat. Omit (legacy callers) + * to keep the pre-v1.15.12 behaviour where the rate IS the score. + */ + bpGradedScore?: number | null; + /** + * v1.15.12 A1 — prior-week graded BP score for the week-over-week + * delta. Omit to fall back to `bpGradedScore` (BP cancels out of the + * delta), mirroring the `bpInTargetPctPriorWeek` fallback. + */ + bpGradedScorePriorWeek?: number | null; heightCm: number | null; now: Date; /** @@ -456,6 +470,9 @@ export async function computeUserHealthScoreFastPath( const current: HealthScoreInput = { bpInTargetRate: bpInTargetPct, + // v1.15.12 A1 — graded BP score drives the pillar value when present + // (the binary rate above only gates presence + is the secondary stat). + bpGradedScore: input.bpGradedScore ?? null, weightSeriesLast30d, weightTargetKg: fallbackTarget, moodEntriesLast30d: moodSeriesLast30d, @@ -484,6 +501,13 @@ export async function computeUserHealthScoreFastPath( // stays single-pass. const previous: HealthScoreInput = { bpInTargetRate: bpInTargetPctPriorWeek, + // v1.15.12 A1 — prior-week graded score so the week-over-week delta + // reflects graded BP movement. Falls back to the current graded + // score (delta cancels to zero for BP) when the caller omits it. + bpGradedScore: + input.bpGradedScorePriorWeek === undefined + ? (input.bpGradedScore ?? null) + : input.bpGradedScorePriorWeek, weightSeriesLast30d: weightSeriesPrev30d, weightTargetKg: fallbackTarget, moodEntriesLast30d: moodSeriesPrev30d, diff --git a/src/lib/analytics/health-score.ts b/src/lib/analytics/health-score.ts index 9b0ca94f8..f2af6973b 100644 --- a/src/lib/analytics/health-score.ts +++ b/src/lib/analytics/health-score.ts @@ -8,7 +8,11 @@ * * Formula (when every component is present): * score = round( - * 0.30 * bpInTargetRate // % of paired BP readings inside band + * 0.30 * bpGradedScore // v1.15.12 A1 — graded clinical + * // proximity of a recency-weighted + * // representative BP (was the binary + * // bpInTargetRate, kept as a secondary + * // "X% in range" stat) * + 0.20 * weightTrendAlignment // 0..100, see weightTrendAlignment() * + 0.20 * moodStability // 0..100, see moodStability() * + 0.30 * complianceRate // mean of medication compliance30 @@ -79,8 +83,26 @@ export interface HealthScoreSourceAttribution { } export interface HealthScoreInput { - /** All-time BP-in-target rate, already 0..100 (or null). */ + /** + * All-time BP-in-target rate, already 0..100 (or null). + * + * v1.15.12 A1 — this is now a SECONDARY honest stat ("X% of readings + * in range"), no longer the pillar score itself. When `bpGradedScore` + * is supplied it drives the pillar value; `bpInTargetRate` is kept so + * the pillar can still be computed (presence check) and the rate + * surfaced alongside. Legacy callers that omit `bpGradedScore` keep + * the pre-v1.15.12 behaviour where the rate IS the score. + */ bpInTargetRate: number | null; + /** + * v1.15.12 A1 — graded clinical-proximity BP score, 0..100 (or null). + * Computed from a recency-weighted representative reading via + * `gradeBpScoreFromSeries`. When present it becomes the BP pillar + * value; the binary `bpInTargetRate` only decides presence and is + * surfaced as a secondary stat. Closes the "borderline-stage-1 reads + * 16/100" trust bug. + */ + bpGradedScore?: number | null; /** Weight readings over the last 30 days, ascending or unsorted (we sort). */ weightSeriesLast30d: Array<{ date: string; kg: number }>; /** User's stored target weight in kg, if any. */ @@ -328,8 +350,18 @@ export function computeHealthScore( input: HealthScoreInput, previous?: HealthScoreInput, ): HealthScoreResult { + // v1.15.12 A1 — the BP pillar value is the graded clinical-proximity + // score when supplied; otherwise it falls back to the legacy binary + // in-target rate (pre-v1.15.12 contract). The pillar is present only + // when the user actually has BP data — gated on `bpInTargetRate` + // (which the caller computes from paired readings) so a graded score + // can never resurrect a pillar that has no underlying readings. const bpValue = - input.bpInTargetRate === null ? null : clampToHundred(input.bpInTargetRate); + input.bpInTargetRate === null + ? null + : input.bpGradedScore !== undefined && input.bpGradedScore !== null + ? clampToHundred(input.bpGradedScore) + : clampToHundred(input.bpInTargetRate); const target = deriveWeightTarget(input.weightTargetKg); const weightValue = weightTrendAlignment(input.weightSeriesLast30d, target);