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,