diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts index 29d5fe949..87cae0237 100644 --- a/src/app/api/export/health-record/__tests__/route.test.ts +++ b/src/app/api/export/health-record/__tests__/route.test.ts @@ -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() }, diff --git a/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts b/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts index c279ed081..3d94b9e17 100644 --- a/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts +++ b/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts @@ -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); diff --git a/src/lib/doctor-report/collect.ts b/src/lib/doctor-report/collect.ts index fe2154b0a..d991edbac 100644 --- a/src/lib/doctor-report/collect.ts +++ b/src/lib/doctor-report/collect.ts @@ -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, }), ]); diff --git a/src/lib/doctor-report/dense-buckets.ts b/src/lib/doctor-report/dense-buckets.ts index 597b55349..bd89d4dc1 100644 --- a/src/lib/doctor-report/dense-buckets.ts +++ b/src/lib/doctor-report/dense-buckets.ts @@ -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 { - 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` 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 diff --git a/tests/integration/dense-series-hrv-spo2.test.ts b/tests/integration/dense-series-hrv-spo2.test.ts index e60c70647..0145a4ae2 100644 --- a/tests/integration/dense-series-hrv-spo2.test.ts +++ b/tests/integration/dense-series-hrv-spo2.test.ts @@ -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); + }); +});