fix(labs): print range bars and lab values in the reader's number format

Range bars and the lab list, detail rows, chart tooltip and reference
ranges printed numbers with a plain `String()`, so a German reader saw
"1.8 mIU/L" and "61.3 kg" under a German interface. The same was true
of the mean, GMI, eA1C and the advanced indices on the glucose panel.

`RangeBar` now prints value, bounds and delta through the locale number
formatter and takes an optional `formatValue` for surfaces with their
own precision. Lab values keep their trim rule (whole numbers bare, at
most two decimals) in `formatLabValue`, which now hands the digits to a
caller-supplied locale formatter; `useLabNumber` wires it to the
reader's locale for every lab surface. Inputs are unchanged.
This commit is contained in:
Marc-André Bombeck committed 2026-10-01 22:09:30 +02:00
1 parent 75361d0666
commit fe13fa966b
16 files changed
+161 -53

No files matched your search

@@ -162,6 +162,11 @@ describe("<GlucoseClinicalPanel> asserted state", () => {
const de = render(<GlucoseClinicalPanel />, "de");
expect(de).toContain("(3,9–10,0 mmol/L ist das Ziel)");
expect(de).not.toMatch(/mg\/dl/i);
// Mean, GMI and eA1C read with the German decimal comma.
expect(de).not.toMatch(/>\d+\.\d+</);
expect(de).toMatch(
/>\d+,\d<\/span><span class="text-muted-foreground text-xs">mmol\/L/,
);
authMock.mockReturnValue({
user: { glucoseUnit: "mg/dL" },
@@ -4,7 +4,7 @@ import { useId, useState } from "react";
import { ChevronDown } from "lucide-react";
import { InfoPopover } from "@/components/ui/info-popover";
import { useTranslations } from "@/lib/i18n/context";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
import type { DataSummary } from "@/lib/analytics/trends";
@@ -49,11 +49,12 @@ export function GlucoseAdvancedDisclosure({
fractionDigits = 1,
}: GlucoseAdvancedDisclosureProps) {
const { t } = useTranslations();
const number = useFormatters().number;
const [open, setOpen] = useState(false);
const regionId = useId();
const fmt = (v: number | null) =>
v === null ? "—" : v.toFixed(fractionDigits);
v === null ? "—" : number(v, fractionDigits);
const contexts = Object.entries(byContext ?? {}).filter(
([, s]) => s && s.count > 0,
@@ -82,7 +82,7 @@ export function GlucoseClinicalPanel() {
const formatGlucoseValue = (mgdl: number): string => {
const v = convertGlucose(mgdl, glucoseUnit);
return isMmol ? v.toFixed(1) : String(Math.round(v));
return fmt.number(v, isMmol ? 1 : 0);
};
// Learning state — calm, never asserting TIR / GMI off thin data.
@@ -189,7 +189,7 @@ export function GlucoseClinicalPanel() {
icon={<Activity className="h-3.5 w-3.5" aria-hidden="true" />}
label={t("insights.bloodGlucose.clinical.gmi.label")}
help={t("insights.bloodGlucose.clinical.gmi.help")}
value={clinical.gmi.toFixed(1)}
value={fmt.number(clinical.gmi, 1)}
unit="%"
/>
) : null}
@@ -198,7 +198,7 @@ export function GlucoseClinicalPanel() {
icon={<TrendingUp className="h-3.5 w-3.5" aria-hidden="true" />}
label={t("insights.bloodGlucose.clinical.eA1c.label")}
help={t("insights.bloodGlucose.clinical.eA1c.help")}
value={clinical.estimatedA1c.toFixed(1)}
value={fmt.number(clinical.estimatedA1c, 1)}
unit="%"
/>
) : null}
@@ -7,9 +7,10 @@ import { LabReferenceRangeBar } from "../lab-reference-range-bar";
function render(
props: Partial<React.ComponentProps<typeof LabReferenceRangeBar>> = {},
locale: "en" | "de" = "en",
) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<I18nProvider initialLocale={locale}>
<LabReferenceRangeBar
value={72}
referenceLow={60}
@@ -45,4 +46,15 @@ describe("<LabReferenceRangeBar>", () => {
expect(render({ referenceLow: null })).toContain('aria-label="≤100 bpm"');
expect(render({ referenceHigh: null })).toContain('aria-label="≥60 bpm"');
});
it("prints the reading and its range in the reader's number format", () => {
const html = render(
{ value: 1.8333, referenceLow: 0.4, referenceHigh: 4.05, unit: "mIU/L" },
"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");
expect(html).not.toMatch(/\d\.\d+ mIU\/L/);
});
});
+3 -2
View File
@@ -13,12 +13,12 @@ import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { formatLabValue } from "@/lib/labs/format-value";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { BiomarkerForm } from "./biomarker-form";
import type { BiomarkerDto, BiomarkerListResponse } from "./types";
import { useLabNumber } from "./use-lab-format";
/**
* v1.18.1 — the Biomarker catalog manager. v1.22 — compact editable rows.
@@ -42,6 +42,7 @@ import type { BiomarkerDto, BiomarkerListResponse } from "./types";
*/
export function BiomarkerManager() {
const { t } = useTranslations();
const labNumber = useLabNumber();
const queryClient = useQueryClient();
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<BiomarkerDto | null>(null);
@@ -101,7 +102,7 @@ export function BiomarkerManager() {
const range = formatReferenceRange(
marker.lowerBound,
marker.upperBound,
formatLabValue,
labNumber,
);
return (
<li
+5 -4
View File
@@ -16,12 +16,12 @@ import {
import { Button } from "@/components/ui/button";
import { CHART_HEIGHT_PX } from "@/lib/charts/constants";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { formatLabValue } from "@/lib/labs/format-value";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { RichChartTooltip, type RichTooltipRow } from "../charts/chart-tooltip";
import type { LabResultDto } from "./types";
import { useLabNumber } from "./use-lab-format";
/**
* v1.18.1 — per-biomarker trend chart, in the dashboard's visual language.
@@ -91,6 +91,7 @@ export function LabBiomarkerChart({
upperBound: number | null;
}) {
const { t } = useTranslations();
const labNumber = useLabNumber();
const fmt = useFormatters();
const [range, setRange] = useState<RangeKey>("365");
// Capture "now" once at mount so the recency filter stays pure across
@@ -224,7 +225,7 @@ export function LabBiomarkerChart({
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--border)"
width={44}
tickFormatter={(v: number) => formatLabValue(v)}
tickFormatter={(v: number) => labNumber(v)}
/>
{/* Reference window — muted band + dashed bounds. No alarm
colour: the in/out verdict is shown by the neutral badge,
@@ -302,7 +303,7 @@ export function LabBiomarkerChart({
const rows: RichTooltipRow[] = [
{
name: t("labs.chart.valueLabel"),
value: `${formatLabValue(point.value)} ${unit}`,
value: `${labNumber(point.value)} ${unit}`,
color: primary,
},
];
@@ -317,7 +318,7 @@ export function LabBiomarkerChart({
`${formatReferenceRange(
point.referenceLow,
point.referenceHigh,
formatLabValue,
labNumber,
)} ${unit}`.trim(),
color: secondary,
});
+4 -7
View File
@@ -27,12 +27,12 @@ import {
parseReferenceRange,
} from "@/lib/labs/parse-reference-range";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { formatLabValue } from "@/lib/labs/format-value";
import { EncounterSuggestionField } from "@/components/encounters/encounter-suggestion-field";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { BiomarkerForm } from "./biomarker-form";
import { useLabNumber } from "./use-lab-format";
import type {
BiomarkerDto,
BiomarkerListResponse,
@@ -135,6 +135,7 @@ export function LabForm({
footerSlot,
}: LabFormProps) {
const { t } = useTranslations();
const labNumber = useLabNumber();
const recordName = useActiveRecordName();
const queryClient = useQueryClient();
const formId = useId();
@@ -325,11 +326,7 @@ export function LabForm({
}
const referenceText = selected
? formatReferenceRange(
selected.lowerBound,
selected.upperBound,
formatLabValue,
)
? formatReferenceRange(selected.lowerBound, selected.upperBound, labNumber)
: "";
// What the typed string resolved to, shown back so the user can see whether
@@ -342,7 +339,7 @@ export function LabForm({
: `${formatReferenceRange(
parsedSourceRange.low,
parsedSourceRange.high,
formatLabValue,
labNumber,
)} ${selected?.unit ?? ""}`.trim()
: "";
+4 -2
View File
@@ -38,6 +38,7 @@ import { ReferenceRangeBadge } from "./reference-range-badge";
import { LabReferenceRangeBar } from "./lab-reference-range-bar";
import { SourceRangeNote } from "./source-range-note";
import type { LabResultDetailDto, LabResultDto } from "./types";
import { useLabNumber } from "./use-lab-format";
const NOTE_MAX_LENGTH = 2000;
@@ -77,6 +78,7 @@ function parseDecimal(raw: string): number | null {
*/
export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
const { t } = useTranslations();
const labNumber = useLabNumber();
const { canManageDomain } = useRecordCapabilities();
const canManageLabs = canManageDomain("labs");
const queryClient = useQueryClient();
@@ -353,7 +355,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
<li key={r.id} className="flex items-start gap-3 py-3">
<div className="min-w-0 flex-1 space-y-2">
<div className="font-semibold tabular-nums">
{formatLabReading(r)}
{formatLabReading(r, labNumber)}
</div>
<LabReferenceRangeBar
value={r.value}
@@ -421,7 +423,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
<TableRow key={r.id}>
<TableCell className="font-semibold tabular-nums">
<div className="space-y-2">
<span>{formatLabReading(r)}</span>
<span>{formatLabReading(r, labNumber)}</span>
<LabReferenceRangeBar
value={r.value}
referenceLow={r.referenceLow}
+6 -4
View File
@@ -15,13 +15,14 @@ import { Skeleton } from "@/components/ui/skeleton";
import { apiGet } from "@/lib/api/api-fetch";
import { formatDate } from "@/lib/format";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { formatLabReading, formatLabValue } from "@/lib/labs/format-value";
import { formatLabReading } from "@/lib/labs/format-value";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { queryKeys } from "@/lib/query-keys";
import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs";
import { LabTrendSparkline } from "./lab-trend-sparkline";
import { useLabNumber } from "./use-lab-format";
import { LabReferenceRangeBar } from "./lab-reference-range-bar";
import { ReferenceRangeBadge } from "./reference-range-badge";
import { SourceRangeNote } from "./source-range-note";
@@ -122,6 +123,7 @@ const MOBILE_CELL = {
export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
const { t } = useTranslations();
const labNumber = useLabNumber();
const { canWriteDomain } = useRecordCapabilities();
const canAddLab = canWriteDomain("labs");
const { prefs } = useModuleListPrefs("labs");
@@ -255,7 +257,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
</div>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-xs">
<span className="text-foreground font-semibold tabular-nums">
{formatLabReading(group.latest)}
{formatLabReading(group.latest, labNumber)}
</span>
<span>{formatDate(group.latest.takenAt)}</span>
</div>
@@ -373,7 +375,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
<div className="flex items-end justify-between gap-3">
<div className="text-muted-foreground flex min-w-0 flex-1 flex-wrap items-center gap-x-2 text-sm">
<span className="text-foreground font-semibold tabular-nums">
{formatLabReading(group.latest)}
{formatLabReading(group.latest, labNumber)}
</span>
{group.latest.value !== null &&
(group.latest.referenceLow !== null ||
@@ -383,7 +385,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
{formatReferenceRange(
group.latest.referenceLow,
group.latest.referenceHigh,
formatLabValue,
labNumber,
)}
</span>
) : null}
@@ -2,6 +2,8 @@
import { RangeBar } from "@/components/targets/range-bar";
import { useLabNumber } from "./use-lab-format";
/**
* v1.37.34 — compact reference-range visualization for saved lab readings.
*
@@ -20,6 +22,7 @@ export function LabReferenceRangeBar({
referenceHigh: number | null;
unit: string;
}) {
const labNumber = useLabNumber();
const hasLow = referenceLow !== null && Number.isFinite(referenceLow);
const hasHigh = referenceHigh !== null && Number.isFinite(referenceHigh);
if (
@@ -37,10 +40,10 @@ export function LabReferenceRangeBar({
const min = referenceLow ?? referenceHigh! - scale;
const max = referenceHigh ?? referenceLow! + scale;
const rangeLabel = !hasLow
? `≤${referenceHigh} ${unit}`
? `≤${labNumber(referenceHigh!)} ${unit}`
: !hasHigh
? `≥${referenceLow} ${unit}`
: `${referenceLow}–${referenceHigh} ${unit}`;
? `≥${labNumber(referenceLow!)} ${unit}`
: `${labNumber(referenceLow!)}–${labNumber(referenceHigh!)} ${unit}`;
return (
<div
@@ -55,6 +58,7 @@ export function LabReferenceRangeBar({
unit={unit}
compact
tone="lab"
formatValue={labNumber}
minLabel={referenceLow === null ? null : undefined}
maxLabel={referenceHigh === null ? null : undefined}
/>
+4 -3
View File
@@ -1,10 +1,10 @@
"use client";
import { formatLabValue } from "@/lib/labs/format-value";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { useTranslations } from "@/lib/i18n/context";
import type { LabResultDto } from "./types";
import { useLabNumber } from "./use-lab-format";
/**
* The reference window a reading's own report printed, shown wherever that
@@ -40,6 +40,7 @@ export function SourceRangeNote({
className?: string;
}) {
const { t } = useTranslations();
const labNumber = useLabNumber();
if (reading.referenceOrigin !== "source") return null;
const printed =
@@ -47,13 +48,13 @@ export function SourceRangeNote({
`${formatReferenceRange(
reading.referenceLow,
reading.referenceHigh,
formatLabValue,
labNumber,
)} ${reading.unit}`.trim();
const catalog = formatReferenceRange(
reading.catalogReferenceLow,
reading.catalogReferenceHigh,
formatLabValue,
labNumber,
);
return (
+18
View File
@@ -0,0 +1,18 @@
"use client";
import { useCallback } from "react";
import { useFormatters } from "@/lib/i18n/context";
import { formatLabValue, type LabNumberFormat } from "@/lib/labs/format-value";
/**
* A lab value in the reader's number format, trimmed the way every lab
* surface trims it (whole numbers bare, at most two decimals).
*/
export function useLabNumber(): LabNumberFormat {
const fmt = useFormatters();
return useCallback(
(value: number) => formatLabValue(value, (n) => fmt.number(n)),
[fmt],
);
}
@@ -11,9 +11,12 @@ import { EDGE_PADDING_PERCENT, RangeBar } from "../range-bar";
* extraction is provably side-effect-free.
*/
function render(props: Parameters<typeof RangeBar>[0]) {
function render(
props: Parameters<typeof RangeBar>[0],
locale: "en" | "de" = "en",
) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<I18nProvider initialLocale={locale}>
<RangeBar {...props} />
</I18nProvider>,
);
@@ -93,4 +96,17 @@ describe("<RangeBar>", () => {
expect(html).toContain('role="img"');
expect(html).toContain('aria-label="Current: 72 bpm');
});
it("prints value, bounds and delta in the reader's number format", () => {
// A German reader saw "61.3 kg" under a German interface.
const html = render(
{ value: 72.4, min: 50.5, max: 70.25, unit: "kg" },
"de",
);
expect(html).toContain("50,5 kg");
expect(html).toContain("70,25 kg");
expect(html).toContain("72,4 kg");
expect(html).toContain("2,2 kg");
expect(html).not.toMatch(/\d\.\d+ kg/);
});
});
+21 -10
View File
@@ -6,7 +6,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useTranslations } from "@/lib/i18n/context";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
/**
* A horizontal range bar with green/yellow/red zones showing where
@@ -28,6 +28,12 @@ export interface RangeBarProps {
tone?: "target" | "lab";
minLabel?: string | null;
maxLabel?: string | null;
/**
* How a value or bound is printed. Defaults to the reader's locale number
* format; a surface with its own precision rule (lab values trim to two
* decimals) passes it here so the bar and the row beside it agree.
*/
formatValue?: (value: number) => string;
}
/**
@@ -49,8 +55,13 @@ export function RangeBar({
tone = "target",
minLabel,
maxLabel,
formatValue,
}: RangeBarProps) {
const { t } = useTranslations();
const fmt = useFormatters();
// Every printed number goes through the locale formatter: a German reader
// sees "61,3 kg", not "61.3 kg".
const show = formatValue ?? ((v: number) => fmt.number(v));
const isLab = tone === "lab";
const span = max - min;
@@ -132,8 +143,8 @@ export function RangeBar({
const deltaText =
delta > 0
? value < min
? t("targets.belowTarget", { delta: delta.toFixed(1), unit })
: t("targets.aboveTarget", { delta: delta.toFixed(1), unit })
? t("targets.belowTarget", { delta: fmt.number(delta, 1), unit })
: t("targets.aboveTarget", { delta: fmt.number(delta, 1), unit })
: t("targets.inTarget");
// 2026-07-17 a11y audit (M1) — the marker's value + range live only in a
@@ -142,8 +153,8 @@ export function RangeBar({
// 1.1.1 (no separate copy to drift) — `tabIndex` below makes the trigger
// focusable, which is all Radix needs to also open the tooltip on focus.
const markerAriaLabel = [
t("targets.currentValue", { value: String(value), unit }),
t("targets.targetRangeValue", { min: String(min), max: String(max), unit }),
t("targets.currentValue", { value: show(value), unit }),
t("targets.targetRangeValue", { min: show(min), max: show(max), unit }),
deltaText,
].join(". ");
@@ -218,12 +229,12 @@ export function RangeBar({
</TooltipTrigger>
<TooltipContent>
<p className="text-xs font-medium">
{t("targets.currentValue", { value: String(value), unit })}
{t("targets.currentValue", { value: show(value), unit })}
</p>
<p className="text-xs">
{t("targets.targetRangeValue", {
min: String(min),
max: String(max),
min: show(min),
max: show(max),
unit,
})}
</p>
@@ -241,13 +252,13 @@ export function RangeBar({
className="absolute -translate-x-1/2 whitespace-nowrap"
style={{ left: `${minLabelPosition}%` }}
>
{minLabel === null ? null : (minLabel ?? `${min} ${unit}`)}
{minLabel === null ? null : (minLabel ?? `${show(min)} ${unit}`)}
</span>
<span
className="absolute -translate-x-1/2 whitespace-nowrap"
style={{ left: `${maxLabelPosition}%` }}
>
{maxLabel === null ? null : (maxLabel ?? `${max} ${unit}`)}
{maxLabel === null ? null : (maxLabel ?? `${show(max)} ${unit}`)}
</span>
</div>
</div>
@@ -0,0 +1,25 @@
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);
describe("lab value formatting", () => {
it("trims to two decimals 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,12");
expect(formatLabValue(5, de)).toBe("5");
});
it("formats a numeric reading with its unit and leaves a qualitative one verbatim", () => {
expect(
formatLabReading({ value: 61.3, valueText: null, unit: "kg" }, de),
).toBe("61,3 kg");
expect(
formatLabReading({ value: null, valueText: "negativ", unit: "" }, de),
).toBe("negativ");
});
});
+22 -10
View File
@@ -5,11 +5,20 @@
* lab reading the same way: whole numbers render bare, fractional values keep
* up to two decimals (no trailing-zero noise). Centralised so the three
* surfaces stay byte-identical.
*
* 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.
*/
export function formatLabValue(value: number): string {
return Number.isInteger(value)
? String(value)
: String(Number(value.toFixed(2)));
export type LabNumberFormat = (value: number) => string;
export function formatLabValue(
value: number,
formatNumber: LabNumberFormat,
): string {
return formatNumber(
Number.isInteger(value) ? value : Number(value.toFixed(2)),
);
}
/**
@@ -18,13 +27,16 @@ export function formatLabValue(value: number): string {
* 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;
}): string {
export function formatLabReading(
reading: {
value: number | null;
valueText: string | null;
unit: string;
},
formatNumber: LabNumberFormat,
): string {
if (reading.value === null) {
return reading.valueText ?? "";
}
return `${formatLabValue(reading.value)} ${reading.unit}`.trim();
return `${formatLabValue(reading.value, formatNumber)} ${reading.unit}`.trim();
}