mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(tz): key the tiered raw daily band on the user's local day
The 0–14d raw band folded same-day readings on a UTC calendar key, so a late-evening reading slipped onto the wrong day for non-UTC users and across DST transitions. Key it on userDayKey(tz) instead and make the timezone a required option so a caller cannot silently fall back to UTC. The coarser DAY/WEEK/MONTH/YEAR bands stay UTC-anchored on purpose: the rollup writer and the live aggregator both bucket on the UTC calendar day and are parity-locked for the read-swap fallback. Document the split and thread the resolved user timezone from the coach snapshot caller.
This commit is contained in:
1 parent
3faafef0df
commit
3fe728c20b
3 files changed
+133
-11
No files matched your search
@@ -415,6 +415,7 @@ async function buildCoarseTimelineTail(
|
||||
userId: string,
|
||||
type: MeasurementType,
|
||||
now: Date,
|
||||
tz: string,
|
||||
): Promise<CoarseTimelineTail | undefined> {
|
||||
// `buildTieredSeries` reads fall back on miss — a coverage miss yields empty
|
||||
// bands, which collapse to `undefined` here. The try/catch keeps the coarse
|
||||
@@ -429,6 +430,7 @@ async function buildCoarseTimelineTail(
|
||||
// band reads it would otherwise discard.
|
||||
series = await buildTieredSeries(userId, type, {
|
||||
now: now.getTime(),
|
||||
tz,
|
||||
coarseOnly: true,
|
||||
});
|
||||
} catch {
|
||||
@@ -884,6 +886,7 @@ async function buildCoachSnapshotImpl(
|
||||
userId,
|
||||
"BLOOD_PRESSURE_SYS" as MeasurementType,
|
||||
now,
|
||||
userTz,
|
||||
);
|
||||
}
|
||||
if (wantsWeight) {
|
||||
@@ -891,6 +894,7 @@ async function buildCoachSnapshotImpl(
|
||||
userId,
|
||||
"WEIGHT" as MeasurementType,
|
||||
now,
|
||||
userTz,
|
||||
);
|
||||
}
|
||||
if (wantsPulse) {
|
||||
@@ -898,6 +902,7 @@ async function buildCoachSnapshotImpl(
|
||||
userId,
|
||||
"PULSE" as MeasurementType,
|
||||
now,
|
||||
userTz,
|
||||
);
|
||||
}
|
||||
const [bpCoarseTail, weightCoarseTail, pulseCoarseTail] = await Promise.all([
|
||||
|
||||
@@ -77,7 +77,7 @@ describe("buildTieredSeries", () => {
|
||||
});
|
||||
|
||||
it("routes each band to the correct granularity over the right window", async () => {
|
||||
await buildTieredSeries("u1", "WEIGHT", { now: NOW });
|
||||
await buildTieredSeries("u1", "WEIGHT", { now: NOW, tz: "Europe/Berlin" });
|
||||
const grans = mocks.readRollupBuckets.mock.calls.map(
|
||||
(c) => c[2] as RollupGranularity,
|
||||
);
|
||||
@@ -90,7 +90,10 @@ describe("buildTieredSeries", () => {
|
||||
{ value: 82, measuredAt: new Date("2026-06-18T20:00:00.000Z") },
|
||||
{ value: 79, measuredAt: new Date("2026-06-17T07:00:00.000Z") },
|
||||
]);
|
||||
const series = await buildTieredSeries("u1", "WEIGHT", { now: NOW });
|
||||
const series = await buildTieredSeries("u1", "WEIGHT", {
|
||||
now: NOW,
|
||||
tz: "Europe/Berlin",
|
||||
});
|
||||
expect(series.recentDaily).toHaveLength(2);
|
||||
const day18 = series.recentDaily.find((p) => p.date === "2026-06-18");
|
||||
expect(day18?.value).toBe(81); // (80 + 82) / 2
|
||||
@@ -113,7 +116,10 @@ describe("buildTieredSeries", () => {
|
||||
return [];
|
||||
},
|
||||
);
|
||||
const series = await buildTieredSeries("u1", "WEIGHT", { now: NOW });
|
||||
const series = await buildTieredSeries("u1", "WEIGHT", {
|
||||
now: NOW,
|
||||
tz: "Europe/Berlin",
|
||||
});
|
||||
expect(series.anomalies.length).toBeGreaterThanOrEqual(1);
|
||||
const peak = series.anomalies.find((a) => a.kind === "peak");
|
||||
expect(peak?.value).toBe(140);
|
||||
@@ -136,7 +142,10 @@ describe("buildTieredSeries", () => {
|
||||
return [];
|
||||
},
|
||||
);
|
||||
const series = await buildTieredSeries("u1", "WEIGHT", { now: NOW });
|
||||
const series = await buildTieredSeries("u1", "WEIGHT", {
|
||||
now: NOW,
|
||||
tz: "Europe/Berlin",
|
||||
});
|
||||
expect(series.anomalies).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -147,7 +156,90 @@ describe("buildTieredSeries", () => {
|
||||
["PULSE", true],
|
||||
]),
|
||||
);
|
||||
await buildTieredSeriesForTypes("u1", ["WEIGHT", "PULSE"], { now: NOW });
|
||||
await buildTieredSeriesForTypes("u1", ["WEIGHT", "PULSE"], {
|
||||
now: NOW,
|
||||
tz: "Europe/Berlin",
|
||||
});
|
||||
expect(mocks.ensureUserRollupsFresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe("user-tz day-keying for the raw 0-14d band", () => {
|
||||
it("folds a late-evening reading onto its LOCAL day, not the UTC day", async () => {
|
||||
// 23:30 local in Berlin (CEST, +02:00) on 2026-06-15 is
|
||||
// 21:30Z on the same date — UTC and local agree here. The
|
||||
// partner reading at 00:30 local on 2026-06-16 (CEST) is
|
||||
// 22:30Z on 2026-06-15: a naive UTC key would mis-fold it onto
|
||||
// the 15th. The tz-aware key keeps it on the 16th.
|
||||
mocks.measurementFindMany.mockResolvedValue([
|
||||
{ value: 70, measuredAt: new Date("2026-06-15T21:30:00.000Z") },
|
||||
{ value: 90, measuredAt: new Date("2026-06-15T22:30:00.000Z") },
|
||||
]);
|
||||
const series = await buildTieredSeries("u1", "WEIGHT", {
|
||||
now: new Date("2026-06-20T00:00:00.000Z").getTime(),
|
||||
tz: "Europe/Berlin",
|
||||
});
|
||||
const days = series.recentDaily.map((p) => p.date).sort();
|
||||
expect(days).toEqual(["2026-06-15", "2026-06-16"]);
|
||||
expect(
|
||||
series.recentDaily.find((p) => p.date === "2026-06-16")?.value,
|
||||
).toBe(90);
|
||||
});
|
||||
|
||||
it("buckets a fall-back DST-night reading on the correct local day", async () => {
|
||||
// Germany falls back 2026-10-25 03:00 CEST → 02:00 CET. A reading
|
||||
// at 23:30 local that night is 21:30Z and must key to 2026-10-25
|
||||
// (local), which it does in both offsets; the partner reading at
|
||||
// 01:00 local the next morning (2026-10-26, after the fall-back,
|
||||
// CET +01:00) is 00:00Z on 2026-10-26 — same date by luck. Pick a
|
||||
// reading that exposes the offset: 00:30 local on 2026-10-26 CET
|
||||
// is 23:30Z on 2026-10-25; a UTC key drops it onto the 25th, the
|
||||
// tz-aware key keeps it on the 26th.
|
||||
mocks.measurementFindMany.mockResolvedValue([
|
||||
{ value: 10, measuredAt: new Date("2026-10-25T21:30:00.000Z") }, // 23:30 CEST, 25th
|
||||
{ value: 20, measuredAt: new Date("2026-10-25T23:30:00.000Z") }, // 00:30 CET, 26th
|
||||
]);
|
||||
const series = await buildTieredSeries("u1", "WEIGHT", {
|
||||
now: new Date("2026-10-30T00:00:00.000Z").getTime(),
|
||||
tz: "Europe/Berlin",
|
||||
});
|
||||
const days = series.recentDaily.map((p) => p.date).sort();
|
||||
expect(days).toEqual(["2026-10-25", "2026-10-26"]);
|
||||
});
|
||||
|
||||
it("buckets a spring-forward DST-night reading on the correct local day", async () => {
|
||||
// Germany springs forward 2026-03-29 02:00 CET → 03:00 CEST. A
|
||||
// 00:30-local reading that morning (CET +01:00) is 23:30Z on
|
||||
// 2026-03-28; the UTC key mis-folds it to the 28th, the tz-aware
|
||||
// key keeps it on the 29th. A 23:30-local reading later that day
|
||||
// (CEST +02:00) is 21:30Z on 2026-03-29 — correct in both.
|
||||
mocks.measurementFindMany.mockResolvedValue([
|
||||
{ value: 5, measuredAt: new Date("2026-03-28T23:30:00.000Z") }, // 00:30 CET, 29th
|
||||
{ value: 15, measuredAt: new Date("2026-03-29T21:30:00.000Z") }, // 23:30 CEST, 29th
|
||||
]);
|
||||
const series = await buildTieredSeries("u1", "WEIGHT", {
|
||||
now: new Date("2026-04-02T00:00:00.000Z").getTime(),
|
||||
tz: "Europe/Berlin",
|
||||
});
|
||||
// Both readings fold onto 2026-03-29 in local time.
|
||||
expect(series.recentDaily).toHaveLength(1);
|
||||
expect(series.recentDaily[0].date).toBe("2026-03-29");
|
||||
expect(series.recentDaily[0].count).toBe(2);
|
||||
expect(series.recentDaily[0].value).toBe(10); // (5 + 15) / 2
|
||||
});
|
||||
|
||||
it("keys west-of-UTC users on their own local day", async () => {
|
||||
// 22:00 local in Honolulu (HST, -10:00) on 2026-06-15 is 08:00Z
|
||||
// on 2026-06-16: a UTC key lands it on the 16th, the tz-aware key
|
||||
// keeps it on the user's local 15th.
|
||||
mocks.measurementFindMany.mockResolvedValue([
|
||||
{ value: 42, measuredAt: new Date("2026-06-16T08:00:00.000Z") },
|
||||
]);
|
||||
const series = await buildTieredSeries("u1", "WEIGHT", {
|
||||
now: new Date("2026-06-20T00:00:00.000Z").getTime(),
|
||||
tz: "Pacific/Honolulu",
|
||||
});
|
||||
expect(series.recentDaily).toHaveLength(1);
|
||||
expect(series.recentDaily[0].date).toBe("2026-06-15");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,7 @@ import type {
|
||||
MeasurementType,
|
||||
RollupGranularity,
|
||||
} from "@/generated/prisma/client";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import {
|
||||
ensureUserRollupsFresh,
|
||||
readRollupBuckets,
|
||||
@@ -52,7 +53,11 @@ export const TIERED_BANDS = {
|
||||
|
||||
/** One raw daily point in the 0–14d band. */
|
||||
export interface TieredRawPoint {
|
||||
/** ISO date (UTC) of the day the point summarises. */
|
||||
/**
|
||||
* `YYYY-MM-DD` of the day the point summarises, keyed in the user's
|
||||
* timezone (`options.tz`). A 23:30-local reading on a DST-changeover
|
||||
* night lands on the correct local calendar day, not the UTC day.
|
||||
*/
|
||||
date: string;
|
||||
/** Mean of that day's readings (a single point for most metrics). */
|
||||
value: number;
|
||||
@@ -105,6 +110,20 @@ export interface TieredSeries {
|
||||
}
|
||||
|
||||
export interface BuildTieredSeriesOptions {
|
||||
/**
|
||||
* The user's IANA timezone. REQUIRED — the raw 0–14d band keys its
|
||||
* daily fold on the user's local calendar day (`userDayKey`) so a
|
||||
* late-evening reading lands on the right day across DST boundaries
|
||||
* and for non-UTC users. The coarser DAY/WEEK/MONTH/YEAR bands read
|
||||
* straight from the rollup table, whose `bucketStart` is UTC-anchored
|
||||
* by system-wide design (the live aggregator's `date_trunc` and the
|
||||
* rollup writer agree on UTC day boundaries — see
|
||||
* `start-of-utc-day.ts`); re-keying those into `tz` here would diverge
|
||||
* from the parity-locked read-swap fallback, so they are intentionally
|
||||
* left UTC. Callers without a resolved zone should pass
|
||||
* `DEFAULT_TIMEZONE`.
|
||||
*/
|
||||
tz: string;
|
||||
/** Clock override for tests; defaults to `Date.now()`. */
|
||||
now?: number;
|
||||
/**
|
||||
@@ -224,11 +243,17 @@ function extractAnomalies(
|
||||
.slice(0, cap);
|
||||
}
|
||||
|
||||
/** Read the raw 0–14d daily points for one type, folding same-day rows. */
|
||||
/**
|
||||
* Read the raw 0–14d daily points for one type, folding same-day rows.
|
||||
* Days are keyed on the user's local calendar via `userDayKey(tz)`, so a
|
||||
* 23:30-local reading folds onto its local day rather than the UTC day —
|
||||
* correct across DST transitions and for non-UTC users.
|
||||
*/
|
||||
async function readRecentDaily(
|
||||
userId: string,
|
||||
type: MeasurementType,
|
||||
now: number,
|
||||
tz: string,
|
||||
): Promise<TieredRawPoint[]> {
|
||||
const since = new Date(now - TIERED_BANDS.rawDays * DAY_MS);
|
||||
const rows = await prisma.measurement.findMany({
|
||||
@@ -238,7 +263,7 @@ async function readRecentDaily(
|
||||
});
|
||||
const byDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const r of rows) {
|
||||
const key = r.measuredAt.toISOString().slice(0, 10);
|
||||
const key = userDayKey(r.measuredAt, tz);
|
||||
const entry = byDay.get(key) ?? { sum: 0, count: 0 };
|
||||
entry.sum += r.value;
|
||||
entry.count += 1;
|
||||
@@ -279,7 +304,7 @@ async function readBand(
|
||||
export async function buildTieredSeries(
|
||||
userId: string,
|
||||
type: MeasurementType,
|
||||
options: BuildTieredSeriesOptions = {},
|
||||
options: BuildTieredSeriesOptions,
|
||||
): Promise<TieredSeries> {
|
||||
const now = options.now ?? Date.now();
|
||||
const cap = options.maxAnomalies ?? 5;
|
||||
@@ -296,7 +321,7 @@ export async function buildTieredSeries(
|
||||
await Promise.all([
|
||||
options.skipRecentDaily || coarseOnly
|
||||
? Promise.resolve<TieredRawPoint[]>([])
|
||||
: readRecentDaily(userId, type, now),
|
||||
: readRecentDaily(userId, type, now, options.tz),
|
||||
coarseOnly
|
||||
? emptyBand
|
||||
: readBand(
|
||||
@@ -371,7 +396,7 @@ export async function buildTieredSeries(
|
||||
export async function buildTieredSeriesForTypes(
|
||||
userId: string,
|
||||
types: MeasurementType[],
|
||||
options: BuildTieredSeriesOptions = {},
|
||||
options: BuildTieredSeriesOptions,
|
||||
): Promise<TieredSeries[]> {
|
||||
const now = options.now ?? Date.now();
|
||||
if (!options.skipEnsureFresh) {
|
||||
|
||||
Reference in new issue
Block a user