fix(health-score): grade the blood-pressure pillar by clinical proximity

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-07 17:36:13 +02:00
1 parent 2851776af6
commit 60359dbcb7
8 files changed
+502 -5

No files matched your search

+13
View File
@@ -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,
@@ -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 }));
});
});
@@ -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<string, boolean>();
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<string, boolean>();
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);
});
});
});
+184
View File
@@ -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<readonly [offset: number, score: number]> = [
[-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<BpPairPoint>;
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 });
}
@@ -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,
};
}
+29 -2
View File
@@ -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.
@@ -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,
+35 -3
View File
@@ -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);