From 56a32c528e92fbb33ac52e8394d79ffd22b2d21b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 06:45:18 +0200 Subject: [PATCH] fix(analytics): pair BP readings on the user's own tz, not a hardcoded Berlin day MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit computeBpInTargetPct's same-calendar-day pairing fallback (for readings whose sys/dia timestamps drift past the 5-minute session window) keyed on toBerlinDayKey regardless of the caller's actual user. A non-Berlin user's two same-local-evening readings straddling Berlin midnight (but not their own midnight) were rejected as a pair, running the in-target percentage on fewer readings than it should have — precisely the population the cross-tz runtime guard in bp-in-target-fast-path.ts force-routes to this same pairing fallback to protect. Threads an optional tz through computeBpInTargetPct / collectBpPairs / computeBpInTargetWindows (default Berlin for untouched legacy callers), wires it through the fast-path's live fallback and the comprehensive-insights route's own computeBpInTargetPct call — the other caller with the user's tz already in scope. Investigated but left the comprehensive route's resting-pulse-proxy day-key and BP-medication-continuity day-key on their current UTC convention: both are joined by an exact YYYY-MM-DD key against a UTC-anchored partner (the mood rollup's bucketStart, and the aggregator's date_trunc'd BLOOD_PRESSURE_SYS daily series). Switching only one side to the user's tz would trade "evening readings shift a day" for "evening readings mismatch their pairing partner" — a different bug, not a fix. Documented in place; a real fix needs the mood-rollup / aggregate tiers themselves to bucket per-user-tz. --- .../comprehensive/__tests__/route.test.ts | 34 ++++++++++++++ src/app/api/insights/comprehensive/route.ts | 37 +++++++++++++-- .../__tests__/bp-in-target-fast-path.test.ts | 36 +++++++++++++++ .../analytics/__tests__/bp-in-target.test.ts | 30 +++++++++++++ src/lib/analytics/bp-in-target-fast-path.ts | 24 ++++++++-- src/lib/analytics/bp-in-target.ts | 45 ++++++++++++------- 6 files changed, 183 insertions(+), 23 deletions(-) diff --git a/src/app/api/insights/comprehensive/__tests__/route.test.ts b/src/app/api/insights/comprehensive/__tests__/route.test.ts index 221b47d5f..9e69f49d6 100644 --- a/src/app/api/insights/comprehensive/__tests__/route.test.ts +++ b/src/app/api/insights/comprehensive/__tests__/route.test.ts @@ -305,6 +305,40 @@ describe("GET /api/insights/comprehensive — envelope shape", () => { expect(body.data.bpClassification?.category).toBe("Normal"); }); + // v1.30.3 (QA F7 — completes the "caller has it" pairing-tz fix across + // every `computeBpInTargetPct` call site, not just the fast-path). + it("pairs bpPctInTarget on the session user's own tz, not a hardcoded Berlin day", async () => { + vi.mocked(getSession).mockResolvedValue({ + ...SESSION_OK, + user: { ...SESSION_OK.user, timezone: "America/New_York" }, + } as never); + // 2026-05-14T21:50Z = 23:50 Berlin (05-14) / 17:50 New York (05-14). + // 2026-05-14T22:10Z = 00:10 Berlin (05-15, the NEXT Berlin day) / + // 18:10 New York (05-14, the SAME New York day). >5-min gap, so only + // the same-calendar-day fallback can pair them — and only New York + // agrees with itself. + (buildComprehensiveAggregate as ReturnType).mockResolvedValue( + { + summaries: {}, + bpRawRows: { + sys: [{ measuredAt: new Date("2026-05-14T21:50:00Z"), value: 125 }], + dia: [{ measuredAt: new Date("2026-05-14T22:10:00Z"), value: 75 }], + }, + dailyByType: {}, + firstMeasurementAt: new Date("2026-05-14T21:50:00Z"), + totalMeasurements: 2, + }, + ); + + const res = await callGet(makeReq()); + const body = (await res.json()) as { + data: { bpPctInTarget: number | null }; + }; + // A Berlin-day pairing would reject this pair (different Berlin + // calendar days) and read null; the user's own tz accepts it. + expect(body.data.bpPctInTarget).toBe(100); + }); + it("consumes the mood-rollup tier and skips the raw findMany when DAY rows exist", async () => { // v1.4.40 W-INSIGHTS — rollup-tier read swap parity. Three DAY rows // populated → the route reads them directly and never touches the diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts index 5f5eb76ad..91f3bbfed 100644 --- a/src/app/api/insights/comprehensive/route.ts +++ b/src/app/api/insights/comprehensive/route.ts @@ -100,6 +100,11 @@ type AuthedUser = AuthContext["user"]; export async function buildComprehensiveResponse(user: AuthedUser) { const userId = user.id; const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); + // v1.30.3 (QA F8) — the one shared per-user tz for every day-bucketing + // pass in this route (resting-pulse proxy, medication-continuity, BP + // pairing). Was previously re-derived ad hoc (or hardcoded UTC) at each + // call site. + const userTz = user.timezone || "Europe/Berlin"; // Fetch user profile (height + DOB drive BMI + BP targets). const dbUser = await prisma.user.findUnique({ @@ -221,10 +226,11 @@ export async function buildComprehensiveResponse(user: AuthedUser) { // sys+dia on the 5-minute window ONLY, dropping readings whose sys/dia // timestamps drift past 5 min on Withings / Apple-Health imports, which // skewed the share iOS renders on the Home Health Score). The helper - // adds the same-Berlin-day pairing fallback so those readings pair, and - // keeps the ESH-2023 band + 90/50 hypotension floor (both sys AND dia at - // or below the age-band ceiling). Window is the aggregator's 90-day raw - // pull — a "recent control" figure, not the all-time headline. + // adds the same-local-day pairing fallback (v1.30.3 QA F7 — keyed on + // the user's OWN tz, not a hardcoded Berlin day) so those readings + // pair, and keeps the ESH-2023 band + 90/50 hypotension floor (both sys + // AND dia at or below the age-band ceiling). Window is the aggregator's + // 90-day raw pull — a "recent control" figure, not the all-time headline. let bpPctInTarget: number | null = null; if (bpTargets) { bpPctInTarget = @@ -232,6 +238,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) { aggregate.bpRawRows.sys, aggregate.bpRawRows.dia, bpTargets, + userTz, )?.pct ?? null; } @@ -274,6 +281,20 @@ export async function buildComprehensiveResponse(user: AuthedUser) { // Day keys follow this route's UTC-bucket convention (the per-user-tz // bucketing is the documented v1.5 follow-up) so the resting series pairs // against the same UTC day keys as the mood series. + // + // v1.30.3 (QA F8) — investigated switching this to `userDayKey(d, userTz)` + // to match the intraday page's resting-proxy bucketing (`71d7ca78c`), but + // `dailyMoodEntries` below (this series' ONLY pairing partner, joined by + // an exact YYYY-MM-DD key in `pairMoodWithDaily`) is itself UTC-anchored — + // it reads `moodRollupDayRows[].bucketStart.toISOString().slice(0, 10)`, + // a real UTC aggregation boundary, not just a display label. Moving ONLY + // this side to the user's tz would not fix anything: it would swap + // "evening readings shift a day relative to the true local day" for + // "evening readings mismatch their mood pairing partner, which used to + // line up precisely because both sides shared the same UTC convention." + // A genuine fix needs the mood-rollup tier itself to bucket per-user-tz + // (the same v1.5 follow-up the comment above already flags) — out of + // scope for this pass. Left UTC on both sides intentionally. const restingPulseRows = await prisma.measurement.findMany({ where: { userId, @@ -437,6 +458,14 @@ export async function buildComprehensiveResponse(user: AuthedUser) { if (expectedBpIntakesPerDay > 0) { // Daily systolic means (already keyed YYYY-MM-DD) from the SQL pass. + // v1.30.3 (QA F8) — `sysDaily` is UTC `date_trunc`-bucketed by the + // aggregator (`comprehensive-aggregator.ts`), so `takenByDay` below + // MUST key on the same UTC day, not the user's own tz — pairing the + // two on different day spaces would silently drop pairs rather than + // fix anything. Making this whole correlation user-tz-consistent + // needs the aggregator's `dailyByType` to bucket in the user's tz + // first (a bigger, already-flagged follow-up); until then this stays + // UTC to match its only pairing partner. const sysByDay = new Map(); for (const row of sysDaily) { sysByDay.set(row.day, row.value); diff --git a/src/lib/analytics/__tests__/bp-in-target-fast-path.test.ts b/src/lib/analytics/__tests__/bp-in-target-fast-path.test.ts index 4115377c3..49b1181f6 100644 --- a/src/lib/analytics/__tests__/bp-in-target-fast-path.test.ts +++ b/src/lib/analytics/__tests__/bp-in-target-fast-path.test.ts @@ -422,6 +422,42 @@ describe("computeBpInTargetFastPath", () => { expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled(); }); + // v1.30.3 (QA F7) — the guard force-routes a non-near-UTC user to the + // live path specifically so their pairing is computed correctly; before + // this fix the live path's same-day fallback still hardcoded the Berlin + // day, undoing exactly the protection the guard exists to provide. + it("pairs on the user's OWN tz on the live path it was force-routed to (Tokyo)", async () => { + const coverage = new Map([ + ["BLOOD_PRESSURE_SYS", true], + ["BLOOD_PRESSURE_DIA", true], + ]); + FULLY_COVERED.mockReturnValue(true); + PROBE.mockResolvedValue(coverage); + + // 2026-05-14T21:50Z = 23:50 Berlin (05-14) / 06:50 Tokyo (05-15). + // 2026-05-14T22:10Z = 00:10 Berlin (05-15, the NEXT Berlin day) / + // 07:10 Tokyo (05-15, the SAME Tokyo day). >5-min gap, so only the + // same-calendar-day fallback can pair them — and only Tokyo agrees. + QUERY_RAW.mockResolvedValueOnce([ + { measured_at: new Date("2026-05-14T21:50:00.000Z"), value: 125 }, + ]).mockResolvedValueOnce([ + { measured_at: new Date("2026-05-14T22:10:00.000Z"), value: 75 }, + ]); + + const result = await computeBpInTargetFastPath({ + userId: "user-tokyo-pair", + targets: TARGETS_UNDER_65, + now: new Date("2026-05-17T12:00:00.000Z"), + coverage, + userTz: "Asia/Tokyo", + }); + + expect(result.path).toBe("live"); + // A Berlin-day pairing would reject this pair (different Berlin + // calendar days) and every window would read null/no-pairs. + expect(result.last30Days).toEqual({ pct: 100, pairs: 1 }); + }); + it("defaults to near-UTC when the caller omits userTz (legacy compat)", async () => { const coverage = new Map([ ["BLOOD_PRESSURE_SYS", true], diff --git a/src/lib/analytics/__tests__/bp-in-target.test.ts b/src/lib/analytics/__tests__/bp-in-target.test.ts index 1da58fab6..13ac814f8 100644 --- a/src/lib/analytics/__tests__/bp-in-target.test.ts +++ b/src/lib/analytics/__tests__/bp-in-target.test.ts @@ -119,6 +119,36 @@ describe("computeBpInTargetPct", () => { expect(result).toEqual({ pct: 100, pairs: 1 }); }); + /** + * v1.30.3 (QA F7) — the same-day pairing fallback used to hardcode the + * Berlin calendar day regardless of the caller's actual user. A New + * York user logging SYS at 17:50 and DIA at 18:10 local time (>5-min + * session gap) sits on 2026-05-01 in New York but straddles 23:50 → + * 00:10 UTC+2 Berlin — two DIFFERENT Berlin days — so the legitimate + * same-local-day pair was rejected. Passing the user's own tz fixes it; + * omitting the tz (legacy callers) keeps the old Berlin-only behaviour. + */ + it("pairs on the user's own tz, not a hardcoded Berlin day", () => { + // 2026-05-01T21:50Z = 17:50 New York (EDT) = 23:50 Berlin (CEST). + // 2026-05-01T22:10Z = 18:10 New York (EDT) = 00:10 Berlin — the NEXT + // Berlin calendar day (2026-05-02). + const sys = [reading("2026-05-01T21:50:00Z", 125)]; + const dia = [reading("2026-05-01T22:10:00Z", 75)]; + + // Without a tz, the legacy Berlin-day fallback rejects the pair. + expect(computeBpInTargetPct(sys, dia, TARGETS_UNDER_65)).toBeNull(); + + // With the user's own tz, both readings fall on the same New York + // calendar day (2026-05-01) and the pair is accepted. + const result = computeBpInTargetPct( + sys, + dia, + TARGETS_UNDER_65, + "America/New_York", + ); + expect(result).toEqual({ pct: 100, pairs: 1 }); + }); + /** * Regression: the old denominator of `sysData.length` made the tile * report 0 % when sys+dia readings shared a calendar day but were diff --git a/src/lib/analytics/bp-in-target-fast-path.ts b/src/lib/analytics/bp-in-target-fast-path.ts index 0d8bae265..db9f6e7e5 100644 --- a/src/lib/analytics/bp-in-target-fast-path.ts +++ b/src/lib/analytics/bp-in-target-fast-path.ts @@ -187,7 +187,7 @@ export async function computeBpInTargetFastPath(input: { if (userNearUtc && sysCovered && diaCovered) { return computeFromRollups(userId, targets, now, tzGuard); } - return computeFromLive(userId, targets, now, tzGuard); + return computeFromLive(userId, targets, now, tzGuard, userTz); } /** @@ -454,6 +454,15 @@ async function computeFromLive( targets: BpTargets, now: Date, tzGuard: "near-utc" | "non-utc-live-fallback", + /** + * v1.30.3 (QA F7) — the user's own IANA tz, threaded into the pairing + * fallback (`computeBpInTargetWindows` / `collectBpPairs`). This is + * exactly the guard's protected population: a non-near-UTC user gets + * force-routed HERE specifically so their pairs are computed correctly + * — keying the fallback on Berlin instead of their own tz undid that + * protection. + */ + userTz: string | undefined, ): Promise { const DAY_MS = 24 * 60 * 60 * 1000; const bpInTargetSince = new Date(now.getTime() - 365 * DAY_MS); @@ -504,11 +513,20 @@ async function computeFromLive( }, }); - const windows = computeBpInTargetWindows(sysData, diaData, targets, now); + // v1.30.3 (QA F7) — `undefined` falls through to each helper's own + // Berlin default (unchanged behaviour for legacy callers that never + // pass a userTz); a real tz here overrides it. + const windows = computeBpInTargetWindows( + sysData, + diaData, + targets, + now, + userTz, + ); // 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), + pairs: collectBpPairs(sysData, diaData, userTz), target: targets, now, }); diff --git a/src/lib/analytics/bp-in-target.ts b/src/lib/analytics/bp-in-target.ts index 6dc86236b..f48b9401e 100644 --- a/src/lib/analytics/bp-in-target.ts +++ b/src/lib/analytics/bp-in-target.ts @@ -60,7 +60,7 @@ export interface BpReading { export const SYS_HYPOTENSION_FLOOR = 90; export const DIA_HYPOTENSION_FLOOR = 50; -import { toBerlinDayKey } from "@/lib/tz/resolver"; +import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/resolver"; /** * Pair a systolic reading with its closest diastolic by absolute time @@ -120,8 +120,14 @@ export function isBpReadingInTarget( * delta. * 2. Accept the pair if `deltaMs <= 5 minutes` (legacy bound) — same * session. - * 3. Otherwise accept the pair if both share the same Berlin - * calendar day (handles imports rounded to the hour or to noon). + * 3. Otherwise accept the pair if both share the same calendar day IN + * THE USER'S OWN TZ (handles imports rounded to the hour or to + * noon). v1.30.3 (QA F7) — this used to hardcode the Berlin day + * regardless of the caller's actual user, rejecting legitimate + * same-local-day pairs for a non-Berlin user whose two readings + * straddle Berlin midnight but not their own (e.g. 23:50/00:10 + * Berlin from a New York evening reading pair) — the in-target % + * then ran on fewer pairs than it should have. * 4. Discard otherwise. * * Denominator is the number of accepted pairs, NOT `sysData.length` — @@ -135,6 +141,9 @@ export function computeBpInTargetPct( sysSeries: BpReading[], diaSeries: BpReading[], targets: BpTargets, + /** v1.30.3 (QA F7) — the user's own IANA tz; defaults to Berlin for + * legacy callers that haven't threaded it through yet. */ + tz: string = DEFAULT_TIMEZONE, ): { pct: number; pairs: number } | null { if (sysSeries.length === 0 || diaSeries.length === 0) return null; @@ -147,11 +156,11 @@ export function computeBpInTargetPct( if (!match) continue; const sameSession = match.deltaMs <= SAME_SESSION_MS; - const sameBerlinDay = + const sameLocalDay = !sameSession && - toBerlinDayKey(sys.measuredAt) === toBerlinDayKey(match.dia.measuredAt); + userDayKey(sys.measuredAt, tz) === userDayKey(match.dia.measuredAt, tz); - if (!sameSession && !sameBerlinDay) continue; + if (!sameSession && !sameLocalDay) continue; pairs += 1; if (isBpReadingInTarget(sys.value, match.dia.value, targets)) { @@ -177,6 +186,8 @@ export function computeBpInTargetPct( export function collectBpPairs( sysSeries: BpReading[], diaSeries: BpReading[], + /** v1.30.3 (QA F7) — see `computeBpInTargetPct`'s identical parameter. */ + tz: string = DEFAULT_TIMEZONE, ): Array<{ at: Date; sys: number; dia: number }> { if (sysSeries.length === 0 || diaSeries.length === 0) return []; const SAME_SESSION_MS = 5 * 60 * 1000; @@ -185,10 +196,10 @@ export function collectBpPairs( const match = findClosestDia(sys, diaSeries); if (!match) continue; const sameSession = match.deltaMs <= SAME_SESSION_MS; - const sameBerlinDay = + const sameLocalDay = !sameSession && - toBerlinDayKey(sys.measuredAt) === toBerlinDayKey(match.dia.measuredAt); - if (!sameSession && !sameBerlinDay) continue; + userDayKey(sys.measuredAt, tz) === userDayKey(match.dia.measuredAt, tz); + if (!sameSession && !sameLocalDay) continue; out.push({ at: sys.measuredAt, sys: sys.value, dia: match.dia.value }); } return out; @@ -234,6 +245,8 @@ export function computeBpInTargetWindows( diaSeries: BpReading[], targets: BpTargets, now: Date = new Date(), + /** v1.30.3 (QA F7) — see `computeBpInTargetPct`'s identical parameter. */ + tz: string = DEFAULT_TIMEZONE, ): { last7Days: { pct: number; pairs: number } | null; last30Days: { pct: number; pairs: number } | null; @@ -322,7 +335,7 @@ export function computeBpInTargetWindows( // v1.17 W1b — oldest accepted pair inside the 90-day window for the // effective-span label. `collectBpPairs` applies the same pairing rules // as `computeBpInTargetPct`, so the anchor matches the counted pairs. - const pairsLast90 = collectBpPairs(sysLast90, diaLast90); + const pairsLast90 = collectBpPairs(sysLast90, diaLast90, tz); const last90EarliestAt = pairsLast90.length === 0 ? null @@ -332,16 +345,16 @@ export function computeBpInTargetWindows( ); return { - last7Days: computeBpInTargetPct(sysLast7, diaLast7, targets), - last30Days: computeBpInTargetPct(sysLast30, diaLast30, targets), + last7Days: computeBpInTargetPct(sysLast7, diaLast7, targets, tz), + last30Days: computeBpInTargetPct(sysLast30, diaLast30, targets, tz), // v1.17 W1d — canonical headline / score / coach window. - last90Days: computeBpInTargetPct(sysLast90, diaLast90, targets), + last90Days: computeBpInTargetPct(sysLast90, diaLast90, targets, tz), last90EarliestAt, // 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`. - allTime: computeBpInTargetPct(sysSeries, diaSeries, targets), - priorMonth: computeBpInTargetPct(sysPriorMonth, diaPriorMonth, targets), - priorYear: computeBpInTargetPct(sysPriorYear, diaPriorYear, targets), + allTime: computeBpInTargetPct(sysSeries, diaSeries, targets, tz), + priorMonth: computeBpInTargetPct(sysPriorMonth, diaPriorMonth, targets, tz), + priorYear: computeBpInTargetPct(sysPriorYear, diaPriorYear, targets, tz), }; }