fix(measurements): cut chart day buckets at the user's midnight

Switching a chart from 7 to 30 days moved steps, active energy,
flights and distance totals onto neighbouring days. The 7-day range
buckets raw rows by the user's calendar day; the 30-day range read the
DAY rollup tier, whose buckets are UTC days, and the chart labelled each
bucket's UTC-midnight start in the user's zone. East of UTC a sample
shortly after local midnight counted on the previous day; west of UTC
every bucket read one day early.

The daily series (the chart read and the batched dashboard read) and the
aggregate=daily|weekly|monthly list now fold live in the user's zone and
return the instant of local midnight, so every range and every summed
metric shares one day boundary. The fold runs in Postgres and returns
one row per day; about 0.1 s for 90 days of per-minute samples. Windows
longer than the DAY cap still step up to the coarser rollup tiers.

Refs #1026
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 07:56:33 +02:00
1 parent 01cd755de6
commit 88cea09c7b
6 files changed
+365 -702

No files matched your search

@@ -0,0 +1,127 @@
/**
* Issue #1026 — a daily total must land on the same calendar day whichever
* chart range reads it.
*
* The 7-day chart pulls raw rows and buckets them by the user's own calendar
* day. The 30-day chart reads the daily series from the rollup tier, whose
* DAY buckets are cut at UTC midnight, and the chart then labels each bucket's
* UTC-midnight start in the user's zone. East of UTC a sample shortly after
* local midnight was counted on the previous day; west of UTC every bucket
* was labelled one day early. Steps, active energy, flights and distance are
* summed per day, so the drift showed up as whole totals moving between
* neighbouring days when the range changed.
*
* This file seeds cumulative samples right next to local midnight for a
* positive, a half-hour and a negative offset, warms the rollup tier the way
* the live instance has it, and asserts that the daily series (both the
* rollup-backed read and the live aggregate) buckets by the user's day.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import {
readDailySeries,
readLiveBuckets,
} from "@/lib/measurements/daily-series-read";
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
import { invalidateUserTimezone } from "@/lib/tz/resolver";
import { userDayKey } from "@/lib/tz/format";
import { localHmAsUtc } from "@/lib/tz/local-day";
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
});
const DAY = new Date("2026-09-10T12:00:00Z");
const NEXT_DAY = new Date("2026-09-11T12:00:00Z");
async function seed(tz: string): Promise<string> {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: `tz-${tz.replace(/\W/g, "-").toLowerCase()}`,
email: `tz-${tz.replace(/\W/g, "-").toLowerCase()}@example.test`,
role: "USER",
timezone: tz,
},
});
invalidateUserTimezone(user.id);
const rows = [
// Just after local midnight: belongs to DAY.
{ at: localHmAsUtc(DAY, tz, 0, 20), value: 100 },
// Just before the next local midnight: still DAY.
{ at: localHmAsUtc(DAY, tz, 23, 40), value: 1000 },
// A daily total stamped at the start of the next local day.
{ at: localHmAsUtc(NEXT_DAY, tz, 0, 0), value: 7 },
{ at: localHmAsUtc(NEXT_DAY, tz, 12, 0), value: 3 },
];
await prisma.measurement.createMany({
data: rows.map((r) => ({
userId: user.id,
type: "ACTIVITY_STEPS" as const,
value: r.value,
unit: "count",
measuredAt: r.at,
source: "APPLE_HEALTH" as const,
})),
});
return user.id;
}
function byLocalDay(
rows: Array<{ measuredAt: string; value: number }>,
tz: string,
): Record<string, number> {
const out: Record<string, number> = {};
for (const r of rows) {
const key = userDayKey(new Date(r.measuredAt), tz);
out[key] = (out[key] ?? 0) + r.value;
}
return out;
}
const EXPECTED = { "2026-09-10": 1100, "2026-09-11": 10 };
describe("daily series buckets by the user's calendar day (#1026)", () => {
for (const tz of ["Europe/Berlin", "Asia/Kolkata", "America/New_York"]) {
it(`chart daily read agrees with the local day (${tz})`, async () => {
const userId = await seed(tz);
const from = new Date("2026-09-05T00:00:00Z");
const to = new Date("2026-09-15T00:00:00Z");
// Warm the rollup tier exactly as the write hook / backfill does.
await recomputeUserRollups(userId, {
types: ["ACTIVITY_STEPS"],
granularities: ["DAY"],
from,
to,
});
const rows = await readDailySeries({
userId,
type: "ACTIVITY_STEPS",
from,
to,
priorityJson: null,
});
expect(byLocalDay(rows, tz)).toEqual(EXPECTED);
});
it(`live daily aggregate agrees with the local day (${tz})`, async () => {
const userId = await seed(tz);
const rows = await readLiveBuckets({
userId,
type: "ACTIVITY_STEPS",
from: new Date("2026-09-05T00:00:00Z"),
to: new Date("2026-09-15T00:00:00Z"),
cap: 366,
priorityJson: null,
grain: "daily",
timeZone: tz,
});
expect(byLocalDay(rows, tz)).toEqual(EXPECTED);
});
}
});
@@ -14,6 +14,7 @@
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { userDayKey } from "@/lib/tz/format";
import { cookieJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
@@ -128,11 +129,15 @@ describe("GET /api/measurements?aggregate=… (real Postgres)", () => {
expect(json.data.meta.aggregate).toBe("daily");
expect(json.data.measurements).toHaveLength(2);
const may1 = json.data.measurements.find((m) =>
m.measuredAt.startsWith("2026-05-01"),
// Buckets are the account's own calendar days (Europe/Berlin, the
// column default), keyed by the instant of local midnight (#1026).
const may1 = json.data.measurements.find(
(m) =>
userDayKey(new Date(m.measuredAt), "Europe/Berlin") === "2026-05-01",
);
const may2 = json.data.measurements.find((m) =>
m.measuredAt.startsWith("2026-05-02"),
const may2 = json.data.measurements.find(
(m) =>
userDayKey(new Date(m.measuredAt), "Europe/Berlin") === "2026-05-02",
);
expect(may1).toBeDefined();
expect(may1!.count).toBe(2);