mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +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);
|
||||
|
||||
Reference in new issue
Block a user