mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
The coach's cycle snapshot read a year of raw readings for the phase crosstab, including steps, heart-rate variability and CGM glucose. On a CGM account that is over 100 000 objects per snapshot. The crosstab only uses each day's sum and count after picking one source and device per day, and the pick depends only on which sources and devices are present. readSourceDayAggregates returns one row per type, day, source and device, and the day map now carries each row's reading count, so the day values are the ones the raw readings give. The 30-day clinical glucose reads stay raw: time in range, SD, J-index and the risk indices are functions of individual readings, and a sensor's rate bounds the window below 45 000 rows. Refs #1023
115 lines
4.7 KiB
TypeScript
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);
|
|
const actual = metricDayMap(aggRows, type, null);
|
|
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);
|
|
});
|