From 114db6bbcaf4a65e930bc961eee1c5e9048ed8fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 13:02:17 +0200 Subject: [PATCH] fix(blood-pressure): unify in-target window on 90 days across headline, score, and tile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "BD im Zielbereich" share was narrated on three different windows on the same screen: the dashboard headline read the trailing 30 days, the dashboard Health-Score BP pillar read all-time, and the analytics route's pillar read all-time while its headline read 30 days. A user could see one in-target rate on the tile and a pillar graded off a different window directly above it. Standardise the headline, the Health-Score BP pillar input, and the week-over-week delta on a single trailing-90-day window everywhere — a span wide enough to stay stable yet recent enough to track current control. `computeBpInTargetWindows` and the rollup fast-path now both surface a `last90Days` window; the analytics route and the dashboard snapshot route the headline and the pillar through it, and both feed the pillar from the identical value so the dashboard ring and the insights card can never disagree. Label the window in the tile ("BD-Ziel · 90 T") so the scope is visible; the 30-day figure stays as the short-horizon caption and the all-time aggregate stays carried for the detail view. Visible-number change is expected: the headline now reflects the quarter rather than the month. --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + src/app/api/analytics/route.ts | 36 ++++++++---- src/app/page.tsx | 6 +- .../analytics/__tests__/bp-in-target.test.ts | 58 +++++++++++++++++++ src/lib/analytics/bp-in-target-fast-path.ts | 11 ++++ src/lib/analytics/bp-in-target.ts | 17 ++++++ src/lib/dashboard/__tests__/snapshot.test.ts | 13 ++++- src/lib/dashboard/snapshot.ts | 19 +++++- 13 files changed, 150 insertions(+), 16 deletions(-) diff --git a/messages/de.json b/messages/de.json index 94442b3f6..019dcdcde 100644 --- a/messages/de.json +++ b/messages/de.json @@ -326,6 +326,7 @@ "stepsShort": "Schritte", "vo2MaxShort": "VO₂ max", "bpInTargetShort": "BD-Ziel", + "bpInTargetWindow90": "BD-Ziel · 90 T", "medications": "Medikamente", "quickAddMeasurement": "Messung erfassen", "quickAddMood": "Stimmung erfassen", diff --git a/messages/en.json b/messages/en.json index febabae4b..cf984502e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -326,6 +326,7 @@ "stepsShort": "Steps", "vo2MaxShort": "VO₂ max", "bpInTargetShort": "BP in Target", + "bpInTargetWindow90": "BP in Target · 90d", "medications": "Medications", "quickAddMeasurement": "Log measurement", "quickAddMood": "Log mood", diff --git a/messages/es.json b/messages/es.json index bad70d032..7d69659f0 100644 --- a/messages/es.json +++ b/messages/es.json @@ -326,6 +326,7 @@ "stepsShort": "Pasos", "vo2MaxShort": "VO₂ máx", "bpInTargetShort": "PA-objetivo", + "bpInTargetWindow90": "PA-objetivo · 90 d", "medications": "Medicamentos", "quickAddMeasurement": "Registrar medición", "quickAddMood": "Registrar estado de ánimo", diff --git a/messages/fr.json b/messages/fr.json index 5efaddac7..7b2c9bd70 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -326,6 +326,7 @@ "stepsShort": "Pas", "vo2MaxShort": "VO₂ max", "bpInTargetShort": "TA-cible", + "bpInTargetWindow90": "TA-cible · 90 j", "medications": "Médicaments", "quickAddMeasurement": "Saisir une mesure", "quickAddMood": "Saisir une humeur", diff --git a/messages/it.json b/messages/it.json index 7c064c30f..91f9469e4 100644 --- a/messages/it.json +++ b/messages/it.json @@ -326,6 +326,7 @@ "stepsShort": "Passi", "vo2MaxShort": "VO₂ max", "bpInTargetShort": "PA-target", + "bpInTargetWindow90": "PA-target · 90 gg", "medications": "Farmaci", "quickAddMeasurement": "Registra misurazione", "quickAddMood": "Registra umore", diff --git a/messages/pl.json b/messages/pl.json index bb5cab1b5..f1f976d49 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -326,6 +326,7 @@ "stepsShort": "Kroki", "vo2MaxShort": "VO₂ max", "bpInTargetShort": "RR-cel", + "bpInTargetWindow90": "RR-cel · 90 d", "medications": "Leki", "quickAddMeasurement": "Zarejestruj pomiar", "quickAddMood": "Zarejestruj nastrój", diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index e706750fc..84df0a8ea 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -242,6 +242,8 @@ 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 @@ -262,10 +264,11 @@ async function buildAnalyticsResponse(user: AuthedUser) { * the resulting `last30Days.pct`. */ let bpInTargetPctPriorWeek: number | null = null; - // All-time prior-week BP pct — feeds the Health-Score delta so its BP - // 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.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. @@ -315,20 +318,24 @@ async function buildAnalyticsResponse(user: AuthedUser) { userTz, }), ]); - bpInTargetPct = windows.last30Days?.pct ?? null; + // 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.last30Days?.pct ?? null; + bpInTargetPctPriorWeek = windowsPriorWeek.last90Days?.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 + // 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. - windowsPriorWeekAllTime = windowsPriorWeek.allTime?.pct ?? null; + windowsPriorWeekForScore = windowsPriorWeek.last90Days?.pct ?? null; } // Per-context glucose summaries (canonical mg/dL). @@ -396,9 +403,14 @@ async function buildAnalyticsResponse(user: AuthedUser) { // 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. - const bpInTargetPctForScore = bpInTargetPctAllTime ?? bpInTargetPct; + // 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 = - windowsPriorWeekAllTime ?? bpInTargetPctPriorWeek; + windowsPriorWeekForScore ?? bpInTargetPctPriorWeek; const healthScore = await computeUserHealthScoreFastPath({ userId: user.id, bpInTargetPct: bpInTargetPctForScore, diff --git a/src/app/page.tsx b/src/app/page.tsx index ba7c5126e..8eded4b07 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1098,7 +1098,11 @@ export default function DashboardPage() { node: ( { expect(result.allTime).toBeNull(); }); + /** + * v1.17 W1d — the BD-Zielbereich headline, the Health-Score BP pillar + * and the coach grounding number standardise on a **90-day** window. + * The helper must surface a `last90Days` window distinct from both the + * 30-day and the all-time aggregate so the route can route the headline + * through it while leaving all-time available for the detail page. + */ + it("returns a last90Days window distinct from 30-day and all-time", () => { + // 10 pairs inside 30d (50 % in target), 10 pairs at 45-60 days old + // (all OUT of target), 20 pairs older than 90 days (all OUT). + const sys: Array> = []; + const dia: Array> = []; + // 0-30d: 5 IN, 5 OUT. + for (let i = 0; i < 10; i++) { + const at = new Date( + NOW_19.getTime() - (i + 0.5) * 24 * 60 * 60 * 1000, + ).toISOString(); + const inTarget = i % 2 === 0; + sys.push(reading(at, inTarget ? 122 : 145)); + dia.push(reading(at, inTarget ? 75 : 95)); + } + // 45-60d (inside 90d, outside 30d): 10 OUT. + for (let i = 0; i < 10; i++) { + const at = new Date( + NOW_19.getTime() - (45 + i) * 24 * 60 * 60 * 1000, + ).toISOString(); + sys.push(reading(at, 160)); + dia.push(reading(at, 100)); + } + // >90d: 20 OUT. + for (let i = 0; i < 20; i++) { + const at = new Date( + NOW_19.getTime() - (100 + i) * 24 * 60 * 60 * 1000, + ).toISOString(); + sys.push(reading(at, 160)); + dia.push(reading(at, 100)); + } + + const result = computeBpInTargetWindows(sys, dia, TARGETS_UNDER_65, NOW_19); + + // 30d = 5 / 10 = 50 %. + expect(result.last30Days).toEqual({ pct: 50, pairs: 10 }); + // 90d = 5 / 20 = 25 % (10 in-target-eligible + 10 OUT from 45-60d). + expect(result.last90Days).not.toBeNull(); + expect(result.last90Days!.pairs).toBe(20); + expect(result.last90Days!.pct).toBe(Math.round((5 / 20) * 100)); + // all-time = 5 / 40 ≈ 13 %. + expect(result.allTime!.pairs).toBe(40); + // The three windows must be genuinely different numbers. + expect(result.last90Days!.pct).not.toBe(result.last30Days!.pct); + expect(result.last90Days!.pct).not.toBe(result.allTime!.pct); + }); + + it("returns null last90Days when both series are empty", () => { + const result = computeBpInTargetWindows([], [], TARGETS_UNDER_65, NOW_19); + expect(result.last90Days).toBeNull(); + }); + it("regression: the live tenant's 30-day fixture mirrors the live tenant's prod headline bug", () => { // Reuse the maintainer's production fixture (10 pairs, 5 in target = 50 %). // When the input is JUST those 10 pairs, allTime equals 30d == 50 % diff --git a/src/lib/analytics/bp-in-target-fast-path.ts b/src/lib/analytics/bp-in-target-fast-path.ts index a901cf2da..3d43eab47 100644 --- a/src/lib/analytics/bp-in-target-fast-path.ts +++ b/src/lib/analytics/bp-in-target-fast-path.ts @@ -76,6 +76,12 @@ import type { BpTargets } from "./bp-targets"; export interface BpInTargetEnvelope { last7Days: { pct: number; pairs: number } | null; last30Days: { pct: number; pairs: number } | null; + /** + * v1.17 W1d — trailing-90-day window. Canonical window for the + * BD-Zielbereich headline, the Health-Score BP pillar and the coach + * grounding number. See `computeBpInTargetWindows` for the rationale. + */ + last90Days: { pct: number; pairs: number } | null; allTime: { pct: number; pairs: number } | null; priorMonth: { pct: number; pairs: number } | null; priorYear: { pct: number; pairs: number } | null; @@ -274,12 +280,15 @@ async function computeFromRollups( const sevenDaysAgo = new Date(now.getTime() - 7 * DAY_MS); const thirtyDaysAgo = new Date(now.getTime() - 30 * DAY_MS); + const ninetyDaysAgo = new Date(now.getTime() - 90 * DAY_MS); const sixtyDaysAgo = new Date(now.getTime() - 60 * DAY_MS); const threeSixtyFiveDaysAgo = new Date(now.getTime() - 365 * DAY_MS); const threeNinetyFiveDaysAgo = new Date(now.getTime() - 395 * DAY_MS); const last7 = bucketWindow(pairsByDay, sevenDaysAgo, now, targets); const last30 = bucketWindow(pairsByDay, thirtyDaysAgo, now, targets); + // v1.17 W1d — canonical headline / score / coach window. + const last90 = bucketWindow(pairsByDay, ninetyDaysAgo, now, targets); // All-time on the rollup path is bounded to the 395-day read window. // Documented above — same trade-off the per-event path makes when the // user's history extends beyond 365 days. @@ -339,6 +348,7 @@ async function computeFromRollups( return { last7Days: last7, last30Days: last30, + last90Days: last90, allTime, priorMonth, priorYear, @@ -453,6 +463,7 @@ async function computeFromLive( return { last7Days: windows.last7Days, last30Days: windows.last30Days, + last90Days: windows.last90Days, allTime: windows.allTime, priorMonth: windows.priorMonth, priorYear: windows.priorYear, diff --git a/src/lib/analytics/bp-in-target.ts b/src/lib/analytics/bp-in-target.ts index 12e0ceaed..8ddaed1e0 100644 --- a/src/lib/analytics/bp-in-target.ts +++ b/src/lib/analytics/bp-in-target.ts @@ -237,6 +237,14 @@ export function computeBpInTargetWindows( ): { last7Days: { pct: number; pairs: number } | null; last30Days: { pct: number; pairs: number } | null; + /** + * v1.17 W1d — the trailing-90-day window. This is the canonical window + * the BD-Zielbereich headline, the Health-Score BP pillar and the coach + * grounding number all standardise on (a 90-day arc balances recency + * against enough pairs to be stable). The all-time aggregate stays + * available below but is reserved for the BP detail page's long view. + */ + last90Days: { pct: number; pairs: number } | null; allTime: { pct: number; pairs: number } | null; /** * v1.4.22 W5 reconcile (Code-H2) — period-aligned 30-day window @@ -254,6 +262,7 @@ export function computeBpInTargetWindows( } { const sevenDaysAgoMs = now.getTime() - 7 * DAY_MS; const thirtyDaysAgoMs = now.getTime() - 30 * DAY_MS; + const ninetyDaysAgoMs = now.getTime() - 90 * DAY_MS; const sixtyDaysAgoMs = now.getTime() - 60 * DAY_MS; const oneYearMinus30DaysAgoMs = now.getTime() - 365 * DAY_MS; const oneYearAgoMs = now.getTime() - 395 * DAY_MS; @@ -270,6 +279,12 @@ export function computeBpInTargetWindows( const diaLast30 = diaSeries.filter( (r) => r.measuredAt.getTime() >= thirtyDaysAgoMs, ); + const sysLast90 = sysSeries.filter( + (r) => r.measuredAt.getTime() >= ninetyDaysAgoMs, + ); + const diaLast90 = diaSeries.filter( + (r) => r.measuredAt.getTime() >= ninetyDaysAgoMs, + ); // Period-aligned prior windows. Same 30-day arc, shifted by 30 / 365 // days respectively. Bounds are inclusive on the lower end and @@ -299,6 +314,8 @@ export function computeBpInTargetWindows( return { last7Days: computeBpInTargetPct(sysLast7, diaLast7, targets), last30Days: computeBpInTargetPct(sysLast30, diaLast30, targets), + // v1.17 W1d — canonical headline / score / coach window. + last90Days: computeBpInTargetPct(sysLast90, diaLast90, targets), // v1.4.19 A1 — independent aggregate over EVERY paired reading. The // analytics route now routes the dashboard tile's headline through // this so it stops mirroring `last30Days`. diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index 74149aba8..564a6ee0f 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -142,6 +142,7 @@ describe("buildDashboardSnapshot — envelope shape", () => { computeBpInTargetFastPath.mockResolvedValue({ last7Days: { pct: 70 }, last30Days: { pct: 80 }, + last90Days: { pct: 80 }, allTime: { pct: 75 }, priorMonth: { pct: 60 }, priorYear: { pct: 50 }, @@ -182,6 +183,7 @@ describe("buildDashboardSnapshot — envelope shape", () => { computeBpInTargetFastPath.mockResolvedValue({ last7Days: { pct: 70 }, last30Days: { pct: 80 }, + last90Days: { pct: 80 }, allTime: { pct: 75 }, priorMonth: { pct: 60 }, priorYear: { pct: 50 }, @@ -238,6 +240,7 @@ describe("buildDashboardSnapshot — per-type thick-phase gate", () => { computeBpInTargetFastPath.mockResolvedValue({ last7Days: { pct: 70 }, last30Days: { pct: 80 }, + last90Days: { pct: 80 }, allTime: { pct: 75 }, priorMonth: { pct: 60 }, priorYear: { pct: 50 }, @@ -279,6 +282,7 @@ describe("buildDashboardSnapshot — per-type thick-phase gate", () => { computeBpInTargetFastPath.mockResolvedValue({ last7Days: null, last30Days: null, + last90Days: null, allTime: null, priorMonth: null, priorYear: null, @@ -364,6 +368,8 @@ describe("buildDashboardSnapshot — healthScore (warm phase only)", () => { computeBpInTargetFastPath.mockResolvedValue({ last7Days: { pct: 70 }, last30Days: { pct: 80 }, + // v1.17 W1d — the BP pillar reads this 90-day window, NOT last30Days. + last90Days: { pct: 77 }, allTime: { pct: 75 }, priorMonth: { pct: 60 }, priorYear: { pct: 50 }, @@ -392,7 +398,10 @@ describe("buildDashboardSnapshot — healthScore (warm phase only)", () => { coverage: unknown; }; expect(arg.userId).toBe("user-1"); - expect(arg.bpInTargetPct).toBe(80); + // v1.17 W1d — the BP pillar reads the 90-day window, identical to the + // analytics route, so dashboard ring and insights card score off one + // number. Previously this was last30Days (80). + expect(arg.bpInTargetPct).toBe(77); expect(arg.bpGradedScore).toBe(88); expect(arg.heightCm).toBe(180); expect(arg.coverage).toBe(coverageMap); @@ -404,6 +413,7 @@ describe("buildDashboardSnapshot — healthScore (warm phase only)", () => { computeBpInTargetFastPath.mockResolvedValue({ last7Days: null, last30Days: null, + last90Days: null, allTime: null, priorMonth: null, priorYear: null, @@ -705,6 +715,7 @@ describe("buildDashboardSnapshot — additive proof", () => { computeBpInTargetFastPath.mockResolvedValue({ last7Days: { pct: 70 }, last30Days: { pct: 80 }, + last90Days: { pct: 80 }, allTime: { pct: 75 }, priorMonth: { pct: 60 }, priorYear: { pct: 50 }, diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 376dcb7e5..3650de8c0 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -440,6 +440,8 @@ 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; @@ -454,9 +456,15 @@ async function buildExtras( coverage, userTz, }); - bpInTargetPct = windows.last30Days?.pct ?? null; + // 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 + // two windows for the same metric. All-time stays carried for the + // 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; @@ -466,10 +474,17 @@ async function buildExtras( // 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; const scoreResult = await time("healthScore", () => computeUserHealthScoreFastPath({ userId: user.id, - bpInTargetPct, + bpInTargetPct: bpInTargetPctForScore, bpGradedScore, heightCm: user.heightCm, now,