fix(clinician): print every shared statistic with its unit

The clinician view printed bare numbers for every measurement group and
the per-context glucose rows in raw mg/dL, so a recipient read glucose
without a unit and in the wrong one for an account on mmol/L. The PDF's
measurement table had the same gap for the raw glucose series.

Both artefacts now take the unit and the displayed number from one helper
(doctor-report/stat-display.ts): SI for mass, length and temperature as
before, hours for sleep, and the record owner's unit for glucose.

Refs #1067
This commit is contained in:
Marc-André Bombeck committed 2026-10-01 08:46:39 +02:00
1 parent 1b694ec222
commit d00e106ce1
13 files changed
+207 -41

No files matched your search

@@ -225,6 +225,49 @@ describe("<ClinicianView>", () => {
expect(html).not.toContain(">WEIGHT<");
});
it("prints every measurement group with its unit", () => {
const html = render(
makeReport({
stats: {
WEIGHT: { avg: 80, min: 78, max: 82, count: 12, latest: 79 },
// Per-night asleep minutes; the report reads them in hours.
SLEEP_DURATION: {
avg: 450,
min: 420,
max: 480,
count: 7,
latest: 450,
},
},
}),
);
expect(html).toContain("latest 79 kg (avg 80 kg, range 78–82 kg)");
expect(html).toContain("latest 7.5 h (avg 7.5 h, range 7–8 h)");
});
it("shows glucose in the record owner's unit, never raw mg/dL", () => {
const html = render(
makeReport({
glucoseUnit: "mmol/L",
stats: {
BLOOD_GLUCOSE: { avg: 108, min: 90, max: 126, count: 9, latest: 99 },
},
glucoseStats: {
FASTING: { avg: 99, min: 90, max: 108, count: 5, latest: 95 },
},
}),
);
// 99 mg/dL = 5.5 mmol/L, 108 = 6.0, 90 = 5.0, 126 = 7.0, 95 = 5.3.
expect(html).toContain(
"latest 5.5 mmol/L (avg 6 mmol/L, range 5–7 mmol/L)",
);
expect(html).toContain(
"latest 5.3 mmol/L (avg 5.5 mmol/L, range 5–6 mmol/L)",
);
expect(html).not.toContain("mg/dL");
expect(html).not.toContain("latest 99");
});
it("omits the wellness card when there are no scores", () => {
const html = render(makeReport({ wellnessScores: [] }));
expect(html).not.toContain("Wellness scores");
+39 -10
View File
@@ -15,6 +15,11 @@
*/
import type { MeasurementType } from "@/generated/prisma/client";
import type { DoctorReportData } from "@/lib/doctor-report-data";
import {
reportStatUnit,
reportStatValue,
} from "@/lib/doctor-report/stat-display";
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys";
import {
isReportLeafId,
@@ -212,6 +217,13 @@ export function MeasurementGroups({
if (groups.length === 0) return null;
// The same unit and the same number the PDF prints for the row: SI for
// mass, length and temperature, the owner's unit for glucose, hours for
// sleep. A clinician must never read a figure without knowing its unit.
const glucoseUnit = resolveGlucoseUnit(report.glucoseUnit ?? null);
const show = (type: string, value: number) =>
fmtNum(reportStatValue(type, value, glucoseUnit));
return (
<>
{groups.map((group) => (
@@ -224,11 +236,11 @@ export function MeasurementGroups({
<StatRow
key={type}
label={t(MEASUREMENT_TYPE_LABEL_KEYS[type])}
value={t("clinicianView.statSummary", {
latest: fmtNum(stat!.latest),
avg: fmtNum(stat!.avg),
min: fmtNum(stat!.min),
max: fmtNum(stat!.max),
value={statSummary(t, reportStatUnit(type, glucoseUnit, t), {
latest: show(type, stat!.latest),
avg: show(type, stat!.avg),
min: show(type, stat!.min),
max: show(type, stat!.max),
})}
/>
))}
@@ -238,6 +250,20 @@ export function MeasurementGroups({
);
}
/**
* "latest 79 kg (avg 80 kg, range 78–82 kg)". A type with no unit (a score, a
* count) keeps the unit-less sentence rather than printing a dangling space.
*/
function statSummary(
t: Translate,
unit: string,
values: { latest: number; avg: number; min: number; max: number },
): string {
return unit
? t("clinicianView.statSummaryWithUnit", { ...values, unit })
: t("clinicianView.statSummary", values);
}
/**
* Per-context glucose stats. The heading used to read "Lab values", which was
* the wrong name for it even before the record's actual lab results reached
@@ -258,6 +284,9 @@ export function GlucoseSection({
const entries = Object.entries(report.glucoseStats).filter(
([, s]) => s.count > 0,
);
// Stored in mg/dL; shown in the record owner's unit, as the PDF shows it.
const glucoseUnit = resolveGlucoseUnit(report.glucoseUnit ?? null);
const show = (value: number) => fmtNum(convertGlucose(value, glucoseUnit));
return (
<LeafSection
t={t}
@@ -270,11 +299,11 @@ export function GlucoseSection({
<StatRow
key={ctx}
label={t(`clinicianView.glucose.${ctx}`)}
value={t("clinicianView.statSummary", {
latest: fmtNum(s.latest),
avg: fmtNum(s.avg),
min: fmtNum(s.min),
max: fmtNum(s.max),
value={statSummary(t, glucoseUnit, {
latest: show(s.latest),
avg: show(s.avg),
min: show(s.min),
max: show(s.max),
})}
/>
))}