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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-26 13:01:39 +02:00
1 parent f2a9716671
commit b75cef9190
5 files changed
+63 -9

No files matched your search

+4 -1
View File
@@ -145,6 +145,7 @@ export async function buildDerivedSnapshotBlock(
userId: string,
profile: BaselineProfile,
now: Date,
tz?: string,
): Promise<DerivedSnapshotBlock | null> {
const block: Record<string, DerivedSnapshotEntry> = {};
@@ -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 —
+1 -1
View File
@@ -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
+4 -2
View File
@@ -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<string, Array<{ value: number; at: Date }>>();
@@ -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);
@@ -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<Derived<CoincidentDeviationValue>> {
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) {