fix(report): print lab values with the precision they were recorded at

The doctor report PDF printed lab values and reference ranges with one
decimal and the share page with two at most, so a PSA of 0.034 read 0.0
or 0.03 and a TSH window of 0.27 read 0.3, while the labs page kept more.
The labs page itself cut values above 1 to two decimals.

One rule now decides every recorded number (src/lib/format/recorded-number):
a stored value keeps every digit it holds, minus binary float noise, and a
value converted from the unit its report printed keeps the precision of the
printed figure. The report payload carries sourceValue for that. The labs
page, its chart and history, the PDF lab table and footnote, the share
page, the day view and the OCR review all print through it, reference
bounds included. Doses on the PDF and the share page follow the same rule.

Measurement statistics in the PDF and on the share page now share one
per-row precision: up to three significant digits but never more decimals
than recorded, so a waist-to-height ratio of 0.52 no longer reads 0.5;
glucose keeps its unit's precision.

Refs #1228
This commit is contained in:
Marc-André Bombeck committed 2026-10-16 11:00:27 +02:00
1 parent f3c4fbff2c
commit 0ee7e156d3
27 files changed
+560 -113

No files matched your search

@@ -176,6 +176,7 @@ describe("<ClinicianView>", () => {
value: 5.2,
valueText: null,
unit: "mmol/L",
sourceValue: null,
referenceLow: 3.9,
referenceHigh: 5.4,
catalogReferenceLow: 3.5,
@@ -194,6 +195,55 @@ describe("<ClinicianView>", () => {
expect(html).toContain("saved range 3.5–5");
});
it("prints a lab value and its range with every digit it was recorded with", () => {
// Two decimals at most read a PSA of 0.034 as "0.03" and a TSH window of
// 0.275 as "0.28": the share page dropped what the lab reported.
const html = render(
makeReport({
labResults: [
{
panel: null,
analyte: "PSA",
value: 0.034,
valueText: null,
unit: "ng/mL",
sourceValue: null,
referenceLow: 0.275,
referenceHigh: 4.2,
catalogReferenceLow: 0.275,
catalogReferenceHigh: 4.2,
sourceReferenceText: null,
referenceOrigin: "catalog",
referenceDivergesFromCatalog: false,
takenAt: "2026-01-20T09:00:00.000Z",
count: 1,
},
],
}),
);
expect(html).toContain("0.034 ng/mL");
expect(html).toContain("Reference 0.275–4.2");
});
it("keeps the digits of a measurement read below one", () => {
const html = render(
makeReport({
stats: {
WAIST_TO_HEIGHT: {
avg: 0.5,
min: 0.495,
max: 0.505,
count: 2,
latest: 0.505,
},
},
}),
{ selection: selectionFromLeaves(["WAIST_TO_HEIGHT"]) },
);
expect(html).toContain("0.505");
expect(html).toContain("0.495");
});
it("renders the fenced wellness card with the descriptive disclaimer", () => {
const html = render(makeReport());
expect(html).toContain("Wellness scores");
@@ -241,8 +291,9 @@ describe("<ClinicianView>", () => {
},
}),
);
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)");
// One precision per row, the one the PDF prints the row with.
expect(html).toContain("latest 79.0 kg (avg 80.0 kg, range 78.0–82.0 kg)");
expect(html).toContain("latest 7.5 h (avg 7.5 h, range 7.0–8.0 h)");
});
it("shows glucose in the record owner's unit, never raw mg/dL", () => {
@@ -259,10 +310,10 @@ describe("<ClinicianView>", () => {
);
// 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)",
"latest 5.5 mmol/L (avg 6.0 mmol/L, range 5.0–7.0 mmol/L)",
);
expect(html).toContain(
"latest 5.3 mmol/L (avg 5.5 mmol/L, range 5–6 mmol/L)",
"latest 5.3 mmol/L (avg 5.5 mmol/L, range 5.0–6.0 mmol/L)",
);
expect(html).not.toContain("mg/dL");
expect(html).not.toContain("latest 99");
+10 -4
View File
@@ -30,6 +30,7 @@
* document list in `./documents-list`, the downloads in `./download-actions`.
*/
import type { DoctorReportData } from "@/lib/doctor-report-data";
import { formatLabValue, type LabNumberFormat } from "@/lib/labs/format-value";
import {
makeFormatters,
type DateFormatPreference,
@@ -152,6 +153,10 @@ export function ClinicianView({
// Two decimals at most, in the owner's locale: a German reader gets
// "80,5", not the "80.5" a bare number prints.
const fmtNum = (n: number) => fmt.number(Math.round(n * 100) / 100);
// A lab figure or a dose is what was recorded, not a statistic: it keeps
// every digit it holds, so a PSA of 0.034 does not read as 0.03.
const fmtRecorded: LabNumberFormat = (n, printed) =>
formatLabValue(n, fmt.number, printed);
const scope = makeLeafScope(selection, unavailableLeaves);
return (
@@ -198,7 +203,7 @@ export function ClinicianView({
t={t}
report={report}
scope={scope}
fmtNum={fmtNum}
fmtDigits={fmt.number}
/>
{report.bmi !== null &&
report.bmi !== undefined &&
@@ -219,14 +224,14 @@ export function ClinicianView({
t={t}
report={report}
scope={scope}
fmtNum={fmtNum}
fmtDigits={fmt.number}
/>
<LabResultsSection
t={t}
report={report}
scope={scope}
fmtDate={fmtDate}
fmtNum={fmtNum}
fmtLab={fmtRecorded}
/>
{/* ── medications ──────────────────────────────────────── */}
@@ -242,13 +247,14 @@ export function ClinicianView({
scope={scope}
fmtDate={fmtDate}
fmtNum={fmtNum}
fmtRecorded={fmtRecorded}
/>
<DoseLogSection
t={t}
report={report}
scope={scope}
fmtDateTime={fmtDateTime}
fmtNum={fmtNum}
fmtRecorded={fmtRecorded}
/>
{/* ── history ──────────────────────────────────────────── */}
+16 -6
View File
@@ -17,6 +17,7 @@ import {
encounterKindLabelKey,
lateralityLabelKey,
} from "@/lib/encounters/kind-label";
import type { LabNumberFormat } from "@/lib/labs/format-value";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import type { DoctorReportData } from "@/lib/doctor-report-data";
import {
@@ -47,13 +48,14 @@ export function LabResultsSection({
report,
scope,
fmtDate,
fmtNum,
fmtLab,
}: {
t: Translate;
report: DoctorReportData;
scope: LeafScope;
fmtDate: (iso: string) => string;
fmtNum: (n: number) => string;
/** Lab figures keep the precision they were recorded at (`formatLabValue`). */
fmtLab: LabNumberFormat;
}) {
const results = report.labResults ?? [];
@@ -71,9 +73,13 @@ export function LabResultsSection({
const qualitative = lab.value === null;
const reading = qualitative
? lab.valueText
: `${fmtNum(lab.value as number)} ${lab.unit}`.trim();
const range = (low: number | null, high: number | null) =>
formatReferenceRange(low, high, fmtNum, {
: `${fmtLab(lab.value as number, lab.sourceValue)} ${lab.unit}`.trim();
const range = (
low: number | null,
high: number | null,
printed?: number | null,
) =>
formatReferenceRange(low, high, (n) => fmtLab(n, printed), {
emptyText: "",
});
// The window the reading was judged against, printed as the source
@@ -83,7 +89,11 @@ export function LabResultsSection({
? null
: lab.referenceOrigin === "source" && lab.sourceReferenceText
? lab.sourceReferenceText
: range(lab.referenceLow, lab.referenceHigh);
: range(
lab.referenceLow,
lab.referenceHigh,
lab.referenceOrigin === "source" ? lab.sourceValue : null,
);
// When the report's own window and the saved band disagree about the
// same number, both are named. Showing one of two disagreeing windows
// is a partial answer, and the PDF's footnote says so; here it fits on
+48 -31
View File
@@ -20,9 +20,11 @@ import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only";
import type { MeasurementType } from "@/generated/prisma/client";
import type { DoctorReportData } from "@/lib/doctor-report-data";
import {
reportStatFractionDigits,
reportStatUnit,
reportStatValue,
} from "@/lib/doctor-report/stat-display";
import type { FractionDigitsFormat } from "@/lib/format/recorded-number";
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys";
import {
@@ -203,12 +205,13 @@ export function MeasurementGroups({
t,
report,
scope,
fmtNum,
fmtDigits,
}: {
t: Translate;
report: DoctorReportData;
scope: LeafScope;
fmtNum: (n: number) => string;
/** A locale number with exactly the given decimals. */
fmtDigits: FractionDigitsFormat;
}) {
const groups = REPORT_GROUPS.map((group) => ({
labelKey: group.labelKey,
@@ -225,8 +228,9 @@ export function MeasurementGroups({
// 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));
// ...and in the same precision: one per row, the one its readings carry.
const show = (type: string, value: number, digits: number) =>
fmtDigits(reportStatValue(type, value, glucoseUnit), digits);
return (
<>
@@ -236,18 +240,21 @@ export function MeasurementGroups({
title={t(group.labelKey)}
leaves={group.rows.map((row) => row.type)}
>
{group.rows.map(({ type, stat }) => (
<StatRow
key={type}
label={t(MEASUREMENT_TYPE_LABEL_KEYS[type])}
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),
})}
/>
))}
{group.rows.map(({ type, stat }) => {
const digits = reportStatFractionDigits(type, stat!, glucoseUnit);
return (
<StatRow
key={type}
label={t(MEASUREMENT_TYPE_LABEL_KEYS[type])}
value={statSummary(t, reportStatUnit(type, glucoseUnit, t), {
latest: show(type, stat!.latest, digits),
avg: show(type, stat!.avg, digits),
min: show(type, stat!.min, digits),
max: show(type, stat!.max, digits),
})}
/>
);
})}
</Section>
))}
</>
@@ -278,19 +285,22 @@ export function GlucoseSection({
t,
report,
scope,
fmtNum,
fmtDigits,
}: {
t: Translate;
report: DoctorReportData;
scope: LeafScope;
fmtNum: (n: number) => string;
/** A locale number with exactly the given decimals. */
fmtDigits: FractionDigitsFormat;
}) {
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));
// The unit's own precision, the one the PDF's glucose rows print with.
const show = (value: number, digits: number) =>
fmtDigits(convertGlucose(value, glucoseUnit), digits);
return (
<LeafSection
t={t}
@@ -299,18 +309,25 @@ export function GlucoseSection({
title={t("reportSelection.groupGlucose")}
empty={entries.length === 0}
>
{entries.map(([ctx, s]) => (
<StatRow
key={ctx}
label={t(`clinicianView.glucose.${ctx}`)}
value={statSummary(t, glucoseUnit, {
latest: show(s.latest),
avg: show(s.avg),
min: show(s.min),
max: show(s.max),
})}
/>
))}
{entries.map(([ctx, s]) => {
const digits = reportStatFractionDigits(
"BLOOD_GLUCOSE",
s,
glucoseUnit,
);
return (
<StatRow
key={ctx}
label={t(`clinicianView.glucose.${ctx}`)}
value={statSummary(t, glucoseUnit, {
latest: show(s.latest, digits),
avg: show(s.avg, digits),
min: show(s.min, digits),
max: show(s.max, digits),
})}
/>
);
})}
</LeafSection>
);
}
@@ -42,12 +42,15 @@ export function Glp1Section({
scope,
fmtDate,
fmtNum,
fmtRecorded,
}: {
t: Translate;
report: DoctorReportData;
scope: LeafScope;
fmtDate: (iso: string) => string;
fmtNum: (n: number) => string;
/** A dose keeps every digit it was recorded with (0.125 mg, not 0.13). */
fmtRecorded: (n: number) => string;
}) {
const glp1 = report.glp1 ?? null;
const medications = glp1?.medications ?? [];
@@ -89,7 +92,7 @@ export function Glp1Section({
value={
med.currentDose
? t("doctorReport.glp1CurrentDose", {
value: fmtNum(med.currentDose.value),
value: fmtRecorded(med.currentDose.value),
unit: med.currentDose.unit,
since: fmtDate(med.currentDose.since),
})
@@ -112,7 +115,7 @@ export function Glp1Section({
key={`${change.effectiveFrom}-${index}`}
label={fmtDate(change.effectiveFrom)}
value={
`${fmtNum(change.value)} ${change.unit}` +
`${fmtRecorded(change.value)} ${change.unit}` +
(change.note ? ` · ${change.note}` : "")
}
/>
@@ -167,13 +170,14 @@ export function DoseLogSection({
report,
scope,
fmtDateTime,
fmtNum,
fmtRecorded,
}: {
t: Translate;
report: DoctorReportData;
scope: LeafScope;
fmtDateTime: (iso: string) => string;
fmtNum: (n: number) => string;
/** A dose keeps every digit it was recorded with (0.125 mg, not 0.13). */
fmtRecorded: (n: number) => string;
}) {
const administrations = report.medicationAdministrations ?? [];
// Newest first: a clinician reads "when did they last take it", not "when
@@ -212,7 +216,7 @@ export function DoseLogSection({
? t("doctorReport.colTaken")
: t("doctorReport.colSkipped")) +
(dose.dose
? ` · ${fmtNum(dose.dose.value)} ${dose.dose.unit}`
? ` · ${fmtRecorded(dose.dose.value)} ${dose.dose.unit}`
: dose.doseText
? ` · ${dose.doseText}`
: "") +
@@ -33,10 +33,10 @@ describe("<LabConvertedNote>", () => {
).toContain("umgerechnet aus 5,6 mmol/L");
});
it("trims a long fraction the way the reading itself is trimmed", () => {
it("prints the figure the report printed, with every digit", () => {
expect(
render({ converted: true, sourceValue: 5.6123456, sourceUnit: "mmol/L" }),
).toContain("converted from 5.61 mmol/L");
).toContain("converted from 5.6123456 mmol/L");
});
it.each([
@@ -53,8 +53,8 @@ describe("<LabReferenceRangeBar>", () => {
"de",
);
expect(html).toContain('aria-label="0,4–4,05 mIU/L"');
// Lab precision: at most two decimals, then the locale's separator.
expect(html).toContain("1,83 mIU/L");
// Lab precision: every recorded digit, in the locale's separator.
expect(html).toContain("1,8333 mIU/L");
expect(html).not.toMatch(/\d\.\d+ mIU\/L/);
});
});
@@ -175,7 +175,8 @@ describe("<OcrRowEditor> unit differs but converts", () => {
it("says what Save will store and what the report stated, not that it is blocked", () => {
const html = render(undefined, "en", converts);
expect(html).toContain('data-slot="ocr-row-converted-badge"');
expect(html).toContain("Saved as 100.9 mg/dL, converted from 5.6 mmol/L");
// The converted value keeps the precision of the printed 5.6.
expect(html).toContain("Saved as 101 mg/dL, converted from 5.6 mmol/L");
expect(html).not.toContain("Tracked in");
expect(html).not.toContain('aria-invalid="true"');
});
+7 -3
View File
@@ -75,6 +75,8 @@ interface ChartPoint {
referenceHigh: number | null;
referenceOrigin: LabResultDto["referenceOrigin"];
sourceReferenceText: string | null;
/** What the report printed, for a converted reading; sets its precision. */
sourceValue: number | null;
}
/** The source window drawn as the second band, resolved from the readings in view. */
@@ -153,6 +155,7 @@ export function LabBiomarkerChart({
referenceHigh: r.referenceHigh,
referenceOrigin: r.referenceOrigin,
sourceReferenceText: r.sourceReferenceText,
sourceValue: r.sourceValue,
}));
}, [readings, range, nowMs]);
@@ -221,7 +224,7 @@ export function LabBiomarkerChart({
const before = at > 0 ? ordered[at - 1] : undefined;
return {
label: readings[0]?.analyte ?? "",
value: labNumber(point.value),
value: labNumber(point.value, point.sourceValue),
unit,
compare:
before && before.value !== null
@@ -229,7 +232,8 @@ export function LabBiomarkerChart({
label: t("day.previousReading", {
date: labShortDate(before.takenAt),
}),
value: `${labNumber(before.value)} ${unit}`.trim(),
value:
`${labNumber(before.value, before.sourceValue)} ${unit}`.trim(),
}
: undefined,
};
@@ -381,7 +385,7 @@ export function LabBiomarkerChart({
const rows: RichTooltipRow[] = [
{
name: t("labs.chart.valueLabel"),
value: `${labNumber(point.value)} ${unit}`,
value: `${labNumber(point.value, point.sourceValue)} ${unit}`,
color: primary,
},
];
+1 -2
View File
@@ -1,7 +1,6 @@
"use client";
import { useTranslations } from "@/lib/i18n/context";
import { formatLabValue } from "@/lib/labs/format-value";
import { useLabNumber } from "./use-lab-format";
@@ -36,7 +35,7 @@ export function LabConvertedNote({
className={className ?? "text-muted-foreground text-xs"}
>
{t("labs.convertedFrom", {
value: formatLabValue(reading.sourceValue, labNumber),
value: labNumber(reading.sourceValue),
unit: reading.sourceUnit,
})}
</span>
+1 -1
View File
@@ -102,7 +102,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
value:
reading.value === null
? (reading.valueText ?? "")
: labNumber(reading.value),
: labNumber(reading.value, reading.sourceValue),
unit: reading.value === null ? undefined : reading.unit,
compare: before
? {
+4 -1
View File
@@ -206,7 +206,10 @@ export function OcrRowEditor({
<Ruler aria-hidden />
<span className="min-w-0 break-words">
{t("labs.ocr.unitConvertedBadge", {
value: labNumber(conversion.value),
value: labNumber(
conversion.value,
conversion.statedValue,
),
markerUnit: conversion.unit,
statedValue: labNumber(conversion.statedValue),
unit: conversion.statedUnit,
+5 -3
View File
@@ -8,13 +8,15 @@ import { formatLabValue, type LabNumberFormat } from "@/lib/labs/format-value";
import { isNoonUtcAnchor } from "@/lib/tz/date-only";
/**
* A lab value in the reader's number format, trimmed the way every lab
* surface trims it (whole numbers bare, at most two decimals).
* A lab value in the reader's number format, with every digit the reading
* holds (see `formatLabValue`). Pass the reading's `sourceValue` as `printed`
* for a converted reading so it keeps the precision its report printed.
*/
export function useLabNumber(): LabNumberFormat {
const fmt = useFormatters();
return useCallback(
(value: number) => formatLabValue(value, (n) => fmt.number(n)),
(value: number, printed?: number | null) =>
formatLabValue(value, fmt.number, printed),
[fmt],
);
}