Merge remote-tracking branch 'origin/main' into fix/v1397-derive

This commit is contained in:
Marc-André Bombeck committed 2026-10-01 10:30:10 +02:00
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();
});
});