mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
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/);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
: "";
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user