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);