From f4f1c0c8b088e4a099103bd6cbae22505752e9da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 13:07:09 +0200 Subject: [PATCH] fix(health-score): build the BP pillar from one shared input builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Personal Health Score was assembled differently by its two callers. The analytics route (the Insights card) graded the BP pillar off one window and passed the prior-week values for the week-over-week delta; the dashboard snapshot (the hero ring) graded it off a different window and omitted the prior-week values, so the ring's delta zeroed BP out and the pillar's presence gate could flip. The same user could see two different scores and two different bands on two surfaces. Extract `buildHealthScoreBpInputs` — one pure builder that turns the current and prior-week in-target envelopes into the BP-pillar inputs (the 90-day rate with an all-time fallback, the graded clinical-proximity score, and the prior-week counterparts for the delta). Both callers now run the same two in-target probes and feed the pillar through this builder, so the window choice, the fallback, the graded-score wiring and the delta inputs are identical by construction and can never drift between the surfaces again. The dashboard ring now runs the prior-week probe it previously skipped, so its week-over-week delta reflects BP movement to match the Insights card. --- src/app/api/analytics/route.ts | 87 +++++------------- .../__tests__/health-score-inputs.test.ts | 89 +++++++++++++++++++ src/lib/analytics/health-score-inputs.ts | 78 ++++++++++++++++ src/lib/dashboard/__tests__/snapshot.test.ts | 4 +- src/lib/dashboard/snapshot.ts | 61 ++++++++----- 5 files changed, 233 insertions(+), 86 deletions(-) create mode 100644 src/lib/analytics/__tests__/health-score-inputs.test.ts create mode 100644 src/lib/analytics/health-score-inputs.ts diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 84df0a8ea..0e0438e3f 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -12,8 +12,12 @@ import { reconstructSleepNights } from "@/lib/analytics/sleep-night"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import { ensureUserRollupsFresh } from "@/lib/rollups/measurement-rollups"; import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage"; -import { computeBpInTargetFastPath } from "@/lib/analytics/bp-in-target-fast-path"; +import { + computeBpInTargetFastPath, + type BpInTargetEnvelope, +} from "@/lib/analytics/bp-in-target-fast-path"; import { computeUserHealthScoreFastPath } from "@/lib/analytics/health-score-fast-path"; +import { buildHealthScoreBpInputs } from "@/lib/analytics/health-score-inputs"; import { computeCorrelationHypothesesFastPath } from "@/lib/analytics/correlations-fast-path"; export const dynamic = "force-dynamic"; @@ -242,8 +246,6 @@ async function buildAnalyticsResponse(user: AuthedUser) { let bpInTargetPct: number | null = null; let bpInTargetPct7d: number | null = null; let bpInTargetPct30d: number | null = null; - // v1.17 W1d — the canonical 90-day window for the headline / score / coach. - let bpInTargetPct90d: number | null = null; let bpInTargetPctAllTime: number | null = null; /** * v1.4.22 W5 reconcile (Code-H2) — period-aligned prior-window @@ -255,25 +257,13 @@ async function buildAnalyticsResponse(user: AuthedUser) { */ let bpInTargetPctPriorMonth: number | null = null; let bpInTargetPctPriorYear: number | null = null; - /** - * v1.4.38 — prior-week BP-in-target pct so the Health-Score - * week-over-week delta reflects BP movement. Pre-v1.4.38 the - * Health-Score helper pinned both windows to the same value, so the - * BP pillar always zeroed out of the delta. Computed by running a - * second `computeBpInTargetFastPath` against `now - 7d` and reading - * the resulting `last30Days.pct`. - */ - let bpInTargetPctPriorWeek: number | null = null; - // v1.17 W1d — prior-week 90-day BP pct. Feeds the Health-Score delta so - // its BP pillar compares the same 90-day window on both ends, identical - // to the headline scope. (Pre-v1.17 the score read all-time; W1d - // unifies headline + score + coach on 90 days.) - let windowsPriorWeekForScore: 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; + // v1.17 W1b — hold the current + prior-week BP envelopes so the shared + // Health-Score input builder grades the pillar off the identical shape the + // dashboard snapshot uses (one builder, no per-surface assembly drift). + // The graded score + prior-week BP figures the score consumes are read off + // these envelopes inside `buildHealthScoreBpInputs`. + let bpEnvelope: BpInTargetEnvelope | null = null; + let bpEnvelopePriorWeek: BpInTargetEnvelope | null = null; const bpTargets = getBpTargets(user.dateOfBirth); if (bpTargets) { const now = new Date(); @@ -318,24 +308,17 @@ async function buildAnalyticsResponse(user: AuthedUser) { userTz, }), ]); + bpEnvelope = windows; + bpEnvelopePriorWeek = windowsPriorWeek; // v1.17 W1d — the BD-Zielbereich headline standardises on the // trailing-90-day window (labelled "· 90 T" in the tile). All-time // remains carried below for the BP detail page's long view only. bpInTargetPct = windows.last90Days?.pct ?? null; bpInTargetPct7d = windows.last7Days?.pct ?? null; bpInTargetPct30d = windows.last30Days?.pct ?? null; - bpInTargetPct90d = windows.last90Days?.pct ?? null; bpInTargetPctAllTime = windows.allTime?.pct ?? null; bpInTargetPctPriorMonth = windows.priorMonth?.pct ?? null; bpInTargetPctPriorYear = windows.priorYear?.pct ?? null; - bpInTargetPctPriorWeek = windowsPriorWeek.last90Days?.pct ?? null; - bpGradedScore = windows.gradedScore; - bpGradedScorePriorWeek = windowsPriorWeek.gradedScore; - // v1.17 W1d — the Health-Score BP pillar reads the same 90-day window - // as the headline (see `bpInTargetPctForScore` below). Capture the - // prior-week run's 90-day pct so the week-over-week delta compares the - // same window on both ends. - windowsPriorWeekForScore = windowsPriorWeek.last90Days?.pct ?? null; } // Per-context glucose summaries (canonical mg/dL). @@ -393,41 +376,17 @@ async function buildAnalyticsResponse(user: AuthedUser) { // 2-column projection from `measurements` for the ingest-path // pills. Falls back to the legacy 37-day raw read on partial / // missing coverage. Path annotate sits on `meta.healthScore.path`. - // The Health-Score BP pillar reads the **all-time** in-target rate, not - // the BD-Zielbereich tile headline (which v1.4.x deliberately scopes to - // the trailing 30 days). Feeding the tile's `bpInTargetPct` (last-30d) - // here regressed the pillar to null for any account with BP history but - // no readings inside the trailing month — the score then dropped the - // 0.30-weight BP pillar entirely and rendered "no rating" despite the - // user having BP data. `health-score.ts` documents the field as the - // all-time rate, so route the all-time window through; fall back to the - // 30-day window only when the all-time window is itself null (it never - // is when 30-day has data), so a brand-new account is unaffected. - // v1.17 W1d — the Health-Score BP pillar reads the SAME 90-day window as - // the BD-Zielbereich tile headline, so the dashboard ring and the - // insights card grade the BP pillar off one number. Fall back to the - // all-time rate only when the 90-day window is itself null so a - // sparse-but-historical account still surfaces a BP pillar. - const bpInTargetPctForScore = bpInTargetPct90d ?? bpInTargetPctAllTime; - const bpInTargetPctPriorWeekForScore = - windowsPriorWeekForScore ?? bpInTargetPctPriorWeek; + // + // v1.17 W1b — the BP-pillar inputs come from the ONE shared + // `buildHealthScoreBpInputs` builder the dashboard snapshot also uses, so + // the ring and the insights card grade the pillar off identical inputs + // (same 90-day window via W1d, same all-time fallback, same graded score, + // same prior-week delta values). The hand-rolled per-surface assembly that + // let the two diverge is gone. + const bpInputs = buildHealthScoreBpInputs(bpEnvelope, bpEnvelopePriorWeek); const healthScore = await computeUserHealthScoreFastPath({ userId: user.id, - bpInTargetPct: bpInTargetPctForScore, - // v1.4.38 — feed the prior-week BP pct into the Health-Score - // helper's previous-window snapshot so the week-over-week delta - // reflects BP movement instead of zeroing out of the BP pillar. - // Computed alongside the current windows via a second - // `computeBpInTargetFastPath` run anchored at `now - 7d`. Uses the - // 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, + ...bpInputs, heightCm: user.heightCm ?? null, now: new Date(), coverage, diff --git a/src/lib/analytics/__tests__/health-score-inputs.test.ts b/src/lib/analytics/__tests__/health-score-inputs.test.ts new file mode 100644 index 000000000..3975800bb --- /dev/null +++ b/src/lib/analytics/__tests__/health-score-inputs.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from "vitest"; +import { buildHealthScoreBpInputs } from "../health-score-inputs"; +import type { BpInTargetEnvelope } from "../bp-in-target-fast-path"; + +function env(partial: Partial): BpInTargetEnvelope { + return { + last7Days: null, + last30Days: null, + last90Days: null, + allTime: null, + priorMonth: null, + priorYear: null, + path: "live", + rowCount: 0, + gradedScore: null, + ...partial, + }; +} + +describe("buildHealthScoreBpInputs — single Health-Score BP input builder", () => { + /** + * v1.17 W1b — the dashboard ring and the insights card must grade the BP + * pillar off identical inputs. This pins the contract: given the same + * current + prior-week envelopes, the builder produces one BP-input shape, + * so whichever surface calls it sees the same pillar presence, value and + * delta. + */ + it("reads the 90-day window for the pillar rate, NOT 30-day or all-time", () => { + const current = env({ + last30Days: { pct: 80, pairs: 10 }, + last90Days: { pct: 65, pairs: 40 }, + allTime: { pct: 50, pairs: 200 }, + gradedScore: 72, + }); + const out = buildHealthScoreBpInputs(current, null); + expect(out.bpInTargetPct).toBe(65); + expect(out.bpGradedScore).toBe(72); + }); + + it("falls back to all-time only when the 90-day window is null", () => { + const current = env({ + last90Days: null, + allTime: { pct: 42, pairs: 100 }, + gradedScore: 60, + }); + const out = buildHealthScoreBpInputs(current, null); + expect(out.bpInTargetPct).toBe(42); + }); + + it("uses the prior-week 90-day window for the delta inputs", () => { + const current = env({ + last90Days: { pct: 65, pairs: 40 }, + gradedScore: 72, + }); + const priorWeek = env({ + last90Days: { pct: 58, pairs: 38 }, + gradedScore: 68, + }); + const out = buildHealthScoreBpInputs(current, priorWeek); + expect(out.bpInTargetPctPriorWeek).toBe(58); + expect(out.bpGradedScorePriorWeek).toBe(68); + }); + + it("collapses every field to null when the current envelope is absent", () => { + const out = buildHealthScoreBpInputs(null, null); + expect(out).toEqual({ + bpInTargetPct: null, + bpInTargetPctPriorWeek: null, + bpGradedScore: null, + bpGradedScorePriorWeek: null, + }); + }); + + it("pillar presence is identical for two callers passing the same envelopes", () => { + // Same data → same `bpInTargetPct !== null` presence verdict on both + // surfaces. A user with BP history outside 30 days but inside 90 days + // keeps the pillar on BOTH the dashboard ring and the insights card. + const current = env({ + last30Days: null, // no readings in the last month + last90Days: { pct: 55, pairs: 12 }, + allTime: { pct: 48, pairs: 300 }, + gradedScore: 64, + }); + const a = buildHealthScoreBpInputs(current, null); + const b = buildHealthScoreBpInputs(current, null); + expect(a).toEqual(b); + expect(a.bpInTargetPct).not.toBeNull(); + }); +}); diff --git a/src/lib/analytics/health-score-inputs.ts b/src/lib/analytics/health-score-inputs.ts new file mode 100644 index 000000000..b81821791 --- /dev/null +++ b/src/lib/analytics/health-score-inputs.ts @@ -0,0 +1,78 @@ +/** + * v1.17 W1b — single Health-Score BP-pillar input builder. + * + * The Personal Health Score is computed by `computeUserHealthScoreFastPath` + * from two server callers: the analytics route (the Insights health-score + * card) and the dashboard snapshot (the hero ring). Before this builder the + * two callers assembled the BP-pillar inputs by hand and DIVERGED: + * + * - the analytics route fed the BP pillar from the all-time in-target rate + * and passed the prior-week values for the week-over-week delta; + * - the dashboard snapshot fed the pillar from the trailing-30-day rate and + * omitted the prior-week values entirely (so the ring's delta zeroed BP + * out and the pillar's presence gate flipped on a user with BP history + * but no readings in the last month). + * + * Same user, two different scores and two different bands. v1.17 W1d already + * unified the WINDOW (both read the trailing-90-day rate); this builder + * unifies the ASSEMBLY so the window choice, the all-time fallback, the + * graded-score wiring and the prior-week delta inputs can never drift between + * the two surfaces again. Both callers pass the identical + * `BpInTargetEnvelope` pair through here and consume the result verbatim. + * + * Pure & deterministic — no I/O. The callers own the + * `computeBpInTargetFastPath` reads (current + prior-week) and hand the two + * envelopes in. + */ +import type { BpInTargetEnvelope } from "./bp-in-target-fast-path"; + +/** + * The BP-pillar slice of `HealthScoreFastPathInput`. Both Health-Score + * callers spread this into their `computeUserHealthScoreFastPath` call so the + * pillar is graded off identical inputs regardless of surface. + */ +export interface HealthScoreBpInputs { + bpInTargetPct: number | null; + bpInTargetPctPriorWeek: number | null; + bpGradedScore: number | null; + bpGradedScorePriorWeek: number | null; +} + +/** + * Build the BP-pillar inputs from the current-window and prior-week + * `computeBpInTargetFastPath` envelopes. + * + * - The pillar rate (presence gate + secondary stat) reads the canonical + * trailing-90-day window (W1d), falling back to the all-time rate only when + * the 90-day window is itself null so a sparse-but-historical account keeps + * its BP pillar. + * - The prior-week rate uses the prior-week run's 90-day window so the + * week-over-week delta compares the same window on both ends. + * - The graded clinical-proximity score (the pillar VALUE) and its prior-week + * counterpart come straight off the envelopes. + * + * `null` for the current envelope (no BP targets / no readings) collapses + * every field to `null`, which the score helper reads as "pillar absent". + */ +export function buildHealthScoreBpInputs( + current: BpInTargetEnvelope | null, + priorWeek: BpInTargetEnvelope | null, +): HealthScoreBpInputs { + if (!current) { + return { + bpInTargetPct: null, + bpInTargetPctPriorWeek: null, + bpGradedScore: null, + bpGradedScorePriorWeek: null, + }; + } + const bpInTargetPct = current.last90Days?.pct ?? current.allTime?.pct ?? null; + const bpInTargetPctPriorWeek = + priorWeek?.last90Days?.pct ?? priorWeek?.allTime?.pct ?? null; + return { + bpInTargetPct, + bpInTargetPctPriorWeek, + bpGradedScore: current.gradedScore, + bpGradedScorePriorWeek: priorWeek?.gradedScore ?? null, + }; +} diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index 564a6ee0f..4cb75dae7 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -258,7 +258,9 @@ describe("buildDashboardSnapshot — per-type thick-phase gate", () => { expect(snap.extras).not.toBeNull(); expect(snap.extras!.bpInTargetPct).toBe(80); expect(snap.healthScore).toEqual({ score: 71, band: "yellow", delta: 3 }); - expect(computeBpInTargetFastPath).toHaveBeenCalledTimes(1); + // v1.17 W1b — two runs (current + prior-week), identical to the analytics + // route, so the ring's delta reflects BP movement. + expect(computeBpInTargetFastPath).toHaveBeenCalledTimes(2); expect(computeUserHealthScoreFastPath).toHaveBeenCalledTimes(1); }); diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 3650de8c0..4152b0af0 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -46,7 +46,11 @@ import { type DataSummary, } from "@/lib/analytics/trends"; import { getBpTargets } from "@/lib/analytics/bp-targets"; -import { computeBpInTargetFastPath } from "@/lib/analytics/bp-in-target-fast-path"; +import { + computeBpInTargetFastPath, + type BpInTargetEnvelope, +} from "@/lib/analytics/bp-in-target-fast-path"; +import { buildHealthScoreBpInputs } from "@/lib/analytics/health-score-inputs"; import { probeRollupCoverage, type RollupCoverageMap, @@ -440,22 +444,40 @@ async function buildExtras( let bpInTargetPct: number | null = null; let bpInTargetPct7d: number | null = null; let bpInTargetPct30d: number | null = null; - // v1.17 W1d — canonical 90-day headline / score window. - let bpInTargetPct90d: number | null = null; let bpInTargetPctAllTime: number | null = null; let bpInTargetPctPriorMonth: number | null = null; let bpInTargetPctPriorYear: number | null = null; - let bpGradedScore: number | null = null; + // v1.17 W1b — hold the current + prior-week BP envelopes so the shared + // Health-Score input builder grades the pillar off the identical shape the + // analytics route uses. + let bpEnvelope: BpInTargetEnvelope | null = null; + let bpEnvelopePriorWeek: BpInTargetEnvelope | null = null; const bpTargets = getBpTargets(user.dateOfBirth); if (bpTargets) { - const windows = await computeBpInTargetFastPath({ - userId: user.id, - targets: bpTargets, - now, - coverage, - userTz, - }); + // v1.17 W1b — two runs (current + prior-week), identical to the + // analytics route, so the dashboard ring's week-over-week delta reflects + // BP movement instead of zeroing it out. Both reuse the already-probed + // coverage map and share the rollup/live branch decision. + const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + const [windows, windowsPriorWeek] = await Promise.all([ + computeBpInTargetFastPath({ + userId: user.id, + targets: bpTargets, + now, + coverage, + userTz, + }), + computeBpInTargetFastPath({ + userId: user.id, + targets: bpTargets, + now: sevenDaysAgo, + coverage, + userTz, + }), + ]); + bpEnvelope = windows; + bpEnvelopePriorWeek = windowsPriorWeek; // v1.17 W1d — the headline standardises on the trailing-90-day // window (labelled "· 90 T" in the tile), identical to the analytics // route, so the dashboard tile and the insights surface never narrate @@ -464,28 +486,25 @@ async function buildExtras( bpInTargetPct = windows.last90Days?.pct ?? null; bpInTargetPct7d = windows.last7Days?.pct ?? null; bpInTargetPct30d = windows.last30Days?.pct ?? null; - bpInTargetPct90d = windows.last90Days?.pct ?? null; bpInTargetPctAllTime = windows.allTime?.pct ?? null; bpInTargetPctPriorMonth = windows.priorMonth?.pct ?? null; bpInTargetPctPriorYear = windows.priorYear?.pct ?? null; - bpGradedScore = windows.gradedScore ?? null; } // Health score — reuses the BP windows captured above plus the // already-probed coverage map (no second probe). Score + band + // delta only; the component breakdown stays off this wire. // - // v1.17 W1b/W1d — the BP pillar reads the SAME 90-day window as the - // analytics route, so the dashboard ring and the insights card compute - // the score from identical inputs (closing the dashboard-vs-insights - // score divergence). Fall back to all-time only when the 90-day window - // is null so a sparse-but-historical account keeps its BP pillar. - const bpInTargetPctForScore = bpInTargetPct90d ?? bpInTargetPctAllTime; + // v1.17 W1b — the BP-pillar inputs come from the ONE shared + // `buildHealthScoreBpInputs` builder the analytics route also uses, so the + // ring and the insights card grade the pillar off identical inputs (same + // 90-day window via W1d, same all-time fallback, same graded score, same + // prior-week delta values). Closes the dashboard-vs-insights divergence. + const bpInputs = buildHealthScoreBpInputs(bpEnvelope, bpEnvelopePriorWeek); const scoreResult = await time("healthScore", () => computeUserHealthScoreFastPath({ userId: user.id, - bpInTargetPct: bpInTargetPctForScore, - bpGradedScore, + ...bpInputs, heightCm: user.heightCm, now, coverage,