From 747e0964ad96a5ac2a16a1111abb212b0d0818c1 Mon Sep 17 00:00:00 2001 From: BernhardBuckel <33808384+BernhardBuckel@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:41:08 +0200 Subject: [PATCH] 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 --- docs/api/openapi.yaml | 13 ++- messages/de.json | 2 + messages/en.json | 2 + messages/es.json | 2 + messages/fr.json | 2 + messages/it.json | 2 + messages/ko.json | 2 + messages/pl.json | 2 + .../__tests__/lab-converted-note.test.tsx | 49 +++++++++++ .../labs/__tests__/lab-list-columns.test.tsx | 20 +++++ .../__tests__/ocr-review-validation.test.ts | 86 +++++++++++++++++++ .../labs/__tests__/ocr-row-editor.test.tsx | 40 +++++++++ src/components/labs/lab-converted-note.tsx | 44 ++++++++++ src/components/labs/lab-history-list.tsx | 9 ++ src/components/labs/lab-list.tsx | 3 + src/components/labs/ocr-review-types.ts | 19 ++-- src/components/labs/ocr-review-validation.ts | 59 ++++++++++++- src/components/labs/ocr-row-editor.tsx | 37 ++++++-- src/lib/labs/ocr-extract.ts | 37 +++++++- src/lib/openapi/routes/ocr.ts | 6 +- src/lib/validations/labs-ocr.ts | 6 ++ .../integration/labs-unit-conversion.test.ts | 42 +++++++++ 22 files changed, 465 insertions(+), 19 deletions(-) create mode 100644 src/components/labs/__tests__/lab-converted-note.test.tsx create mode 100644 src/components/labs/lab-converted-note.tsx diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d6dae8879..90ae0fee6 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/messages/de.json b/messages/de.json index 118df8494..2dede887c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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.", diff --git a/messages/en.json b/messages/en.json index 7191e99c1..b1d26fda7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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.", diff --git a/messages/es.json b/messages/es.json index 7ce217112..35975a9f1 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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.", diff --git a/messages/fr.json b/messages/fr.json index 5181f03ba..fecb4638e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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.", diff --git a/messages/it.json b/messages/it.json index 102b09984..122e85b73 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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.", diff --git a/messages/ko.json b/messages/ko.json index d05ccb066..61cd6cee5 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -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": "측정값을 저장하지 못했어요. 다시 시도해 주세요.", diff --git a/messages/pl.json b/messages/pl.json index a8d64dd6c..51fd76613 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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.", diff --git a/src/components/labs/__tests__/lab-converted-note.test.tsx b/src/components/labs/__tests__/lab-converted-note.test.tsx new file mode 100644 index 000000000..2f0c79c49 --- /dev/null +++ b/src/components/labs/__tests__/lab-converted-note.test.tsx @@ -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[0]["reading"], + locale: "en" | "de" = "en", +) { + return renderToStaticMarkup( + + + , + ); +} + +describe("", () => { + 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(""); + }); +}); diff --git a/src/components/labs/__tests__/lab-list-columns.test.tsx b/src/components/labs/__tests__/lab-list-columns.test.tsx index 21e133d8a..44c7505b6 100644 --- a/src/components/labs/__tests__/lab-list-columns.test.tsx +++ b/src/components/labs/__tests__/lab-list-columns.test.tsx @@ -29,6 +29,9 @@ function reading(over: Partial): 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(" 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"); + }); }); diff --git a/src/components/labs/__tests__/ocr-review-validation.test.ts b/src/components/labs/__tests__/ocr-review-validation.test.ts index ac47ea7a9..234d18a59 100644 --- a/src/components/labs/__tests__/ocr-review-validation.test.ts +++ b/src/components/labs/__tests__/ocr-review-validation.test.ts @@ -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 { 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 = {}) => + 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(); + }); +}); diff --git a/src/components/labs/__tests__/ocr-row-editor.test.tsx b/src/components/labs/__tests__/ocr-row-editor.test.tsx index 0079dfafd..aef017711 100644 --- a/src/components/labs/__tests__/ocr-row-editor.test.tsx +++ b/src/components/labs/__tests__/ocr-row-editor.test.tsx @@ -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(" unit differs from the marker's", () => { expect(html).toContain("Geführt in mmol/L; dieser Befund nennt mg/dL"); }); }); + +describe(" 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"); + }); +}); diff --git a/src/components/labs/lab-converted-note.tsx b/src/components/labs/lab-converted-note.tsx new file mode 100644 index 000000000..dfbdc9e85 --- /dev/null +++ b/src/components/labs/lab-converted-note.tsx @@ -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 ( + + {t("labs.convertedFrom", { + value: formatLabValue(reading.sourceValue, labNumber), + unit: reading.sourceUnit, + })} + + ); +} diff --git a/src/components/labs/lab-history-list.tsx b/src/components/labs/lab-history-list.tsx index 1e5348cd9..6e581f259 100644 --- a/src/components/labs/lab-history-list.tsx +++ b/src/components/labs/lab-history-list.tsx @@ -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[] }) {
{formatLabReading(r, labNumber)} +
{formatLabReading(r, labNumber)} + {labDate(group.latest.takenAt)} +
); @@ -429,6 +431,7 @@ export function LabList({ {labDate(group.latest.takenAt)} + {group.readings.length > 1 ? ( {t("labs.readingsCount", { diff --git a/src/components/labs/ocr-review-types.ts b/src/components/labs/ocr-review-types.ts index 7def77782..c05b394b2 100644 --- a/src/components/labs/ocr-review-types.ts +++ b/src/components/labs/ocr-review-types.ts @@ -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, }), })); } diff --git a/src/components/labs/ocr-review-validation.ts b/src/components/labs/ocr-review-validation.ts index a2ab7fea2..339fbe992 100644 --- a/src/components/labs/ocr-review-validation.ts +++ b/src/components/labs/ocr-review-validation.ts @@ -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, diff --git a/src/components/labs/ocr-row-editor.tsx b/src/components/labs/ocr-row-editor.tsx index b103f09f7..338c90a85 100644 --- a/src/components/labs/ocr-row-editor.tsx +++ b/src/components/labs/ocr-row-editor.tsx @@ -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")} ) : null} - {unitDiffers ? ( + {unitBlocked ? ( @@ -180,6 +188,23 @@ export function OcrRowEditor({ ) : null} + {conversion ? ( + + + + {t("labs.ocr.unitConvertedBadge", { + value: labNumber(conversion.value), + markerUnit: conversion.unit, + statedValue: labNumber(conversion.statedValue), + unit: conversion.statedUnit, + })} + + + ) : null} {lowRowConfidence ? ( diff --git a/src/lib/labs/ocr-extract.ts b/src/lib/labs/ocr-extract.ts index f217f2657..657445fd3 100644 --- a/src/lib/labs/ocr-extract.ts +++ b/src/lib/labs/ocr-extract.ts @@ -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, }; } diff --git a/src/lib/openapi/routes/ocr.ts b/src/lib/openapi/routes/ocr.ts index 94ffe0c80..af682c554 100644 --- a/src/lib/openapi/routes/ocr.ts +++ b/src/lib/openapi/routes/ocr.ts @@ -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(), }) diff --git a/src/lib/validations/labs-ocr.ts b/src/lib/validations/labs-ocr.ts index 59c076f61..d19bb0f4e 100644 --- a/src/lib/validations/labs-ocr.ts +++ b/src/lib/validations/labs-ocr.ts @@ -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; } diff --git a/tests/integration/labs-unit-conversion.test.ts b/tests/integration/labs-unit-conversion.test.ts index b6199c01d..9634476ac 100644 --- a/tests/integration/labs-unit-conversion.test.ts +++ b/tests/integration/labs-unit-conversion.test.ts @@ -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[];