From b75cef9190e8c45a37470083bc5130b76eac2165 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 13:01:39 +0200 Subject: [PATCH] fix(insights): key coincident-deviation 'today' in the user's timezone The coincident-deviation read grouped the latest day with a UTC date slice while the sibling readiness read already used the tz-aware day key. For a user east or west of UTC, a late-evening or early-morning reading landed on the wrong calendar day, so a fired flag could compare a vital from the wrong day against its band and narrate '2 or more vitals out of band today' on the wrong day. Thread the user's timezone into the latest-day read and mint the day key with userDayKey, matching readiness. The Coach derived-snapshot and the correlations reader pass the real account timezone through. Adds a regression covering a UTC+2 user whose late-night reading rolls to the next local day. --- src/lib/ai/coach/derived-snapshot.ts | 5 ++- src/lib/ai/coach/snapshot.ts | 2 +- src/lib/ai/coach/tools/correlations-read.ts | 6 ++-- .../__tests__/coincident-deviation.test.ts | 35 +++++++++++++++++++ .../insights/derived/coincident-deviation.ts | 24 ++++++++++--- 5 files changed, 63 insertions(+), 9 deletions(-) diff --git a/src/lib/ai/coach/derived-snapshot.ts b/src/lib/ai/coach/derived-snapshot.ts index 34422b03b..ad007f456 100644 --- a/src/lib/ai/coach/derived-snapshot.ts +++ b/src/lib/ai/coach/derived-snapshot.ts @@ -145,6 +145,7 @@ export async function buildDerivedSnapshotBlock( userId: string, profile: BaselineProfile, now: Date, + tz?: string, ): Promise { const block: Record = {}; @@ -152,9 +153,11 @@ export async function buildDerivedSnapshotBlock( // off the one shared profile alongside the scores; fail-soft to null so a // baseline hiccup never sinks the derived block. Only attached when it // FIRED (≥2 vitals out of band today) — a quiet day adds no entry, keeping - // the snapshot noise-free. + // the snapshot noise-free. D2-8: pass the user's tz so the "today" grouping + // matches the user's calendar day, not UTC's. const coincidentPromise = computeCoincidentDeviation(userId, profile, { now, + ...(tz ? { tz } : {}), }).catch(() => null); // The metrics are independent passthrough reads off the one shared profile — diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index a94714540..69050bdd2 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -1237,7 +1237,7 @@ async function buildCoachSnapshotImpl( ? null : buildGlp1SnapshotBlock(userId, now); const derivedBlockPromise = derivedActive - ? buildDerivedSnapshotBlock(userId, derivedProfile, now) + ? buildDerivedSnapshotBlock(userId, derivedProfile, now, userTz) : null; // v1.17.0 — WHOOP-native day strain (0–21), distinct from the COMPUTED // STRAIN_SCORE (0–100) the derived block carries. Gated on the same diff --git a/src/lib/ai/coach/tools/correlations-read.ts b/src/lib/ai/coach/tools/correlations-read.ts index 1d1424ab3..131fb5890 100644 --- a/src/lib/ai/coach/tools/correlations-read.ts +++ b/src/lib/ai/coach/tools/correlations-read.ts @@ -157,8 +157,10 @@ export async function readCoachCorrelations( select: { score: true, moodLoggedAt: true }, }), // Coincident-deviation is its own derived metric — fail-soft to null so a - // baseline hiccup never sinks the whole correlations read. - computeCoincidentDeviation(userId, profile).catch(() => null), + // baseline hiccup never sinks the whole correlations read. D2-8: pass the + // user's tz so the "today" grouping matches the user's calendar day, not + // UTC's, before the fired flag is narrated as "out of band TODAY". + computeCoincidentDeviation(userId, profile, { tz }).catch(() => null), ]); const byType = new Map>(); diff --git a/src/lib/insights/derived/__tests__/coincident-deviation.test.ts b/src/lib/insights/derived/__tests__/coincident-deviation.test.ts index 75e21b1be..c101ef457 100644 --- a/src/lib/insights/derived/__tests__/coincident-deviation.test.ts +++ b/src/lib/insights/derived/__tests__/coincident-deviation.test.ts @@ -180,6 +180,41 @@ describe("computeCoincidentDeviation", () => { } }); + it("keys 'today' in the user's timezone, not UTC (D2-8)", async () => { + // A non-UTC user (UTC+2) logs both vitals at 23:30 UTC on Jun 2 — which is + // 01:30 LOCAL on Jun 3. Under the old UTC slice the day key would read + // "2026-06-02"; tz-aware it must read "2026-06-03", the user's real "today". + const TZ = "Europe/Berlin"; // UTC+2 in summer + const NOW_LATE = new Date("2026-06-02T23:45:00Z"); // 01:45 local Jun 3 + const baseline = ( + base: number, + outlier: number, + ): Array<{ value: number; measuredAt: Date }> => [ + ...Array.from({ length: 9 }, (_, i) => ({ + value: base, + measuredAt: new Date( + `2026-05-${String(15 + i).padStart(2, "0")}T07:00:00Z`, + ), + })), + // 23:30 UTC Jun 2 == 01:30 local Jun 3. + { value: outlier, measuredAt: new Date("2026-06-02T23:30:00Z") }, + ]; + findMany.mockImplementation(async (args: { where: { type: string } }) => { + if (args.where.type === "RESTING_HEART_RATE") return baseline(58, 95); + if (args.where.type === "HEART_RATE_VARIABILITY") return baseline(60, 10); + return []; + }); + const result = await computeCoincidentDeviation("u1", PROFILE, { + now: NOW_LATE, + tz: TZ, + }); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(result.value.day).toBe("2026-06-03"); + expect(result.value.fired).toBe(true); + } + }); + it("does not resolve Rest Mode (no reframe) when the flag did not fire", async () => { // Banded but inside-band: the reframe must not even query Rest Mode. findMany.mockImplementation(quietBandedRows); diff --git a/src/lib/insights/derived/coincident-deviation.ts b/src/lib/insights/derived/coincident-deviation.ts index 9c6ab4dcc..8b1bfdc18 100644 --- a/src/lib/insights/derived/coincident-deviation.ts +++ b/src/lib/insights/derived/coincident-deviation.ts @@ -40,6 +40,7 @@ import { computeVitalsBaseline, type BaselineProfile } from "./baseline"; import { VITALS_BASELINE_TYPES } from "./registry"; import type { Derived, DerivedProvenanceSource } from "./types"; import { resolveRestMode } from "@/lib/illness/rest-mode"; +import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format"; const MS_PER_DAY = 24 * 60 * 60 * 1000; const DEFAULT_WINDOW_DAYS = 30; @@ -120,17 +121,31 @@ export interface CoincidentDeviationOpts { windowDays?: number; now?: Date; coverage?: RollupCoverageMap; + /** + * D2-8 — IANA timezone the "today" grouping is keyed in. A 23:30-local + * reading must land on the user's local "today", not UTC's; mirrors + * `readiness.ts`. Defaults to `DEFAULT_TIMEZONE` when the caller has no tz. + */ + tz?: string; } /** * The most recent DAY mean for a type within the window, plus its day key. * Bounded raw read; null when no reading in the window. + * + * D2-8 — day keys are minted in the user's timezone (`userDayKey`), not UTC + * (`toISOString().slice(0,10)`), so a late-evening / early-morning reading for + * a non-UTC user is grouped under the right calendar "today" — the same + * tz-aware basis `readiness.ts` already uses. Without this, the fired + * coincident flag could compare a vital from the wrong calendar day against + * its band and narrate "≥2 vitals out of band TODAY" on the wrong day. */ async function readLatestDayMean( userId: string, type: MeasurementType, windowDays: number, now: Date, + tz: string, ): Promise<{ value: number; day: string } | null> { const since = new Date(now.getTime() - windowDays * MS_PER_DAY); const rows = await prisma.measurement.findMany({ @@ -144,12 +159,10 @@ async function readLatestDayMean( // so the "today" reading is always the genuine latest, then mean its rows. let day = ""; for (const r of rows) { - const d = r.measuredAt.toISOString().slice(0, 10); + const d = userDayKey(r.measuredAt, tz); if (d > day) day = d; } - const sameDay = rows.filter( - (r) => r.measuredAt.toISOString().slice(0, 10) === day, - ); + const sameDay = rows.filter((r) => userDayKey(r.measuredAt, tz) === day); return { value: sameDay.reduce((s, r) => s + r.value, 0) / sameDay.length, day, @@ -167,6 +180,7 @@ export async function computeCoincidentDeviation( ): Promise> { const windowDays = opts.windowDays ?? DEFAULT_WINDOW_DAYS; const now = opts.now ?? new Date(); + const tz = opts.tz ?? DEFAULT_TIMEZONE; const computedAt = nowProvenanceTimestamp(now); const coverage = opts.coverage ?? (await probeRollupCoverage(userId)); @@ -186,7 +200,7 @@ export async function computeCoincidentDeviation( }); if (baseline.status !== "ok") continue; if (baseline.provenance.source === "DAY") anyDaySource = true; - const latest = await readLatestDayMean(userId, type, windowDays, now); + const latest = await readLatestDayMean(userId, type, windowDays, now, tz); if (!latest) continue; if (latest.day > latestDay) latestDay = latest.day; if (baseline.coverage.historyDays > maxHistoryDays) {