Files
HealthLog/tests/integration/daily-series-user-tz.test.ts
T
Marc-André Bombeck 70dba821be perf(measurements): fold each source before picking the canonical one
The live daily fold ranked every row by source before folding, which sorted
the whole window twice. A year of per-minute heart rate (525,600 rows) took
about 0.55 s per series on Postgres 16, with both sorts spilling to disk.

The fold now aggregates per source and bucket in one hashed pass and then
keeps each bucket's ladder-canonical source. Folding the canonical source's
rows is the same as folding that source's group, so the numbers do not
change; the same year reads in about 0.29 s. A new integration case pins the
canonical pick, mean, count and spread for a day with three sources.
2026-09-24 12:37:51 +02:00

188 lines
6.3 KiB
TypeScript

/**
* 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 };
/** The ladder puts Withings first for pulse, so its two readings are the day. */
const CANON_EXPECTED = [
{ day: "2026-09-10", value: 95, count: 2, min: 90, max: 100 },
{ day: "2026-09-11", value: 64, count: 1, min: 64, max: 64 },
];
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);
});
}
it("collapses a day to its canonical source before folding it", async () => {
// Two sources on one day: only the ladder's pick is folded, and the
// mean, count and spread all come from that one source.
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: "tz-canon",
email: "tz-canon@example.test",
role: "USER",
timezone: "Europe/Berlin",
},
});
const at = (h: number) => localHmAsUtc(DAY, "Europe/Berlin", h, 0);
await prisma.measurement.createMany({
data: [
{ value: 60, source: "APPLE_HEALTH" as const, measuredAt: at(8) },
{ value: 70, source: "APPLE_HEALTH" as const, measuredAt: at(9) },
{ value: 90, source: "WITHINGS" as const, measuredAt: at(10) },
{ value: 100, source: "WITHINGS" as const, measuredAt: at(12) },
{ value: 50, source: "MANUAL" as const, measuredAt: at(11) },
// The next day has one source only.
{
value: 64,
source: "WITHINGS" as const,
measuredAt: localHmAsUtc(NEXT_DAY, "Europe/Berlin", 7, 0),
},
].map((r) => ({
...r,
userId: user.id,
type: "PULSE" as const,
unit: "bpm",
})),
});
const rows = await readLiveBuckets({
userId: user.id,
type: "PULSE",
from: new Date("2026-09-05T00:00:00Z"),
to: new Date("2026-09-15T00:00:00Z"),
cap: 366,
priorityJson: null,
grain: "daily",
timeZone: "Europe/Berlin",
});
expect(
rows.map((r) => ({
day: userDayKey(new Date(r.measuredAt), "Europe/Berlin"),
value: r.value,
count: r.count,
min: r.minValue,
max: r.maxValue,
})),
).toEqual(CANON_EXPECTED);
});
});