fix(analytics): pair BP readings on the user's own tz, not a hardcoded Berlin day

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-18 06:47:32 +02:00
1 parent e51e81c76f
commit 56a32c528e
6 files changed
+183 -23

No files matched your search

@@ -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<string, boolean>([
["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<string, boolean>([
["BLOOD_PRESSURE_SYS", true],
@@ -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
+21 -3
View File
@@ -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<BpInTargetEnvelope> {
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,
});
+29 -16
View File
@@ -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),
};
}