mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
2851776af6
commit
60359dbcb7
8 files changed
+502
-5
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user