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

+1
View File
@@ -8167,6 +8167,7 @@
"medications": "Medikamente & Therapietreue",
"bmi": "Body-Mass-Index (BMI)",
"statSummary": "zuletzt {latest} (Ø {avg}, Bereich {min}–{max})",
"statSummaryWithUnit": "zuletzt {latest} {unit} (Ø {avg} {unit}, Bereich {min}–{max} {unit})",
"adherence": "{rate} Therapietreue",
"noAdherence": "—",
"glucose": {
+1
View File
@@ -8167,6 +8167,7 @@
"medications": "Medications & adherence",
"bmi": "Body mass index (BMI)",
"statSummary": "latest {latest} (avg {avg}, range {min}–{max})",
"statSummaryWithUnit": "latest {latest} {unit} (avg {avg} {unit}, range {min}–{max} {unit})",
"adherence": "{rate} adherence",
"noAdherence": "—",
"glucose": {
+1
View File
@@ -8167,6 +8167,7 @@
"medications": "Medicamentos y adherencia",
"bmi": "Índice de masa corporal (IMC)",
"statSummary": "último {latest} (prom. {avg}, rango {min}–{max})",
"statSummaryWithUnit": "último {latest} {unit} (prom. {avg} {unit}, rango {min}–{max} {unit})",
"adherence": "{rate} de adherencia",
"noAdherence": "—",
"glucose": {
+1
View File
@@ -8167,6 +8167,7 @@
"medications": "Médicaments et observance",
"bmi": "Indice de masse corporelle (IMC)",
"statSummary": "dernier {latest} (moy. {avg}, plage {min}–{max})",
"statSummaryWithUnit": "dernier {latest} {unit} (moy. {avg} {unit}, plage {min}–{max} {unit})",
"adherence": "{rate} d'observance",
"noAdherence": "—",
"glucose": {
+1
View File
@@ -8167,6 +8167,7 @@
"medications": "Farmaci e aderenza",
"bmi": "Indice di massa corporea (IMC)",
"statSummary": "ultimo {latest} (media {avg}, intervallo {min}–{max})",
"statSummaryWithUnit": "ultimo {latest} {unit} (media {avg} {unit}, intervallo {min}–{max} {unit})",
"adherence": "{rate} di aderenza",
"noAdherence": "—",
"glucose": {
+1
View File
@@ -8167,6 +8167,7 @@
"medications": "복약 및 복약 순응도",
"bmi": "체질량지수(BMI)",
"statSummary": "최근 {latest} (평균 {avg}, 범위 {min}–{max})",
"statSummaryWithUnit": "최근 {latest} {unit} (평균 {avg} {unit}, 범위 {min}–{max} {unit})",
"adherence": "복약 순응도 {rate}",
"noAdherence": "—",
"glucose": {
+1
View File
@@ -8167,6 +8167,7 @@
"medications": "Leki i przestrzeganie zaleceń",
"bmi": "Wskaźnik masy ciała (BMI)",
"statSummary": "ostatnio {latest} (śr. {avg}, zakres {min}–{max})",
"statSummaryWithUnit": "ostatnio {latest} {unit} (śr. {avg} {unit}, zakres {min}–{max} {unit})",
"adherence": "{rate} przestrzegania",
"noAdherence": "—",
"glucose": {
@@ -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),
})}
/>
))}
@@ -227,6 +227,30 @@ describe("renderDoctorReportPdfBytes", () => {
expect(text).not.toContain("450 h");
});
it("prints the raw glucose row in the owner's unit, like the glucose panel", async () => {
// The measurement table used to take BLOOD_GLUCOSE's canonical unit and
// number, so an mmol/L account read "99.0 mg/dL" two lines above its own
// context rows in mmol/L.
const data = makeData({
glucoseUnit: "mmol/L",
stats: {
...makeData().stats,
BLOOD_GLUCOSE: { avg: 108, min: 90, max: 126, count: 9, latest: 99 },
},
});
const bytes = renderDoctorReportPdfBytes(data, {
timeFormat: "AUTO",
dateFormat: "AUTO",
t: getServerTranslator("en").t,
locale: "en",
now: FIXED_NOW,
});
const text = await extractText(bytes);
expect(text).toContain("5.5 mmol/L");
expect(text).toContain("6.0 mmol/L");
expect(text).not.toContain("mg/dL");
});
it("omits the sleep vitals row when there are no sleep stats", async () => {
const bytes = renderDoctorReportPdfBytes(makeData(), {
timeFormat: "AUTO",
+11 -25
View File
@@ -19,7 +19,11 @@ import {
REPORT_GROUPS,
} from "./report-selection/catalogue";
import { getUnitForType } from "./validations/measurement";
import {
DOCTOR_REPORT_TYPE_UNIT_KEYS,
reportStatUnit,
} from "./doctor-report/stat-display";
import { resolveGlucoseUnit } from "./glucose";
import {
makeFormatters,
type DateFormatPreference,
@@ -175,18 +179,9 @@ export interface DoctorReportRenderOptions {
includeCharts?: boolean;
}
export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record<string, string | null> = {
WEIGHT: "kg",
BLOOD_PRESSURE_SYS: "mmHg",
BLOOD_PRESSURE_DIA: "mmHg",
PULSE: "bpm",
BODY_FAT: "%",
SLEEP_DURATION: "h",
ACTIVITY_STEPS: null, // translated unit
TOTAL_BODY_WATER: "kg",
BONE_MASS: "kg",
OXYGEN_SATURATION: "%",
};
// The unit map moved beside the clinician view's reader of it; re-exported so
// the enum-coverage test keeps one import.
export { DOCTOR_REPORT_TYPE_UNIT_KEYS };
/**
* The measurement table is driven by the selection, not by a whitelist.
@@ -238,18 +233,9 @@ export function buildDoctorReportPdfDocument(
const footerTz =
userTz && isValidTimezone(userTz) ? userTz : DEFAULT_TIMEZONE;
const unitFor = (type: string): string => {
const staticUnit = DOCTOR_REPORT_TYPE_UNIT_KEYS[type];
if (staticUnit === null && type === "ACTIVITY_STEPS") {
return t("doctorReport.unitSteps");
}
if (staticUnit !== undefined && staticUnit !== null) return staticUnit;
// Every other type falls back to the canonical unit the measurement
// validation layer already records. A type with no recorded unit prints
// none — an absent unit is absent, not a guessed symbol.
const canonical = getUnitForType(type);
return canonical === "unknown" ? "" : canonical;
};
const glucoseUnit = resolveGlucoseUnit(data.glucoseUnit ?? null);
const unitFor = (type: string): string =>
reportStatUnit(type, glucoseUnit, t);
const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" });
patchPdfTextSanitiser(doc);
@@ -2,6 +2,7 @@ import type { jsPDF } from "jspdf";
import { GLUCOSE_CONTEXT_BUCKETS } from "@/lib/glucose";
import autoTable from "jspdf-autotable";
import { convertGlucose, resolveGlucoseUnit } from "../glucose";
import { reportStatValue } from "../doctor-report/stat-display";
import type { MeasurementType } from "@/generated/prisma/client";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "../measurements/type-label-keys";
import {
@@ -235,12 +236,9 @@ export function buildMeasurementsChartsSection(
const s = data.stats[type];
if (!s) continue;
const unit = unitFor(type);
// SLEEP_DURATION stats are per-night asleep totals in MINUTES (the data
// layer reconstructs them for exactly this row); the vitals unit is
// hours (`h`), so convert to keep value and unit consistent — the same
// hours value the FHIR sleep Observation emits.
const conv =
type === "SLEEP_DURATION" ? (v: number) => v / 60 : (v: number) => v;
// Sleep minutes print as hours and glucose in the owner's unit; the
// clinician view reads the same helper, so the two cannot drift.
const conv = (v: number) => reportStatValue(type, v, glucoseUnit);
groupRows.push([
t(MEASUREMENT_TYPE_LABEL_KEYS[type as MeasurementType] ?? ""),
`${num(conv(s.latest))} ${unit}`.trim(),
+79
View File
@@ -0,0 +1,79 @@
/**
* How a measurement statistic reads in the clinician-facing artefacts: the
* PDF report and the shared clinician view.
*
* Both describe the same record to the same reader, so they take the unit and
* the displayed number from here and nowhere else. Before this module the PDF
* carried its own unit map and the clinician view printed bare numbers, so a
* doctor opening a share link read "latest 5.8 (avg 6.1, range 4.9–7.4)" with
* no unit at all, and for an account on mmol/L the glucose rows of the PDF's
* measurement table still printed raw mg/dL beside a mmol/L glucose panel.
*
* The policy, which `display-transform.ts` states for the metric/imperial
* preference: a document handed to a clinician stays SI for mass, length and
* temperature regardless of the owner's imperial preference. Glucose is the
* exception, because mmol/L versus mg/dL is not an imperial question — it is
* the clinical convention of the country the record lives in — so glucose
* follows the owner's `glucoseUnit` here as it does everywhere a person reads
* it.
*/
import { convertGlucose, type GlucoseUnit } from "@/lib/glucose";
import { getUnitForType } from "@/lib/validations/measurement";
/**
* Report-specific unit symbols, where the report's own reading differs from
* the validation layer's canonical unit. `null` marks a unit that has to be
* translated (steps). Everything absent falls back to `getUnitForType`.
*/
export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record<string, string | null> = {
WEIGHT: "kg",
BLOOD_PRESSURE_SYS: "mmHg",
BLOOD_PRESSURE_DIA: "mmHg",
PULSE: "bpm",
BODY_FAT: "%",
SLEEP_DURATION: "h",
ACTIVITY_STEPS: null, // translated unit
TOTAL_BODY_WATER: "kg",
BONE_MASS: "kg",
OXYGEN_SATURATION: "%",
};
/**
* The unit symbol a report statistic of `type` is printed with.
*
* `translate` resolves the one translated unit (steps). A type with no
* recorded unit prints none — an absent unit is absent, not a guessed symbol.
*/
export function reportStatUnit(
type: string,
glucoseUnit: GlucoseUnit,
translate: (key: string) => string,
): string {
if (type === "BLOOD_GLUCOSE") return glucoseUnit;
const staticUnit = DOCTOR_REPORT_TYPE_UNIT_KEYS[type];
if (staticUnit === null && type === "ACTIVITY_STEPS") {
return translate("doctorReport.unitSteps");
}
if (staticUnit !== undefined && staticUnit !== null) return staticUnit;
const canonical = getUnitForType(type);
return canonical === "unknown" ? "" : canonical;
}
/**
* The number a report statistic of `type` is printed as, in the unit
* {@link reportStatUnit} names.
*
* SLEEP_DURATION statistics are per-night asleep totals in minutes (the data
* layer reconstructs them for exactly this row) while the unit is hours, so
* they are divided — the same hours value the FHIR sleep Observation emits.
* Glucose is stored in mg/dL and converted to the owner's unit.
*/
export function reportStatValue(
type: string,
value: number,
glucoseUnit: GlucoseUnit,
): number {
if (type === "SLEEP_DURATION") return value / 60;
if (type === "BLOOD_GLUCOSE") return convertGlucose(value, glucoseUnit);
return value;
}