mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Merge remote-tracking branch 'origin/main' into fix/v1397-derive
This commit is contained in:
commit
533b75fdcf
70 files changed
+4167
-566
No files matched your search
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* `GET /api/cycle/insights` reads its outcome channels as per-source day
|
||||
* aggregates, not as a year of readings.
|
||||
*
|
||||
* The route compared a year of resting heart rate, heart-rate variability,
|
||||
* sleep, steps, weight, temperature and glucose across the cycle phases, and
|
||||
* read every reading of it. A sensor reporting glucose every five minutes is
|
||||
* over 100 000 readings in that year. The contrast only uses each local day's
|
||||
* sum and count after one source and device is picked per day, which is what
|
||||
* `readSourceDayAggregates` returns.
|
||||
*
|
||||
* Two properties on real Postgres: no outcome channel is read as rows, and the
|
||||
* response is the one the raw readings give. The second runs the route twice,
|
||||
* once over the SQL fold and once over every raw reading handed in as its own
|
||||
* one-reading aggregate (exactly the old input), and compares the bodies.
|
||||
*/
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import type { SourceDayAggregateRow } from "@/lib/measurements/day-aggregates";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { invalidateUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => cookieJar.set(name, value),
|
||||
delete: (name: string) => cookieJar.delete(name),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
/** When set, the fold is replaced by every raw reading as a one-row group. */
|
||||
const mode = vi.hoisted(() => ({ raw: false }));
|
||||
|
||||
vi.mock("@/lib/measurements/day-aggregates", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/measurements/day-aggregates")>();
|
||||
return {
|
||||
...actual,
|
||||
readSourceDayAggregates: async (
|
||||
opts: Parameters<typeof actual.readSourceDayAggregates>[0],
|
||||
): Promise<SourceDayAggregateRow[]> => {
|
||||
if (!mode.raw) return actual.readSourceDayAggregates(opts);
|
||||
const { getPrismaClient } = await import("./setup");
|
||||
const rows = await getPrismaClient().measurement.findMany({
|
||||
where: {
|
||||
userId: opts.userId,
|
||||
deletedAt: null,
|
||||
type: { in: [...opts.types] },
|
||||
measuredAt: { gte: opts.since },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: {
|
||||
type: true,
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
source: true,
|
||||
deviceType: true,
|
||||
},
|
||||
});
|
||||
return rows.map((r) => ({
|
||||
type: r.type,
|
||||
day: userDayKey(r.measuredAt, opts.timeZone),
|
||||
source: r.source,
|
||||
deviceType: r.deviceType,
|
||||
n: 1,
|
||||
sum: r.value,
|
||||
firstAt: r.measuredAt,
|
||||
}));
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const OWNER = "cycle-dense-owner";
|
||||
const TZ = "Europe/Berlin";
|
||||
const CYCLE_LENGTH = 28;
|
||||
const OBSERVED_CYCLES = 5;
|
||||
const DAY = 86_400_000;
|
||||
|
||||
function isoDay(offsetDays: number): string {
|
||||
return new Date(Date.now() + offsetDays * DAY).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
await prisma.user.create({
|
||||
data: { id: OWNER, username: OWNER, gender: "FEMALE", timezone: TZ },
|
||||
});
|
||||
invalidateUserTimezone(OWNER);
|
||||
await prisma.cycleProfile.create({
|
||||
data: {
|
||||
userId: OWNER,
|
||||
cycleTrackingEnabled: true,
|
||||
predictionEnabled: false,
|
||||
lutealPhaseLength: 14,
|
||||
},
|
||||
});
|
||||
for (let i = OBSERVED_CYCLES; i >= 1; i--) {
|
||||
await prisma.menstrualCycle.create({
|
||||
data: {
|
||||
userId: OWNER,
|
||||
startDate: isoDay(-i * CYCLE_LENGTH),
|
||||
endDate: i > 1 ? isoDay(-(i - 1) * CYCLE_LENGTH - 1) : null,
|
||||
periodEndDate: isoDay(-i * CYCLE_LENGTH + 4),
|
||||
tz: "UTC",
|
||||
},
|
||||
});
|
||||
}
|
||||
const start = `${isoDay(-OBSERVED_CYCLES * CYCLE_LENGTH)} 00:00:00`;
|
||||
// Sensor glucose every five minutes from two sources, higher in the second
|
||||
// half of each cycle; heart-rate variability every twenty minutes from a
|
||||
// watch, lower in the second half; steps every fifteen minutes from a watch
|
||||
// and a phone; weight once a day next to the Berlin midnight.
|
||||
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,
|
||||
(CASE WHEN (g / 288) % 28 >= 14 THEN 120 ELSE 95 END) + (g % 23) + s * 0.5, 'mg/dL',
|
||||
(CASE WHEN s = 0 THEN 'APPLE_HEALTH' ELSE 'NIGHTSCOUT' END)::measurement_source,
|
||||
$2::timestamp + (g * interval '5 minutes') + (s * interval '1 second'),
|
||||
now(), now(), 1
|
||||
FROM generate_series(0, ${OBSERVED_CYCLES * CYCLE_LENGTH} * 288 - 1) g, generate_series(0, 1) s`,
|
||||
OWNER,
|
||||
start,
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at, sync_version)
|
||||
SELECT 'hv' || g, $1, 'HEART_RATE_VARIABILITY'::measurement_type,
|
||||
(CASE WHEN (g / 72) % 28 >= 14 THEN 38 ELSE 52 END) + (g % 11) * 0.7, 'ms',
|
||||
'APPLE_HEALTH'::measurement_source,
|
||||
$2::timestamp + (g * interval '20 minutes'), now(), now(), 1
|
||||
FROM generate_series(0, ${OBSERVED_CYCLES * CYCLE_LENGTH} * 72 - 1) g`,
|
||||
OWNER,
|
||||
start,
|
||||
);
|
||||
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,
|
||||
$2::timestamp + (g * interval '15 minutes') + (d * interval '2 seconds'),
|
||||
now(), now(), 1
|
||||
FROM generate_series(0, ${OBSERVED_CYCLES * CYCLE_LENGTH} * 96 - 1) g, generate_series(0, 1) d
|
||||
WHERE d = 0 OR g % 3 = 0`,
|
||||
OWNER,
|
||||
start,
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at, sync_version)
|
||||
SELECT 'wt' || g, $1, 'WEIGHT'::measurement_type,
|
||||
(CASE WHEN g % 28 >= 14 THEN 71.2 ELSE 70.1 END) + (g % 5) * 0.1, 'kg',
|
||||
'WITHINGS'::measurement_source,
|
||||
$2::timestamp + (g * interval '1 day') + interval '22 hours 50 minutes', now(), now(), 1
|
||||
FROM generate_series(0, ${OBSERVED_CYCLES * CYCLE_LENGTH} - 1) g`,
|
||||
OWNER,
|
||||
start,
|
||||
);
|
||||
await prisma.$executeRawUnsafe(`ANALYZE measurements`);
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: OWNER, expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
});
|
||||
cookieJar.clear();
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
}, 180_000);
|
||||
|
||||
async function readInsights(): Promise<{ status: number; body: unknown }> {
|
||||
const { GET } = await import("@/app/api/cycle/insights/route");
|
||||
const handler = GET as unknown as (request: Request) => Promise<Response>;
|
||||
const res = await handler(new Request("http://localhost/api/cycle/insights"));
|
||||
return { status: res.status, body: await res.json() };
|
||||
}
|
||||
|
||||
function readsOutcomeRows(args: unknown): boolean {
|
||||
const type = (args as { where?: { type?: unknown } } | undefined)?.where
|
||||
?.type;
|
||||
const list = (type as { in?: unknown } | undefined)?.in;
|
||||
return (
|
||||
type === "BLOOD_GLUCOSE" ||
|
||||
type === "HEART_RATE_VARIABILITY" ||
|
||||
(Array.isArray(list) &&
|
||||
(list.includes("BLOOD_GLUCOSE") ||
|
||||
list.includes("HEART_RATE_VARIABILITY")))
|
||||
);
|
||||
}
|
||||
|
||||
function expectSameUpToRounding(
|
||||
actual: unknown,
|
||||
expected: unknown,
|
||||
path: string,
|
||||
): void {
|
||||
if (typeof expected === "number" && typeof actual === "number") {
|
||||
const scale = Math.max(Math.abs(expected), Math.abs(actual));
|
||||
expect(
|
||||
Math.abs(actual - expected) <= scale * 1e-9,
|
||||
`${path}: ${actual} vs ${expected}`,
|
||||
).toBe(true);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(expected)) {
|
||||
expect(Array.isArray(actual), path).toBe(true);
|
||||
expect((actual as unknown[]).length, path).toBe(expected.length);
|
||||
expected.forEach((e, i) =>
|
||||
expectSameUpToRounding((actual as unknown[])[i], e, `${path}[${i}]`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (expected !== null && typeof expected === "object") {
|
||||
expect(actual !== null && typeof actual === "object", path).toBe(true);
|
||||
const a = actual as Record<string, unknown>;
|
||||
const e = expected as Record<string, unknown>;
|
||||
expect(Object.keys(a).sort(), path).toEqual(Object.keys(e).sort());
|
||||
for (const key of Object.keys(e)) {
|
||||
expectSameUpToRounding(a[key], e[key], `${path}.${key}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
expect(actual, path).toEqual(expected);
|
||||
}
|
||||
|
||||
describe("cycle insights on a dense account", () => {
|
||||
it("reads no outcome channel as rows", async () => {
|
||||
mode.raw = false;
|
||||
const spy = vi.spyOn(prisma.measurement, "findMany");
|
||||
try {
|
||||
const started = performance.now();
|
||||
const { status } = await readInsights();
|
||||
process.stderr.write(
|
||||
`[cycle-insights-dense] ${(performance.now() - started).toFixed(0)} ms\n`,
|
||||
);
|
||||
expect(status).toBe(200);
|
||||
expect(spy.mock.calls.filter(([a]) => readsOutcomeRows(a))).toEqual([]);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it("answers exactly what the raw readings give", async () => {
|
||||
mode.raw = false;
|
||||
const folded = await readInsights();
|
||||
mode.raw = true;
|
||||
const raw = await readInsights();
|
||||
mode.raw = false;
|
||||
expect(folded.status).toBe(200);
|
||||
const rows = (folded.body as { data: { rows: unknown[] } }).data.rows;
|
||||
// The fixture is built so the contrast has something to say.
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
// A day's sum is added in another order in SQL than in JavaScript, so a
|
||||
// p-value can differ in its last bits; every figure is otherwise equal.
|
||||
expectSameUpToRounding(folded.body, raw.body, "body");
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* The per-kind series for heart-rate variability and blood oxygen follows the
|
||||
* same dense rule as pulse and glucose.
|
||||
*
|
||||
* Both used to read every raw row for any window up to 3650 days, and a ring
|
||||
* or a watch that writes them every minute overnight, or all day, makes that
|
||||
* the same unbounded read pulse had before #1023. Beyond 90 days they now fold
|
||||
* per local day; within 90 days a window of more than 10 000 readings folds
|
||||
* per local hour. A sparse account inside 90 days keeps its raw readings,
|
||||
* byte for byte, and a bucket has the shape of a reading of its kind (no
|
||||
* `valueMin` / `valueMax`, which only pulse carries).
|
||||
*
|
||||
* The doctor report applies the same rule to both: their sections are a
|
||||
* statistics row and a series, so a dense window takes the per-day path pulse
|
||||
* already takes, with exact count, minimum, maximum and latest reading.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import { collectDoctorReportData } from "@/lib/doctor-report-data";
|
||||
import { latestReading } from "@/lib/fhir/resources/common";
|
||||
import { selectionFromLeaves } from "@/lib/report-selection/selection";
|
||||
import { invalidateUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => cookieJar.set(name, value),
|
||||
delete: (name: string) => cookieJar.delete(name),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const TZ = "Europe/Berlin";
|
||||
const DENSE = "dense-hrv-spo2-owner";
|
||||
const SPARSE = "sparse-hrv-spo2-owner";
|
||||
const NOW = new Date(Math.floor(Date.now() / 60_000) * 60_000 - 60_000);
|
||||
/** Twelve days of one reading a minute: over the 10 000-row cap. */
|
||||
const DENSE_ROWS = 12 * 24 * 60;
|
||||
|
||||
const KINDS = [
|
||||
{ kind: "heartRateVariability", type: "HEART_RATE_VARIABILITY", unit: "ms" },
|
||||
{ kind: "oxygenSaturation", type: "OXYGEN_SATURATION", unit: "%" },
|
||||
] as const;
|
||||
|
||||
beforeAll(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
for (const id of [DENSE, SPARSE]) {
|
||||
await prisma.user.create({ data: { id, username: id, timezone: TZ } });
|
||||
invalidateUserTimezone(id);
|
||||
}
|
||||
for (const { type, unit } of KINDS) {
|
||||
await prisma.$executeRawUnsafe(
|
||||
`INSERT INTO measurements (
|
||||
id, user_id, type, value, unit, source, measured_at,
|
||||
created_at, updated_at, sync_version)
|
||||
SELECT $4 || lpad(g::text, 7, '0'), $1, $5::measurement_type,
|
||||
CASE WHEN $5 = 'OXYGEN_SATURATION' THEN 92 + (g % 8) ELSE 30 + (g % 40) * 0.5 END,
|
||||
$6, 'GOOGLE_HEALTH'::measurement_source,
|
||||
($2::timestamptz AT TIME ZONE 'UTC') - (g * interval '1 minute'),
|
||||
now(), now(), 1
|
||||
FROM generate_series(0, $3::int - 1) g`,
|
||||
DENSE,
|
||||
NOW.toISOString(),
|
||||
DENSE_ROWS,
|
||||
type === "OXYGEN_SATURATION" ? "ds" : "dh",
|
||||
type,
|
||||
unit,
|
||||
);
|
||||
// A few readings a night for a month and a half.
|
||||
await prisma.measurement.createMany({
|
||||
data: Array.from({ length: 45 * 3 }, (_, i) => ({
|
||||
id: `sp-${type}-${i}`,
|
||||
userId: SPARSE,
|
||||
type,
|
||||
value: type === "OXYGEN_SATURATION" ? 94 + (i % 5) : 35 + (i % 17),
|
||||
unit,
|
||||
source: "MANUAL" as const,
|
||||
measuredAt: new Date(
|
||||
NOW.getTime() - Math.floor(i / 3) * 86_400_000 - (i % 3) * 7_200_000,
|
||||
),
|
||||
})),
|
||||
});
|
||||
}
|
||||
await prisma.$executeRawUnsafe(`ANALYZE measurements`);
|
||||
}, 120_000);
|
||||
|
||||
async function series(userId: string, kind: string, days: number) {
|
||||
const s = await prisma.session.create({
|
||||
data: { userId, expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
});
|
||||
cookieJar.clear();
|
||||
cookieJar.set("healthlog_session", s.id);
|
||||
const { GET } = await import("@/app/api/measurements/series/route");
|
||||
const res = await GET(
|
||||
new NextRequest(
|
||||
`http://localhost/api/measurements/series?kind=${kind}&days=${days}`,
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
return (
|
||||
(await res.json()) as {
|
||||
data: {
|
||||
unit: string;
|
||||
points: Array<Record<string, unknown> & { id: string; value: number }>;
|
||||
stats: { count: number; mean: number; min: number; max: number };
|
||||
};
|
||||
}
|
||||
).data;
|
||||
}
|
||||
|
||||
describe.each(KINDS)("$kind series", ({ kind, type, unit }) => {
|
||||
it("sends a dense stream inside 90 days as hour buckets", async () => {
|
||||
const data = await series(DENSE, kind, 30);
|
||||
expect(data.unit).toBe(unit);
|
||||
expect(data.points.length).toBeLessThanOrEqual(12 * 24 + 2);
|
||||
expect(data.points.length).toBeGreaterThanOrEqual(12 * 24 - 2);
|
||||
expect(data.points.every((p) => p.id.startsWith("hour:"))).toBe(true);
|
||||
// A bucket has the shape of a reading of its kind.
|
||||
expect(
|
||||
data.points.every((p) => !("valueMin" in p) && !("valueMax" in p)),
|
||||
).toBe(true);
|
||||
// Statistics stay over every reading.
|
||||
const agg = await prisma.measurement.aggregate({
|
||||
where: {
|
||||
userId: DENSE,
|
||||
type,
|
||||
measuredAt: { gte: new Date(Date.now() - 30 * 86_400_000) },
|
||||
},
|
||||
_count: true,
|
||||
_min: { value: true },
|
||||
_max: { value: true },
|
||||
});
|
||||
expect(data.stats.count).toBe(agg._count);
|
||||
expect(data.stats.min).toBe(agg._min.value);
|
||||
expect(data.stats.max).toBe(agg._max.value);
|
||||
});
|
||||
|
||||
it("folds the long ranges per local day", async () => {
|
||||
const data = await series(SPARSE, kind, 3650);
|
||||
expect(data.points.length).toBeGreaterThan(40);
|
||||
expect(data.points.length).toBeLessThanOrEqual(46);
|
||||
expect(data.points.every((p) => p.id.startsWith("day:"))).toBe(true);
|
||||
expect(data.stats.count).toBe(45 * 3);
|
||||
});
|
||||
|
||||
it("keeps a sparse series inside 90 days raw", async () => {
|
||||
const data = await series(SPARSE, kind, 30);
|
||||
const raw = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: SPARSE,
|
||||
type,
|
||||
measuredAt: { gte: new Date(Date.now() - 30 * 86_400_000) },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { id: true, value: true, measuredAt: true },
|
||||
});
|
||||
expect(data.points).toEqual(
|
||||
raw.map((r) => ({
|
||||
id: r.id,
|
||||
at: r.measuredAt.toISOString(),
|
||||
value: r.value,
|
||||
secondary: null,
|
||||
})),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(KINDS)("doctor report $type", ({ type }) => {
|
||||
async function report(userId: string, days: number) {
|
||||
const end = new Date();
|
||||
const start = new Date(end.getTime() - days * 86_400_000);
|
||||
return collectDoctorReportData(
|
||||
userId,
|
||||
{ start, end, days },
|
||||
selectionFromLeaves([type]),
|
||||
);
|
||||
}
|
||||
|
||||
it("folds a dense window to one point per day, statistics exact", async () => {
|
||||
const data = await report(DENSE, 30);
|
||||
const points = data.measurements[type] ?? [];
|
||||
expect(points.length).toBeLessThanOrEqual(14);
|
||||
expect(points.length).toBeGreaterThanOrEqual(12);
|
||||
const agg = await prisma.measurement.aggregate({
|
||||
where: { userId: DENSE, type },
|
||||
_count: true,
|
||||
_avg: { value: true },
|
||||
_min: { value: true },
|
||||
_max: { value: true },
|
||||
});
|
||||
expect(data.stats[type]?.count).toBe(agg._count);
|
||||
expect(data.stats[type]?.min).toBe(agg._min.value);
|
||||
expect(data.stats[type]?.max).toBe(agg._max.value);
|
||||
expect(data.stats[type]?.avg).toBeCloseTo(agg._avg.value ?? 0, 9);
|
||||
const newest = await prisma.measurement.findFirstOrThrow({
|
||||
where: { userId: DENSE, type },
|
||||
orderBy: { measuredAt: "desc" },
|
||||
select: { value: true, measuredAt: true },
|
||||
});
|
||||
expect(latestReading(data, type)).toEqual({
|
||||
value: newest.value,
|
||||
measuredAt: newest.measuredAt.toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a sparse window raw", async () => {
|
||||
const data = await report(SPARSE, 30);
|
||||
const count = await prisma.measurement.count({
|
||||
where: {
|
||||
userId: SPARSE,
|
||||
type,
|
||||
measuredAt: { gte: new Date(Date.now() - 30 * 86_400_000) },
|
||||
},
|
||||
});
|
||||
expect(data.measurements[type]).toHaveLength(count);
|
||||
expect(data.stats[type]?.count).toBe(count);
|
||||
});
|
||||
|
||||
it("folds a window longer than 90 days per day", async () => {
|
||||
const data = await report(SPARSE, 120);
|
||||
expect(data.stats[type]?.count).toBe(45 * 3);
|
||||
expect((data.measurements[type] ?? []).length).toBeLessThanOrEqual(46);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* The targets page on a dense account: a glucose sensor reporting every five
|
||||
* minutes and a watch recording heart rate once a minute.
|
||||
*
|
||||
* `buildTargetsResponse` read a year of raw glucose readings to keep the
|
||||
* newest one per meal context, and thirty days of raw heart rate to derive one
|
||||
* resting figure per day. Both now fold in SQL. These cases pin that neither
|
||||
* stream is read as rows any more, and that the cards come out exactly as the
|
||||
* in-memory fold over the raw readings makes them, on a dense account and on
|
||||
* a sparse one with readings next to the local midnight.
|
||||
*/
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import { deriveRestingProxyFromPulse } from "@/lib/analytics/resting-pulse";
|
||||
import { buildTargetsResponse } from "@/lib/targets/build-response";
|
||||
import { buildGlucoseTargets } from "@/lib/targets/glucose-builder";
|
||||
import {
|
||||
foldGlucoseTargetRows,
|
||||
readGlucoseTargetSummaries,
|
||||
} from "@/lib/targets/glucose-read";
|
||||
import { buildVitalTargets } from "@/lib/targets/vitals-builder";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { invalidateUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const TZ = "America/New_York";
|
||||
const DENSE = "dense-targets-owner";
|
||||
const SPARSE = "sparse-targets-owner";
|
||||
const NOW = new Date(Math.floor(Date.now() / 60_000) * 60_000 - 60_000);
|
||||
const DAY = 86_400_000;
|
||||
|
||||
beforeAll(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
for (const id of [DENSE, SPARSE]) {
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id,
|
||||
username: id,
|
||||
timezone: TZ,
|
||||
dateOfBirth: new Date("1980-05-01T00:00:00Z"),
|
||||
gender: "FEMALE",
|
||||
heightCm: 170,
|
||||
},
|
||||
});
|
||||
invalidateUserTimezone(id);
|
||||
}
|
||||
// 200 days of a sensor every five minutes, untagged, with every 37th
|
||||
// reading tagged fasting so two contexts are present.
|
||||
await prisma.$executeRawUnsafe(
|
||||
`INSERT INTO measurements (
|
||||
id, user_id, type, value, unit, source, glucose_context, measured_at,
|
||||
created_at, updated_at, sync_version)
|
||||
SELECT 'dg' || lpad(g::text, 7, '0'), $1, 'BLOOD_GLUCOSE'::measurement_type,
|
||||
70 + (g % 110) + (g % 3) * 0.25, 'mg/dL', 'NIGHTSCOUT'::measurement_source,
|
||||
CASE WHEN g % 37 = 0 THEN 'FASTING'::glucose_context ELSE NULL END,
|
||||
($2::timestamptz AT TIME ZONE 'UTC') - (g * interval '5 minutes'),
|
||||
now(), now(), 1
|
||||
FROM generate_series(0, 200 * 288 - 1) g`,
|
||||
DENSE,
|
||||
NOW.toISOString(),
|
||||
);
|
||||
// Ten days of heart rate once a minute.
|
||||
await prisma.$executeRawUnsafe(
|
||||
`INSERT INTO measurements (
|
||||
id, user_id, type, value, unit, source, measured_at,
|
||||
created_at, updated_at, sync_version)
|
||||
SELECT 'dp' || lpad(g::text, 7, '0'), $1, 'PULSE'::measurement_type,
|
||||
55 + (g % 50), 'bpm', 'GOOGLE_HEALTH'::measurement_source,
|
||||
($2::timestamptz AT TIME ZONE 'UTC') - (g * interval '1 minute'),
|
||||
now(), now(), 1
|
||||
FROM generate_series(0, 10 * 24 * 60 - 1) g`,
|
||||
DENSE,
|
||||
NOW.toISOString(),
|
||||
);
|
||||
|
||||
// Sparse: fingerstick readings in three contexts, a few a day, some older
|
||||
// than thirty days, some within minutes of the New York midnight; a cuff a
|
||||
// few times a day; a resting heart rate on alternate days.
|
||||
const contexts = ["FASTING", "POSTPRANDIAL", null] as const;
|
||||
const sparse: Array<{
|
||||
type: "BLOOD_GLUCOSE" | "PULSE" | "RESTING_HEART_RATE";
|
||||
value: number;
|
||||
at: Date;
|
||||
ctx?: (typeof contexts)[number];
|
||||
}> = [];
|
||||
for (let day = 0; day < 60; day++) {
|
||||
for (let i = 0; i < 1 + (day % 3); i++) {
|
||||
sparse.push({
|
||||
type: "BLOOD_GLUCOSE",
|
||||
value: 85 + ((day * 13 + i * 29) % 90) + (i % 2 ? 0.5 : 0),
|
||||
at: new Date(NOW.getTime() - day * DAY - (i * 311 + 7) * 60_000),
|
||||
ctx: contexts[(day + i) % 3],
|
||||
});
|
||||
}
|
||||
if (day < 25) {
|
||||
for (let i = 0; i < 2 + (day % 4); i++) {
|
||||
sparse.push({
|
||||
type: "PULSE",
|
||||
value: 54 + ((day * 7 + i * 11) % 40),
|
||||
at: new Date(NOW.getTime() - day * DAY - (i * 173 + 3) * 60_000),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (day % 2 === 0 && day < 30) {
|
||||
sparse.push({
|
||||
type: "RESTING_HEART_RATE",
|
||||
value: 58 + (day % 6),
|
||||
at: new Date(NOW.getTime() - day * DAY - 4 * 3_600_000),
|
||||
});
|
||||
}
|
||||
}
|
||||
await prisma.measurement.createMany({
|
||||
data: sparse.map((r, i) => ({
|
||||
id: `st${String(i).padStart(5, "0")}`,
|
||||
userId: SPARSE,
|
||||
type: r.type,
|
||||
value: r.value,
|
||||
unit: r.type === "BLOOD_GLUCOSE" ? "mg/dL" : "bpm",
|
||||
source: "MANUAL" as const,
|
||||
glucoseContext: r.ctx ?? null,
|
||||
measuredAt: r.at,
|
||||
})),
|
||||
});
|
||||
await prisma.$executeRawUnsafe(`ANALYZE measurements`);
|
||||
}, 180_000);
|
||||
|
||||
function readsDenseRows(args: unknown): boolean {
|
||||
const type = (args as { where?: { type?: unknown } } | undefined)?.where
|
||||
?.type;
|
||||
if (type === "BLOOD_GLUCOSE" || type === "PULSE") return true;
|
||||
const list = (type as { in?: unknown } | undefined)?.in;
|
||||
return (
|
||||
Array.isArray(list) &&
|
||||
(list.includes("PULSE") || list.includes("BLOOD_GLUCOSE"))
|
||||
);
|
||||
}
|
||||
|
||||
describe("targets on a dense account", () => {
|
||||
it("never reads the glucose or heart-rate stream as rows", async () => {
|
||||
const spy = vi.spyOn(prisma.measurement, "findMany");
|
||||
try {
|
||||
const started = performance.now();
|
||||
const res = await buildTargetsResponse({ id: DENSE, timezone: TZ });
|
||||
const elapsed = performance.now() - started;
|
||||
process.stderr.write(
|
||||
`[dense-targets] buildTargetsResponse ${elapsed.toFixed(0)} ms\n`,
|
||||
);
|
||||
expect(res.targets.length).toBeGreaterThan(0);
|
||||
const denseReads = spy.mock.calls.filter(([args]) =>
|
||||
readsDenseRows(args),
|
||||
);
|
||||
expect(denseReads).toEqual([]);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("targets fold equals the raw-reading fold", () => {
|
||||
for (const userId of [SPARSE, DENSE]) {
|
||||
it(`glucose summaries match the in-memory fold (${userId})`, async () => {
|
||||
const since = new Date(NOW.getTime() - 365 * DAY);
|
||||
const recentSince = new Date(NOW.getTime() - 30 * DAY);
|
||||
const raw = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "BLOOD_GLUCOSE",
|
||||
deletedAt: null,
|
||||
measuredAt: { gte: since },
|
||||
},
|
||||
orderBy: [{ measuredAt: "desc" }, { id: "desc" }],
|
||||
select: { value: true, measuredAt: true, glucoseContext: true },
|
||||
});
|
||||
const expected = foldGlucoseTargetRows(raw, recentSince, TZ);
|
||||
const actual = await readGlucoseTargetSummaries({
|
||||
userId,
|
||||
since,
|
||||
recentSince,
|
||||
timeZone: TZ,
|
||||
});
|
||||
const byCtx = <T extends { glucoseContext: string | null }>(xs: T[]) =>
|
||||
[...xs].sort((a, b) =>
|
||||
(a.glucoseContext ?? "").localeCompare(b.glucoseContext ?? ""),
|
||||
);
|
||||
expect(actual.length).toBeGreaterThanOrEqual(2);
|
||||
const exp = byCtx(expected);
|
||||
const act = byCtx(actual);
|
||||
expect(act.map((s) => s.glucoseContext)).toEqual(
|
||||
exp.map((s) => s.glucoseContext),
|
||||
);
|
||||
for (let i = 0; i < exp.length; i++) {
|
||||
expect(act[i].latest).toBe(exp[i].latest);
|
||||
expect(act[i].latestAt).toEqual(exp[i].latestAt);
|
||||
expect(act[i].recentCount).toBe(exp[i].recentCount);
|
||||
expect([...act[i].recentByDay.keys()].sort()).toEqual(
|
||||
[...exp[i].recentByDay.keys()].sort(),
|
||||
);
|
||||
for (const [day, bucket] of exp[i].recentByDay) {
|
||||
const got = act[i].recentByDay.get(day)!;
|
||||
expect(got.count, day).toBe(bucket.count);
|
||||
expect(got.sum, day).toBeCloseTo(bucket.sum, 6);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it(`glucose and resting-pulse cards equal the raw path (${userId})`, async () => {
|
||||
const res = await buildTargetsResponse({ id: userId, timezone: TZ });
|
||||
// The builder takes its own clock after the reads; rebuild the raw
|
||||
// expectation against the same thirty-day cut.
|
||||
const now = new Date();
|
||||
const recentSince = new Date(now.getTime() - 30 * DAY);
|
||||
const raw = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "BLOOD_GLUCOSE",
|
||||
deletedAt: null,
|
||||
measuredAt: { gte: new Date(now.getTime() - 365 * DAY) },
|
||||
},
|
||||
orderBy: [{ measuredAt: "desc" }, { id: "desc" }],
|
||||
select: { value: true, measuredAt: true, glucoseContext: true },
|
||||
});
|
||||
const expectedGlucose = buildGlucoseTargets({
|
||||
summaries: foldGlucoseTargetRows(raw, recentSince, TZ),
|
||||
profile: {
|
||||
heightCm: 170,
|
||||
dateOfBirth: new Date("1980-05-01T00:00:00Z"),
|
||||
gender: "FEMALE",
|
||||
glucoseUnit: null,
|
||||
hasDiabetes: false,
|
||||
thresholdsJson: null,
|
||||
},
|
||||
timezone: TZ,
|
||||
now,
|
||||
});
|
||||
const glucose = res.targets.filter((t) =>
|
||||
t.type.startsWith("BLOOD_GLUCOSE_"),
|
||||
);
|
||||
expect(glucose.length).toBe(expectedGlucose.length);
|
||||
for (let i = 0; i < glucose.length; i++) {
|
||||
const { average30, ...rest } = glucose[i];
|
||||
const { average30: expAvg, ...expRest } = expectedGlucose[i];
|
||||
expect(rest).toEqual(expRest);
|
||||
if (expAvg === null) expect(average30).toBeNull();
|
||||
else expect(average30).toBeCloseTo(expAvg, 6);
|
||||
}
|
||||
|
||||
const recent = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: { in: ["PULSE", "RESTING_HEART_RATE"] },
|
||||
deletedAt: null,
|
||||
measuredAt: { gte: recentSince },
|
||||
},
|
||||
select: { type: true, value: true, measuredAt: true },
|
||||
});
|
||||
const expectedPulse = buildVitalTargets({
|
||||
recentMeasurements: recent.filter(
|
||||
(r) => r.type === "RESTING_HEART_RATE",
|
||||
),
|
||||
restingPulseProxy: deriveRestingProxyFromPulse(
|
||||
recent.filter((r) => r.type === "PULSE"),
|
||||
(d) => userDayKey(d, TZ),
|
||||
),
|
||||
latestByType: {},
|
||||
average30ByType: {},
|
||||
heightCm: 170,
|
||||
age: res.profile.age,
|
||||
gender: "FEMALE",
|
||||
timezone: TZ,
|
||||
now,
|
||||
weightTargetOverride: null,
|
||||
}).targets.find((t) => t.type === "PULSE");
|
||||
const pulse = res.targets.find((t) => t.type === "PULSE");
|
||||
expect(pulse?.current).not.toBeNull();
|
||||
expect(pulse).toEqual(expectedPulse);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -56,7 +56,7 @@ interface ListEnvelope {
|
||||
napCount?: number;
|
||||
awakenings?: number;
|
||||
}>;
|
||||
meta: { total: number; groupBy?: string };
|
||||
meta: { total: number; groupBy?: string; windowFrom?: string };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -160,4 +160,61 @@ describe("GET /api/measurements?type=SLEEP_DURATION", () => {
|
||||
const stages = body.data.measurements.map((m) => m.sleepStage).sort();
|
||||
expect(stages).toEqual(["CORE", "DEEP", "REM"]);
|
||||
});
|
||||
|
||||
it("reads at most a year ending at `to`, however early `from` is", async () => {
|
||||
// An explicit early `from` used to walk every per-stage row back to it.
|
||||
const prisma = getPrismaClient();
|
||||
const user = await seedSession("sleep-window-user");
|
||||
const day = 86_400_000;
|
||||
const morning = new Date();
|
||||
morning.setUTCHours(4, 0, 0, 0);
|
||||
// One night of a single CORE stage every day for two years.
|
||||
await prisma.measurement.createMany({
|
||||
data: Array.from({ length: 730 }, (_, i) => ({
|
||||
userId: user.id,
|
||||
type: "SLEEP_DURATION" as const,
|
||||
value: 420,
|
||||
unit: "minutes",
|
||||
source: "APPLE_HEALTH" as const,
|
||||
measuredAt: new Date(morning.getTime() - i * day),
|
||||
externalId: `uuid-night-${i}`,
|
||||
sleepStage: "CORE" as const,
|
||||
})),
|
||||
});
|
||||
const spy = vi.spyOn(prisma.measurement, "findMany");
|
||||
const from = new Date(morning.getTime() - 800 * day).toISOString();
|
||||
const res = await callList(
|
||||
`type=SLEEP_DURATION&from=${encodeURIComponent(from)}&limit=1000`,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as ListEnvelope;
|
||||
expect(body.data.meta.total).toBeLessThanOrEqual(366);
|
||||
expect(body.data.meta.total).toBeGreaterThanOrEqual(364);
|
||||
expect(body.data.meta.windowFrom).toBeDefined();
|
||||
const windowFrom = Date.parse(body.data.meta.windowFrom!);
|
||||
expect(Date.now() - windowFrom).toBeLessThan(366 * day);
|
||||
expect(Date.now() - windowFrom).toBeGreaterThan(364 * day);
|
||||
// The read itself was bounded, not just the answer.
|
||||
const read = spy.mock.calls
|
||||
.map(([args]) => args as { where?: { measuredAt?: { gte?: Date } } })
|
||||
.find((args) => args?.where?.measuredAt?.gte);
|
||||
expect(read?.where?.measuredAt?.gte?.getTime()).toBe(windowFrom);
|
||||
|
||||
// Older nights are reached by moving `to` back.
|
||||
const to = new Date(morning.getTime() - 400 * day).toISOString();
|
||||
const older = await callList(
|
||||
`type=SLEEP_DURATION&from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}&limit=1000`,
|
||||
);
|
||||
const olderBody = (await older.json()) as ListEnvelope;
|
||||
expect(olderBody.data.meta.total).toBeGreaterThanOrEqual(329);
|
||||
expect(olderBody.data.meta.total).toBeLessThanOrEqual(331);
|
||||
|
||||
// A window inside the year is untouched and carries no marker.
|
||||
const recent = await callList(
|
||||
`type=SLEEP_DURATION&from=${encodeURIComponent(new Date(morning.getTime() - 10.5 * day).toISOString())}`,
|
||||
);
|
||||
const recentBody = (await recent.json()) as ListEnvelope;
|
||||
expect(recentBody.data.meta.total).toBe(11);
|
||||
expect(recentBody.data.meta.windowFrom).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user