mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
f3c4fbff2c
commit
0ee7e156d3
27 files changed
+560
-113
No files matched your search
@@ -208,6 +208,7 @@ const DISPOSITIONS: Record<StructuredLeafId, Disposition> = {
|
||||
value: 42,
|
||||
valueText: null,
|
||||
unit: "ng/mL",
|
||||
sourceValue: null,
|
||||
referenceLow: 30,
|
||||
referenceHigh: 400,
|
||||
catalogReferenceLow: 30,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 ──────────────────────────────────────────── */}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -635,6 +635,106 @@ describe("doctor-report clinical glucose panel", () => {
|
||||
// v1.18.1 P4 — illness / condition episodes section. Retrospective + factual:
|
||||
// label, type, course, onset, resolved. Present only when the aggregator
|
||||
// populated `illnessEpisodes` (module on + episode in window).
|
||||
describe("doctor-report recorded precision", () => {
|
||||
function lab(
|
||||
over: Partial<NonNullable<DoctorReportData["labResults"]>[number]>,
|
||||
): NonNullable<DoctorReportData["labResults"]>[number] {
|
||||
return {
|
||||
panel: null,
|
||||
analyte: "PSA",
|
||||
value: 0.034,
|
||||
valueText: null,
|
||||
unit: "ng/mL",
|
||||
sourceValue: null,
|
||||
referenceLow: null,
|
||||
referenceHigh: 4,
|
||||
catalogReferenceLow: null,
|
||||
catalogReferenceHigh: 4,
|
||||
sourceReferenceText: null,
|
||||
referenceOrigin: "catalog",
|
||||
referenceDivergesFromCatalog: false,
|
||||
takenAt: "2026-04-20T08:00:00.000Z",
|
||||
count: 1,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
async function render(data: DoctorReportData): Promise<string> {
|
||||
const bytes = renderDoctorReportPdfBytes(data, {
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
t: getServerTranslator("en").t,
|
||||
locale: "en",
|
||||
now: FIXED_NOW,
|
||||
});
|
||||
return extractText(bytes);
|
||||
}
|
||||
|
||||
it("prints a lab value and its range with the digits they were recorded with", async () => {
|
||||
const text = await render(
|
||||
makeData({
|
||||
labResults: [
|
||||
lab({}),
|
||||
lab({
|
||||
analyte: "TSH",
|
||||
value: 2.345,
|
||||
unit: "mIU/L",
|
||||
referenceLow: 0.27,
|
||||
referenceHigh: 4.2,
|
||||
catalogReferenceLow: 0.27,
|
||||
catalogReferenceHigh: 4.2,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
// A one-decimal report printed these as "0.0 ng/mL" and "0.3–4.2".
|
||||
expect(text).toContain("0.034 ng/mL");
|
||||
expect(text).toContain("2.345 mIU/L");
|
||||
expect(text).toContain("0.27–4.2");
|
||||
});
|
||||
|
||||
it("prints a converted lab value with the precision its report printed", async () => {
|
||||
const text = await render(
|
||||
makeData({
|
||||
labResults: [
|
||||
lab({
|
||||
analyte: "Cholesterol",
|
||||
value: 5.2 * 38.67,
|
||||
unit: "mg/dL",
|
||||
sourceValue: 5.2,
|
||||
referenceLow: null,
|
||||
referenceHigh: 5 * 38.67,
|
||||
catalogReferenceHigh: null,
|
||||
referenceOrigin: "source",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(text).toContain("201 mg/dL");
|
||||
// The PDF spells "≤" as "<=" (WinAnsi).
|
||||
expect(text).toContain("<= 193");
|
||||
});
|
||||
|
||||
it("keeps the digits of a measurement read below one", async () => {
|
||||
const text = await render(
|
||||
makeData({
|
||||
stats: {
|
||||
WAIST_TO_HEIGHT: {
|
||||
avg: 0.525,
|
||||
min: 0.52,
|
||||
max: 0.53,
|
||||
count: 2,
|
||||
latest: 0.53,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
// The fixed single decimal printed every one of these as "0.5".
|
||||
expect(text).toContain("0.53");
|
||||
expect(text).toContain("0.52");
|
||||
});
|
||||
});
|
||||
|
||||
describe("doctor-report illness section", () => {
|
||||
it("prints the conditions table with label, type, and dates", async () => {
|
||||
const data = makeData({
|
||||
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
DaySectionKey,
|
||||
} from "@/lib/day/contract";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { formatRecordedNumber } from "@/lib/format/recorded-number";
|
||||
import { SCREENER_INSTRUMENTS } from "@/lib/validations/mental-health";
|
||||
import { listTargetsBySource } from "@/lib/links/link-service";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
@@ -686,6 +687,7 @@ async function labsPart(frame: DayFrame): Promise<SectionPart> {
|
||||
value: true,
|
||||
valueText: true,
|
||||
unit: true,
|
||||
sourceValue: true,
|
||||
takenAt: true,
|
||||
biomarkerId: true,
|
||||
noteEncrypted: true,
|
||||
@@ -703,7 +705,9 @@ async function labsPart(frame: DayFrame): Promise<SectionPart> {
|
||||
title: row.analyte,
|
||||
meta:
|
||||
row.value !== null
|
||||
? `${row.value} ${row.unit}`.trim()
|
||||
? // Every recorded digit, without the float noise a converted
|
||||
// reading carries (the labs page prints the same figure).
|
||||
`${formatRecordedNumber(row.value, (v, digits) => v.toFixed(digits), row.sourceValue)} ${row.unit}`.trim()
|
||||
: (row.valueText ?? null),
|
||||
note: openText(row.noteEncrypted, "lab note"),
|
||||
href: row.biomarkerId ? `/labs/${row.biomarkerId}` : "/labs",
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
classifyReferenceRange,
|
||||
formatReferenceRange,
|
||||
} from "../labs/reference-range";
|
||||
import { formatLabValue } from "../labs/format-value";
|
||||
import {
|
||||
pdfCursorState,
|
||||
type DoctorReportPdfCursorState,
|
||||
@@ -46,8 +47,19 @@ export function buildClinicalRecordsNotesSection(
|
||||
doc.text(t("doctorReport.labsTitle"), margin, y);
|
||||
y += 6;
|
||||
|
||||
const rangeText = (low: number | null, high: number | null): string =>
|
||||
formatReferenceRange(low, high, num, { emptyText: "—" });
|
||||
// Lab figures print with the precision they were recorded at, never the
|
||||
// report's one-decimal default: a PSA of 0.034 is not "0.0". A window
|
||||
// converted with its reading keeps the precision of what was printed.
|
||||
const labNum = (value: number, printed?: number | null): string =>
|
||||
formatLabValue(value, num, printed);
|
||||
const rangeText = (
|
||||
low: number | null,
|
||||
high: number | null,
|
||||
printed?: number | null,
|
||||
): string =>
|
||||
formatReferenceRange(low, high, (n) => labNum(n, printed), {
|
||||
emptyText: "—",
|
||||
});
|
||||
// Quiet, non-colour status glyph. In-range reads as a neutral dash so
|
||||
// the table is not a field of warning marks; out-of-range reads as a
|
||||
// direction arrow the clinician can scan, with no alarm tint.
|
||||
@@ -77,7 +89,11 @@ export function buildClinicalRecordsNotesSection(
|
||||
const printed =
|
||||
lr.referenceOrigin === "source" && lr.sourceReferenceText
|
||||
? lr.sourceReferenceText
|
||||
: rangeText(lr.referenceLow, lr.referenceHigh);
|
||||
: rangeText(
|
||||
lr.referenceLow,
|
||||
lr.referenceHigh,
|
||||
lr.referenceOrigin === "source" ? lr.sourceValue : null,
|
||||
);
|
||||
return lr.referenceDivergesFromCatalog ? `${printed} *` : printed;
|
||||
};
|
||||
|
||||
@@ -93,7 +109,7 @@ export function buildClinicalRecordsNotesSection(
|
||||
lr.panel ? `${lr.analyte} (${lr.panel})` : lr.analyte,
|
||||
isQualitative
|
||||
? (lr.valueText ?? "")
|
||||
: `${num(lr.value as number)} ${lr.unit}`.trim(),
|
||||
: `${labNum(lr.value as number, lr.sourceValue)} ${lr.unit}`.trim(),
|
||||
isQualitative ? "—" : referenceCell(lr),
|
||||
isQualitative
|
||||
? ""
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
glucoseBandParams,
|
||||
resolveGlucoseUnit,
|
||||
} from "../glucose";
|
||||
import { reportStatValue } from "../doctor-report/stat-display";
|
||||
import {
|
||||
reportStatFractionDigits,
|
||||
reportStatValue,
|
||||
} from "../doctor-report/stat-display";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { MEASUREMENT_TYPE_LABEL_KEYS } from "../measurements/type-label-keys";
|
||||
import {
|
||||
@@ -223,13 +226,14 @@ export function buildMeasurementsChartsSection(
|
||||
for (const ctx of GLUCOSE_CONTEXTS) {
|
||||
const s = data.glucoseStats?.[ctx];
|
||||
if (!s) continue;
|
||||
const conv = (v: number) => convertGlucose(v, glucoseUnit);
|
||||
const digits = reportStatFractionDigits("BLOOD_GLUCOSE", s, glucoseUnit);
|
||||
const show = (v: number) => num(convertGlucose(v, glucoseUnit), digits);
|
||||
glucoseContextRows.push([
|
||||
t(GLUCOSE_LABEL_KEYS[ctx]),
|
||||
`${num(conv(s.latest))} ${glucoseUnit}`.trim(),
|
||||
`${num(conv(s.avg))} ${glucoseUnit}`.trim(),
|
||||
num(conv(s.min)),
|
||||
num(conv(s.max)),
|
||||
`${show(s.latest)} ${glucoseUnit}`.trim(),
|
||||
`${show(s.avg)} ${glucoseUnit}`.trim(),
|
||||
show(s.min),
|
||||
show(s.max),
|
||||
String(s.count),
|
||||
]);
|
||||
}
|
||||
@@ -242,13 +246,17 @@ export function buildMeasurementsChartsSection(
|
||||
const unit = unitFor(type);
|
||||
// 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);
|
||||
// One precision per row, the one the readings carry (see
|
||||
// `reportStatFractionDigits`); the share page prints the same.
|
||||
const digits = reportStatFractionDigits(type, s, glucoseUnit);
|
||||
const show = (v: number) =>
|
||||
num(reportStatValue(type, v, glucoseUnit), digits);
|
||||
groupRows.push([
|
||||
t(MEASUREMENT_TYPE_LABEL_KEYS[type as MeasurementType] ?? ""),
|
||||
`${num(conv(s.latest))} ${unit}`.trim(),
|
||||
`${num(conv(s.avg))} ${unit}`.trim(),
|
||||
num(conv(s.min)),
|
||||
num(conv(s.max)),
|
||||
`${show(s.latest)} ${unit}`.trim(),
|
||||
`${show(s.avg)} ${unit}`.trim(),
|
||||
show(s.min),
|
||||
show(s.max),
|
||||
String(s.count),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { jsPDF } from "jspdf";
|
||||
import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only";
|
||||
import autoTable from "jspdf-autotable";
|
||||
import { adherenceRatePercent } from "../doctor-report-data";
|
||||
import { formatRecordedNumber } from "../format/recorded-number";
|
||||
import {
|
||||
pdfCursorState,
|
||||
type DoctorReportPdfCursorState,
|
||||
@@ -184,7 +185,7 @@ export function buildMedicationMoodWellnessSection(
|
||||
if (med.currentDose) {
|
||||
doc.text(
|
||||
t("doctorReport.glp1CurrentDose", {
|
||||
value: num(med.currentDose.value, 2),
|
||||
value: formatRecordedNumber(med.currentDose.value, num),
|
||||
unit: med.currentDose.unit,
|
||||
since: fmtDate(med.currentDose.since),
|
||||
}),
|
||||
@@ -212,7 +213,7 @@ export function buildMedicationMoodWellnessSection(
|
||||
if (med.doseHistory.length > 0) {
|
||||
const historyRows = med.doseHistory.map((dc) => [
|
||||
fmtDate(dc.effectiveFrom),
|
||||
`${num(dc.value, 2)} ${dc.unit}`,
|
||||
`${formatRecordedNumber(dc.value, num)} ${dc.unit}`,
|
||||
dc.note ?? "",
|
||||
]);
|
||||
// A titration history reads as one block — break before it if the
|
||||
|
||||
@@ -283,6 +283,12 @@ export interface DoctorReportData {
|
||||
*/
|
||||
valueText: string | null;
|
||||
unit: string;
|
||||
/**
|
||||
* The figure the source report printed when `value` was converted into
|
||||
* the marker's unit; null for a reading stored as stated. It sets the
|
||||
* precision the converted value is printed with (`formatLabValue`).
|
||||
*/
|
||||
sourceValue: number | null;
|
||||
/**
|
||||
* The reference window this reading is judged against: the range printed
|
||||
* on its own report when it carries one, the catalog band otherwise.
|
||||
|
||||
@@ -60,6 +60,7 @@ export async function loadLabResults(
|
||||
sourceReferenceHigh: true,
|
||||
sourceReferenceText: true,
|
||||
sourceUnit: true,
|
||||
sourceValue: true,
|
||||
takenAt: true,
|
||||
biomarkerId: true,
|
||||
biomarker: { select: { name: true, panel: true } },
|
||||
@@ -93,6 +94,7 @@ export async function loadLabResults(
|
||||
value: r.value,
|
||||
valueText: r.valueText,
|
||||
unit: r.unit,
|
||||
sourceValue: r.sourceValue,
|
||||
referenceLow: range.low,
|
||||
referenceHigh: range.high,
|
||||
catalogReferenceLow: range.catalogLow,
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
* it.
|
||||
*/
|
||||
import { convertGlucose, type GlucoseUnit } from "@/lib/glucose";
|
||||
import { recordedPrecision } from "@/lib/format/recorded-number";
|
||||
import { getReadingTransform } from "@/lib/measurements/display-transform";
|
||||
import { getUnitForType } from "@/lib/validations/measurement";
|
||||
|
||||
/**
|
||||
@@ -77,3 +79,49 @@ export function reportStatValue(
|
||||
if (type === "BLOOD_GLUCOSE") return convertGlucose(value, glucoseUnit);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Significant digits a statistic keeps before it is cut to decimals. */
|
||||
const STAT_SIGNIFICANT_DIGITS = 3;
|
||||
|
||||
/** Decimals a statistic row never goes below, and never above. */
|
||||
const STAT_MIN_FRACTION_DIGITS = 1;
|
||||
const STAT_MAX_FRACTION_DIGITS = 3;
|
||||
|
||||
/**
|
||||
* Decimals for one statistic row of `type` (latest, average and range alike,
|
||||
* so a row reads in one precision), for the number {@link reportStatValue}
|
||||
* returns.
|
||||
*
|
||||
* A fixed single decimal printed a waist-to-height ratio of 0.52 as "0.5" and
|
||||
* a temperature deviation of 0.25 as "0.3": a measurement below or near 1
|
||||
* lost the digits that carry it. Each reading now keeps up to
|
||||
* {@link STAT_SIGNIFICANT_DIGITS} significant digits, but never more decimals
|
||||
* than it was recorded with, so a weight of 79.5 stays "79.5" and a device
|
||||
* figure of 45.123456 ms reads "45.1" rather than six decimals of sensor
|
||||
* noise. Glucose follows its unit's own precision (whole mg/dL, one decimal
|
||||
* mmol/L) and sleep, a quotient of minutes, keeps one decimal of hours.
|
||||
*/
|
||||
export function reportStatFractionDigits(
|
||||
type: string,
|
||||
stat: { latest: number; min: number; max: number },
|
||||
glucoseUnit: GlucoseUnit,
|
||||
): number {
|
||||
if (type === "BLOOD_GLUCOSE") {
|
||||
// The report stays SI for everything but glucose, so only its unit counts.
|
||||
return getReadingTransform(type, { system: "metric", glucoseUnit })
|
||||
.decimals;
|
||||
}
|
||||
if (type === "SLEEP_DURATION") return 1;
|
||||
const needed = [stat.latest, stat.min, stat.max].map((raw) => {
|
||||
const value = reportStatValue(type, raw, glucoseUnit);
|
||||
if (value === 0 || !Number.isFinite(value)) return 0;
|
||||
const recorded = recordedPrecision(value).fractionDigits;
|
||||
const magnitude = Math.floor(Math.log10(Math.abs(value)));
|
||||
const significant = Math.max(0, STAT_SIGNIFICANT_DIGITS - 1 - magnitude);
|
||||
return Math.min(recorded, significant);
|
||||
});
|
||||
return Math.min(
|
||||
STAT_MAX_FRACTION_DIGITS,
|
||||
Math.max(STAT_MIN_FRACTION_DIGITS, ...needed),
|
||||
);
|
||||
}
|
||||
@@ -52,6 +52,7 @@ describe("lab-result FHIR Observations", () => {
|
||||
value: 110,
|
||||
valueText: null,
|
||||
unit: "mg/dL",
|
||||
sourceValue: null,
|
||||
referenceLow: null,
|
||||
referenceHigh: 116,
|
||||
catalogReferenceLow: null,
|
||||
@@ -68,6 +69,7 @@ describe("lab-result FHIR Observations", () => {
|
||||
value: 5.4,
|
||||
valueText: null,
|
||||
unit: "%",
|
||||
sourceValue: null,
|
||||
referenceLow: 4,
|
||||
referenceHigh: 5.6,
|
||||
catalogReferenceLow: null,
|
||||
@@ -145,6 +147,7 @@ describe("lab-result FHIR Observations", () => {
|
||||
value: 2.1,
|
||||
valueText: null,
|
||||
unit: "mg/L",
|
||||
sourceValue: null,
|
||||
referenceLow: null,
|
||||
referenceHigh: null,
|
||||
catalogReferenceLow: null,
|
||||
@@ -178,6 +181,7 @@ describe("lab-result FHIR Observations", () => {
|
||||
value: 100.9,
|
||||
valueText: null,
|
||||
unit: "mg/dL",
|
||||
sourceValue: null,
|
||||
referenceLow: 70.27,
|
||||
referenceHigh: 99.1,
|
||||
catalogReferenceLow: 70,
|
||||
@@ -237,6 +241,7 @@ describe("lab-result LOINC + canonical UCUM coding (v1.18.8)", () => {
|
||||
value,
|
||||
valueText: null,
|
||||
unit,
|
||||
sourceValue: null,
|
||||
referenceLow: bounds?.low ?? null,
|
||||
referenceHigh: bounds?.high ?? null,
|
||||
catalogReferenceLow: null,
|
||||
@@ -345,6 +350,7 @@ describe("qualitative lab-result FHIR Observations (v1.18.9)", () => {
|
||||
value: null,
|
||||
valueText,
|
||||
unit: "",
|
||||
sourceValue: null,
|
||||
referenceLow: null,
|
||||
referenceHigh: null,
|
||||
catalogReferenceLow: null,
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* How many digits a number somebody recorded is printed with.
|
||||
*
|
||||
* A lab value, a dose or a reference bound is a figure a person typed or a
|
||||
* report printed, and its digits are the precision it was measured at. A
|
||||
* PSA of 0.034 ng/mL is not "0.03", and a dose of 0.125 mg is not "0.13".
|
||||
* Every surface that prints such a figure used to pick its own fixed number
|
||||
* of decimals (one on the PDF, two on the share page, "two above 1, three
|
||||
* significant below" on the labs page), so the same reading read differently
|
||||
* in each place and some of them dropped digits that carried meaning.
|
||||
*
|
||||
* The rule here keeps every digit the number holds:
|
||||
*
|
||||
* - A stored value is printed exactly as stored. Nothing records how many
|
||||
* trailing zeros were typed, so "4.20" prints as "4.2"; every digit that is
|
||||
* there survives. The value is first read back at
|
||||
* {@link STORED_SIGNIFICANT_DIGITS} significant digits, which removes
|
||||
* binary floating-point noise ("0.30000000000000004") and nothing a person
|
||||
* could have entered.
|
||||
* - A value converted from another unit carries no precision of its own, its
|
||||
* digits are the product of a conversion factor. When the figure the report
|
||||
* printed is known (`printed`), the converted value keeps as many
|
||||
* significant digits as that figure had, never fewer than
|
||||
* {@link CONVERTED_MIN_SIGNIFICANT_DIGITS} and never fewer than its own
|
||||
* integer digits, so 5.2 mmol/L reads as 201 mg/dL rather than 201.0844.
|
||||
*
|
||||
* The caller formats the result in its own locale with exactly
|
||||
* `fractionDigits` decimals, so the separator follows the reader.
|
||||
*/
|
||||
|
||||
/** Significant digits a stored value is read back at. */
|
||||
const STORED_SIGNIFICANT_DIGITS = 12;
|
||||
|
||||
/** Floor for a converted value's significant digits. */
|
||||
const CONVERTED_MIN_SIGNIFICANT_DIGITS = 3;
|
||||
|
||||
/** `Intl.NumberFormat` accepts at most this many fraction digits everywhere. */
|
||||
const MAX_FRACTION_DIGITS = 20;
|
||||
|
||||
/** A locale number formatter that prints exactly `fractionDigits` decimals. */
|
||||
export type FractionDigitsFormat = (
|
||||
value: number,
|
||||
fractionDigits: number,
|
||||
) => string;
|
||||
|
||||
/** Significant digits and decimal exponent of a finite, nonzero number. */
|
||||
function shape(n: number): { digits: number; exponent: number } {
|
||||
// Without an argument `toExponential` prints as many digits as it takes to
|
||||
// identify the number uniquely: the shortest round-trip spelling.
|
||||
const [mantissa, exponent] = Math.abs(n).toExponential().split("e");
|
||||
return {
|
||||
digits: mantissa.replace(".", "").length,
|
||||
exponent: Number(exponent),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The value to print and how many decimals it carries. `printed` is the
|
||||
* figure the source printed when `value` was converted from it, else absent.
|
||||
*/
|
||||
export function recordedPrecision(
|
||||
value: number,
|
||||
printed?: number | null,
|
||||
): { value: number; fractionDigits: number } {
|
||||
if (!Number.isFinite(value) || value === 0) {
|
||||
return { value, fractionDigits: 0 };
|
||||
}
|
||||
let significant = STORED_SIGNIFICANT_DIGITS;
|
||||
if (
|
||||
printed !== null &&
|
||||
printed !== undefined &&
|
||||
Number.isFinite(printed) &&
|
||||
printed !== 0
|
||||
) {
|
||||
const integerDigits = Math.max(1, shape(value).exponent + 1);
|
||||
significant = Math.max(
|
||||
shape(printed).digits,
|
||||
CONVERTED_MIN_SIGNIFICANT_DIGITS,
|
||||
integerDigits,
|
||||
);
|
||||
}
|
||||
const rounded = Number(value.toPrecision(Math.min(significant, 100)));
|
||||
const { digits, exponent } = shape(rounded);
|
||||
return {
|
||||
value: rounded,
|
||||
fractionDigits: Math.min(
|
||||
MAX_FRACTION_DIGITS,
|
||||
Math.max(0, digits - 1 - exponent),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** A recorded number in the caller's locale, with every digit it holds. */
|
||||
export function formatRecordedNumber(
|
||||
value: number,
|
||||
formatNumber: FractionDigitsFormat,
|
||||
printed?: number | null,
|
||||
): string {
|
||||
const { value: rounded, fractionDigits } = recordedPrecision(value, printed);
|
||||
return formatNumber(rounded, fractionDigits);
|
||||
}
|
||||
@@ -3,30 +3,65 @@ import { describe, expect, it } from "vitest";
|
||||
import { makeFormatters } from "@/lib/format-locale";
|
||||
import { formatLabReading, formatLabValue } from "../format-value";
|
||||
|
||||
const de = (n: number) => makeFormatters("de", "UTC", "AUTO", "AUTO").number(n);
|
||||
const en = (n: number) => makeFormatters("en", "UTC", "AUTO", "AUTO").number(n);
|
||||
const de = makeFormatters("de", "UTC", "AUTO", "AUTO").number;
|
||||
const en = makeFormatters("en", "UTC", "AUTO", "AUTO").number;
|
||||
const labNumber = (value: number, printed?: number | null) =>
|
||||
formatLabValue(value, de, printed);
|
||||
|
||||
describe("lab value formatting", () => {
|
||||
it("trims to two decimals and prints the locale's separator", () => {
|
||||
// These readings used to be trimmed to two decimals above 1 and three
|
||||
// significant digits below, so 12.3456 read "12.35" and 0.123456 "0.123":
|
||||
// digits the lab reported were dropped. A reading now keeps them all.
|
||||
it("keeps every recorded digit and prints the locale's separator", () => {
|
||||
expect(formatLabValue(1.8, de)).toBe("1,8");
|
||||
expect(formatLabValue(1.8, en)).toBe("1.8");
|
||||
expect(formatLabValue(0.123456, de)).toBe("0,123");
|
||||
expect(formatLabValue(12.3456, de)).toBe("12,35");
|
||||
expect(formatLabValue(0.123456, de)).toBe("0,123456");
|
||||
expect(formatLabValue(12.3456, de)).toBe("12,3456");
|
||||
expect(formatLabValue(5, de)).toBe("5");
|
||||
});
|
||||
|
||||
it("never shows a small nonzero reading as zero", () => {
|
||||
it("never shows a small nonzero reading as zero or shortened", () => {
|
||||
expect(formatLabValue(0.004, de)).toBe("0,004");
|
||||
expect(formatLabValue(0.012, en)).toBe("0.012");
|
||||
expect(formatLabValue(0.035, de)).toBe("0,035");
|
||||
expect(formatLabValue(0.034, en)).toBe("0.034");
|
||||
expect(formatLabValue(0.0012, en)).toBe("0.0012");
|
||||
});
|
||||
|
||||
it("drops binary floating-point noise, never a recorded digit", () => {
|
||||
expect(formatLabValue(0.1 + 0.2, en)).toBe("0.3");
|
||||
expect(formatLabValue(1234.5678, en)).toBe("1,234.5678");
|
||||
});
|
||||
|
||||
it("prints a converted reading with the precision its report printed", () => {
|
||||
// 5.2 mmol/L of cholesterol converted to mg/dL.
|
||||
expect(formatLabValue(5.2 * 38.67, en, 5.2)).toBe("201");
|
||||
// A four-digit report figure keeps four significant digits.
|
||||
expect(formatLabValue(5.234 * 38.67, en, 5.234)).toBe("202.4");
|
||||
// Never fewer digits than the integer part.
|
||||
expect(formatLabValue(12.5 * 88.4, en, 12.5)).toBe("1,105");
|
||||
});
|
||||
|
||||
it("formats a numeric reading with its unit and leaves a qualitative one verbatim", () => {
|
||||
expect(
|
||||
formatLabReading({ value: 61.3, valueText: null, unit: "kg" }, de),
|
||||
formatLabReading({ value: 61.3, valueText: null, unit: "kg" }, labNumber),
|
||||
).toBe("61,3 kg");
|
||||
expect(
|
||||
formatLabReading({ value: null, valueText: "negativ", unit: "" }, de),
|
||||
formatLabReading(
|
||||
{ value: null, valueText: "negativ", unit: "" },
|
||||
labNumber,
|
||||
),
|
||||
).toBe("negativ");
|
||||
expect(
|
||||
formatLabReading(
|
||||
{
|
||||
value: 5.2 * 38.67,
|
||||
valueText: null,
|
||||
unit: "mg/dL",
|
||||
sourceValue: 5.2,
|
||||
},
|
||||
labNumber,
|
||||
),
|
||||
).toBe("201 mg/dL");
|
||||
});
|
||||
});
|
||||
@@ -1,46 +1,57 @@
|
||||
/**
|
||||
* v1.18.1 — shared lab-value number formatting.
|
||||
* Shared lab-value number formatting.
|
||||
*
|
||||
* The list card, the detail history rows, and the chart tooltip all trim a
|
||||
* lab reading the same way: whole numbers render bare, fractional values keep
|
||||
* up to two decimals (no trailing-zero noise), and a value below 1 keeps three
|
||||
* significant digits so a small reading (TSH 0.004, troponin 0.012) never
|
||||
* reads as zero. Centralised so the three surfaces stay byte-identical.
|
||||
* Every surface that prints a lab reading (the list card, the detail history,
|
||||
* the chart tooltip, the doctor report PDF, the clinician share page) prints
|
||||
* it through here, so a reading reads the same everywhere. The precision rule
|
||||
* is the recorded-number rule in `src/lib/format/recorded-number.ts`: a
|
||||
* reading keeps every digit it was stored with (TSH 0.004, PSA 0.034, a
|
||||
* troponin of 0.0012 never shortened), and a reading converted from the unit
|
||||
* its report printed keeps the precision of the printed figure.
|
||||
*
|
||||
* The digits themselves come from the caller's locale number formatter
|
||||
* (`useFormatters().number`), so a German reader sees "1,8 mIU/L" where an
|
||||
* English one sees "1.8 mIU/L". This module only decides the precision.
|
||||
* The digits themselves come from the caller's locale number formatter, so a
|
||||
* German reader sees "1,8 mIU/L" where an English one sees "1.8 mIU/L". This
|
||||
* module only decides the precision.
|
||||
*/
|
||||
export type LabNumberFormat = (value: number) => string;
|
||||
import {
|
||||
formatRecordedNumber,
|
||||
type FractionDigitsFormat,
|
||||
} from "@/lib/format/recorded-number";
|
||||
|
||||
/**
|
||||
* A lab number in the reader's locale. `printed` is the figure the source
|
||||
* report printed when the number was converted from it (`sourceValue`).
|
||||
*/
|
||||
export type LabNumberFormat = (
|
||||
value: number,
|
||||
printed?: number | null,
|
||||
) => string;
|
||||
|
||||
export function formatLabValue(
|
||||
value: number,
|
||||
formatNumber: LabNumberFormat,
|
||||
formatNumber: FractionDigitsFormat,
|
||||
printed?: number | null,
|
||||
): string {
|
||||
if (Number.isInteger(value)) return formatNumber(value);
|
||||
const rounded =
|
||||
Math.abs(value) < 1
|
||||
? Number(value.toPrecision(3))
|
||||
: Number(value.toFixed(2));
|
||||
return formatNumber(rounded);
|
||||
return formatRecordedNumber(value, formatNumber, printed);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.18.9 — render a reading's display value: a qualitative row (numeric
|
||||
* `value` is null) shows its `valueText` verbatim; a numeric row shows the
|
||||
* formatted number plus its unit. One helper so the list card, the history
|
||||
* rows, and the detail header stay consistent.
|
||||
* Render a reading's display value: a qualitative row (numeric `value` is
|
||||
* null) shows its `valueText` verbatim; a numeric row shows the formatted
|
||||
* number plus its unit. One helper so the list card, the history rows, and
|
||||
* the detail header stay consistent.
|
||||
*/
|
||||
export function formatLabReading(
|
||||
reading: {
|
||||
value: number | null;
|
||||
valueText: string | null;
|
||||
unit: string;
|
||||
sourceValue?: number | null;
|
||||
},
|
||||
formatNumber: LabNumberFormat,
|
||||
): string {
|
||||
if (reading.value === null) {
|
||||
return reading.valueText ?? "";
|
||||
}
|
||||
return `${formatLabValue(reading.value, formatNumber)} ${reading.unit}`.trim();
|
||||
return `${formatNumber(reading.value, reading.sourceValue)} ${reading.unit}`.trim();
|
||||
}
|
||||
@@ -281,6 +281,15 @@ describe("a printed reference range, end to end", () => {
|
||||
);
|
||||
expect(labs?.[0].sourceReferenceText).toBeNull();
|
||||
expect(labs?.[0].referenceLow).toBeCloseTo(3.9 * 18.0182, 6);
|
||||
// ...but it carries the printed figure, so the converted value prints
|
||||
// with the precision the report gave it rather than a float's tail.
|
||||
expect(labs?.[0].sourceValue).toBe(5.6);
|
||||
const { formatLabValue } = await import("@/lib/labs/format-value");
|
||||
const { makeFormatters } = await import("@/lib/format-locale");
|
||||
const en = makeFormatters("en", "UTC", "AUTO", "AUTO").number;
|
||||
expect(
|
||||
formatLabValue(labs![0].value as number, en, labs![0].sourceValue),
|
||||
).toBe("101");
|
||||
|
||||
// FHIR: the text, when present, must agree with low and high. Here it is
|
||||
// left out, and the bounds carry the marker's unit like the value.
|
||||
@@ -361,6 +370,8 @@ describe("a printed reference range, end to end", () => {
|
||||
new Date("2026-12-31T23:59:59.000Z"),
|
||||
);
|
||||
expect(labs?.[0].sourceReferenceText).toBeNull();
|
||||
// An edited value is no longer a conversion, and prints as typed.
|
||||
expect(labs?.[0].sourceValue).toBeNull();
|
||||
|
||||
const { labObservations } = await import("@/lib/fhir/resources/labs");
|
||||
let seq = 0;
|
||||
|
||||
Reference in new issue
Block a user