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

+11 -2
View File
@@ -68232,8 +68232,16 @@ components:
- type: string
- type: "null"
description: "The unit the matched marker is tracked in, or null when the marker does not exist yet (it will adopt this
row's unit). A row whose `unit` differs from it cannot be committed: the commit skips it with `reason:
unit_mismatch`, and the review screen marks it before Save."
row's unit). A row whose `unit` differs from it is converted on commit when the marker's analyte is known
and the pair is in the unit-conversion registry; otherwise the commit skips it with `reason: unit_mismatch`,
and the review screen marks it before Save."
markerAnalyteKey:
anyOf:
- type: string
- type: "null"
description: The analyte the matched marker is (the unit-conversion registry's key, e.g. `fasting-glucose`), or null
when the marker does not exist yet or its name matches none. With `markerUnit` it tells a client whether a
row in another unit can be converted on commit.
duplicateOf:
anyOf:
- type: string
@@ -68250,6 +68258,7 @@ components:
- confidence
- biomarkerMatch
- markerUnit
- markerAnalyteKey
- duplicateOf
additionalProperties: false
description: "One proposed reading transcribed from the upload. UNTRUSTED model output annotated server-side:
+2
View File
@@ -9522,6 +9522,7 @@
},
"noteLoadFailed": "Notiz konnte nicht geladen werden; sie bleibt beim Speichern unverändert.",
"showingLatestOf": "Die neuesten {shown} von {total} Messwerten werden angezeigt",
"convertedFrom": "umgerechnet aus {value} {unit}",
"ocr": {
"scanButton": "Befund scannen",
"addScan": "Dokument scannen",
@@ -9568,6 +9569,7 @@
"errorUnit": "Bitte eine Einheit angeben.",
"errorUnitMismatch": "Dieser Marker wird in {markerUnit} geführt. Ändere die Einheit, falls der Befund falsch gelesen wurde, oder wähle den Wert ab.",
"unitDiffersBadge": "Geführt in {markerUnit}; dieser Befund nennt {unit}",
"unitConvertedBadge": "Wird als {value} {markerUnit} gespeichert, umgerechnet aus {statedValue} {unit}",
"savedPartialUnitToast": "{count} Laborwerte gespeichert. Nicht gespeichert, weil die Einheit von der des Markers abweicht: {analytes}",
"savedNothingUnitToast": "Nichts gespeichert: Die Einheit weicht bei {analytes} von der des Markers ab",
"saveFailed": "Werte konnten nicht gespeichert werden. Bitte erneut versuchen.",
+2
View File
@@ -9522,6 +9522,7 @@
},
"noteLoadFailed": "Note could not be loaded; it will be kept unchanged when you save.",
"showingLatestOf": "Showing the latest {shown} of {total} readings",
"convertedFrom": "converted from {value} {unit}",
"ocr": {
"scanButton": "Scan a report",
"addScan": "Scan a document",
@@ -9568,6 +9569,7 @@
"errorUnit": "Add a unit.",
"errorUnitMismatch": "This marker is tracked in {markerUnit}. Change the unit if the report was misread, or deselect this reading.",
"unitDiffersBadge": "Tracked in {markerUnit}; this report says {unit}",
"unitConvertedBadge": "Saved as {value} {markerUnit}, converted from {statedValue} {unit}",
"savedPartialUnitToast": "{count} lab results saved. Not saved, because the unit differs from the marker's: {analytes}",
"savedNothingUnitToast": "Nothing saved: the unit differs from the marker's for {analytes}",
"saveFailed": "Couldn't save the readings. Please try again.",
+2
View File
@@ -9522,6 +9522,7 @@
},
"noteLoadFailed": "No se pudo cargar la nota; se mantendrá sin cambios al guardar.",
"showingLatestOf": "Mostrando los {shown} resultados más recientes de {total}",
"convertedFrom": "convertido desde {value} {unit}",
"ocr": {
"scanButton": "Escanear un informe",
"addScan": "Escanear un documento",
@@ -9568,6 +9569,7 @@
"errorUnit": "Añade una unidad.",
"errorUnitMismatch": "Este marcador se registra en {markerUnit}. Cambia la unidad si el informe se leyó mal o deselecciona este valor.",
"unitDiffersBadge": "Registrado en {markerUnit}; este informe indica {unit}",
"unitConvertedBadge": "Se guardará como {value} {markerUnit}, convertido desde {statedValue} {unit}",
"savedPartialUnitToast": "{count} resultados guardados. No guardados porque la unidad difiere de la del marcador: {analytes}",
"savedNothingUnitToast": "No se guardó nada: la unidad difiere de la del marcador en {analytes}",
"saveFailed": "No se pudieron guardar los valores. Inténtalo de nuevo.",
+2
View File
@@ -9522,6 +9522,7 @@
},
"noteLoadFailed": "La note n’a pas pu être chargée ; elle restera inchangée à l’enregistrement.",
"showingLatestOf": "Affichage des {shown} derniers résultats sur {total}",
"convertedFrom": "converti depuis {value} {unit}",
"ocr": {
"scanButton": "Scanner un compte rendu",
"addScan": "Scanner un document",
@@ -9568,6 +9569,7 @@
"errorUnit": "Indiquez une unité.",
"errorUnitMismatch": "Ce marqueur est suivi en {markerUnit}. Modifiez l’unité si le compte rendu a été mal lu, ou désélectionnez cette valeur.",
"unitDiffersBadge": "Suivi en {markerUnit} ; ce compte rendu indique {unit}",
"unitConvertedBadge": "Enregistré comme {value} {markerUnit}, converti depuis {statedValue} {unit}",
"savedPartialUnitToast": "{count} résultats enregistrés. Non enregistrés, car l’unité diffère de celle du marqueur : {analytes}",
"savedNothingUnitToast": "Rien n’a été enregistré : l’unité diffère de celle du marqueur pour {analytes}",
"saveFailed": "Impossible d'enregistrer les valeurs. Veuillez réessayer.",
+2
View File
@@ -9522,6 +9522,7 @@
},
"noteLoadFailed": "Impossibile caricare la nota; resterà invariata al salvataggio.",
"showingLatestOf": "Visualizzazione degli ultimi {shown} risultati su {total}",
"convertedFrom": "convertito da {value} {unit}",
"ocr": {
"scanButton": "Scansiona un referto",
"addScan": "Scansiona un documento",
@@ -9568,6 +9569,7 @@
"errorUnit": "Inserisci un'unità.",
"errorUnitMismatch": "Questo marcatore è registrato in {markerUnit}. Cambia l’unità se il referto è stato letto male, oppure deseleziona questo valore.",
"unitDiffersBadge": "Registrato in {markerUnit}; questo referto indica {unit}",
"unitConvertedBadge": "Verrà salvato come {value} {markerUnit}, convertito da {statedValue} {unit}",
"savedPartialUnitToast": "{count} risultati salvati. Non salvati perché l’unità è diversa da quella del marcatore: {analytes}",
"savedNothingUnitToast": "Nulla salvato: l’unità è diversa da quella del marcatore per {analytes}",
"saveFailed": "Impossibile salvare i valori. Riprova.",
+2
View File
@@ -9522,6 +9522,7 @@
},
"noteLoadFailed": "메모를 불러오지 못했어요. 저장해도 메모는 그대로 유지돼요.",
"showingLatestOf": "측정값 {total}개 중 최근 {shown}개를 보여주고 있어요",
"convertedFrom": "{value} {unit}에서 변환됨",
"ocr": {
"scanButton": "결과지 스캔",
"addScan": "문서 스캔",
@@ -9568,6 +9569,7 @@
"errorUnit": "단위를 입력하세요.",
"errorUnitMismatch": "이 지표는 {markerUnit} 단위로 기록됩니다. 결과지를 잘못 읽었다면 단위를 바꾸거나 이 값의 선택을 해제하세요.",
"unitDiffersBadge": "{markerUnit} 단위로 기록 중; 이 결과지는 {unit}",
"unitConvertedBadge": "{value} {markerUnit}(으)로 저장됩니다. {statedValue} {unit}에서 변환됨",
"savedPartialUnitToast": "검사 결과 {count}개를 저장했습니다. 단위가 지표와 달라 저장하지 않은 항목: {analytes}",
"savedNothingUnitToast": "저장된 항목이 없습니다: {analytes}의 단위가 지표와 다릅니다",
"saveFailed": "측정값을 저장하지 못했어요. 다시 시도해 주세요.",
+2
View File
@@ -9522,6 +9522,7 @@
},
"noteLoadFailed": "Nie udało się wczytać notatki; pozostanie niezmieniona po zapisaniu.",
"showingLatestOf": "Wyświetlanie najnowszych {shown} z {total} wyników",
"convertedFrom": "przeliczono z {value} {unit}",
"ocr": {
"scanButton": "Zeskanuj wynik",
"addScan": "Zeskanuj dokument",
@@ -9568,6 +9569,7 @@
"errorUnit": "Podaj jednostkę.",
"errorUnitMismatch": "Ten wskaźnik jest prowadzony w {markerUnit}. Zmień jednostkę, jeśli wynik został źle odczytany, albo odznacz tę wartość.",
"unitDiffersBadge": "Prowadzony w {markerUnit}; ten wynik podaje {unit}",
"unitConvertedBadge": "Zostanie zapisane jako {value} {markerUnit}, przeliczone z {statedValue} {unit}",
"savedPartialUnitToast": "Zapisano {count} wyników. Nie zapisano, bo jednostka różni się od jednostki wskaźnika: {analytes}",
"savedNothingUnitToast": "Nic nie zapisano: jednostka różni się od jednostki wskaźnika dla {analytes}",
"saveFailed": "Nie udało się zapisać wyników. Spróbuj ponownie.",
@@ -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 />
+35 -2
View File
@@ -28,6 +28,8 @@ import {
isUnreadableRange,
parseReferenceRange,
} from "@/lib/labs/parse-reference-range";
import { matchAnalyteKey } from "@/lib/labs/analyte-match";
import { sameLabUnit } from "@/lib/labs/unit-normalise";
import { annotate } from "@/lib/logging/context";
import {
extractedLabsSchema,
@@ -144,8 +146,17 @@ export async function annotateRow(
// Biomarker match — case-insensitive on the catalog identity, presence-only.
const existingBiomarker = await prisma.biomarker.findFirst({
where: { userId, name: { equals: analyte, mode: "insensitive" } },
select: { id: true, unit: true },
select: { id: true, name: true, unit: true, analyteKey: true },
});
// The analyte the marker is, so the review can say whether a row in another
// unit converts. Read only: the key is filled when a reading is written.
const markerAnalyteKey = existingBiomarker
? (existingBiomarker.analyteKey ??
matchAnalyteKey({
name: existingBiomarker.name,
unit: existingBiomarker.unit,
}))
: null;
// Duplicate check: a live lab_results row with the same analyte (fuzzy,
// case-insensitive), the same calendar day, and the same value/valueText is
@@ -161,7 +172,14 @@ export async function annotateRow(
analyte: { equals: analyte, mode: "insensitive" },
takenAt: labReadingDaySearchRange(takenAt),
},
select: { id: true, value: true, valueText: true, takenAt: true },
select: {
id: true,
value: true,
valueText: true,
sourceValue: true,
sourceUnit: true,
takenAt: true,
},
take: 100,
});
const candidates = rows.filter(
@@ -172,6 +190,20 @@ export async function annotateRow(
duplicateOf = c.id;
break;
}
// A reading stored converted holds the number in its marker's unit; what
// the report printed is beside it. The same number in the same unit is
// the same reading (the commit applies the same rule).
if (
row.value !== null &&
row.unit !== null &&
c.sourceValue !== null &&
c.sourceValue === row.value &&
c.sourceUnit !== null &&
sameLabUnit(c.sourceUnit, row.unit)
) {
duplicateOf = c.id;
break;
}
if (
row.valueText !== null &&
c.valueText !== null &&
@@ -199,6 +231,7 @@ export async function annotateRow(
confidence: row.confidence,
biomarkerMatch: existingBiomarker ? "existing" : "new",
markerUnit: existingBiomarker?.unit ?? null,
markerAnalyteKey,
duplicateOf,
};
}
+5 -1
View File
@@ -75,7 +75,11 @@ const extractedRow = z
biomarkerMatch: z.enum(["new", "existing"]),
markerUnit: z.string().nullable().meta({
description:
"The unit the matched marker is tracked in, or null when the marker does not exist yet (it will adopt this row's unit). A row whose `unit` differs from it cannot be committed: the commit skips it with `reason: unit_mismatch`, and the review screen marks it before Save.",
"The unit the matched marker is tracked in, or null when the marker does not exist yet (it will adopt this row's unit). A row whose `unit` differs from it is converted on commit when the marker's analyte is known and the pair is in the unit-conversion registry; otherwise the commit skips it with `reason: unit_mismatch`, and the review screen marks it before Save.",
}),
markerAnalyteKey: z.string().nullable().meta({
description:
"The analyte the matched marker is (the unit-conversion registry's key, e.g. `fasting-glucose`), or null when the marker does not exist yet or its name matches none. With `markerUnit` it tells a client whether a row in another unit can be converted on commit.",
}),
duplicateOf: z.string().nullable(),
})
+6
View File
@@ -131,6 +131,12 @@ export interface OcrExtractedRowDto {
* so before Save when the scanned unit differs.
*/
markerUnit: string | null;
/**
* Which analyte the matched marker is (unit-conversion registry key), or null
* when the marker is new or none is known. With `markerUnit` it tells the
* review screen whether a row in another unit can be converted on Save.
*/
markerAnalyteKey: string | null;
duplicateOf: string | null;
}
@@ -250,6 +250,48 @@ describe("the OCR commit", () => {
expect(json.data!.skipped.map((s) => s.reason)).toEqual(["duplicate"]);
});
it("tells the scan review which analyte the marker is, and flags a re-scan as a duplicate", async () => {
const { annotateRow } = await import("@/lib/labs/ocr-extract");
const scanned = {
analyte: "Glucose",
value: 5.0,
valueText: null,
unit: "mmol/L",
referenceLow: null,
referenceHigh: null,
referenceText: null,
takenAt: "2026-06-01",
confidence: { analyte: 1, value: 1, unit: 1, range: 1 },
};
const annotated = await annotateRow(USER, scanned, "2026-06-01", "UTC");
expect(annotated).toMatchObject({
biomarkerMatch: "existing",
markerUnit: "mg/dL",
markerAnalyteKey: "fasting-glucose",
});
// The converted reading from the commit above holds 90.09 mg/dL; the scan
// says 5.0 mmol/L, which is what the report printed for it.
expect(annotated.duplicateOf).not.toBeNull();
// A different value is not a duplicate.
const other = await annotateRow(
USER,
{ ...scanned, value: 6.5 },
"2026-06-01",
"UTC",
);
expect(other.duplicateOf).toBeNull();
// A marker the registry knows nothing about reports no analyte.
const free = await annotateRow(
USER,
{ ...scanned, analyte: "My own marker", unit: "mg/dL" },
"2026-06-01",
"UTC",
);
expect(free.markerAnalyteKey).toBeNull();
});
it("skips a row whose unit cannot be converted, as before", async () => {
const { json } = await call<{
inserted: unknown[];