Merge branch 'feat/unitless' into release/v1.46.6

This commit is contained in:
Marc-André Bombeck committed 2026-11-02 20:30:41 +01:00
commit 2c6fb82326
55 files changed
+1029 -127

No files matched your search

@@ -26,6 +26,7 @@ import {
type LeafScope,
type Translate,
} from "./report-sections";
import { withLabUnit } from "@/lib/labs/dimensionless";
/** Join the parts of a composed value, dropping the ones with nothing in. */
function compose(parts: Array<string | null | undefined>): string {
@@ -73,7 +74,7 @@ export function LabResultsSection({
const qualitative = lab.value === null;
const reading = qualitative
? lab.valueText
: `${fmtLab(lab.value as number, lab.sourceValue)} ${lab.unit}`.trim();
: withLabUnit(fmtLab(lab.value as number, lab.sourceValue), lab.unit);
const range = (
low: number | null,
high: number | null,
@@ -37,10 +37,12 @@ import { Checkbox } from "@/components/ui/checkbox";
import { DateField } from "@/components/ui/date-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { LabUnitInput } from "@/components/labs/lab-unit-input";
import { MarkerMatchField } from "@/components/labs/marker-match-field";
import type { BiomarkerListResponse } from "@/components/labs/types";
import { apiGet } from "@/lib/api/api-fetch";
import type { DocumentExtractRunResult } from "@/lib/documents/ai-runs/types";
import { withLabUnit } from "@/lib/labs/dimensionless";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
@@ -81,7 +83,7 @@ function factHeadline(fact: ExtractedFactDto): string {
const data = fact.data as ObservationFactData;
const value =
typeof data.value === "number"
? `${data.value}${data.unit ? ` ${data.unit}` : ""}`
? withLabUnit(String(data.value), data.unit)
: (data.valueText ?? "");
return value ? `${data.label}: ${value}` : data.label;
}
@@ -412,13 +414,12 @@ export function FactRow({
? t("documents.review.fieldUnit")
: t("documents.review.fieldDose")}
</Label>
<Input
<LabUnitInput
id={`fact-unit-${fact.id}`}
value={draft.unit}
onChange={(e) =>
setDraft((d) => ({ ...d, unit: e.target.value }))
}
onChange={(unit) => setDraft((d) => ({ ...d, unit }))}
maxLength={isObservation ? 80 : 120}
allowNoUnit={isObservation}
/>
</div>
) : null}
@@ -11,6 +11,7 @@ import { queryKeys } from "@/lib/query-keys";
import { SectionHeading } from "@/components/ui/section-heading";
import type { LabChange } from "@/lib/insights/labs-changes";
import { InsightSectionCard } from "./insight-section-card";
import { withLabUnit } from "@/lib/labs/dimensionless";
/**
* v1.25 — "what changed since your last panel" card.
@@ -81,7 +82,7 @@ export function LabsChangesCard({
const signedDelta =
c.direction === "flat"
? t("insights.labsChanges.directionFlat")
: `${c.delta > 0 ? "+" : ""}${c.delta} ${c.unit}`.trim();
: withLabUnit(`${c.delta > 0 ? "+" : ""}${c.delta}`, c.unit);
return (
<div
key={c.analyte}
@@ -93,7 +94,7 @@ export function LabsChangesCard({
{c.analyte}
</p>
<p className="text-muted-foreground text-xs">
{`${c.latest} ${c.unit}`.trim()}
{withLabUnit(String(c.latest), c.unit)}
{labelKey ? ` · ${t(labelKey)}` : ""}
</p>
</div>
@@ -0,0 +1,142 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { I18nProvider } from "@/lib/i18n/context";
import en from "../../../../messages/en.json";
vi.mock("@/lib/api/api-fetch", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/api/api-fetch")>()),
apiPost: vi.fn(),
apiPut: vi.fn(),
}));
import { BiomarkerForm } from "../biomarker-form";
import { LabUnitInput } from "../lab-unit-input";
import type { OcrReviewRow } from "../ocr-review-types";
import { OcrRowEditor } from "../ocr-row-editor";
/**
* The explicit "no unit" choice for a ratio or an index. It is offered next to
* every lab unit field a person fills in, it shows as chosen when the unit is
* the UCUM unity `"1"`, and it never reads an empty unit as that choice.
*/
function render(node: React.ReactNode): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
const NONE = en.labs.unit.none;
function noneBox(html: string): string {
const box = html.match(/<button[^>]*role="checkbox"[^>]*>/)?.[0];
if (!box) throw new Error("no checkbox rendered");
return box;
}
describe("<LabUnitInput>", () => {
it("offers the choice and leaves it unticked for an empty unit", () => {
const html = render(
<LabUnitInput id="u" value="" onChange={() => {}} required />,
);
expect(html).toContain(NONE);
expect(html).toContain(en.labs.unit.noneHint);
expect(noneBox(html)).toContain('aria-checked="false"');
expect(html).toMatch(/<input[^>]*id="u"[^>]*required/);
});
it("shows the unity as the ticked choice, not as a typed 1", () => {
const html = render(
<LabUnitInput id="u" value="1" onChange={() => {}} required />,
);
expect(noneBox(html)).toContain('aria-checked="true"');
const input = html.match(/<input[^>]*id="u"[^>]*>/)?.[0] ?? "";
expect(input).toContain('value=""');
expect(input).toContain("disabled");
expect(input).not.toContain("required");
});
it("leaves a real unit as it is", () => {
const html = render(
<LabUnitInput id="u" value="mg/dL" onChange={() => {}} />,
);
expect(noneBox(html)).toContain('aria-checked="false"');
expect(html).toContain('value="mg/dL"');
});
it("is a plain field where no unit makes no sense", () => {
const html = render(
<LabUnitInput id="u" value="1" onChange={() => {}} allowNoUnit={false} />,
);
expect(html).not.toContain(NONE);
expect(html).toContain('value="1"');
});
});
describe("the no-unit choice on the lab forms", () => {
it("is offered when a marker is defined", () => {
const html = render(<BiomarkerForm />);
expect(html).toContain(NONE);
});
it("is offered on a scanned report's row", () => {
const row: OcrReviewRow = {
key: "INR-0",
analyte: "INR",
value: 1.1,
valueText: null,
unit: null,
referenceLow: 0.8,
referenceHigh: 1.2,
referenceText: null,
takenAt: null,
confidence: { analyte: 1, value: 1, unit: 1, range: 1 },
biomarkerMatch: "new",
markerUnit: null,
markerAnalyteKey: null,
duplicateOf: null,
biomarkerId: null,
matchKind: null,
suggestions: [],
chosenBiomarkerId: null,
confirmed: true,
};
const html = render(<OcrRowEditor row={row} onChange={() => {}} />);
expect(html).toContain(NONE);
});
it("names a marker without a unit in words where a scanned row disagrees", () => {
const row: OcrReviewRow = {
key: "INR-1",
analyte: "INR",
value: 1.1,
valueText: null,
unit: "s",
referenceLow: null,
referenceHigh: null,
referenceText: null,
takenAt: null,
confidence: { analyte: 1, value: 1, unit: 1, range: 1 },
biomarkerMatch: "existing",
markerUnit: "1",
markerAnalyteKey: null,
duplicateOf: null,
biomarkerId: "bm-inr",
matchKind: "name",
suggestions: [],
chosenBiomarkerId: null,
confirmed: true,
};
const html = render(
<OcrRowEditor
row={row}
onChange={() => {}}
errors={{ unitMismatch: true }}
/>,
);
expect(html).toContain(en.labs.ocr.errorUnitMismatchNoUnit);
expect(html).toContain("Tracked without a unit; this report says s");
expect(html).not.toContain("tracked in 1");
});
});
+3 -2
View File
@@ -26,6 +26,7 @@ import {
} from "@/lib/labs/biomarker-catalog";
import { useTranslations } from "@/lib/i18n/context";
import { LabUnitInput } from "./lab-unit-input";
import type { BiomarkerDto } from "./types";
const CONTEXT_MAX_LENGTH = 2000;
@@ -219,10 +220,10 @@ export function BiomarkerForm({
<Label htmlFor="biomarker-unit">
{t("labs.biomarker.form.unit")}
</Label>
<Input
<LabUnitInput
id="biomarker-unit"
value={unit}
onChange={(e) => setUnit(e.target.value)}
onChange={setUnit}
placeholder={t("labs.biomarker.form.unitPlaceholder")}
maxLength={40}
required
+4 -2
View File
@@ -24,6 +24,8 @@ import { BiomarkerForm } from "./biomarker-form";
import { BiomarkerMergeSheet } from "./biomarker-merge-sheet";
import type { BiomarkerDto, BiomarkerListResponse } from "./types";
import { useLabNumber } from "./use-lab-format";
import { withLabUnit } from "@/lib/labs/dimensionless";
import { LabUnitName } from "./lab-unit-input";
/**
* v1.18.1 — the Biomarker catalog manager. v1.22 — compact editable rows.
@@ -124,11 +126,11 @@ export function BiomarkerManager() {
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-2">
<span className="truncate text-sm font-medium">{marker.name}</span>
<span className="text-muted-foreground text-xs">{marker.unit}</span>
<LabUnitName unit={marker.unit} />
</div>
<p className="text-muted-foreground text-xs">
{range
? `${t("labs.referenceLabel")} ${range} ${marker.unit}`
? `${t("labs.referenceLabel")} ${withLabUnit(range, marker.unit)}`
: t("labs.biomarker.noRange")}
{marker.panel ? ` · ${marker.panel}` : ""}
</p>
@@ -28,6 +28,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { BiomarkerDto, BiomarkerMergeResultDto } from "./types";
import { labUnitLabel } from "@/lib/labs/dimensionless";
/** The most sources one merge takes, matching the server. */
const MAX_SOURCES = 10;
@@ -166,7 +167,7 @@ export function BiomarkerMergeSheet({
<p className="text-sm">
{tCount("labs.merge.previewSummary", preview.movedReadings, {
name: preview.target.name,
unit: preview.target.unit,
unit: labUnitLabel(preview.target.unit, t),
})}
</p>
<ul className="space-y-2">
@@ -178,7 +179,7 @@ export function BiomarkerMergeSheet({
<p className="text-sm font-medium break-words">
{source.name}{" "}
<span className="text-muted-foreground text-xs font-normal">
{source.unit}
{labUnitLabel(source.unit, t)}
</span>
</p>
<p
@@ -190,8 +191,8 @@ export function BiomarkerMergeSheet({
>
{source.conversion === "refused"
? t("labs.merge.refused", {
unit: source.unit,
targetUnit: preview.target.unit,
unit: labUnitLabel(source.unit, t),
targetUnit: labUnitLabel(preview.target.unit, t),
})
: source.conversion === "converted"
? tCount("labs.merge.converted", source.readingCount, {
@@ -247,7 +248,7 @@ export function BiomarkerMergeSheet({
{marker.name}
</span>
<span className="text-muted-foreground shrink-0 text-xs">
{marker.unit}
{labUnitLabel(marker.unit, t)}
</span>
</label>
</li>
+5 -4
View File
@@ -61,6 +61,7 @@ import type {
LabResultDto,
LabResultListResponse,
} from "./types";
import { labUnitLabel, labUnitText } from "@/lib/labs/dimensionless";
// v1.18.11 — defer the recharts biomarker chart through
// `next/dynamic` so recharts is off `/labs/[biomarkerId]`'s first-load JS.
@@ -426,7 +427,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
one description line every page header carries. */}
{marker?.unit ? (
<p className="text-muted-foreground text-sm">
{marker.unit}
{labUnitLabel(marker.unit, t)}
{marker.panel ? ` · ${marker.panel}` : ""}
</p>
) : null}
@@ -492,7 +493,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
numeric readings, mirroring the metric sub-pages. */}
<MetricStatStrip
summary={summary}
unit={marker?.unit ?? latest?.unit ?? ""}
unit={labUnitText(marker?.unit ?? latest?.unit)}
seriesLabel={marker?.name}
icon={FlaskConical}
/>
@@ -500,7 +501,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
<CardContent>
<LabBiomarkerChart
readings={readings}
unit={marker?.unit ?? latest?.unit ?? ""}
unit={labUnitText(marker?.unit ?? latest?.unit)}
lowerBound={marker?.lowerBound ?? null}
upperBound={marker?.upperBound ?? null}
/>
@@ -508,7 +509,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
</Card>
<LabTrendCard
trend={marker?.trend}
unit={marker?.unit ?? latest?.unit ?? ""}
unit={labUnitText(marker?.unit ?? latest?.unit)}
/>
</>
)}
+12 -7
View File
@@ -39,6 +39,11 @@ import type {
BiomarkerListResponse,
LabResultDto,
} from "./types";
import {
labUnitLabel,
labUnitText,
withLabUnit,
} from "@/lib/labs/dimensionless";
const NOTE_MAX_LENGTH = 2000;
const DEFINE_NEW = "__define_new__";
@@ -330,9 +335,8 @@ export function LabForm({
? formatReferenceRange(selected.lowerBound, selected.upperBound, labNumber)
: "";
// What the typed string resolved to, shown back so the user can see whether
// a window was read out of it before the reading is saved. When none was,
// the hint says the text is kept as written rather than staying silent.
// What the typed string resolved to, shown back before the save. When no
// window was read out of it, the hint says the text stays as written.
const parsedSourceRange = parseReferenceRange(sourceRange, selected?.unit);
const sourceRangeHint = parsedSourceRange
? isUnreadableRange(parsedSourceRange)
@@ -341,7 +345,7 @@ export function LabForm({
parsedSourceRange.low,
parsedSourceRange.high,
labNumber,
)} ${selected?.unit ?? ""}`.trim()
)} ${labUnitText(selected?.unit)}`.trim()
: "";
const footerNode = (
@@ -406,7 +410,7 @@ export function LabForm({
<SelectContent>
{markers.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name} · {m.unit}
{m.name} · {labUnitLabel(m.unit, t)}
</SelectItem>
))}
{!lockedBiomarkerId ? (
@@ -457,7 +461,7 @@ export function LabForm({
{selected ? (
<span className="text-muted-foreground font-normal">
{" "}
({selected.unit})
({labUnitLabel(selected.unit, t)})
</span>
) : null}
</Label>
@@ -515,7 +519,8 @@ export function LabForm({
{resultType === "numeric" && selected && referenceText ? (
<p className="text-muted-foreground text-xs">
{t("labs.referenceLabel")} {referenceText} {selected.unit}
{t("labs.referenceLabel")}{" "}
{withLabUnit(referenceText, selected.unit)}
</p>
) : null}
+2 -1
View File
@@ -46,6 +46,7 @@ import {
} from "./lab-result-related-links";
import type { LabResultDto } from "./types";
import { useLabDate, useLabNumber } from "./use-lab-format";
import { labUnitText } from "@/lib/labs/dimensionless";
const NOTE_MAX_LENGTH = 2000;
@@ -103,7 +104,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
reading.value === null
? (reading.valueText ?? "")
: labNumber(reading.value, reading.sourceValue),
unit: reading.value === null ? undefined : reading.unit,
unit: reading.value === null ? undefined : labUnitText(reading.unit),
compare: before
? {
label: t("day.previousReading", { date: labDate(before.takenAt) }),
+9 -4
View File
@@ -31,6 +31,7 @@ import type {
LabOverviewResponse,
LabResultDto,
} from "./types";
import { labUnitLabel } from "@/lib/labs/dimensionless";
interface MarkerGroup {
/** Stable group key: `bm:<biomarkerId>`, or `analyte:<lower>` for legacy rows. */
@@ -39,6 +40,7 @@ interface MarkerGroup {
biomarkerId: string | null;
analyte: string;
panel: string | null;
/** The marker's unit as the card names it ("No unit" for a ratio). */
unit: string;
/** Every live reading of the marker, counted by the server. */
count: number;
@@ -53,13 +55,16 @@ interface MarkerGroup {
* inert until the backfill links them. The server already orders by the
* newest reading.
*/
function toGroups(markers: LabOverviewMarkerDto[]): MarkerGroup[] {
function toGroups(
markers: LabOverviewMarkerDto[],
t: (key: string) => string,
): MarkerGroup[] {
return markers.map((marker) => ({
key: marker.key,
biomarkerId: marker.latest.biomarkerId,
analyte: marker.latest.analyte,
panel: marker.latest.panel,
unit: marker.latest.unit,
unit: labUnitLabel(marker.latest.unit, t),
count: marker.count,
trend: marker.trend,
latest: marker.latest,
@@ -152,7 +157,7 @@ export function LabList({
});
const groups = useMemo(() => {
const base = toGroups(data?.markers ?? []);
const base = toGroups(data?.markers ?? [], t);
// v1.18.6 — honour the user's Labs sort choice. The server
// order is most-recent-first; `recentAsc` reverses, the alpha
// options sort by analyte name (#43), and `manual` applies the persisted
@@ -173,7 +178,7 @@ export function LabList({
return applyOrder(base, prefs.order, (g) => g.biomarkerId ?? g.key);
}
return base;
}, [data?.markers, prefs.sortDir, prefs.order]);
}, [data?.markers, prefs.sortDir, prefs.order, t]);
const listEmpty = !isLoading && !isError && groups.length === 0;
useEffect(() => {
@@ -4,6 +4,7 @@ import { RangeBar } from "@/components/targets/range-bar";
import { cn } from "@/lib/utils";
import { useLabNumber } from "./use-lab-format";
import { labUnitText, withLabUnit } from "@/lib/labs/dimensionless";
/**
* v1.37.34 — compact reference-range visualization for saved lab readings.
@@ -43,11 +44,14 @@ export function LabReferenceRangeBar({
const scale = Math.max(Math.abs(referenceLow ?? referenceHigh!), 1);
const min = referenceLow ?? referenceHigh! - scale;
const max = referenceHigh ?? referenceLow! + scale;
const rangeLabel = !hasLow
? `≤${labNumber(referenceHigh!)} ${unit}`
: !hasHigh
? `≥${labNumber(referenceLow!)} ${unit}`
: `${labNumber(referenceLow!)}–${labNumber(referenceHigh!)} ${unit}`;
const rangeLabel = withLabUnit(
!hasLow
? `≤${labNumber(referenceHigh!)}`
: !hasHigh
? `≥${labNumber(referenceLow!)}`
: `${labNumber(referenceLow!)}–${labNumber(referenceHigh!)}`,
unit,
);
return (
<div
@@ -59,7 +63,7 @@ export function LabReferenceRangeBar({
value={value}
min={min}
max={max}
unit={unit}
unit={labUnitText(unit)}
compact
tone="lab"
formatValue={labNumber}
+21 -11
View File
@@ -7,6 +7,7 @@ import { TileHeader } from "@/components/insights/tile-header";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { BiomarkerTrendDto } from "./types";
import { labUnitText } from "@/lib/labs/dimensionless";
/**
* v1.44 — the marker's slope over all its readings, and where that straight
@@ -35,12 +36,19 @@ export function LabTrendCard({
: trend.slopePerYear < 0
? "falling"
: "flat";
const slopeLine = t(`labs.trend.slope.${direction}`, {
amount: per(trend.slopePerYear),
unit,
n: trend.n,
since: fmt.dateShortSmartCalendar(trend.firstAt),
});
const shownUnit = labUnitText(unit);
// A value without a unit leaves the {unit} slot empty; close the gap it
// leaves rather than print a space before the full stop.
const tidy = (text: string) =>
shownUnit ? text : text.replace(/ +([.,])/g, "$1").replace(/ {2,}/g, " ");
const slopeLine = tidy(
t(`labs.trend.slope.${direction}`, {
amount: per(trend.slopePerYear),
unit: shownUnit,
n: trend.n,
since: fmt.dateShortSmartCalendar(trend.firstAt),
}),
);
const projectionLine =
trend.projection === "exits" && trend.exitDate
? t(`labs.trend.projection.exits.${trend.exitBound ?? "high"}`, {
@@ -85,11 +93,13 @@ export function LabTrendCard({
</p>
) : null}
<p className="text-muted-foreground text-xs">
{t("labs.trend.interval", {
lo: fmt.number(trend.slopeCi95[0], 2),
hi: fmt.number(trend.slopeCi95[1], 2),
unit,
})}
{tidy(
t("labs.trend.interval", {
lo: fmt.number(trend.slopeCi95[0], 2),
hi: fmt.number(trend.slopeCi95[1], 2),
unit: shownUnit,
}),
)}
</p>
</CardContent>
</Card>
+119
View File
@@ -0,0 +1,119 @@
"use client";
import { useId, useRef } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import {
DIMENSIONLESS_UNIT,
isDimensionlessUnit,
labUnitLabel,
} from "@/lib/labs/dimensionless";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
/** A marker's unit as a muted label, "No unit" for a ratio or an index. */
export function LabUnitName({
unit,
className,
}: {
unit: string;
className?: string;
}) {
const { t } = useTranslations();
return (
<span className={cn("text-muted-foreground text-xs", className)}>
{labUnitLabel(unit, t)}
</span>
);
}
interface LabUnitInputProps {
id: string;
value: string;
onChange: (unit: string) => void;
maxLength?: number;
placeholder?: string;
required?: boolean;
"aria-describedby"?: string;
"aria-invalid"?: boolean;
/**
* Offer the explicit "no unit" choice. Off for a field that only shares the
* input, such as a medication dose on the document review.
*/
allowNoUnit?: boolean;
}
/**
* The unit of a lab value, with an explicit "no unit" choice for a ratio or an
* index. Ticking it sets the unit to the UCUM unity `"1"`, which every write
* path accepts as a real unit; leaving the field empty is still refused, so a
* unit an extraction dropped is never taken for a value that has none. Typing
* `1` by hand is the same choice and ticks the box.
*/
export function LabUnitInput({
id,
value,
onChange,
maxLength,
placeholder,
required,
allowNoUnit = true,
...aria
}: LabUnitInputProps) {
const { t } = useTranslations();
const noneId = useId();
const hintId = `${noneId}-hint`;
const none = allowNoUnit && isDimensionlessUnit(value);
// What was typed before the box was ticked, so unticking gives it back.
const typed = useRef(none ? "" : value);
const input = (
<Input
id={id}
value={none ? "" : value}
onChange={(e) => {
typed.current = e.target.value;
onChange(e.target.value);
}}
placeholder={none ? t("labs.unit.none") : placeholder}
maxLength={maxLength}
required={required && !none}
disabled={none}
{...aria}
/>
);
if (!allowNoUnit) return input;
return (
<div className="space-y-1.5" data-slot="lab-unit-input">
{input}
<label
htmlFor={noneId}
className="flex min-h-11 cursor-pointer items-start gap-2 sm:min-h-0"
>
<Checkbox
id={noneId}
checked={none}
onCheckedChange={(state) =>
onChange(
state === true
? DIMENSIONLESS_UNIT
: isDimensionlessUnit(typed.current)
? ""
: typed.current,
)
}
aria-describedby={hintId}
className="mt-0.5"
/>
<span className="space-y-0.5">
<span className="block text-sm">{t("labs.unit.none")}</span>
<span id={hintId} className="text-muted-foreground block text-xs">
{t("labs.unit.noneHint")}
</span>
</span>
</label>
</div>
);
}
+2 -1
View File
@@ -46,6 +46,7 @@ import {
import { BiomarkerManager } from "./biomarker-manager";
import type { BiomarkerDto, BiomarkerListResponse } from "./types";
import { useLabsLocalOcr, useUpdateLabsLocalOcr } from "./use-ocr-extract";
import { labUnitLabel } from "@/lib/labs/dimensionless";
export function LabsSettings() {
const { t } = useTranslations();
@@ -61,7 +62,7 @@ export function LabsSettings() {
const reorderItems: ReorderItem[] = ordered.map((m) => ({
id: m.id,
name: m.name,
secondary: m.panel ? `${m.unit} · ${m.panel}` : m.unit,
secondary: [labUnitLabel(m.unit, t), m.panel].filter(Boolean).join(" · "),
}));
const manual = prefs.sortDir === "manual";
+2 -1
View File
@@ -21,6 +21,7 @@ import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { useTranslations } from "@/lib/i18n/context";
import { labUnitLabel } from "@/lib/labs/dimensionless";
export interface MarkerSuggestionDto {
biomarkerId: string;
@@ -110,7 +111,7 @@ export function MarkerMatchField({
<option key={suggestion.biomarkerId} value={suggestion.biomarkerId}>
{t("labs.match.suggestionOption", {
name: suggestion.name,
unit: suggestion.unit,
unit: labUnitLabel(suggestion.unit, t),
})}
</option>
))}
+28 -11
View File
@@ -24,12 +24,14 @@ import { DateField } from "@/components/ui/date-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTranslations } from "@/lib/i18n/context";
import { isDimensionlessUnit, labUnitLabel } from "@/lib/labs/dimensionless";
import {
OCR_ANALYTE_MAX,
OCR_UNIT_MAX,
OCR_VALUE_TEXT_MAX,
} from "@/lib/validations/labs-ocr";
import { LabUnitInput } from "./lab-unit-input";
import { MarkerMatchField } from "./marker-match-field";
import { chooseMarker, type OcrReviewRow } from "./ocr-review-types";
import {
@@ -51,6 +53,28 @@ function RequiredMark() {
);
}
type Translate = (key: string, params?: Record<string, string>) => string;
/** The badge naming a row whose unit differs from its marker's. */
function unitDiffersText(row: OcrReviewRow, t: Translate): string {
const unit = labUnitLabel(row.unit, t);
return isDimensionlessUnit(row.markerUnit)
? t("labs.ocr.unitDiffersBadgeNoUnit", { unit })
: t("labs.ocr.unitDiffersBadge", {
markerUnit: row.markerUnit ?? "",
unit,
});
}
/** The message key and params of a unit the marker cannot take. */
function unitMismatchMessage(
row: OcrReviewRow,
): [string, Record<string, string>?] {
return isDimensionlessUnit(row.markerUnit)
? ["labs.ocr.errorUnitMismatchNoUnit"]
: ["labs.ocr.errorUnitMismatch", { markerUnit: row.markerUnit ?? "" }];
}
export function OcrRowEditor({
row,
onChange,
@@ -197,12 +221,7 @@ export function OcrRowEditor({
{unitBlocked ? (
<Badge variant="secondary" className="max-w-full">
<Ruler aria-hidden />
<span className="truncate">
{t("labs.ocr.unitDiffersBadge", {
markerUnit: row.markerUnit ?? "",
unit: (row.unit ?? "").trim(),
})}
</span>
<span className="truncate">{unitDiffersText(row, t)}</span>
</Badge>
) : null}
{conversion ? (
@@ -294,10 +313,10 @@ export function OcrRowEditor({
{t("labs.ocr.unitLabel")}
<RequiredMark />
</Label>
<Input
<LabUnitInput
id={`${fieldId}-unit`}
value={row.unit ?? ""}
onChange={(e) => onChange({ ...row, unit: e.target.value })}
onChange={(unit) => onChange({ ...row, unit })}
{...invalidProps(
errors?.unitMismatch
? "unitMismatch"
@@ -310,9 +329,7 @@ export function OcrRowEditor({
{fieldError("unitTooLong", "labs.ocr.errorTooLong", {
max: String(OCR_UNIT_MAX),
})}
{fieldError("unitMismatch", "labs.ocr.errorUnitMismatch", {
markerUnit: row.markerUnit ?? "",
})}
{fieldError("unitMismatch", ...unitMismatchMessage(row))}
</div>
</>
)}
+10 -6
View File
@@ -5,6 +5,7 @@ import { useTranslations } from "@/lib/i18n/context";
import type { LabResultDto } from "./types";
import { useLabNumber } from "./use-lab-format";
import { withLabUnit } from "@/lib/labs/dimensionless";
/**
* The reference window a reading's own report printed, shown wherever that
@@ -54,11 +55,14 @@ export function SourceRangeNote({
// then stated from the converted bounds, in the marker's unit.
const inForce =
reading.sourceReferenceText ??
`${formatReferenceRange(
reading.referenceLow,
reading.referenceHigh,
labNumber,
)} ${reading.unit}`.trim();
withLabUnit(
formatReferenceRange(
reading.referenceLow,
reading.referenceHigh,
labNumber,
),
reading.unit,
);
const catalog = formatReferenceRange(
reading.catalogReferenceLow,
@@ -77,7 +81,7 @@ export function SourceRangeNote({
: null}
{reading.referenceDivergesFromCatalog && catalog
? ` · ${t("labs.sourceRange.catalogDiffers", {
range: `${catalog} ${reading.unit}`.trim(),
range: withLabUnit(catalog, reading.unit),
})}`
: null}
</span>
@@ -100,6 +100,26 @@ export function RecordCsvImportCard() {
);
}
/** What the sheet says about reading the file, below the format choices. */
function ImportNotes({ kind }: { kind: RecordImportKind }) {
const { t } = useTranslations();
return (
<>
<p className="text-muted-foreground text-xs">
{t("recordImport.timezoneNote")}
</p>
{kind === "labs" ? (
<p
className="text-muted-foreground text-xs"
data-slot="record-import-unitless-hint"
>
{t("recordImport.unitlessHint")}
</p>
) : null}
</>
);
}
function RecordCsvImportSheet({
kind,
onOpenChange,
@@ -421,9 +441,7 @@ function RecordCsvImportSheet({
/>
</div>
) : null}
<p className="text-muted-foreground text-xs">
{t("recordImport.timezoneNote")}
</p>
<ImportNotes kind={kind} />
<div
className="max-h-48 overflow-auto overscroll-contain rounded-lg border"
+3 -2
View File
@@ -38,6 +38,7 @@ import {
formatDayMonthYear,
} from "./timeline-dates";
import { seriesNameLines, type TimelineLayout } from "./timeline-geometry";
import { withLabUnit } from "@/lib/labs/dimensionless";
/** "18 ng/mL", every digit the reading holds, in the reader's locale. */
export type LabValueText = (
@@ -50,7 +51,7 @@ export function useLabValueText(): LabValueText {
const labNumber = useLabNumber();
return useCallback(
(value, sourceValue, unit) =>
`${labNumber(value, sourceValue)} ${unit}`.trim(),
withLabUnit(labNumber(value, sourceValue), unit),
[labNumber],
);
}
@@ -84,7 +85,7 @@ export function useLabReference(): (
(v) => labNumber(v),
);
return range
? t("timeline.labs.reference", { range: `${range} ${unit}`.trim() })
? t("timeline.labs.reference", { range: withLabUnit(range, unit) })
: null;
},
[t, labNumber],