mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
caa7a937fd
commit
747e0964ad
22 files changed
+465
-19
No files matched your search
+11
-2
@@ -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:
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "측정값을 저장하지 못했어요. 다시 시도해 주세요.",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in new issue
Block a user