feat(labs): show a reading's conversion in the scan review and beside the value

Slice 3 of phase 3 of #1095. The scan review no longer treats every reading in
another unit as unwritable. The server tells it which analyte the matched marker
is (markerAnalyteKey on each extracted row); a row whose unit differs but can be
converted starts selected and carries a badge saying what Save will store and
what the report stated ("Saved as 100.9 mg/dL, converted from 5.6 mmol/L"). A
row that cannot be converted keeps the existing blocked badge and error. Save
still sends what the report printed; the server converts, as in slice 2.

The review's duplicate flag also recognises a re-scan of a reading that was
stored converted, by what the report printed, the same rule the commit applies.

A converted reading carries a quiet "converted from 5.6 mmol/L" on the lab list
(row and tile) and the history list (phone and table), so a number nobody typed
can always be traced back to what the report printed.

Strings in all seven locales.

Refs #1095
This commit is contained in:
BernhardBuckel committed 2026-10-11 20:41:08 +02:00
1 parent caa7a937fd
commit 747e0964ad
22 files changed
+465 -19

No files matched your search

@@ -0,0 +1,49 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { I18nProvider } from "@/lib/i18n/context";
import { LabConvertedNote } from "../lab-converted-note";
function render(
reading: Parameters<typeof LabConvertedNote>[0]["reading"],
locale: "en" | "de" = "en",
) {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<LabConvertedNote reading={reading} />
</I18nProvider>,
);
}
describe("<LabConvertedNote>", () => {
it("says what the report printed beside a converted reading", () => {
const html = render({
converted: true,
sourceValue: 5.6,
sourceUnit: "mmol/L",
});
expect(html).toContain('data-slot="lab-converted-note"');
expect(html).toContain("converted from 5.6 mmol/L");
});
it("formats the number for the reader's language", () => {
expect(
render({ converted: true, sourceValue: 5.6, sourceUnit: "mmol/L" }, "de"),
).toContain("umgerechnet aus 5,6 mmol/L");
});
it("trims a long fraction the way the reading itself is trimmed", () => {
expect(
render({ converted: true, sourceValue: 5.6123456, sourceUnit: "mmol/L" }),
).toContain("converted from 5.61 mmol/L");
});
it.each([
[{ converted: false, sourceValue: null, sourceUnit: null }],
[{ converted: true, sourceValue: null, sourceUnit: "mmol/L" }],
[{ converted: true, sourceValue: 5.6, sourceUnit: null }],
])("renders nothing for %j", (reading) => {
expect(render(reading)).toBe("");
});
});
@@ -29,6 +29,9 @@ function reading(over: Partial<LabResultDto>): LabResultDto {
value: 80,
valueText: null,
unit: "ng/mL",
converted: false,
sourceValue: null,
sourceUnit: null,
referenceLow: 30,
referenceHigh: 400,
catalogReferenceLow: 30,
@@ -165,4 +168,21 @@ describe("<LabList> columns", () => {
}
});
});
it("notes where a converted reading came from, and only for that reading", () => {
const html = renderList([
reading({
id: "c",
biomarkerId: "b1",
analyte: "Glucose",
unit: "mg/dL",
converted: true,
sourceValue: 5.6,
sourceUnit: "mmol/L",
}),
reading({ id: "d", biomarkerId: "b2", analyte: "Ferritin" }),
]);
expect(count(html, 'data-slot="lab-converted-note"')).toBe(1);
expect(html).toContain("converted from 5.6 mmol/L");
});
});
@@ -5,6 +5,8 @@ import { seedReviewRows } from "../ocr-review-types";
import {
collectRowErrors,
planSave,
readingConversion,
readingUnitBlocked,
readingUnitDiffers,
toCommitRow,
validateReviewRow,
@@ -24,6 +26,7 @@ function row(overrides: Partial<OcrReviewRow> = {}): OcrReviewRow {
confidence: { analyte: 1, value: 1, unit: 1, range: 1 },
biomarkerMatch: "existing",
markerUnit: "mmol/L",
markerAnalyteKey: null,
duplicateOf: null,
confirmed: true,
...overrides,
@@ -200,6 +203,7 @@ describe("a reading in another unit than its marker's", () => {
confidence: { analyte: 1, value: 1, unit: 1, range: 1 },
biomarkerMatch: "existing" as const,
markerUnit,
markerAnalyteKey: null,
duplicateOf: null,
});
const [same, other, fresh] = seedReviewRows(
@@ -212,3 +216,85 @@ describe("a reading in another unit than its marker's", () => {
expect(fresh.confirmed).toBe(true);
});
});
describe("a reading in another unit that can be converted", () => {
// Glucose stated in mmol/L against a marker kept in mg/dL, and the analyte is
// known: Save converts it, so nothing blocks and the row starts selected.
const glucose = (overrides: Partial<OcrReviewRow> = {}) =>
row({
analyte: "Glucose",
value: 5.6,
unit: "mmol/L",
markerUnit: "mg/dL",
markerAnalyteKey: "fasting-glucose",
...overrides,
});
it("previews what Save will store, beside what the report stated", () => {
const conversion = readingConversion(glucose());
expect(conversion).toMatchObject({
unit: "mg/dL",
statedValue: 5.6,
statedUnit: "mmol/L",
});
expect(conversion!.value).toBeCloseTo(5.6 * 18.0182, 9);
});
it("is not blocked, and not a validation error", () => {
expect(readingUnitDiffers(glucose())).toBe(true);
expect(readingUnitBlocked(glucose())).toBe(false);
expect(validateReviewRow(glucose())).toEqual({});
});
it("sends what the report printed, for the server to convert", () => {
const payload = toCommitRow(glucose());
expect(payload).toMatchObject({
analyte: "Glucose",
value: 5.6,
unit: "mmol/L",
});
expect(planSave([glucose()]).kind).toBe("ready");
});
it("starts selected on the review screen", () => {
const dto = {
analyte: "Glucose",
value: 5.6,
valueText: null,
unit: "mmol/L",
referenceLow: null,
referenceHigh: null,
referenceText: null,
takenAt: "2026-06-10",
confidence: { analyte: 1, value: 1, unit: 1, range: 1 },
biomarkerMatch: "existing" as const,
markerUnit: "mg/dL",
markerAnalyteKey: "fasting-glucose",
duplicateOf: null,
};
const [converts, blocked] = seedReviewRows(
[dto, { ...dto, markerAnalyteKey: null }],
null,
);
expect(converts.confirmed).toBe(true);
expect(blocked.confirmed).toBe(false);
});
it("stays blocked when the marker has no known analyte, or the pair is not in the registry", () => {
expect(readingUnitBlocked(glucose({ markerAnalyteKey: null }))).toBe(true);
expect(readingConversion(glucose({ markerAnalyteKey: null }))).toBeNull();
expect(validateReviewRow(glucose({ markerAnalyteKey: null }))).toEqual({
unitMismatch: true,
});
// The analyte is known but the unit is not one it reaches.
expect(readingUnitBlocked(glucose({ unit: "%" }))).toBe(true);
});
it("has nothing to preview when the units agree or the row is qualitative", () => {
expect(readingConversion(glucose({ unit: "mg/dL" }))).toBeNull();
expect(
readingConversion(glucose({ value: null, valueText: "negative" })),
).toBeNull();
expect(readingConversion(glucose({ value: null }))).toBeNull();
});
});
@@ -19,6 +19,7 @@ const baseRow: OcrReviewRow = {
confidence: { analyte: 1, value: 1, unit: 1, range: 1 },
biomarkerMatch: "existing",
markerUnit: "mmol/L",
markerAnalyteKey: null,
duplicateOf: null,
confirmed: true,
};
@@ -155,3 +156,42 @@ describe("<OcrRowEditor> unit differs from the marker's", () => {
expect(html).toContain("Geführt in mmol/L; dieser Befund nennt mg/dL");
});
});
describe("<OcrRowEditor> unit differs but converts", () => {
const converts: OcrReviewRow = {
...baseRow,
analyte: "Glucose",
value: 5.6,
takenAt: "2026-06-10",
unit: "mmol/L",
markerUnit: "mg/dL",
markerAnalyteKey: "fasting-glucose",
};
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");
expect(html).not.toContain("Tracked in");
expect(html).not.toContain('aria-invalid="true"');
});
it("speaks German too", () => {
const html = render(undefined, "de", converts);
expect(html).toContain("umgerechnet aus 5,6 mmol/L");
});
it("shows nothing when the units agree", () => {
const html = render(undefined, "en", { ...converts, unit: "mg/dL" });
expect(html).not.toContain("ocr-row-converted-badge");
});
it("still says it is blocked when the analyte is not known", () => {
const html = render(undefined, "en", {
...converts,
markerAnalyteKey: null,
});
expect(html).toContain("Tracked in mg/dL; this report says mmol/L");
expect(html).not.toContain("ocr-row-converted-badge");
});
});
@@ -0,0 +1,44 @@
"use client";
import { useTranslations } from "@/lib/i18n/context";
import { formatLabValue } from "@/lib/labs/format-value";
import { useLabNumber } from "./use-lab-format";
/**
* A quiet "converted from 5.6 mmol/L" beside a reading whose stored number is a
* conversion of what the report printed, so a number nobody typed can always be
* traced back. Renders nothing for a reading stored as stated.
*/
export function LabConvertedNote({
reading,
className,
}: {
reading: {
converted: boolean;
sourceValue: number | null;
sourceUnit: string | null;
};
className?: string;
}) {
const { t } = useTranslations();
const labNumber = useLabNumber();
if (
!reading.converted ||
reading.sourceValue === null ||
reading.sourceUnit === null
) {
return null;
}
return (
<span
data-slot="lab-converted-note"
className={className ?? "text-muted-foreground text-xs"}
>
{t("labs.convertedFrom", {
value: formatLabValue(reading.sourceValue, labNumber),
unit: reading.sourceUnit,
})}
</span>
);
}
+9
View File
@@ -30,6 +30,7 @@ import { useTableSort } from "@/hooks/use-table-sort";
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { formatLabReading } from "@/lib/labs/format-value";
import { LabConvertedNote } from "./lab-converted-note";
import { resolveNoteForUpdate } from "@/lib/labs/note-update";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -381,6 +382,10 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
<div className="min-w-0 flex-1 space-y-2">
<div className="font-semibold tabular-nums">
{formatLabReading(r, labNumber)}
<LabConvertedNote
reading={r}
className="text-muted-foreground ml-2 text-xs font-normal"
/>
</div>
<LabReferenceRangeBar
value={r.value}
@@ -453,6 +458,10 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
<TableCell className="font-semibold tabular-nums">
<div className="space-y-2">
<span>{formatLabReading(r, labNumber)}</span>
<LabConvertedNote
reading={r}
className="text-muted-foreground block text-xs font-normal"
/>
<LabReferenceRangeBar
value={r.value}
referenceLow={r.referenceLow}
+3
View File
@@ -15,6 +15,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { apiGet } from "@/lib/api/api-fetch";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { formatLabReading } from "@/lib/labs/format-value";
import { LabConvertedNote } from "./lab-converted-note";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { queryKeys } from "@/lib/query-keys";
@@ -299,6 +300,7 @@ export function LabList({
{formatLabReading(group.latest, labNumber)}
</span>
<span>{labDate(group.latest.takenAt)}</span>
<LabConvertedNote reading={group.latest} />
</div>
</div>
);
@@ -429,6 +431,7 @@ export function LabList({
<span className="text-xs">
{labDate(group.latest.takenAt)}
</span>
<LabConvertedNote reading={group.latest} />
{group.readings.length > 1 ? (
<span className="text-xs">
{t("labs.readingsCount", {
+13 -6
View File
@@ -7,7 +7,7 @@
*/
import type { OcrExtractedRowDto } from "@/lib/validations/labs-ocr";
import { readingUnitDiffers } from "./ocr-review-validation";
import { readingUnitBlocked } from "./ocr-review-validation";
export interface OcrReviewRow {
/** Stable client key for the list (the analyte+index at seed time). */
@@ -26,9 +26,12 @@ export interface OcrReviewRow {
biomarkerMatch: "new" | "existing";
/**
* The unit the matched marker is tracked in; null for a marker that does not
* exist yet. A numeric row whose `unit` differs from it cannot be saved.
* exist yet. A numeric row whose `unit` differs from it is converted on
* Save when it can be (see `readingConversion`) and cannot be saved when not.
*/
markerUnit: string | null;
/** Which analyte the matched marker is (conversion registry key), or null. */
markerAnalyteKey: string | null;
/** Non-null when the server flagged a likely duplicate of a live reading. */
duplicateOf: string | null;
/** Whether the row is selected for saving. Defaults false for duplicates. */
@@ -53,17 +56,21 @@ export function seedReviewRows(
confidence: row.confidence,
biomarkerMatch: row.biomarkerMatch,
markerUnit: row.markerUnit,
markerAnalyteKey: row.markerAnalyteKey,
duplicateOf: row.duplicateOf,
// A flagged duplicate starts unchecked, and so does a reading in another
// unit than its marker's (it carries a badge saying why, and ticking it
// marks the unit field until it is fixed); everything else starts
// confirmed.
// unit than its marker's that cannot be converted (it carries a badge
// saying why, and ticking it marks the unit field until it is fixed); a
// reading that converts starts confirmed, with a badge saying what Save
// will store; everything else starts confirmed.
confirmed:
row.duplicateOf === null &&
!readingUnitDiffers({
!readingUnitBlocked({
value: row.value,
valueText: row.valueText,
unit: row.unit,
markerUnit: row.markerUnit,
markerAnalyteKey: row.markerAnalyteKey,
}),
}));
}
+57 -2
View File
@@ -7,6 +7,7 @@
* build the payload: it returns null exactly when `validateReviewRow` reports
* at least one field.
*/
import { convertLabValue } from "@/lib/labs/unit-conversion/convert";
import { sameLabUnit } from "@/lib/labs/unit-normalise";
import type { OcrReviewRow } from "./ocr-review-types";
@@ -46,6 +47,60 @@ export function readingUnitDiffers(row: {
return !sameLabUnit(stated, row.markerUnit);
}
/**
* What Save will store for a numeric row whose unit differs from its marker's
* and can be converted: the value in the marker's unit, beside what the report
* stated. Null when the units agree, the row is qualitative, or the pair has no
* conversion. The commit does the same conversion on the server; this only lets
* the screen say so before Save.
*/
export function readingConversion(row: {
value: number | null | undefined;
valueText: string | null | undefined;
unit: string | null | undefined;
markerUnit: string | null | undefined;
markerAnalyteKey: string | null | undefined;
}): {
value: number;
unit: string;
statedValue: number;
statedUnit: string;
} | null {
if (!readingUnitDiffers(row)) return null;
if (row.value === null || row.value === undefined) return null;
if (!Number.isFinite(row.value)) return null;
const statedUnit = (row.unit ?? "").trim();
const converted = convertLabValue({
analyteKey: row.markerAnalyteKey,
value: row.value,
from: statedUnit,
to: row.markerUnit as string,
});
if (!converted.ok) return null;
return {
value: converted.value,
unit: row.markerUnit as string,
statedValue: row.value,
statedUnit,
};
}
/**
* A numeric reading in another unit than its marker's that cannot be
* converted: the one case that still cannot be saved. A row with no number yet
* has nothing to convert, so it is blocked here as well as on `value`.
*/
export function readingUnitBlocked(row: {
value: number | null | undefined;
valueText: string | null | undefined;
unit: string | null | undefined;
markerUnit: string | null | undefined;
markerAnalyteKey: string | null | undefined;
}): boolean {
if (!readingUnitDiffers(row)) return false;
return readingConversion(row) === null;
}
export function validateReviewRow(row: OcrReviewRow): OcrRowErrors {
const errors: OcrRowErrors = {};
if (!row.analyte.trim()) errors.analyte = true;
@@ -56,7 +111,7 @@ export function validateReviewRow(row: OcrReviewRow): OcrRowErrors {
} else {
if (row.value === null || !Number.isFinite(row.value)) errors.value = true;
if (!(row.unit ?? "").trim()) errors.unit = true;
if (readingUnitDiffers(row)) errors.unitMismatch = true;
if (readingUnitBlocked(row)) errors.unitMismatch = true;
}
return errors;
}
@@ -80,7 +135,7 @@ export function toCommitRow(row: OcrReviewRow): OcrCommitRowInput | null {
if (row.value === null || !Number.isFinite(row.value)) return null;
const unit = (row.unit ?? "").trim();
if (!unit) return null;
if (readingUnitDiffers(row)) return null;
if (readingUnitBlocked(row)) return null;
return {
analyte,
value: row.value,
+31 -6
View File
@@ -32,7 +32,12 @@ import { Label } from "@/components/ui/label";
import { useTranslations } from "@/lib/i18n/context";
import type { OcrReviewRow } from "./ocr-review-types";
import { readingUnitDiffers, type OcrRowErrors } from "./ocr-review-validation";
import {
readingConversion,
readingUnitBlocked,
type OcrRowErrors,
} from "./ocr-review-validation";
import { useLabNumber } from "./use-lab-format";
/** Below this per-field confidence the field is flagged for a second look. */
const CONFIDENCE_THRESHOLD = 0.6;
@@ -76,10 +81,13 @@ export function OcrRowEditor({
message={t(messageKey, params)}
/>
) : null;
// Stated before Save, not only after a failed attempt: a reading in another
// unit than its marker's cannot be written, and the person should see why
// the row is unticked (or will not save) while they are still reading it.
const unitDiffers = readingUnitDiffers(row);
// Stated before Save, not only after a failed attempt. A reading in another
// unit than its marker's is either converted (the badge says what Save will
// store) or cannot be written (the person should see why the row is unticked,
// or will not save, while they are still reading it).
const unitBlocked = readingUnitBlocked(row);
const conversion = readingConversion(row);
const labNumber = useLabNumber();
const isQualitative = row.valueText !== null && row.valueText !== undefined;
const lowValueConfidence =
@@ -169,7 +177,7 @@ export function OcrRowEditor({
{t("labs.ocr.duplicateWarning")}
</Badge>
) : null}
{unitDiffers ? (
{unitBlocked ? (
<Badge variant="secondary" className="max-w-full">
<Ruler aria-hidden />
<span className="truncate">
@@ -180,6 +188,23 @@ export function OcrRowEditor({
</span>
</Badge>
) : null}
{conversion ? (
<Badge
variant="outline"
className="max-w-full"
data-slot="ocr-row-converted-badge"
>
<Ruler aria-hidden />
<span className="truncate">
{t("labs.ocr.unitConvertedBadge", {
value: labNumber(conversion.value),
markerUnit: conversion.unit,
statedValue: labNumber(conversion.statedValue),
unit: conversion.statedUnit,
})}
</span>
</Badge>
) : null}
{lowRowConfidence ? (
<Badge variant="secondary" className="text-muted-foreground">
<AlertCircle aria-hidden />