mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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);
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user