From 68368300389002fa550ebcedff6f5b1cd9b755db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 27 Sep 2026 21:20:24 +0200 Subject: [PATCH] fix(doctor-report): report readings, not day buckets, for a dense type A type served from day buckets (pulse or glucose past 90 days, and since the previous change a dense pulse stream on any window) is drawn one point per day, each the day's mean at the day's start. The summary line counted those points as measurements, and the FHIR export stamped the last day's mean as the latest observation: a value and a time nobody measured. The day-bucket stats now carry when their newest reading was taken (`latestAt`). `latestReading` returns that reading when it is there, and the summary line counts each type's readings from its stats. Raw series are unchanged: their stats count equals their points and their last point is the newest reading. --- src/lib/doctor-report-pdf/clinical-summary.ts | 6 +- src/lib/doctor-report-types.ts | 8 ++ .../day-bucket-latest-and-count.test.ts | 121 ++++++++++++++++++ src/lib/doctor-report/measurement-series.ts | 1 + src/lib/fhir/resources/common.ts | 11 +- .../dense-pulse-raw-window-reads.test.ts | 11 ++ 6 files changed, 155 insertions(+), 3 deletions(-) create mode 100644 src/lib/doctor-report/__tests__/day-bucket-latest-and-count.test.ts diff --git a/src/lib/doctor-report-pdf/clinical-summary.ts b/src/lib/doctor-report-pdf/clinical-summary.ts index 9543f5e45..013847f6a 100644 --- a/src/lib/doctor-report-pdf/clinical-summary.ts +++ b/src/lib/doctor-report-pdf/clinical-summary.ts @@ -55,8 +55,10 @@ export function buildClinicalSummaryLines( num: DoctorReportNumberFormatter, ): string[] { const lines: string[] = []; - const totalReadings = Object.values(data.measurements).reduce( - (sum, arr) => sum + arr.length, + // The reading count, not the point count: a dense type served from day + // buckets draws one point per day, but its stats count every reading. + const totalReadings = Object.entries(data.measurements).reduce( + (sum, [type, arr]) => sum + (data.stats[type]?.count ?? arr.length), 0, ); const paramCount = Object.keys(data.stats).length; diff --git a/src/lib/doctor-report-types.ts b/src/lib/doctor-report-types.ts index c37bab2c4..cdacd85b1 100644 --- a/src/lib/doctor-report-types.ts +++ b/src/lib/doctor-report-types.ts @@ -36,8 +36,16 @@ export interface DoctorReportStats { avg: number; min: number; max: number; + /** Readings behind the figures, however the series was drawn. */ count: number; latest: number; + /** + * When the newest reading behind `latest` was taken (ISO). Set for a type + * served from day buckets, whose series points are day means: the series' + * last point is then not a reading, and this is the only place the newest + * one survives. Absent on the raw path, where the series' last point is it. + */ + latestAt?: string; } /** diff --git a/src/lib/doctor-report/__tests__/day-bucket-latest-and-count.test.ts b/src/lib/doctor-report/__tests__/day-bucket-latest-and-count.test.ts new file mode 100644 index 000000000..feb000741 --- /dev/null +++ b/src/lib/doctor-report/__tests__/day-bucket-latest-and-count.test.ts @@ -0,0 +1,121 @@ +/** + * A type served from day buckets (pulse or glucose on a long window, or a + * dense stream on any window) is drawn one point per day, and each point is + * that day's mean stamped at the day's start. Two readers used to treat those + * points as readings: the report's summary line counted them as measurements, + * and the FHIR export stamped the last day's mean as the latest observation. + * Both now read the stats, which describe the readings themselves. + */ +import { describe, expect, it } from "vitest"; + +import type { DoctorReportData } from "@/lib/doctor-report-data"; +import { buildClinicalSummaryLines } from "@/lib/doctor-report-pdf/clinical-summary"; +import { latestReading } from "@/lib/fhir/resources/common"; +import type { DenseMeasurementBucket } from "../dense-buckets"; +import { summariseDenseBuckets } from "../measurement-series"; + +function bucket( + day: string, + count: number, + sum: number, + latestAt: string, + latestValue: number, +): DenseMeasurementBucket { + return { + bucketStart: new Date(`${day}T00:00:00.000Z`), + type: "PULSE", + source: "GOOGLE_HEALTH", + deviceType: null, + glucoseContext: null, + count, + sumValue: sum, + minValue: 50, + maxValue: 120, + latestAt: new Date(latestAt), + latestValue, + clinicalCount: count, + clinicalSum: sum, + clinicalSumSquares: null, + clinicalFirstAt: null, + clinicalLastAt: null, + clinicalTirCount: 0, + clinicalTbr1Count: 0, + clinicalTbr2Count: 0, + clinicalTar1Count: 0, + clinicalTar2Count: 0, + clinicalLowRiskSum: null, + clinicalHighRiskSum: null, + }; +} + +/** Two days of one reading a minute; the newest reading is 61 bpm at 23:59. */ +const summary = summariseDenseBuckets( + [ + bucket("2026-09-01", 1440, 1440 * 70, "2026-09-01T23:59:00.000Z", 66), + bucket("2026-09-02", 1440, 1440 * 75, "2026-09-02T23:59:00.000Z", 61), + ], + "UTC", + null, + 120, +); + +function reportData(): DoctorReportData { + return { + measurements: summary.byType, + stats: summary.stats, + } as unknown as DoctorReportData; +} + +describe("a pulse series served from day buckets", () => { + it("is drawn as day means", () => { + expect(summary.byType.PULSE).toEqual([ + { value: 70, measuredAt: "2026-09-01T00:00:00.000Z" }, + { value: 75, measuredAt: "2026-09-02T00:00:00.000Z" }, + ]); + }); + + it("reports its newest actual reading to FHIR, not the last day's mean", () => { + expect(latestReading(reportData(), "PULSE")).toEqual({ + value: 61, + measuredAt: "2026-09-02T23:59:00.000Z", + }); + }); + + it("counts its readings in the summary line, not its points", () => { + const counts: unknown[] = []; + const lines = buildClinicalSummaryLines( + { + ...reportData(), + period: { days: 120 }, + compliance: {}, + } as unknown as DoctorReportData, + (key, vars) => { + if (key === "doctorReport.summaryReadings") counts.push(vars?.count); + return key; + }, + (n) => String(n), + ); + expect(lines).toContain("doctorReport.summaryReadings"); + expect(counts).toEqual([2880]); + }); +}); + +describe("a raw series", () => { + it("still reports its last point as the latest reading", () => { + const data = { + measurements: { + WEIGHT: [ + { value: 80, measuredAt: "2026-09-01T07:00:00.000Z" }, + { value: 79.5, measuredAt: "2026-09-02T07:00:00.000Z" }, + ], + }, + stats: { + WEIGHT: { avg: 79.75, min: 79.5, max: 80, count: 2, latest: 79.5 }, + }, + } as unknown as DoctorReportData; + expect(latestReading(data, "WEIGHT")).toEqual({ + value: 79.5, + measuredAt: "2026-09-02T07:00:00.000Z", + }); + }); +}); diff --git a/src/lib/doctor-report/measurement-series.ts b/src/lib/doctor-report/measurement-series.ts index f6d6e1d07..1f0d0a6ef 100644 --- a/src/lib/doctor-report/measurement-series.ts +++ b/src/lib/doctor-report/measurement-series.ts @@ -143,6 +143,7 @@ export function summariseDenseBuckets( min: value.min, max: value.max, latest: value.latest, + latestAt: value.latestAt.toISOString(), }; } const glucoseStats: DenseMeasurementSummary["glucoseStats"] = {}; diff --git a/src/lib/fhir/resources/common.ts b/src/lib/fhir/resources/common.ts index 920ef0592..13c0751b7 100644 --- a/src/lib/fhir/resources/common.ts +++ b/src/lib/fhir/resources/common.ts @@ -127,13 +127,22 @@ export function codeableFromMapping(m: LoincMapping): FhirCodeableConcept { return { text: m.display }; } -/** Latest `{ value, measuredAt }` for a type, or null when no rows. */ +/** + * Latest `{ value, measuredAt }` for a type, or null when no rows. + * + * A dense type (pulse, glucose) can be served from day buckets, and then the + * series' last point is the last day's MEAN stamped at that day's start: a + * value nobody measured, at a time nobody measured it. Its stats carry the + * newest actual reading, so that wins whenever it is there. + */ export function latestReading( data: DoctorReportData, type: string, ): { value: number; measuredAt: string } | null { const series = data.measurements[type]; if (!series || series.length === 0) return null; + const stat = data.stats[type]; + if (stat?.latestAt) return { value: stat.latest, measuredAt: stat.latestAt }; return series[series.length - 1]; } diff --git a/tests/integration/dense-pulse-raw-window-reads.test.ts b/tests/integration/dense-pulse-raw-window-reads.test.ts index b613840db..886507ace 100644 --- a/tests/integration/dense-pulse-raw-window-reads.test.ts +++ b/tests/integration/dense-pulse-raw-window-reads.test.ts @@ -22,6 +22,7 @@ import { getPrismaClient, truncateAllTables } from "./setup"; import { deriveRestingProxyFromPulse } from "@/lib/analytics/resting-pulse"; import { readRestingPulseProxy } from "@/lib/analytics/resting-pulse-read"; import { collectDoctorReportData } from "@/lib/doctor-report-data"; +import { latestReading } from "@/lib/fhir/resources/common"; import { selectionFromLeaves } from "@/lib/report-selection/selection"; import { userDayKey } from "@/lib/tz/format"; import { invalidateUserTimezone } from "@/lib/tz/resolver"; @@ -230,6 +231,16 @@ describe("doctor report inside the raw window", () => { expect(points.length).toBeLessThanOrEqual(12); expect(points.length).toBeGreaterThanOrEqual(10); expect(data.stats.PULSE?.count).toBe(DENSE_ROWS); + // The export's latest observation is the newest reading itself. + const newest = await prisma.measurement.findFirstOrThrow({ + where: { userId: DENSE, type: "PULSE" }, + orderBy: { measuredAt: "desc" }, + select: { value: true, measuredAt: true }, + }); + expect(latestReading(data, "PULSE")).toEqual({ + value: newest.value, + measuredAt: newest.measuredAt.toISOString(), + }); }); it("keeps sparse readings raw", async () => {