mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
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:
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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user