From 730e9e701580f2d2ca73675e5326218e80da2ecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 08:56:19 +0200 Subject: [PATCH] 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. --- .../health-record/__tests__/route.test.ts | 8 ++- .../__tests__/leaf-gating-sweep.test.ts | 1 + src/lib/doctor-report/collect.ts | 64 +++++++++++++----- src/lib/doctor-report/dense-buckets.ts | 31 +++++---- .../integration/dense-series-hrv-spo2.test.ts | 65 +++++++++++++++++++ 5 files changed, 133 insertions(+), 36 deletions(-) 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); + }); +});