mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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.
This commit is contained in:
1 parent
3458100ac4
commit
6836830038
6 files changed
+155
-3
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -143,6 +143,7 @@ export function summariseDenseBuckets(
|
||||
min: value.min,
|
||||
max: value.max,
|
||||
latest: value.latest,
|
||||
latestAt: value.latestAt.toISOString(),
|
||||
};
|
||||
}
|
||||
const glucoseStats: DenseMeasurementSummary["glucoseStats"] = {};
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user