From b47a39319cb336cc3cad38cffda45f2cb7a84bc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 9 Oct 2026 12:02:25 +0200 Subject: [PATCH] fix(timeline): read rolled-up months by their UTC date West of UTC the local midnight of the boundary month falls hours after the month's UTC bucket start, so the first rolled-up month went missing and the first quarter was averaged over two of its months. The rollup window is now compared by UTC date at both ends. --- src/lib/timeline/series.ts | 14 ++- .../timeline-rollup-boundary-zone.test.ts | 104 ++++++++++++++++++ 2 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 tests/integration/timeline-rollup-boundary-zone.test.ts diff --git a/src/lib/timeline/series.ts b/src/lib/timeline/series.ts index 4a6be0a76..942115a0f 100644 --- a/src/lib/timeline/series.ts +++ b/src/lib/timeline/series.ts @@ -35,7 +35,7 @@ import { prisma } from "@/lib/db"; import { isCumulativeDaySumType } from "@/lib/measurements/cumulative-day-sum"; import { readCanonicalRollupBuckets } from "@/lib/rollups/measurement-read"; import { ROLLUP_FOLD_WINDOW_MS } from "@/lib/rollups/measurement-rollups"; -import { dateOnlyKey } from "@/lib/tz/date-only"; +import { dateOnlyKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; import { daysBetweenDateKeys, shiftDateKey, @@ -247,6 +247,14 @@ async function measurementSeries(args: { bucket, ); const boundary = startOfLocalDayKey(boundaryKey, args.tz); + // The live days end at the local boundary; the rolled-up months start at + // the boundary month. The rollup tier keys its months by the UTC + // calendar, so the window is compared by UTC date: west of UTC the local + // midnight of the boundary month comes hours after that month's UTC + // start, and a read from it left the month out. + const rollupFrom = dayKeyAsUtcMidnight( + boundaryKey > args.from ? boundaryKey : args.from, + ); const [older, recent] = await Promise.all([ preFoldBuckets({ userId: args.userId, @@ -261,8 +269,8 @@ async function measurementSeries(args: { ? monthsFromRollups({ userId: args.userId, type: args.type, - from: boundary > start ? boundary : start, - to: end, + from: rollupFrom, + to: dayKeyAsUtcMidnight(args.to), priorityJson: args.priorityJson, }).then((months) => foldMonths(months, bucket)) : Promise.resolve([]), diff --git a/tests/integration/timeline-rollup-boundary-zone.test.ts b/tests/integration/timeline-rollup-boundary-zone.test.ts new file mode 100644 index 000000000..1fb74bd51 --- /dev/null +++ b/tests/integration/timeline-rollup-boundary-zone.test.ts @@ -0,0 +1,104 @@ +/** + * v1.42 — the timeline's long windows read the rolled-up months from the + * month the rollup fold boundary falls in, whatever the account's zone. + * Against Postgres. + * + * The rollup tier keys its months by the UTC calendar. West of UTC the local + * midnight of the boundary month comes hours after that month's UTC start, so + * a read from the local midnight left the month out: a gap in a month series, + * and a quarter averaged over two of its three months. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +/** + * Five years before this the fold boundary falls on 2021-10-10, so the first + * rolled-up month is November 2021 and the first rolled-up quarter Q1 2022. + */ +const NOW = new Date("2026-10-09T12:00:00.000Z"); +const TZ = "America/New_York"; +const USER = "timeline-west"; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(NOW); + await getPrismaClient().user.create({ + data: { + id: USER, + username: USER, + email: `${USER}@example.test`, + timezone: TZ, + }, + }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +/** One DAY rollup row of resting heart rate per `[UTC date, mean, count]`. */ +async function seedDays(days: Array<[string, number, number]>) { + await getPrismaClient().measurementRollup.createMany({ + data: days.map(([date, mean, count]) => ({ + userId: USER, + type: "RESTING_HEART_RATE" as const, + granularity: "DAY" as const, + bucketStart: new Date(`${date}T00:00:00.000Z`), + source: "APPLE_HEALTH" as const, + count, + mean, + minValue: mean, + maxValue: mean, + sumValue: mean * count, + })), + }); +} + +async function series(bucket: "month" | "quarter") { + const { loadTimelineSeries } = await import("@/lib/timeline/series"); + const [out] = await loadTimelineSeries({ + userId: USER, + keys: ["RESTING_HEART_RATE"], + from: "2016-10-09", + to: "2026-10-09", + tz: TZ, + bucket, + priorityJson: null, + now: NOW, + }); + return out?.points ?? []; +} + +describe("timeline rollup months west of UTC", () => { + it("keeps the first rolled-up month after the fold boundary", async () => { + await seedDays([ + ["2021-11-05", 60, 2], + ["2021-12-05", 62, 2], + ["2022-01-05", 64, 2], + ]); + const points = await series("month"); + expect(points).toEqual([ + { t: "2021-11-01", mean: 60, count: 2 }, + { t: "2021-12-01", mean: 62, count: 2 }, + { t: "2022-01-01", mean: 64, count: 2 }, + ]); + }); + + it("averages the first rolled-up quarter over all three of its months", async () => { + await seedDays([ + ["2022-01-05", 60, 1], + ["2022-02-05", 63, 1], + ["2022-02-06", 63, 1], + ["2022-03-05", 66, 1], + ]); + const points = await series("quarter"); + // Each month weighs its days: (60 + 63 + 63 + 66) / 4. + expect(points).toEqual([{ t: "2022-01-01", mean: 63, count: 4 }]); + }); +});