mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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:
1 parent
f2a9716671
commit
b75cef9190
5 files changed
+63
-9
No files matched your search
@@ -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 —
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user