diff --git a/messages/de.json b/messages/de.json index cecd50085..fc3b54979 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index 322af6ad7..5fcf26297 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index 06010c86e..4bdfb5534 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index 5135074b2..f1c57d412 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index 7ffad325e..79b439e50 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/ko.json b/messages/ko.json index 28038e56f..26052e28f 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -8167,6 +8167,7 @@ "medications": "복약 및 복약 순응도", "bmi": "체질량지수(BMI)", "statSummary": "최근 {latest} (평균 {avg}, 범위 {min}–{max})", + "statSummaryWithUnit": "최근 {latest} {unit} (평균 {avg} {unit}, 범위 {min}–{max} {unit})", "adherence": "복약 순응도 {rate}", "noAdherence": "—", "glucose": { diff --git a/messages/pl.json b/messages/pl.json index 295afc34f..396f758be 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/src/components/clinician/__tests__/clinician-view.test.tsx b/src/components/clinician/__tests__/clinician-view.test.tsx index cb62d4f82..22fb9d4e0 100644 --- a/src/components/clinician/__tests__/clinician-view.test.tsx +++ b/src/components/clinician/__tests__/clinician-view.test.tsx @@ -225,6 +225,49 @@ describe("", () => { 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"); diff --git a/src/components/clinician/report-sections.tsx b/src/components/clinician/report-sections.tsx index bf41f8535..db44a8de2 100644 --- a/src/components/clinician/report-sections.tsx +++ b/src/components/clinician/report-sections.tsx @@ -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({ ))} @@ -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 ( ))} diff --git a/src/lib/__tests__/doctor-report-pdf-core.test.ts b/src/lib/__tests__/doctor-report-pdf-core.test.ts index b525b4857..58f8688af 100644 --- a/src/lib/__tests__/doctor-report-pdf-core.test.ts +++ b/src/lib/__tests__/doctor-report-pdf-core.test.ts @@ -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", diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index bdb689fea..3da0ac540 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -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 = { - 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); diff --git a/src/lib/doctor-report-pdf/measurements-charts-section.ts b/src/lib/doctor-report-pdf/measurements-charts-section.ts index fef72fd68..32fdd4e1a 100644 --- a/src/lib/doctor-report-pdf/measurements-charts-section.ts +++ b/src/lib/doctor-report-pdf/measurements-charts-section.ts @@ -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(), diff --git a/src/lib/doctor-report/stat-display.ts b/src/lib/doctor-report/stat-display.ts new file mode 100644 index 000000000..a2ede71f9 --- /dev/null +++ b/src/lib/doctor-report/stat-display.ts @@ -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 = { + 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; +}