mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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