fix(doctor-report): take the day path for dense heart-rate variability and blood oxygen

The report's dense exception covered pulse and glucose. Heart-rate
variability and blood oxygen were read raw for any window, and a watch
or ring writing them every minute puts six figures of readings into a
long report that draws them as one sparkline.

Their report sections are a statistics row and a series, the same as
pulse, so they take the same rule: beyond 90 days per local day, and
inside 90 days per local day once the window holds more than 10 000 of
their readings. One grouped count decides each type on its own. Count,
minimum, maximum and the latest reading stay exact; the mean is the
readings' mean. Glucose keeps its own rule because its clinical panel
needs the per-reading counts.

The bucket query now takes a list of types instead of two flags.
This commit is contained in:
Marc-André Bombeck committed 2026-10-01 08:56:19 +02:00
1 parent e7fdcc208e
commit 730e9e7015
5 files changed
+133 -36

No files matched your search

@@ -19,8 +19,12 @@ import { unzipSync } from "fflate";
vi.mock("@/lib/db", () => ({
prisma: {
// `count` sizes the report's pulse window (#1023): a sparse account.
measurement: { findMany: vi.fn(), count: vi.fn(async () => 0) },
// `groupBy` sizes the report's dense windows (#1023): a sparse account.
measurement: {
findMany: vi.fn(),
count: vi.fn(async () => 0),
groupBy: vi.fn(async () => []),
},
medication: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
@@ -49,6 +49,7 @@ const { calls, rows, prismaMock } = vi.hoisted(() => {
return Promise.resolve(rows[name] ?? []);
},
count: () => Promise.resolve(0),
groupBy: () => Promise.resolve([]),
findUnique: () => {
calls.push(name);
return Promise.resolve(rows[`${name}Unique`]?.[0] ?? null);
+46 -18
View File
@@ -77,8 +77,21 @@ import {
const DENSE_REPORT_RAW_WINDOW_DAYS = 90;
/**
* #1023 — inside the raw window, pulse takes the per-day path anyway once the
* window holds more readings than this.
* The types a device writes at sampling rate whose report sections are
* statistics and a series and nothing else: the figures the day path keeps
* exactly (count, mean, minimum, maximum, latest) plus one point per local
* day. Glucose is dense too but has its own rule below, because its clinical
* panel needs the per-reading counts the bucket query carries.
*/
const DENSE_STAT_TYPES = [
"PULSE",
"HEART_RATE_VARIABILITY",
"OXYGEN_SATURATION",
] as const satisfies readonly MeasurementType[];
/**
* #1023 — inside the raw window, a dense statistics type takes the per-day
* path anyway once the window holds more of its readings than this.
*
* A watch that records heart rate once a minute puts 130 000 readings in a
* 90-day report. Read raw, every one of them became an object, went through
@@ -170,22 +183,38 @@ export async function collectDoctorReportData(
const lastDay = new Date(`${userDayKey(end, reportTz)}T00:00:00.000Z`);
const aggregateDenseTypes = days > DENSE_REPORT_RAW_WINDOW_DAYS;
const densePulse =
!excluded.includes("PULSE") &&
(aggregateDenseTypes ||
(await prisma.measurement.count({
where: {
userId,
type: "PULSE",
measuredAt: { gte: start, lte: end },
deletedAt: null,
},
})) > DENSE_REPORT_RAW_ROW_CAP);
const includedDenseStatTypes = DENSE_STAT_TYPES.filter(
(type) => !excluded.includes(type),
);
// Inside the raw window, one grouped count decides each type on its own:
// a watch's minute-by-minute heart rate folds while a few blood-oxygen
// readings a night beside it stay raw.
const denseStatCounts =
aggregateDenseTypes || includedDenseStatTypes.length === 0
? null
: new Map(
(
await prisma.measurement.groupBy({
by: ["type"],
where: {
userId,
type: { in: [...includedDenseStatTypes] },
measuredAt: { gte: start, lte: end },
deletedAt: null,
},
_count: { _all: true },
})
).map((row) => [row.type, row._count._all]),
);
const denseTypes: MeasurementType[] = includedDenseStatTypes.filter(
(type) =>
denseStatCounts === null ||
(denseStatCounts.get(type) ?? 0) > DENSE_REPORT_RAW_ROW_CAP,
);
const denseGlucose =
aggregateDenseTypes && !excluded.includes("BLOOD_GLUCOSE");
const rawExcluded = [...excluded];
if (densePulse) rawExcluded.push("PULSE");
if (denseGlucose) rawExcluded.push("BLOOD_GLUCOSE");
if (denseGlucose) denseTypes.push("BLOOD_GLUCOSE");
const rawExcluded = [...excluded, ...denseTypes];
const wantsMedications = MEDICATION_LEAVES.some((leaf) => gate.admits(leaf));
const wantsIntakeEvents =
@@ -318,8 +347,7 @@ export async function collectDoctorReportData(
start,
end,
reportTz,
densePulse,
denseGlucose,
denseTypes,
}),
]);
+15 -16
View File
@@ -1,16 +1,17 @@
/**
* The per-local-day pre-aggregation for the two sample-dense measurement
* types.
* The per-local-day pre-aggregation for the sample-dense measurement types.
*
* On a long window a CGM or a per-sample heart-rate account produces six
* figures of rows, so pulse and glucose collapse to one row per local
* day/source/device/context in SQL and the canonical-source picker then works
* over that bounded intermediate set. Every other type keeps the raw-row path.
* figures of rows, so the dense types (glucose, pulse, heart-rate variability,
* blood oxygen) collapse to one row per local day/source/device/context in
* SQL and the canonical-source picker then works over that bounded
* intermediate set. Every other type keeps the raw-row path.
*
* Extracted from the aggregator with the selection rework: the query is a job
* of its own, it is the one raw-SQL surface on this path, and keeping it in a
* thousand-line function is how a parameter stops being reviewed.
*/
import { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import type {
GlucoseContext,
@@ -46,8 +47,8 @@ export interface DenseMeasurementBucket {
}
/**
* Load the dense buckets for whichever of the two types the caller wants. Both
* flags false is not a call: the caller skips it entirely.
* Load the dense buckets for the types the caller names. An empty list is not
* a call: nothing is read.
*
* Every value is bound as a tagged-template parameter — no splice, no
* interpolation of anything that came off the wire.
@@ -57,11 +58,13 @@ export async function loadDenseMeasurementBuckets(params: {
start: Date;
end: Date;
reportTz: string;
densePulse: boolean;
denseGlucose: boolean;
denseTypes: readonly MeasurementType[];
}): Promise<DenseMeasurementBucket[]> {
const { userId, start, end, reportTz, densePulse, denseGlucose } = params;
if (!densePulse && !denseGlucose) return [];
const { userId, start, end, reportTz, denseTypes } = params;
if (denseTypes.length === 0) return [];
const typeList = Prisma.join(
denseTypes.map((type) => Prisma.sql`${type}::"measurement_type"`),
);
return prisma.$queryRaw<DenseMeasurementBucket[]>`
WITH localized AS (
SELECT
@@ -81,11 +84,7 @@ export async function loadDenseMeasurementBuckets(params: {
AND m."measured_at" >= ${start}
AND m."measured_at" <= ${end}
AND m."deleted_at" IS NULL
AND (
(${densePulse} AND m."type" = 'PULSE'::"measurement_type")
OR
(${denseGlucose} AND m."type" = 'BLOOD_GLUCOSE'::"measurement_type")
)
AND m."type" IN (${typeList})
),
scored AS (
SELECT
@@ -9,12 +9,19 @@
* 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 () => {
@@ -173,3 +180,61 @@ describe.each(KINDS)("$kind series", ({ kind, type, unit }) => {
);
});
});
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);
});
});