Files
HealthLog/tests/integration/cycle-crosstab-day-aggregates.test.ts
T
Marc-André Bombeck 9d2fd1db14 fix(tz): one default zone, and the user's own calendar where Berlin was hard-coded
The status assessments folded their recent days, weeks, months and years on
Berlin days whatever the user's zone, the mood and cycle crosstabs keyed
their metric days in Berlin, the resting-pulse proxy defaulted to Berlin
days, the briefing's opener rotated at Berlin midnight, and every
assessment prompt labelled today's date as Berlin's. They now use the
user's zone. Rollup months and years are labelled by their UTC start,
which is how they are cut; read in a zone west of UTC the label named the
month before.

"Europe/Berlin" was written out in some eighty places. DEFAULT_TIMEZONE in
lib/tz/format is now the one definition: the second copy in mood/date-key,
DISPLAY_TIMEZONE, the Berlin-only day key helpers and the per-route copies
are gone. An in-repo lint rule rejects the literal outside lib/tz and
tests; four areas another change owns are listed as pending.
2026-09-26 16:23:14 +02:00

115 lines
4.7 KiB
TypeScript

/**
* The cycle-phase crosstab reads per-day, per-source aggregates instead of a
* year of raw readings (#1023). This pins that the day values it tests are
* the ones the raw readings give: for every crosstab metric, `metricDayMap`
* over the aggregates equals `metricDayMap` over the raw rows, on a fixture
* with two sources on the same days, two device types within one source,
* dense CGM glucose, summed steps and readings next to Berlin midnight.
*/
import { beforeAll, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import { readSourceDayAggregates } from "@/lib/measurements/day-aggregates";
import { metricDayMap } from "@/lib/insights/mood-crosstab";
import { PHASE_CROSSTAB_METRIC_TYPES } from "@/lib/cycle/phase-crosstab";
import type { CrossMetricMeasurement } from "@/lib/insights/mood-aggregates";
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const prisma = getPrismaClient();
const USER = "cycle-crosstab-agg";
const SINCE = new Date("2026-06-01T00:00:00Z");
beforeAll(async () => {
await truncateAllTables(prisma);
await prisma.user.create({ data: { id: USER, username: USER } });
// CGM glucose every 5 minutes for 60 days from two sources.
await prisma.$executeRawUnsafe(
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at, sync_version)
SELECT 'cg' || s || g, $1, 'BLOOD_GLUCOSE'::measurement_type, 80 + (g % 60), 'mg/dL',
(CASE WHEN s = 0 THEN 'APPLE_HEALTH' ELSE 'NIGHTSCOUT' END)::measurement_source,
timestamp '2026-06-02 00:00:00' + (g * interval '5 minutes') + (s * interval '1 second'),
now(), now(), 1
FROM generate_series(0, 60 * 288 - 1) g, generate_series(0, 1) s`,
USER,
);
// Steps every 15 minutes from a watch and a phone, plus Google totals on
// some days; resting heart rate once a day near Berlin midnight.
await prisma.$executeRawUnsafe(
`INSERT INTO measurements (id, user_id, type, value, unit, source, device_type, measured_at, created_at, updated_at, sync_version)
SELECT 'st' || d || g, $1, 'ACTIVITY_STEPS'::measurement_type, 10 + (g % 7), 'count',
'APPLE_HEALTH'::measurement_source, CASE WHEN d = 0 THEN 'watch' ELSE 'phone' END,
timestamp '2026-06-02 00:00:00' + (g * interval '15 minutes') + (d * interval '2 seconds'),
now(), now(), 1
FROM generate_series(0, 60 * 96 - 1) g, generate_series(0, 1) d
WHERE d = 0 OR g % 3 = 0`,
USER,
);
await prisma.$executeRawUnsafe(
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at, sync_version)
SELECT 'gs' || g, $1, 'ACTIVITY_STEPS'::measurement_type, 5000 + g, 'count',
'GOOGLE_HEALTH'::measurement_source,
timestamp '2026-06-02 12:00:00' + (g * interval '1 day'), now(), now(), 1
FROM generate_series(0, 59, 4) g`,
USER,
);
await prisma.$executeRawUnsafe(
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at, sync_version)
SELECT 'rh' || g, $1, 'RESTING_HEART_RATE'::measurement_type, 55 + (g % 9), 'bpm',
'WITHINGS'::measurement_source,
timestamp '2026-06-02 22:30:00' + (g * interval '1 day'), now(), now(), 1
FROM generate_series(0, 59) g`,
USER,
);
}, 120_000);
describe("cycle crosstab day values (#1023)", () => {
it("per-source day aggregates give the raw rows' day values for every metric", async () => {
const raw = await prisma.measurement.findMany({
where: {
userId: USER,
deletedAt: null,
type: { in: PHASE_CROSSTAB_METRIC_TYPES },
measuredAt: { gte: SINCE },
},
select: {
type: true,
value: true,
measuredAt: true,
source: true,
deviceType: true,
},
});
const agg = await readSourceDayAggregates({
userId: USER,
types: PHASE_CROSSTAB_METRIC_TYPES,
since: SINCE,
timeZone: "Europe/Berlin",
});
const aggRows: CrossMetricMeasurement[] = agg.map((a) => ({
type: a.type,
value: a.sum,
count: a.n,
measuredAt: a.firstAt,
source: a.source,
deviceType: a.deviceType,
}));
expect(raw.length).toBeGreaterThan(40_000);
expect(aggRows.length).toBeLessThan(500);
for (const type of PHASE_CROSSTAB_METRIC_TYPES) {
const expected = metricDayMap(raw, type, null, "Europe/Berlin");
const actual = metricDayMap(aggRows, type, null, "Europe/Berlin");
expect([...actual.keys()].sort(), type).toEqual(
[...expected.keys()].sort(),
);
for (const [day, value] of expected) {
expect(actual.get(day), `${type} ${day}`).toBeCloseTo(value, 9);
}
}
}, 60_000);
});