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 () => {