Files
HealthLog/tests/integration/chart-all-range-meaning.test.ts
T
Marc-André Bombeck b45a668cf4 fix(charts): serve the All range's coarse buckets as their average day
The All range is read from the WEEK or MONTH rollup tier once a history is
long enough, from DAY rows for a short one, and from the live table on a
coverage miss. The chart folds every path into the average day of each
week or month, but a tier row carried a cumulative metric's whole total
and a level's reading-weighted mean. A month of steps therefore read about
thirty times higher for a long history than for a short one, under a
caption that said average.

A tier row now carries the average day of its bucket on every tier: a
cumulative metric's total over the days that hold a reading, a level's
mean of its daily means. The rollup fold counts the bucket's days and adds
up their means for it. Only the web chart's series reads (the chart
endpoint and the dashboard batch) use these rows.
2026-09-27 14:08:49 +02:00

187 lines
6.1 KiB
TypeScript

/**
* v1.39.4 — the chart's "All" range means one thing at every history length.
*
* A long history is served from the WEEK or MONTH rollup tier, a short one
* from DAY rows, and a coverage miss from the live table. The chart folds
* whatever arrives into the average day of each week (a range up to two
* years) or month (longer). Seeded with real rows and folded through the real
* rollup writer, a fully covered week or month must read the same average day
* on every path: for steps the average daily total, for a level the average
* of its days. Before, a MONTH-tier month of steps read
* as the month's total, about thirty times the same month served by days.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import { readDailySeries } from "@/lib/measurements/daily-series-read";
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
import { bucketTimeSeries } from "@/lib/charts/bucket-time-series";
import type { MeasurementType } from "@/generated/prisma/client";
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/jobs/boss-instance", () => ({
getGlobalBoss: vi.fn(() => null),
}));
const TZ = "America/Los_Angeles";
const DAY_MS = 86_400_000;
const NOW = new Date("2026-09-20T17:00:00.000Z");
/** The week and the month every history below covers fully. */
const WEEK = { from: "2026-06-08", to: "2026-06-14" };
const MONTH = { from: "2026-06-01", to: "2026-06-30" };
let seq = 0;
async function seedUser() {
seq += 1;
return getPrismaClient().user.create({
data: {
username: `chart-all-${seq}`,
email: `chart-all-${seq}@example.test`,
role: "USER",
timezone: TZ,
},
});
}
/**
* `days` days ending yesterday, at 18:00 UTC (11:00 in Los Angeles, the same
* calendar day in both). Steps come as two rows a day; the level as one or
* two readings, so a reading-weighted mean would differ from a day-weighted
* one.
*/
function history(days: number) {
const rows: Array<{ type: MeasurementType; value: number; at: Date }> = [];
const end = Date.parse("2026-09-19T18:00:00.000Z");
for (let i = 0; i < days; i += 1) {
const at = new Date(end - i * DAY_MS);
const steps = 6_000 + ((i * 37) % 5_000);
rows.push({ type: "ACTIVITY_STEPS", value: steps - 1_000, at });
rows.push({
type: "ACTIVITY_STEPS",
value: 1_000,
at: new Date(at.getTime() + 60_000),
});
rows.push({ type: "PULSE", value: 60 + (i % 9), at });
if (i % 3 === 0) {
rows.push({
type: "PULSE",
value: 90,
at: new Date(at.getTime() + 120_000),
});
}
}
return rows;
}
/** The average day of a period straight from the seeded rows. */
function expectedAverageDay(
rows: ReturnType<typeof history>,
type: MeasurementType,
period: { from: string; to: string },
): number {
const byDay = new Map<string, number[]>();
for (const r of rows) {
if (r.type !== type) continue;
const day = r.at.toISOString().slice(0, 10);
if (day < period.from || day > period.to) continue;
byDay.set(day, [...(byDay.get(day) ?? []), r.value]);
}
const perDay = [...byDay.values()].map((values) =>
type === "ACTIVITY_STEPS"
? values.reduce((s, v) => s + v, 0)
: values.reduce((s, v) => s + v, 0) / values.length,
);
return perDay.reduce((s, v) => s + v, 0) / perDay.length;
}
/** The chart's point for `period`, folded at the bucket the range shows. */
async function chartPoint(
userId: string,
type: MeasurementType,
bucket: "week" | "month",
period: { from: string },
) {
const rows = await readDailySeries({
userId,
type,
from: new Date(NOW.getTime() - 3_650 * DAY_MS),
to: NOW,
priorityJson: null,
timeZone: TZ,
});
const points = bucketTimeSeries(
rows.map((row) => ({
timestamp: new Date(row.measuredAt),
values: { v: row.value },
})),
{ bucket, timeZone: TZ },
).points;
const point = points.find(
(p) => new Date(p.timestamp).toISOString().slice(0, 10) === period.from,
);
return point?.values.v;
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("chart All range — one quantity at every history length", () => {
it.each([
["days (a short history)", 200],
["weeks (one to two years)", 500],
["months (a long history)", 900],
])(
"reads the same average day for a period served by %s, and by the live table",
async (_label, days) => {
const user = await seedUser();
const rows = history(days);
await getPrismaClient().measurement.createMany({
data: rows.map((r) => ({
userId: user.id,
type: r.type,
value: r.value,
unit: r.type === "PULSE" ? "bpm" : "count",
source: "APPLE_HEALTH" as const,
measuredAt: r.at,
})),
});
await recomputeUserRollups(user.id, {
from: new Date(NOW.getTime() - (days + 40) * DAY_MS),
to: NOW,
});
// The chart folds a span over two years into months, else weeks.
const bucket = days > 730 ? "month" : "week";
const period = bucket === "month" ? MONTH : WEEK;
for (const type of ["ACTIVITY_STEPS", "PULSE"] as const) {
const expected = expectedAverageDay(rows, type, period);
const tier = await chartPoint(user.id, type, bucket, period);
expect(tier, `${type} from the tier`).toBeCloseTo(expected, 6);
}
// A coverage miss serves the live table's days instead. Its daily
// path keeps the oldest 365 days, so only the short history still
// reaches June through it.
if (days <= 365) {
await getPrismaClient().measurementRollup.deleteMany({
where: { userId: user.id },
});
for (const type of ["ACTIVITY_STEPS", "PULSE"] as const) {
const live = await chartPoint(user.id, type, bucket, period);
expect(live, `${type} from the live table`).toBeCloseTo(
expectedAverageDay(rows, type, period),
6,
);
}
}
},
);
});