Files
HealthLog/src/lib/format/recorded-number.ts
T
Marc-André Bombeck 0ee7e156d3 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
2026-10-16 11:00:27 +02:00

102 lines
3.8 KiB
TypeScript

/**
* 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);
}