Merge w11b/tz into release/v1.19.0

This commit is contained in:
Marc-André Bombeck committed 2026-06-21 12:22:23 +02:00
commit 7c693e94b5
3 files changed
+133 -11

No files matched your search

+5
View File
@@ -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");
});
});
});
+31 -6
View File
@@ -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) {