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

@@ -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<typeof vi.fn>).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
+33 -4
View File
@@ -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<string, number>();
for (const row of sysDaily) {
sysByDay.set(row.day, row.value);
@@ -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),
};
}