mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
1b694ec222
commit
d00e106ce1
13 files changed
+207
-41
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -8167,6 +8167,7 @@
|
||||
"medications": "복약 및 복약 순응도",
|
||||
"bmi": "체질량지수(BMI)",
|
||||
"statSummary": "최근 {latest} (평균 {avg}, 범위 {min}–{max})",
|
||||
"statSummaryWithUnit": "최근 {latest} {unit} (평균 {avg} {unit}, 범위 {min}–{max} {unit})",
|
||||
"adherence": "복약 순응도 {rate}",
|
||||
"noAdherence": "—",
|
||||
"glucose": {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user