From 79ac5670c7a905a803dd83910e8c7ee17bdecec9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 2 Nov 2026 20:28:23 +0100 Subject: [PATCH 1/4] feat(labs): offer an explicit no-unit choice for ratios and indices A ratio (cholesterol/HDL, albumin/globulin) or an index such as INR has no unit, and every lab write path refuses a numeric value without one. That refusal stays: an empty unit usually means the extraction dropped it. Instead the value is stored as the UCUM unity "1", chosen on purpose. The unit field when a marker is defined, on a scanned report's row and in the document review edit now carries a "No unit" checkbox that sets "1". The CSV import takes 1 in the unit column, as the template and the import sheet say; an empty cell is still refused, and other spellings such as "ratio" or "-" stay the literal unit they always were. "1" compares as a unit of its own: it matches only itself, so it is refused under a marker kept in mg/dL and the other way round. Refs #1313 --- messages/de.json | 11 +- messages/en.json | 11 +- messages/es.json | 11 +- messages/fr.json | 11 +- messages/it.json | 11 +- messages/ko.json | 11 +- messages/nl.json | 11 +- messages/pl.json | 11 +- messages/pt-BR.json | 11 +- .../documents/document-facts-review.tsx | 11 +- .../labs/__tests__/lab-unit-input.test.tsx | 142 ++++++++++++++++++ src/components/labs/biomarker-form.tsx | 5 +- src/components/labs/lab-unit-input.tsx | 119 +++++++++++++++ src/components/labs/ocr-row-editor.tsx | 39 +++-- .../import-panel/record-csv-import-card.tsx | 24 ++- .../__tests__/commit-dimensionless.test.ts | 118 +++++++++++++++ .../__tests__/record-import-rows.test.ts | 28 ++++ src/lib/import/csv-mapping.ts | 1 + src/lib/labs/__tests__/dimensionless.test.ts | 90 +++++++++++ src/lib/labs/dimensionless.ts | 56 +++++++ src/lib/labs/format-value.ts | 8 +- .../lab-dimensionless-unit.test.ts | 92 ++++++++++++ 22 files changed, 791 insertions(+), 41 deletions(-) create mode 100644 src/components/labs/__tests__/lab-unit-input.test.tsx create mode 100644 src/components/labs/lab-unit-input.tsx create mode 100644 src/lib/documents/__tests__/commit-dimensionless.test.ts create mode 100644 src/lib/labs/__tests__/dimensionless.test.ts create mode 100644 src/lib/labs/dimensionless.ts create mode 100644 tests/integration/lab-dimensionless-unit.test.ts diff --git a/messages/de.json b/messages/de.json index 1614425f1..3c914dfc9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -10396,7 +10396,9 @@ "pdfNeedsAnthropic": "PDF-Scannen benötigt einen Claude-Bildanbieter; lade stattdessen ein Foto hoch.", "refTextLabel": "Bereich wie gedruckt", "refTextPlaceholder": "z. B. 3,5 - 5,0", - "aiUnavailable": "Das Scannen eines Befunds mit KI ist hier gerade nicht verfügbar." + "aiUnavailable": "Das Scannen eines Befunds mit KI ist hier gerade nicht verfügbar.", + "errorUnitMismatchNoUnit": "Dieser Marker wird ohne Einheit geführt. Setz das Häkchen bei Keine Einheit, falls der Befund falsch gelesen wurde, oder wähle den Wert ab.", + "unitDiffersBadgeNoUnit": "Ohne Einheit geführt; dieser Befund nennt {unit}" }, "loadMoreReadings": "Weitere Werte laden", "documentsLink": "Befund-Dokumente", @@ -10474,6 +10476,10 @@ "stale": "Der neueste Wert ist vom {date}, also älter als ein Jahr. Die Gerade gilt für diesen Stand, ohne Projektion." }, "interval": "95-%-Intervall der jährlichen Veränderung: {lo} bis {hi} {unit}." + }, + "unit": { + "none": "Keine Einheit", + "noneHint": "Für einen Quotienten oder Index wie INR oder Cholesterin/HDL." } }, "modules": { @@ -14093,6 +14099,7 @@ "asNeeded": "Bei Bedarf", "startsOn": "Beginn", "endsOn": "Ende" - } + }, + "unitlessHint": "Ein Quotient oder Index ohne Einheit (INR, Cholesterin/HDL): Trag in der Spalte für die Einheit eine 1 ein. Eine leere Einheit bleibt ein Fehler." } } diff --git a/messages/en.json b/messages/en.json index 0b9a78b16..aea2d6a89 100644 --- a/messages/en.json +++ b/messages/en.json @@ -10396,7 +10396,9 @@ "pdfNeedsAnthropic": "PDF scanning needs a Claude vision provider; upload a photo instead.", "refTextLabel": "Range as printed", "refTextPlaceholder": "e.g. 3.5 - 5.0", - "aiUnavailable": "Scanning a report with AI is not available here right now." + "aiUnavailable": "Scanning a report with AI is not available here right now.", + "errorUnitMismatchNoUnit": "This marker is tracked without a unit. Tick No unit if the report was misread, or deselect this reading.", + "unitDiffersBadgeNoUnit": "Tracked without a unit; this report says {unit}" }, "loadMoreReadings": "Load more readings", "documentsLink": "Lab report documents", @@ -10474,6 +10476,10 @@ "stale": "The newest reading is from {date}, more than a year ago. The line is shown as of then, without a projection." }, "interval": "95 % interval of the yearly change: {lo} to {hi} {unit}." + }, + "unit": { + "none": "No unit", + "noneHint": "For a ratio or an index such as INR or cholesterol/HDL." } }, "modules": { @@ -14093,6 +14099,7 @@ "asNeeded": "As needed", "startsOn": "Start", "endsOn": "End" - } + }, + "unitlessHint": "A ratio or an index without a unit (INR, cholesterol/HDL): write 1 in the unit column. An empty unit cell stays an error." } } diff --git a/messages/es.json b/messages/es.json index 494161998..a729ed506 100644 --- a/messages/es.json +++ b/messages/es.json @@ -10396,7 +10396,9 @@ "pdfNeedsAnthropic": "El escaneo de PDF necesita un proveedor de visión de Claude; sube una foto en su lugar.", "refTextLabel": "Rango tal como aparece", "refTextPlaceholder": "p. ej. 3,5 - 5,0", - "aiUnavailable": "Escanear un informe con IA no está disponible aquí ahora mismo." + "aiUnavailable": "Escanear un informe con IA no está disponible aquí ahora mismo.", + "errorUnitMismatchNoUnit": "Este marcador se registra sin unidad. Marca Sin unidad si el informe se leyó mal o deselecciona este valor.", + "unitDiffersBadgeNoUnit": "Registrado sin unidad; este informe indica {unit}" }, "loadMoreReadings": "Cargar más mediciones", "documentsLink": "Documentos de resultados", @@ -10474,6 +10476,10 @@ "stale": "El valor más reciente es del {date}, hace más de un año. La línea se muestra a esa fecha, sin proyección." }, "interval": "Intervalo del 95 % del cambio anual: de {lo} a {hi} {unit}." + }, + "unit": { + "none": "Sin unidad", + "noneHint": "Para un cociente o un índice como el INR o colesterol/HDL." } }, "modules": { @@ -14093,6 +14099,7 @@ "asNeeded": "A demanda", "startsOn": "Inicio", "endsOn": "Fin" - } + }, + "unitlessHint": "Un cociente o un índice sin unidad (INR, colesterol/HDL): escribe 1 en la columna de la unidad. Una unidad vacía sigue siendo un error." } } diff --git a/messages/fr.json b/messages/fr.json index 3cf6c27ec..130a2dcec 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -10396,7 +10396,9 @@ "pdfNeedsAnthropic": "Le scan PDF nécessite un fournisseur de vision Claude ; importez plutôt une photo.", "refTextLabel": "Plage telle qu'imprimée", "refTextPlaceholder": "p. ex. 3,5 - 5,0", - "aiUnavailable": "La numérisation d'un compte rendu par IA n'est pas disponible ici pour le moment." + "aiUnavailable": "La numérisation d'un compte rendu par IA n'est pas disponible ici pour le moment.", + "errorUnitMismatchNoUnit": "Ce marqueur est suivi sans unité. Cochez Sans unité si le compte rendu a été mal lu, ou désélectionnez cette valeur.", + "unitDiffersBadgeNoUnit": "Suivi sans unité ; ce compte rendu indique {unit}" }, "loadMoreReadings": "Charger plus de mesures", "documentsLink": "Documents de résultats", @@ -10474,6 +10476,10 @@ "stale": "La valeur la plus récente date du {date}, il y a plus d’un an. La droite est montrée à cette date, sans projection." }, "interval": "Intervalle à 95 % de la variation annuelle : de {lo} à {hi} {unit}." + }, + "unit": { + "none": "Sans unité", + "noneHint": "Pour un rapport ou un indice comme l'INR ou cholestérol/HDL." } }, "modules": { @@ -14093,6 +14099,7 @@ "asNeeded": "Si besoin", "startsOn": "Début", "endsOn": "Fin" - } + }, + "unitlessHint": "Un rapport ou un indice sans unité (INR, cholestérol/HDL) : inscrivez 1 dans la colonne de l'unité. Une unité vide reste une erreur." } } diff --git a/messages/it.json b/messages/it.json index f72bbde59..7c417dc4c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -10396,7 +10396,9 @@ "pdfNeedsAnthropic": "La scansione PDF richiede un provider di visione Claude; carica invece una foto.", "refTextLabel": "Intervallo come stampato", "refTextPlaceholder": "es. 3,5 - 5,0", - "aiUnavailable": "La scansione di un referto con IA non è disponibile qui al momento." + "aiUnavailable": "La scansione di un referto con IA non è disponibile qui al momento.", + "errorUnitMismatchNoUnit": "Questo marcatore è registrato senza unità. Spunta Nessuna unità se il referto è stato letto male, oppure deseleziona questo valore.", + "unitDiffersBadgeNoUnit": "Registrato senza unità; questo referto indica {unit}" }, "loadMoreReadings": "Carica altre misurazioni", "documentsLink": "Documenti dei referti", @@ -10474,6 +10476,10 @@ "stale": "Il valore più recente è del {date}, più di un anno fa. La retta è mostrata a quella data, senza proiezione." }, "interval": "Intervallo al 95 % della variazione annua: da {lo} a {hi} {unit}." + }, + "unit": { + "none": "Nessuna unità", + "noneHint": "Per un rapporto o un indice come l'INR o colesterolo/HDL." } }, "modules": { @@ -14093,6 +14099,7 @@ "asNeeded": "Al bisogno", "startsOn": "Inizio", "endsOn": "Fine" - } + }, + "unitlessHint": "Un rapporto o un indice senza unità (INR, colesterolo/HDL): scrivi 1 nella colonna dell'unità. Un'unità vuota resta un errore." } } diff --git a/messages/ko.json b/messages/ko.json index fa858cc6a..6687f8fd6 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -10396,7 +10396,9 @@ "pdfNeedsAnthropic": "PDF 스캔에는 Claude 비전 제공자가 필요해요. 대신 사진을 올려 주세요.", "refTextLabel": "적힌 그대로의 범위", "refTextPlaceholder": "예: 3.5 - 5.0", - "aiUnavailable": "지금 여기에서는 AI로 결과지를 스캔할 수 없습니다." + "aiUnavailable": "지금 여기에서는 AI로 결과지를 스캔할 수 없습니다.", + "errorUnitMismatchNoUnit": "이 지표는 단위 없이 기록됩니다. 결과지를 잘못 읽었다면 단위 없음을 선택하거나 이 값의 선택을 해제하세요.", + "unitDiffersBadgeNoUnit": "단위 없이 기록 중; 이 결과지는 {unit}" }, "loadMoreReadings": "측정값 더 보기", "documentsLink": "검사 결과지 문서", @@ -10474,6 +10476,10 @@ "stale": "가장 최근 값은 {date}로 1년이 넘었습니다. 직선은 그 시점 기준으로 표시하며 예측은 하지 않습니다." }, "interval": "연간 변화의 95% 구간: {lo}~{hi} {unit}." + }, + "unit": { + "none": "단위 없음", + "noneHint": "INR이나 콜레스테롤/HDL 같은 비율 또는 지수에 사용해요." } }, "modules": { @@ -14093,6 +14099,7 @@ "asNeeded": "필요시", "startsOn": "시작", "endsOn": "종료" - } + }, + "unitlessHint": "단위가 없는 비율이나 지수(INR, 콜레스테롤/HDL)는 단위 열에 1을 입력하세요. 단위 칸이 비어 있으면 여전히 오류예요." } } diff --git a/messages/nl.json b/messages/nl.json index 0d7a0de24..17aefb7de 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -10396,7 +10396,9 @@ "pdfNeedsAnthropic": "Voor het scannen van PDF's is een Claude-provider met beeldherkenning nodig; upload in plaats daarvan een foto.", "refTextLabel": "Bereik zoals afgedrukt", "refTextPlaceholder": "bijv. 3,5 - 5,0", - "aiUnavailable": "Een rapport scannen met AI is hier op dit moment niet beschikbaar." + "aiUnavailable": "Een rapport scannen met AI is hier op dit moment niet beschikbaar.", + "errorUnitMismatchNoUnit": "Deze marker wordt zonder eenheid bijgehouden. Vink Geen eenheid aan als het rapport verkeerd is gelezen, of deselecteer deze meting.", + "unitDiffersBadgeNoUnit": "Zonder eenheid bijgehouden; dit rapport vermeldt {unit}" }, "loadMoreReadings": "Meer metingen laden", "documentsLink": "Documenten met labrapporten", @@ -10474,6 +10476,10 @@ "stale": "De nieuwste waarde is van {date}, meer dan een jaar geleden. De lijn staat op die datum, zonder projectie." }, "interval": "95%-interval van de jaarlijkse verandering: {lo} tot {hi} {unit}." + }, + "unit": { + "none": "Geen eenheid", + "noneHint": "Voor een verhouding of index zoals INR of cholesterol/HDL." } }, "modules": { @@ -14093,6 +14099,7 @@ "asNeeded": "Zo nodig", "startsOn": "Begin", "endsOn": "Einde" - } + }, + "unitlessHint": "Een verhouding of index zonder eenheid (INR, cholesterol/HDL): vul 1 in de eenheidskolom in. Een lege eenheid blijft een fout." } } diff --git a/messages/pl.json b/messages/pl.json index 03b936c1b..5132eeb8a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -10396,7 +10396,9 @@ "pdfNeedsAnthropic": "Skanowanie PDF wymaga dostawcy wizji Claude; prześlij zamiast tego zdjęcie.", "refTextLabel": "Zakres tak jak wydrukowano", "refTextPlaceholder": "np. 3,5 - 5,0", - "aiUnavailable": "Skanowanie wyniku przez AI jest tu teraz niedostępne." + "aiUnavailable": "Skanowanie wyniku przez AI jest tu teraz niedostępne.", + "errorUnitMismatchNoUnit": "Ten wskaźnik jest prowadzony bez jednostki. Zaznacz Bez jednostki, jeśli wynik został źle odczytany, albo odznacz tę wartość.", + "unitDiffersBadgeNoUnit": "Prowadzony bez jednostki; ten wynik podaje {unit}" }, "loadMoreReadings": "Wczytaj więcej pomiarów", "documentsLink": "Dokumenty wyników badań", @@ -10474,6 +10476,10 @@ "stale": "Najnowszy wynik pochodzi z {date}, sprzed ponad roku. Prosta jest pokazana na ten dzień, bez projekcji." }, "interval": "95% przedział rocznej zmiany: od {lo} do {hi} {unit}." + }, + "unit": { + "none": "Bez jednostki", + "noneHint": "Dla ilorazu lub wskaźnika, np. INR albo cholesterol/HDL." } }, "modules": { @@ -14093,6 +14099,7 @@ "asNeeded": "W razie potrzeby", "startsOn": "Początek", "endsOn": "Koniec" - } + }, + "unitlessHint": "Iloraz lub wskaźnik bez jednostki (INR, cholesterol/HDL): wpisz 1 w kolumnie jednostki. Pusta jednostka nadal jest błędem." } } diff --git a/messages/pt-BR.json b/messages/pt-BR.json index d3a4ecb84..d8d87e84b 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -10396,7 +10396,9 @@ "pdfNeedsAnthropic": "A digitalização de PDF precisa de um provedor de visão Claude; envie uma foto em vez disso.", "refTextLabel": "Faixa como impressa", "refTextPlaceholder": "ex.: 3,5 - 5,0", - "aiUnavailable": "Digitalizar um laudo com IA não está disponível aqui agora." + "aiUnavailable": "Digitalizar um laudo com IA não está disponível aqui agora.", + "errorUnitMismatchNoUnit": "Este marcador é acompanhado sem unidade. Marque Sem unidade se o laudo foi lido errado, ou desmarque esta leitura.", + "unitDiffersBadgeNoUnit": "Acompanhado sem unidade; este laudo diz {unit}" }, "loadMoreReadings": "Carregar mais leituras", "documentsLink": "Documentos de laudos laboratoriais", @@ -10474,6 +10476,10 @@ "stale": "O resultado mais recente é de {date}, há mais de um ano. A linha é mostrada até essa data, sem projeção." }, "interval": "Intervalo de 95 % da mudança anual: {lo} a {hi} {unit}." + }, + "unit": { + "none": "Sem unidade", + "noneHint": "Para uma razão ou um índice como INR ou colesterol/HDL." } }, "modules": { @@ -14093,6 +14099,7 @@ "asNeeded": "Conforme necessário", "startsOn": "Início", "endsOn": "Fim" - } + }, + "unitlessHint": "Uma razão ou um índice sem unidade (INR, colesterol/HDL): escreva 1 na coluna da unidade. Uma unidade vazia continua sendo um erro." } } diff --git a/src/components/documents/document-facts-review.tsx b/src/components/documents/document-facts-review.tsx index ded9d2d6a..f005ebbd8 100644 --- a/src/components/documents/document-facts-review.tsx +++ b/src/components/documents/document-facts-review.tsx @@ -37,10 +37,12 @@ import { Checkbox } from "@/components/ui/checkbox"; import { DateField } from "@/components/ui/date-field"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { LabUnitInput } from "@/components/labs/lab-unit-input"; import { MarkerMatchField } from "@/components/labs/marker-match-field"; import type { BiomarkerListResponse } from "@/components/labs/types"; import { apiGet } from "@/lib/api/api-fetch"; import type { DocumentExtractRunResult } from "@/lib/documents/ai-runs/types"; +import { withLabUnit } from "@/lib/labs/dimensionless"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { cn } from "@/lib/utils"; @@ -81,7 +83,7 @@ function factHeadline(fact: ExtractedFactDto): string { const data = fact.data as ObservationFactData; const value = typeof data.value === "number" - ? `${data.value}${data.unit ? ` ${data.unit}` : ""}` + ? withLabUnit(String(data.value), data.unit) : (data.valueText ?? ""); return value ? `${data.label}: ${value}` : data.label; } @@ -411,13 +413,12 @@ function FactRow({ ? t("documents.review.fieldUnit") : t("documents.review.fieldDose")} - - setDraft((d) => ({ ...d, unit: e.target.value })) - } + onChange={(unit) => setDraft((d) => ({ ...d, unit }))} maxLength={isObservation ? 80 : 120} + allowNoUnit={isObservation} /> ) : null} diff --git a/src/components/labs/__tests__/lab-unit-input.test.tsx b/src/components/labs/__tests__/lab-unit-input.test.tsx new file mode 100644 index 000000000..ebc06b31c --- /dev/null +++ b/src/components/labs/__tests__/lab-unit-input.test.tsx @@ -0,0 +1,142 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +import { I18nProvider } from "@/lib/i18n/context"; +import en from "../../../../messages/en.json"; + +vi.mock("@/lib/api/api-fetch", async (importOriginal) => ({ + ...(await importOriginal()), + apiPost: vi.fn(), + apiPut: vi.fn(), +})); + +import { BiomarkerForm } from "../biomarker-form"; +import { LabUnitInput } from "../lab-unit-input"; +import type { OcrReviewRow } from "../ocr-review-types"; +import { OcrRowEditor } from "../ocr-row-editor"; + +/** + * The explicit "no unit" choice for a ratio or an index. It is offered next to + * every lab unit field a person fills in, it shows as chosen when the unit is + * the UCUM unity `"1"`, and it never reads an empty unit as that choice. + */ + +function render(node: React.ReactNode): string { + return renderToStaticMarkup( + {node}, + ); +} + +const NONE = en.labs.unit.none; + +function noneBox(html: string): string { + const box = html.match(/]*role="checkbox"[^>]*>/)?.[0]; + if (!box) throw new Error("no checkbox rendered"); + return box; +} + +describe("", () => { + it("offers the choice and leaves it unticked for an empty unit", () => { + const html = render( + {}} required />, + ); + expect(html).toContain(NONE); + expect(html).toContain(en.labs.unit.noneHint); + expect(noneBox(html)).toContain('aria-checked="false"'); + expect(html).toMatch(/]*id="u"[^>]*required/); + }); + + it("shows the unity as the ticked choice, not as a typed 1", () => { + const html = render( + {}} required />, + ); + expect(noneBox(html)).toContain('aria-checked="true"'); + const input = html.match(/]*id="u"[^>]*>/)?.[0] ?? ""; + expect(input).toContain('value=""'); + expect(input).toContain("disabled"); + expect(input).not.toContain("required"); + }); + + it("leaves a real unit as it is", () => { + const html = render( + {}} />, + ); + expect(noneBox(html)).toContain('aria-checked="false"'); + expect(html).toContain('value="mg/dL"'); + }); + + it("is a plain field where no unit makes no sense", () => { + const html = render( + {}} allowNoUnit={false} />, + ); + expect(html).not.toContain(NONE); + expect(html).toContain('value="1"'); + }); +}); + +describe("the no-unit choice on the lab forms", () => { + it("is offered when a marker is defined", () => { + const html = render(); + expect(html).toContain(NONE); + }); + + it("is offered on a scanned report's row", () => { + const row: OcrReviewRow = { + key: "INR-0", + analyte: "INR", + value: 1.1, + valueText: null, + unit: null, + referenceLow: 0.8, + referenceHigh: 1.2, + referenceText: null, + takenAt: null, + confidence: { analyte: 1, value: 1, unit: 1, range: 1 }, + biomarkerMatch: "new", + markerUnit: null, + markerAnalyteKey: null, + duplicateOf: null, + biomarkerId: null, + matchKind: null, + suggestions: [], + chosenBiomarkerId: null, + confirmed: true, + }; + const html = render( {}} />); + expect(html).toContain(NONE); + }); + + it("names a marker without a unit in words where a scanned row disagrees", () => { + const row: OcrReviewRow = { + key: "INR-1", + analyte: "INR", + value: 1.1, + valueText: null, + unit: "s", + referenceLow: null, + referenceHigh: null, + referenceText: null, + takenAt: null, + confidence: { analyte: 1, value: 1, unit: 1, range: 1 }, + biomarkerMatch: "existing", + markerUnit: "1", + markerAnalyteKey: null, + duplicateOf: null, + biomarkerId: "bm-inr", + matchKind: "name", + suggestions: [], + chosenBiomarkerId: null, + confirmed: true, + }; + const html = render( + {}} + errors={{ unitMismatch: true }} + />, + ); + expect(html).toContain(en.labs.ocr.errorUnitMismatchNoUnit); + expect(html).toContain("Tracked without a unit; this report says s"); + expect(html).not.toContain("tracked in 1"); + }); +}); diff --git a/src/components/labs/biomarker-form.tsx b/src/components/labs/biomarker-form.tsx index 036e04642..168e68bad 100644 --- a/src/components/labs/biomarker-form.tsx +++ b/src/components/labs/biomarker-form.tsx @@ -26,6 +26,7 @@ import { } from "@/lib/labs/biomarker-catalog"; import { useTranslations } from "@/lib/i18n/context"; +import { LabUnitInput } from "./lab-unit-input"; import type { BiomarkerDto } from "./types"; const CONTEXT_MAX_LENGTH = 2000; @@ -219,10 +220,10 @@ export function BiomarkerForm({ - setUnit(e.target.value)} + onChange={setUnit} placeholder={t("labs.biomarker.form.unitPlaceholder")} maxLength={40} required diff --git a/src/components/labs/lab-unit-input.tsx b/src/components/labs/lab-unit-input.tsx new file mode 100644 index 000000000..cdbc5d1df --- /dev/null +++ b/src/components/labs/lab-unit-input.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { useId, useRef } from "react"; + +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { + DIMENSIONLESS_UNIT, + isDimensionlessUnit, + labUnitLabel, +} from "@/lib/labs/dimensionless"; +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; + +/** A marker's unit as a muted label, "No unit" for a ratio or an index. */ +export function LabUnitName({ + unit, + className, +}: { + unit: string; + className?: string; +}) { + const { t } = useTranslations(); + return ( + + {labUnitLabel(unit, t)} + + ); +} + +interface LabUnitInputProps { + id: string; + value: string; + onChange: (unit: string) => void; + maxLength?: number; + placeholder?: string; + required?: boolean; + "aria-describedby"?: string; + "aria-invalid"?: boolean; + /** + * Offer the explicit "no unit" choice. Off for a field that only shares the + * input, such as a medication dose on the document review. + */ + allowNoUnit?: boolean; +} + +/** + * The unit of a lab value, with an explicit "no unit" choice for a ratio or an + * index. Ticking it sets the unit to the UCUM unity `"1"`, which every write + * path accepts as a real unit; leaving the field empty is still refused, so a + * unit an extraction dropped is never taken for a value that has none. Typing + * `1` by hand is the same choice and ticks the box. + */ +export function LabUnitInput({ + id, + value, + onChange, + maxLength, + placeholder, + required, + allowNoUnit = true, + ...aria +}: LabUnitInputProps) { + const { t } = useTranslations(); + const noneId = useId(); + const hintId = `${noneId}-hint`; + const none = allowNoUnit && isDimensionlessUnit(value); + // What was typed before the box was ticked, so unticking gives it back. + const typed = useRef(none ? "" : value); + + const input = ( + { + typed.current = e.target.value; + onChange(e.target.value); + }} + placeholder={none ? t("labs.unit.none") : placeholder} + maxLength={maxLength} + required={required && !none} + disabled={none} + {...aria} + /> + ); + if (!allowNoUnit) return input; + + return ( +
+ {input} + +
+ ); +} diff --git a/src/components/labs/ocr-row-editor.tsx b/src/components/labs/ocr-row-editor.tsx index f2794e321..942d93d90 100644 --- a/src/components/labs/ocr-row-editor.tsx +++ b/src/components/labs/ocr-row-editor.tsx @@ -24,12 +24,14 @@ import { DateField } from "@/components/ui/date-field"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useTranslations } from "@/lib/i18n/context"; +import { isDimensionlessUnit, labUnitLabel } from "@/lib/labs/dimensionless"; import { OCR_ANALYTE_MAX, OCR_UNIT_MAX, OCR_VALUE_TEXT_MAX, } from "@/lib/validations/labs-ocr"; +import { LabUnitInput } from "./lab-unit-input"; import { MarkerMatchField } from "./marker-match-field"; import { chooseMarker, type OcrReviewRow } from "./ocr-review-types"; import { @@ -51,6 +53,28 @@ function RequiredMark() { ); } +type Translate = (key: string, params?: Record) => string; + +/** The badge naming a row whose unit differs from its marker's. */ +function unitDiffersText(row: OcrReviewRow, t: Translate): string { + const unit = labUnitLabel(row.unit, t); + return isDimensionlessUnit(row.markerUnit) + ? t("labs.ocr.unitDiffersBadgeNoUnit", { unit }) + : t("labs.ocr.unitDiffersBadge", { + markerUnit: row.markerUnit ?? "", + unit, + }); +} + +/** The message key and params of a unit the marker cannot take. */ +function unitMismatchMessage( + row: OcrReviewRow, +): [string, Record?] { + return isDimensionlessUnit(row.markerUnit) + ? ["labs.ocr.errorUnitMismatchNoUnit"] + : ["labs.ocr.errorUnitMismatch", { markerUnit: row.markerUnit ?? "" }]; +} + export function OcrRowEditor({ row, onChange, @@ -197,12 +221,7 @@ export function OcrRowEditor({ {unitBlocked ? ( - - {t("labs.ocr.unitDiffersBadge", { - markerUnit: row.markerUnit ?? "", - unit: (row.unit ?? "").trim(), - })} - + {unitDiffersText(row, t)} ) : null} {conversion ? ( @@ -294,10 +313,10 @@ export function OcrRowEditor({ {t("labs.ocr.unitLabel")} - onChange({ ...row, unit: e.target.value })} + onChange={(unit) => onChange({ ...row, unit })} {...invalidProps( errors?.unitMismatch ? "unitMismatch" @@ -310,9 +329,7 @@ export function OcrRowEditor({ {fieldError("unitTooLong", "labs.ocr.errorTooLong", { max: String(OCR_UNIT_MAX), })} - {fieldError("unitMismatch", "labs.ocr.errorUnitMismatch", { - markerUnit: row.markerUnit ?? "", - })} + {fieldError("unitMismatch", ...unitMismatchMessage(row))} )} diff --git a/src/components/settings/import-panel/record-csv-import-card.tsx b/src/components/settings/import-panel/record-csv-import-card.tsx index 2613f2b0a..4ec0f9b6b 100644 --- a/src/components/settings/import-panel/record-csv-import-card.tsx +++ b/src/components/settings/import-panel/record-csv-import-card.tsx @@ -100,6 +100,26 @@ export function RecordCsvImportCard() { ); } +/** What the sheet says about reading the file, below the format choices. */ +function ImportNotes({ kind }: { kind: RecordImportKind }) { + const { t } = useTranslations(); + return ( + <> +

+ {t("recordImport.timezoneNote")} +

+ {kind === "labs" ? ( +

+ {t("recordImport.unitlessHint")} +

+ ) : null} + + ); +} + function RecordCsvImportSheet({ kind, onOpenChange, @@ -421,9 +441,7 @@ function RecordCsvImportSheet({ /> ) : null} -

- {t("recordImport.timezoneNote")} -

+
({ + prisma: { + labResult: { create: vi.fn() }, + biomarker: { updateMany: vi.fn(), findUnique: vi.fn() }, + }, +})); + +vi.mock("@/lib/labs/biomarker-store", () => ({ + resolveOrMintBiomarker: vi.fn(), +})); + +vi.mock("@/lib/documents/store", () => ({ + decryptFactData: vi.fn(), +})); + +import type { ExtractedFact, LabResult } from "@/generated/prisma/client"; +import { commitApprovedFact } from "@/lib/documents/commit"; +import { decryptFactData } from "@/lib/documents/store"; +import { prisma } from "@/lib/db"; +import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store"; +import type { ObservationFactData } from "@/lib/validations/inbound-documents"; + +const FACT = { + id: "fact-1", + factType: "OBSERVATION", + dataEncrypted: new Uint8Array(), +} as unknown as ExtractedFact; + +function stated(unit: string | null): void { + const data: ObservationFactData = { + label: "Cholesterol/HDL ratio", + code: null, + codeSystem: null, + value: 3.6, + valueText: null, + unit, + referenceLow: null, + referenceHigh: null, + referenceText: "< 5.0", + effectiveDate: "2026-08-01", + }; + vi.mocked(decryptFactData).mockReturnValue(data); +} + +function marker(unit: string): void { + vi.mocked(resolveOrMintBiomarker).mockResolvedValue({ + id: "bm-ratio", + name: "Cholesterol/HDL ratio", + unit, + lowerBound: null, + upperBound: 5, + panel: null, + analyteKey: null, + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.labResult.create).mockResolvedValue({ + id: "lr-1", + takenAt: new Date("2026-08-01T12:00:00Z"), + panel: null, + } as unknown as LabResult); +}); + +describe("commitApprovedFact — a value without a unit", () => { + it("saves a value the reviewer marked as having no unit", async () => { + stated("1"); + marker("1"); + const ref = await commitApprovedFact("user-1", FACT); + expect(ref).toEqual({ recordType: "labResult", recordId: "lr-1" }); + const data = vi.mocked(prisma.labResult.create).mock.calls[0]![0].data; + expect(data.unit).toBe("1"); + expect(data.value).toBe(3.6); + expect(data.sourceUnit).toBeNull(); + // The minted marker carries the unity, so later readings match it. + expect(vi.mocked(resolveOrMintBiomarker).mock.calls[0]![1].unit).toBe("1"); + }); + + it.each([null, "", " "])( + "still refuses a numeric value whose unit is missing (%j)", + async (unit) => { + stated(unit); + marker("1"); + await expect(commitApprovedFact("user-1", FACT)).rejects.toMatchObject({ + code: "observation.unitRequired", + }); + expect(prisma.labResult.create).not.toHaveBeenCalled(); + }, + ); + + it("refuses no unit against a marker kept in a real unit", async () => { + stated("1"); + marker("mg/dL"); + await expect(commitApprovedFact("user-1", FACT)).rejects.toMatchObject({ + code: "observation.unitMismatch", + }); + expect(prisma.labResult.create).not.toHaveBeenCalled(); + }); + + it("refuses a real unit against a marker without one", async () => { + stated("mg/dL"); + marker("1"); + await expect(commitApprovedFact("user-1", FACT)).rejects.toMatchObject({ + code: "observation.unitMismatch", + }); + }); +}); diff --git a/src/lib/import/__tests__/record-import-rows.test.ts b/src/lib/import/__tests__/record-import-rows.test.ts index 36507af4a..606edce3b 100644 --- a/src/lib/import/__tests__/record-import-rows.test.ts +++ b/src/lib/import/__tests__/record-import-rows.test.ts @@ -65,6 +65,34 @@ describe("lab rows", () => { }); }); + it("reads 1 in the unit column as a value without a unit", () => { + expect(lab(["INR", "1.1", "1", "2025-03-01", "0.8-1.2"])).toMatchObject({ + ok: true, + row: { value: 1.1, unit: "1", sourceReferenceHigh: 1.2 }, + }); + }); + + it("still refuses a number whose unit cell is empty", () => { + for (const unit of ["", " "]) { + expect(lab(["INR", "1.1", unit, "2025-03-01", ""])).toEqual({ + ok: false, + reason: "missing_unit", + field: "unit", + }); + } + }); + + it("keeps any other spelling of no unit as the text it is", () => { + // Only the UCUM unity means "no unit"; a word or a dash stays the literal + // unit the file wrote, so a marker that already holds it keeps matching. + for (const unit of ["ratio", "-", "none"]) { + expect(lab(["A/G", "1.4", unit, "2025-03-01", ""])).toMatchObject({ + ok: true, + row: { unit }, + }); + } + }); + it("keeps a non-numeric result as a qualitative reading", () => { expect( lab(["Urine protein", "negative", "", "2025-03-01", ""]), diff --git a/src/lib/import/csv-mapping.ts b/src/lib/import/csv-mapping.ts index e8135729a..a8e792597 100644 --- a/src/lib/import/csv-mapping.ts +++ b/src/lib/import/csv-mapping.ts @@ -332,6 +332,7 @@ export const RECORD_IMPORT_TEMPLATES: Record = { "analyte,value,unit,date,time,reference range,panel,note", "HbA1c,5.4,%,2025-03-01,08:15,4.0-5.6,Diabetes,", "Ferritin,85,ng/mL,2025-03-01,08:15,30-400,Iron,", + "INR,1.1,1,2025-03-01,08:15,0.8-1.2,Coagulation,", "Urine protein,negative,,2025-03-01,,,Urine,", "", ].join("\n"), diff --git a/src/lib/labs/__tests__/dimensionless.test.ts b/src/lib/labs/__tests__/dimensionless.test.ts new file mode 100644 index 000000000..a80c7aade --- /dev/null +++ b/src/lib/labs/__tests__/dimensionless.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; + +import { + DIMENSIONLESS_UNIT, + isDimensionlessUnit, + labUnitText, + withLabUnit, +} from "../dimensionless"; +import { formatLabReading } from "../format-value"; +import { readingInMarkerUnit } from "../reading-conversion"; +import { sameLabUnit } from "../unit-normalise"; + +describe("the dimensionless lab unit", () => { + it("is the UCUM unity", () => { + expect(DIMENSIONLESS_UNIT).toBe("1"); + }); + + it("recognises only the explicit unity, never an empty unit", () => { + expect(isDimensionlessUnit("1")).toBe(true); + expect(isDimensionlessUnit(" 1 ")).toBe(true); + expect(isDimensionlessUnit("")).toBe(false); + expect(isDimensionlessUnit(null)).toBe(false); + expect(isDimensionlessUnit(undefined)).toBe(false); + expect(isDimensionlessUnit("10")).toBe(false); + expect(isDimensionlessUnit("1/min")).toBe(false); + }); + + it("renders as nothing beside a value", () => { + expect(labUnitText("1")).toBe(""); + expect(labUnitText("mg/dL")).toBe("mg/dL"); + expect(labUnitText(null)).toBe(""); + expect(withLabUnit("3.6", "1")).toBe("3.6"); + expect(withLabUnit("3.6", " mg/dL ")).toBe("3.6 mg/dL"); + expect(withLabUnit("3.6", "")).toBe("3.6"); + }); + + it("never prints a literal 1 after a reading", () => { + const text = formatLabReading( + { value: 3.6, valueText: null, unit: "1" }, + (v) => String(v), + ); + expect(text).toBe("3.6"); + }); + + it("is a unit of its own when compared", () => { + expect(sameLabUnit("1", "1")).toBe(true); + expect(sameLabUnit("1", "")).toBe(false); + expect(sameLabUnit("1", "mg/dL")).toBe(false); + expect(sameLabUnit("1", "%")).toBe(false); + }); +}); + +describe("a dimensionless reading against a marker", () => { + const client = { + biomarker: { update: async () => ({}) }, + } as unknown as Parameters[0]; + + it("is stored as it is under a dimensionless marker", async () => { + const reading = await readingInMarkerUnit(client, { + marker: { id: "m1", name: "INR", unit: "1", analyteKey: null }, + value: 1.1, + unit: "1", + sourceReferenceLow: 0.8, + sourceReferenceHigh: 1.2, + }); + expect(reading).toMatchObject({ ok: true, converted: false, value: 1.1 }); + }); + + it("is refused under a marker with a real unit", async () => { + const reading = await readingInMarkerUnit(client, { + marker: { id: "m2", name: "LDL", unit: "mg/dL", analyteKey: "ldl" }, + value: 3.6, + unit: "1", + sourceReferenceLow: null, + sourceReferenceHigh: null, + }); + expect(reading.ok).toBe(false); + }); + + it("refuses a real unit under a dimensionless marker", async () => { + const reading = await readingInMarkerUnit(client, { + marker: { id: "m3", name: "INR", unit: "1", analyteKey: null }, + value: 1.1, + unit: "s", + sourceReferenceLow: null, + sourceReferenceHigh: null, + }); + expect(reading.ok).toBe(false); + }); +}); diff --git a/src/lib/labs/dimensionless.ts b/src/lib/labs/dimensionless.ts new file mode 100644 index 000000000..91a7d413a --- /dev/null +++ b/src/lib/labs/dimensionless.ts @@ -0,0 +1,56 @@ +/** + * The unit of a lab value that has none. + * + * A ratio (total cholesterol over HDL, albumin over globulin) or an index + * (INR) is a number without a unit. It is stored and sent as the UCUM unity + * `"1"`, never as an empty unit: an empty unit on a numeric reading is what an + * extraction leaves behind when it dropped the unit, and every write path + * refuses it for that reason. `"1"` is a choice somebody made on purpose. + * + * For every comparison `"1"` is an ordinary unit. It matches only itself, so + * a reading without a unit is refused under a marker kept in mg/dL and the + * other way round. Only the display differs: nobody wants "3.6 1", so the + * helpers below render the unity as nothing beside a value. + * + * Pure and dependency-free. + */ + +/** The UCUM unit of a dimensionless quantity. */ +export const DIMENSIONLESS_UNIT = "1"; + +/** Whether a stored or stated unit is the explicit "no unit" choice. */ +export function isDimensionlessUnit(unit: string | null | undefined): boolean { + return typeof unit === "string" && unit.trim() === DIMENSIONLESS_UNIT; +} + +/** The unit as it reads beside a value: trimmed, and empty for the unity. */ +export function labUnitText(unit: string | null | undefined): string { + if (typeof unit !== "string") return ""; + const trimmed = unit.trim(); + return trimmed === DIMENSIONLESS_UNIT ? "" : trimmed; +} + +/** A formatted number followed by its unit, or the number alone. */ +export function withLabUnit( + number: string, + unit: string | null | undefined, +): string { + const shown = labUnitText(unit); + return shown ? `${number} ${shown}` : number; +} + +/** + * The unit where it stands on its own (a column, a marker's subtitle, a + * sentence naming it): the localised "no unit" label for the unity, the unit + * itself otherwise. + */ +export function labUnitLabel( + unit: string | null | undefined, + t: (key: string) => string, +): string { + return isDimensionlessUnit(unit) ? t("labs.unit.none") : labUnitText(unit); +} + +/** How the API contract describes a lab unit field. */ +export const LAB_UNIT_DESCRIPTION = + 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is never read as `"1"` and stays refused.'; diff --git a/src/lib/labs/format-value.ts b/src/lib/labs/format-value.ts index cc4baf4ae..b55bdca3b 100644 --- a/src/lib/labs/format-value.ts +++ b/src/lib/labs/format-value.ts @@ -13,6 +13,7 @@ * German reader sees "1,8 mIU/L" where an English one sees "1.8 mIU/L". This * module only decides the precision. */ +import { withLabUnit } from "@/lib/labs/dimensionless"; import { formatRecordedNumber, type FractionDigitsFormat, @@ -38,7 +39,7 @@ export function formatLabValue( /** * Render a reading's display value: a qualitative row (numeric `value` is * null) shows its `valueText` verbatim; a numeric row shows the formatted - * number plus its unit. One helper so the list card, the history rows, and + * number plus its unit (none for a dimensionless reading). One helper so the list card, the history rows, and * the detail header stay consistent. */ export function formatLabReading( @@ -53,5 +54,8 @@ export function formatLabReading( if (reading.value === null) { return reading.valueText ?? ""; } - return `${formatNumber(reading.value, reading.sourceValue)} ${reading.unit}`.trim(); + return withLabUnit( + formatNumber(reading.value, reading.sourceValue), + reading.unit, + ); } diff --git a/tests/integration/lab-dimensionless-unit.test.ts b/tests/integration/lab-dimensionless-unit.test.ts new file mode 100644 index 000000000..a3b4e25d0 --- /dev/null +++ b/tests/integration/lab-dimensionless-unit.test.ts @@ -0,0 +1,92 @@ +/** + * A lab value without a unit, through the CSV import against a real database. + * + * `1` in the unit column is the explicit "no unit" choice (the UCUM unity): it + * mints a marker kept in "1" and stores the reading under it. An empty unit + * cell is still refused, and "1" stays a unit of its own in both directions: + * it does not go under a marker kept in mg/dL, and mg/dL does not go under it. + */ +import { beforeAll, describe, expect, it } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { textToStream } from "@/lib/import/csv-stream"; +import { importLabsCsv } from "@/lib/import/labs-csv"; +import { labsCsvQuerySchema } from "@/lib/validations/record-import"; + +const prisma = getPrismaClient(); +const USER = "unitless-lab-user"; +const TZ = "Europe/Berlin"; + +const query = labsCsvQuerySchema.parse({ + analyte: "0", + value: "1", + unit: "2", + date: "3", + referenceRange: "4", + decimal: "comma", + dateOrder: "dmy", +}); + +function run(csv: string) { + return importLabsCsv({ + userId: USER, + tz: TZ, + stream: textToStream(csv), + maxBytes: 1_000_000, + query, + }); +} + +beforeAll(async () => { + await truncateAllTables(prisma); + await prisma.user.create({ + data: { id: USER, username: USER, timezone: TZ }, + }); + await prisma.biomarker.create({ + data: { userId: USER, name: "LDL", unit: "mg/dL" }, + }); +}); + +describe("lab CSV import of a value without a unit", () => { + it("mints a marker without a unit and refuses the rows that are not one", async () => { + const result = await run( + [ + "Analyt;Wert;Einheit;Datum;Referenz", + "Cholesterin/HDL-Quotient;3,6;1;01.03.2025;0 - 5", + "INR;1,1;;01.03.2025;", + "LDL;2,1;1;01.03.2025;", + ].join("\n"), + ); + expect(result.errors).toEqual([ + { line: 3, reason: "missing_unit", field: "unit" }, + { line: 4, reason: "unit_mismatch", field: "unit" }, + ]); + expect(result.inserted).toBe(1); + + const marker = await prisma.biomarker.findFirstOrThrow({ + where: { userId: USER, name: "Cholesterin/HDL-Quotient" }, + }); + expect(marker.unit).toBe("1"); + const reading = await prisma.labResult.findFirstOrThrow({ + where: { userId: USER, biomarkerId: marker.id }, + }); + expect(reading.unit).toBe("1"); + expect(reading.value).toBe(3.6); + expect(reading.sourceUnit).toBeNull(); + expect(reading.sourceReferenceHigh).toBe(5); + }); + + it("refuses a real unit under the marker without one", async () => { + const result = await run( + [ + "Analyt;Wert;Einheit;Datum", + "Cholesterin/HDL-Quotient;3,4;mg/dL;02.03.2025", + "Cholesterin/HDL-Quotient;3,2;1;03.03.2025", + ].join("\n"), + ); + expect(result.errors).toEqual([ + { line: 2, reason: "unit_mismatch", field: "unit" }, + ]); + expect(result.inserted).toBe(1); + }); +}); From 5405ce053d84122c4eb32808a3882aef43e6a1ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 2 Nov 2026 20:28:34 +0100 Subject: [PATCH 2/4] feat(fhir): code a lab value without a unit as the UCUM unity A value stored with unit "1" carries system and code on its quantity even when the analyte map knows no coding for it: the unity is UCUM by definition. Refs #1313 --- src/lib/fhir/__tests__/lab-observations.test.ts | 10 ++++++++++ src/lib/fhir/resources/labs.ts | 11 ++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/lib/fhir/__tests__/lab-observations.test.ts b/src/lib/fhir/__tests__/lab-observations.test.ts index 274f0798b..3cb005613 100644 --- a/src/lib/fhir/__tests__/lab-observations.test.ts +++ b/src/lib/fhir/__tests__/lab-observations.test.ts @@ -276,6 +276,16 @@ describe("lab-result LOINC + canonical UCUM coding (v1.18.8)", () => { expect(o?.valueQuantity?.unit).toBe("mg/dL"); }); + it("codes a value without a unit as the UCUM unity", () => { + const o = labOf("Cholesterol/HDL ratio", "1", 3.6); + expect(o?.valueQuantity).toEqual({ + value: 3.6, + unit: "1", + system: "http://unitsofmeasure.org", + code: "1", + }); + }); + it("resolves analyte aliases to the same canonical LOINC", () => { // "LDL", "LDL-C", "LDL Cholesterol" all fold to 2089-1. for (const name of ["LDL-C", "LDL Cholesterol", "ldl_c"]) { diff --git a/src/lib/fhir/resources/labs.ts b/src/lib/fhir/resources/labs.ts index 3b9bf0dab..92ffedf85 100644 --- a/src/lib/fhir/resources/labs.ts +++ b/src/lib/fhir/resources/labs.ts @@ -21,6 +21,10 @@ import type { DoctorReportData } from "@/lib/doctor-report-data"; import { LOINC_SYSTEM, UCUM_SYSTEM } from "@/lib/fhir/loinc-map"; import { resolveLabCoding } from "@/lib/fhir/lab-loinc"; import { qualitativeValueConcept } from "@/lib/fhir/lab-qualitative"; +import { + DIMENSIONLESS_UNIT, + isDimensionlessUnit, +} from "@/lib/labs/dimensionless"; import type { FhirObservation, @@ -84,10 +88,15 @@ export function labObservations( // the code system while withholding the code claims a coding the bundle // does not carry, and leaves a receiver unable to machine-compare the // value it is attached to. + // A value without a unit is the UCUM unity by definition, so it carries + // the code even for an analyte the map does not know. + const ucum = isDimensionlessUnit(lab.unit) + ? DIMENSIONLESS_UNIT + : coding?.ucum; const quantity = (value: number) => ({ value, unit: lab.unit, - ...(coding?.ucum ? { system: UCUM_SYSTEM, code: coding.ucum } : {}), + ...(ucum ? { system: UCUM_SYSTEM, code: ucum } : {}), }); // The bounds are stated in the SAME unit as the value, so they carry the // SAME coding — a bare unit beside a coded value leaves a receiver unable From f9431da64f7798ed44a43ca24e81d54e667c9933 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 2 Nov 2026 20:28:34 +0100 Subject: [PATCH 3/4] feat(labs): show a value without a unit as the number alone Every place a lab unit is printed beside a value now goes through one helper, so a reading stored in "1" reads "3.6" and not "3.6 1": the list, history and detail, the chart and its tooltips, the reference bar and range notes, the trend card, the timeline, the day view, the clinician page, the doctor-report PDF and the changes card. Where the unit stands alone (a marker's subtitle, a picker row, the merge preview) it reads "No unit". The Coach's lab block, the get_labs history and the visit-prep summary hand the model an empty unit for such a value, so it never quotes the "1" either. Refs #1313 --- src/components/clinician/history-sections.tsx | 3 +- src/components/insights/labs-changes-card.tsx | 5 +-- src/components/labs/biomarker-manager.tsx | 6 ++-- src/components/labs/biomarker-merge-sheet.tsx | 11 ++++--- src/components/labs/lab-biomarker-detail.tsx | 9 +++--- src/components/labs/lab-form.tsx | 19 +++++++---- src/components/labs/lab-history-list.tsx | 3 +- src/components/labs/lab-list.tsx | 13 +++++--- .../labs/lab-reference-range-bar.tsx | 16 ++++++---- src/components/labs/lab-trend-card.tsx | 32 ++++++++++++------- src/components/labs/labs-settings.tsx | 3 +- src/components/labs/marker-match-field.tsx | 3 +- src/components/labs/source-range-note.tsx | 16 ++++++---- src/components/timeline/lab-lines.tsx | 5 +-- src/lib/ai/coach/labs-snapshot.ts | 7 ++-- src/lib/day/records.ts | 6 ++-- .../clinical-records-notes-section.ts | 11 ++++--- src/lib/insights/biomarker-status.ts | 3 +- src/lib/jobs/reaction-line-labs.ts | 4 ++- src/lib/mcp/doctor-visit-summary.ts | 3 +- src/lib/mcp/prompts.ts | 5 +-- src/lib/mcp/tools/get-labs.ts | 22 +++++++++++-- src/lib/mcp/tools/render/citation-text.ts | 5 ++- 23 files changed, 139 insertions(+), 71 deletions(-) diff --git a/src/components/clinician/history-sections.tsx b/src/components/clinician/history-sections.tsx index 308f74ea4..dd6a25138 100644 --- a/src/components/clinician/history-sections.tsx +++ b/src/components/clinician/history-sections.tsx @@ -26,6 +26,7 @@ import { type LeafScope, type Translate, } from "./report-sections"; +import { withLabUnit } from "@/lib/labs/dimensionless"; /** Join the parts of a composed value, dropping the ones with nothing in. */ function compose(parts: Array): string { @@ -73,7 +74,7 @@ export function LabResultsSection({ const qualitative = lab.value === null; const reading = qualitative ? lab.valueText - : `${fmtLab(lab.value as number, lab.sourceValue)} ${lab.unit}`.trim(); + : withLabUnit(fmtLab(lab.value as number, lab.sourceValue), lab.unit); const range = ( low: number | null, high: number | null, diff --git a/src/components/insights/labs-changes-card.tsx b/src/components/insights/labs-changes-card.tsx index 8ed2a69dc..30a8e146a 100644 --- a/src/components/insights/labs-changes-card.tsx +++ b/src/components/insights/labs-changes-card.tsx @@ -11,6 +11,7 @@ import { queryKeys } from "@/lib/query-keys"; import { SectionHeading } from "@/components/ui/section-heading"; import type { LabChange } from "@/lib/insights/labs-changes"; import { InsightSectionCard } from "./insight-section-card"; +import { withLabUnit } from "@/lib/labs/dimensionless"; /** * v1.25 — "what changed since your last panel" card. @@ -81,7 +82,7 @@ export function LabsChangesCard({ const signedDelta = c.direction === "flat" ? t("insights.labsChanges.directionFlat") - : `${c.delta > 0 ? "+" : ""}${c.delta} ${c.unit}`.trim(); + : withLabUnit(`${c.delta > 0 ? "+" : ""}${c.delta}`, c.unit); return (

- {`${c.latest} ${c.unit}`.trim()} + {withLabUnit(String(c.latest), c.unit)} {labelKey ? ` · ${t(labelKey)}` : ""}

diff --git a/src/components/labs/biomarker-manager.tsx b/src/components/labs/biomarker-manager.tsx index f204f0842..c69d11aba 100644 --- a/src/components/labs/biomarker-manager.tsx +++ b/src/components/labs/biomarker-manager.tsx @@ -24,6 +24,8 @@ import { BiomarkerForm } from "./biomarker-form"; import { BiomarkerMergeSheet } from "./biomarker-merge-sheet"; import type { BiomarkerDto, BiomarkerListResponse } from "./types"; import { useLabNumber } from "./use-lab-format"; +import { withLabUnit } from "@/lib/labs/dimensionless"; +import { LabUnitName } from "./lab-unit-input"; /** * v1.18.1 — the Biomarker catalog manager. v1.22 — compact editable rows. @@ -124,11 +126,11 @@ export function BiomarkerManager() {
{marker.name} - {marker.unit} +

{range - ? `${t("labs.referenceLabel")} ${range} ${marker.unit}` + ? `${t("labs.referenceLabel")} ${withLabUnit(range, marker.unit)}` : t("labs.biomarker.noRange")} {marker.panel ? ` · ${marker.panel}` : ""}

diff --git a/src/components/labs/biomarker-merge-sheet.tsx b/src/components/labs/biomarker-merge-sheet.tsx index 0853b410b..721af3157 100644 --- a/src/components/labs/biomarker-merge-sheet.tsx +++ b/src/components/labs/biomarker-merge-sheet.tsx @@ -28,6 +28,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import type { BiomarkerDto, BiomarkerMergeResultDto } from "./types"; +import { labUnitLabel } from "@/lib/labs/dimensionless"; /** The most sources one merge takes, matching the server. */ const MAX_SOURCES = 10; @@ -166,7 +167,7 @@ export function BiomarkerMergeSheet({

{tCount("labs.merge.previewSummary", preview.movedReadings, { name: preview.target.name, - unit: preview.target.unit, + unit: labUnitLabel(preview.target.unit, t), })}

    @@ -178,7 +179,7 @@ export function BiomarkerMergeSheet({

    {source.name}{" "} - {source.unit} + {labUnitLabel(source.unit, t)}

    {source.conversion === "refused" ? t("labs.merge.refused", { - unit: source.unit, - targetUnit: preview.target.unit, + unit: labUnitLabel(source.unit, t), + targetUnit: labUnitLabel(preview.target.unit, t), }) : source.conversion === "converted" ? tCount("labs.merge.converted", source.readingCount, { @@ -247,7 +248,7 @@ export function BiomarkerMergeSheet({ {marker.name} - {marker.unit} + {labUnitLabel(marker.unit, t)} diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx index 67978895b..cfff0826d 100644 --- a/src/components/labs/lab-biomarker-detail.tsx +++ b/src/components/labs/lab-biomarker-detail.tsx @@ -61,6 +61,7 @@ import type { LabResultDto, LabResultListResponse, } from "./types"; +import { labUnitLabel, labUnitText } from "@/lib/labs/dimensionless"; // v1.18.11 — defer the recharts biomarker chart through // `next/dynamic` so recharts is off `/labs/[biomarkerId]`'s first-load JS. @@ -426,7 +427,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { one description line every page header carries. */} {marker?.unit ? (

    - {marker.unit} + {labUnitLabel(marker.unit, t)} {marker.panel ? ` · ${marker.panel}` : ""}

    ) : null} @@ -492,7 +493,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { numeric readings, mirroring the metric sub-pages. */} @@ -500,7 +501,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { @@ -508,7 +509,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { )} diff --git a/src/components/labs/lab-form.tsx b/src/components/labs/lab-form.tsx index b75b4f90b..83652c303 100644 --- a/src/components/labs/lab-form.tsx +++ b/src/components/labs/lab-form.tsx @@ -39,6 +39,11 @@ import type { BiomarkerListResponse, LabResultDto, } from "./types"; +import { + labUnitLabel, + labUnitText, + withLabUnit, +} from "@/lib/labs/dimensionless"; const NOTE_MAX_LENGTH = 2000; const DEFINE_NEW = "__define_new__"; @@ -330,9 +335,8 @@ export function LabForm({ ? formatReferenceRange(selected.lowerBound, selected.upperBound, labNumber) : ""; - // What the typed string resolved to, shown back so the user can see whether - // a window was read out of it before the reading is saved. When none was, - // the hint says the text is kept as written rather than staying silent. + // What the typed string resolved to, shown back before the save. When no + // window was read out of it, the hint says the text stays as written. const parsedSourceRange = parseReferenceRange(sourceRange, selected?.unit); const sourceRangeHint = parsedSourceRange ? isUnreadableRange(parsedSourceRange) @@ -341,7 +345,7 @@ export function LabForm({ parsedSourceRange.low, parsedSourceRange.high, labNumber, - )} ${selected?.unit ?? ""}`.trim() + )} ${labUnitText(selected?.unit)}`.trim() : ""; const footerNode = ( @@ -406,7 +410,7 @@ export function LabForm({ {markers.map((m) => ( - {m.name} · {m.unit} + {m.name} · {labUnitLabel(m.unit, t)} ))} {!lockedBiomarkerId ? ( @@ -457,7 +461,7 @@ export function LabForm({ {selected ? ( {" "} - ({selected.unit}) + ({labUnitLabel(selected.unit, t)}) ) : null} @@ -515,7 +519,8 @@ export function LabForm({ {resultType === "numeric" && selected && referenceText ? (

    - {t("labs.referenceLabel")} {referenceText} {selected.unit} + {t("labs.referenceLabel")}{" "} + {withLabUnit(referenceText, selected.unit)}

    ) : null} diff --git a/src/components/labs/lab-history-list.tsx b/src/components/labs/lab-history-list.tsx index cd794a430..130e85faa 100644 --- a/src/components/labs/lab-history-list.tsx +++ b/src/components/labs/lab-history-list.tsx @@ -46,6 +46,7 @@ import { } from "./lab-result-related-links"; import type { LabResultDto } from "./types"; import { useLabDate, useLabNumber } from "./use-lab-format"; +import { labUnitText } from "@/lib/labs/dimensionless"; const NOTE_MAX_LENGTH = 2000; @@ -103,7 +104,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) { reading.value === null ? (reading.valueText ?? "") : labNumber(reading.value, reading.sourceValue), - unit: reading.value === null ? undefined : reading.unit, + unit: reading.value === null ? undefined : labUnitText(reading.unit), compare: before ? { label: t("day.previousReading", { date: labDate(before.takenAt) }), diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx index d3219e0cd..43ddc437a 100644 --- a/src/components/labs/lab-list.tsx +++ b/src/components/labs/lab-list.tsx @@ -31,6 +31,7 @@ import type { LabOverviewResponse, LabResultDto, } from "./types"; +import { labUnitLabel } from "@/lib/labs/dimensionless"; interface MarkerGroup { /** Stable group key: `bm:`, or `analyte:` for legacy rows. */ @@ -39,6 +40,7 @@ interface MarkerGroup { biomarkerId: string | null; analyte: string; panel: string | null; + /** The marker's unit as the card names it ("No unit" for a ratio). */ unit: string; /** Every live reading of the marker, counted by the server. */ count: number; @@ -53,13 +55,16 @@ interface MarkerGroup { * inert until the backfill links them. The server already orders by the * newest reading. */ -function toGroups(markers: LabOverviewMarkerDto[]): MarkerGroup[] { +function toGroups( + markers: LabOverviewMarkerDto[], + t: (key: string) => string, +): MarkerGroup[] { return markers.map((marker) => ({ key: marker.key, biomarkerId: marker.latest.biomarkerId, analyte: marker.latest.analyte, panel: marker.latest.panel, - unit: marker.latest.unit, + unit: labUnitLabel(marker.latest.unit, t), count: marker.count, trend: marker.trend, latest: marker.latest, @@ -152,7 +157,7 @@ export function LabList({ }); const groups = useMemo(() => { - const base = toGroups(data?.markers ?? []); + const base = toGroups(data?.markers ?? [], t); // v1.18.6 — honour the user's Labs sort choice. The server // order is most-recent-first; `recentAsc` reverses, the alpha // options sort by analyte name (#43), and `manual` applies the persisted @@ -173,7 +178,7 @@ export function LabList({ return applyOrder(base, prefs.order, (g) => g.biomarkerId ?? g.key); } return base; - }, [data?.markers, prefs.sortDir, prefs.order]); + }, [data?.markers, prefs.sortDir, prefs.order, t]); const listEmpty = !isLoading && !isError && groups.length === 0; useEffect(() => { diff --git a/src/components/labs/lab-reference-range-bar.tsx b/src/components/labs/lab-reference-range-bar.tsx index 0fc0e1079..443855020 100644 --- a/src/components/labs/lab-reference-range-bar.tsx +++ b/src/components/labs/lab-reference-range-bar.tsx @@ -4,6 +4,7 @@ import { RangeBar } from "@/components/targets/range-bar"; import { cn } from "@/lib/utils"; import { useLabNumber } from "./use-lab-format"; +import { labUnitText, withLabUnit } from "@/lib/labs/dimensionless"; /** * v1.37.34 — compact reference-range visualization for saved lab readings. @@ -43,11 +44,14 @@ export function LabReferenceRangeBar({ const scale = Math.max(Math.abs(referenceLow ?? referenceHigh!), 1); const min = referenceLow ?? referenceHigh! - scale; const max = referenceHigh ?? referenceLow! + scale; - const rangeLabel = !hasLow - ? `≤${labNumber(referenceHigh!)} ${unit}` - : !hasHigh - ? `≥${labNumber(referenceLow!)} ${unit}` - : `${labNumber(referenceLow!)}–${labNumber(referenceHigh!)} ${unit}`; + const rangeLabel = withLabUnit( + !hasLow + ? `≤${labNumber(referenceHigh!)}` + : !hasHigh + ? `≥${labNumber(referenceLow!)}` + : `${labNumber(referenceLow!)}–${labNumber(referenceHigh!)}`, + unit, + ); return (
    + shownUnit ? text : text.replace(/ +([.,])/g, "$1").replace(/ {2,}/g, " "); + const slopeLine = tidy( + t(`labs.trend.slope.${direction}`, { + amount: per(trend.slopePerYear), + unit: shownUnit, + n: trend.n, + since: fmt.dateShortSmartCalendar(trend.firstAt), + }), + ); const projectionLine = trend.projection === "exits" && trend.exitDate ? t(`labs.trend.projection.exits.${trend.exitBound ?? "high"}`, { @@ -85,11 +93,13 @@ export function LabTrendCard({

    ) : null}

    - {t("labs.trend.interval", { - lo: fmt.number(trend.slopeCi95[0], 2), - hi: fmt.number(trend.slopeCi95[1], 2), - unit, - })} + {tidy( + t("labs.trend.interval", { + lo: fmt.number(trend.slopeCi95[0], 2), + hi: fmt.number(trend.slopeCi95[1], 2), + unit: shownUnit, + }), + )}

    diff --git a/src/components/labs/labs-settings.tsx b/src/components/labs/labs-settings.tsx index 12ca02e37..709f550b5 100644 --- a/src/components/labs/labs-settings.tsx +++ b/src/components/labs/labs-settings.tsx @@ -46,6 +46,7 @@ import { import { BiomarkerManager } from "./biomarker-manager"; import type { BiomarkerDto, BiomarkerListResponse } from "./types"; import { useLabsLocalOcr, useUpdateLabsLocalOcr } from "./use-ocr-extract"; +import { labUnitLabel } from "@/lib/labs/dimensionless"; export function LabsSettings() { const { t } = useTranslations(); @@ -61,7 +62,7 @@ export function LabsSettings() { const reorderItems: ReorderItem[] = ordered.map((m) => ({ id: m.id, name: m.name, - secondary: m.panel ? `${m.unit} · ${m.panel}` : m.unit, + secondary: [labUnitLabel(m.unit, t), m.panel].filter(Boolean).join(" · "), })); const manual = prefs.sortDir === "manual"; diff --git a/src/components/labs/marker-match-field.tsx b/src/components/labs/marker-match-field.tsx index 399bdac2a..cd1521bd0 100644 --- a/src/components/labs/marker-match-field.tsx +++ b/src/components/labs/marker-match-field.tsx @@ -21,6 +21,7 @@ import { Badge } from "@/components/ui/badge"; import { Label } from "@/components/ui/label"; import { NativeSelect } from "@/components/ui/native-select"; import { useTranslations } from "@/lib/i18n/context"; +import { labUnitLabel } from "@/lib/labs/dimensionless"; export interface MarkerSuggestionDto { biomarkerId: string; @@ -110,7 +111,7 @@ export function MarkerMatchField({ ))} diff --git a/src/components/labs/source-range-note.tsx b/src/components/labs/source-range-note.tsx index d5e3dde2c..b5a7f195e 100644 --- a/src/components/labs/source-range-note.tsx +++ b/src/components/labs/source-range-note.tsx @@ -5,6 +5,7 @@ import { useTranslations } from "@/lib/i18n/context"; import type { LabResultDto } from "./types"; import { useLabNumber } from "./use-lab-format"; +import { withLabUnit } from "@/lib/labs/dimensionless"; /** * The reference window a reading's own report printed, shown wherever that @@ -54,11 +55,14 @@ export function SourceRangeNote({ // then stated from the converted bounds, in the marker's unit. const inForce = reading.sourceReferenceText ?? - `${formatReferenceRange( - reading.referenceLow, - reading.referenceHigh, - labNumber, - )} ${reading.unit}`.trim(); + withLabUnit( + formatReferenceRange( + reading.referenceLow, + reading.referenceHigh, + labNumber, + ), + reading.unit, + ); const catalog = formatReferenceRange( reading.catalogReferenceLow, @@ -77,7 +81,7 @@ export function SourceRangeNote({ : null} {reading.referenceDivergesFromCatalog && catalog ? ` · ${t("labs.sourceRange.catalogDiffers", { - range: `${catalog} ${reading.unit}`.trim(), + range: withLabUnit(catalog, reading.unit), })}` : null} diff --git a/src/components/timeline/lab-lines.tsx b/src/components/timeline/lab-lines.tsx index 619f627bc..833a4a7bc 100644 --- a/src/components/timeline/lab-lines.tsx +++ b/src/components/timeline/lab-lines.tsx @@ -38,6 +38,7 @@ import { formatDayMonthYear, } from "./timeline-dates"; import { seriesNameLines, type TimelineLayout } from "./timeline-geometry"; +import { withLabUnit } from "@/lib/labs/dimensionless"; /** "18 ng/mL", every digit the reading holds, in the reader's locale. */ export type LabValueText = ( @@ -50,7 +51,7 @@ export function useLabValueText(): LabValueText { const labNumber = useLabNumber(); return useCallback( (value, sourceValue, unit) => - `${labNumber(value, sourceValue)} ${unit}`.trim(), + withLabUnit(labNumber(value, sourceValue), unit), [labNumber], ); } @@ -84,7 +85,7 @@ export function useLabReference(): ( (v) => labNumber(v), ); return range - ? t("timeline.labs.reference", { range: `${range} ${unit}`.trim() }) + ? t("timeline.labs.reference", { range: withLabUnit(range, unit) }) : null; }, [t, labNumber], diff --git a/src/lib/ai/coach/labs-snapshot.ts b/src/lib/ai/coach/labs-snapshot.ts index 85e102617..5407b90aa 100644 --- a/src/lib/ai/coach/labs-snapshot.ts +++ b/src/lib/ai/coach/labs-snapshot.ts @@ -67,6 +67,7 @@ import { resolveLabFields } from "@/lib/labs/serialise"; import { sanitizeForPrompt } from "@/lib/insights/sanitize"; import { prisma } from "@/lib/db"; import { isModuleEnabled } from "@/lib/modules/gate"; +import { labUnitText } from "@/lib/labs/dimensionless"; /** Cap on distinct biomarkers in the block (newest reading first). */ const MAX_BIOMARKERS = 24; @@ -120,7 +121,7 @@ export interface CoachLabReading { value: number | null; /** Qualitative result text ("negativ" / …); null for a numeric row. */ valueText: string | null; - /** Resolved unit; empty for a qualitative marker. */ + /** Resolved unit; empty for a qualitative marker and for a ratio. */ unit: string; referenceLow: number | null; referenceHigh: number | null; @@ -359,7 +360,9 @@ export async function buildLabsSnapshotBlock( : null, value: row.value, valueText: row.valueText ? sanitizeValueText(row.valueText) : null, - unit: sanitizeForPrompt(resolved.unit, MAX_UNIT_CHARS), + // A value without a unit is stored as "1"; the model is shown none, + // so it never writes "3.6 1". + unit: sanitizeForPrompt(labUnitText(resolved.unit), MAX_UNIT_CHARS), referenceLow: resolved.referenceLow, referenceHigh: resolved.referenceHigh, // A qualitative row has nothing to compare against the bounds → diff --git a/src/lib/day/records.ts b/src/lib/day/records.ts index 31be1a0bb..ec2073c8e 100644 --- a/src/lib/day/records.ts +++ b/src/lib/day/records.ts @@ -47,6 +47,7 @@ import { readWorkoutsAroundRange, WORKOUT_PICK_SELECT, } from "@/lib/workouts/canonical-read"; +import { labUnitText } from "@/lib/labs/dimensionless"; /** Everything a section reader needs about the day. */ export interface DayFrame { @@ -717,10 +718,9 @@ async function labsPart(frame: DayFrame): Promise { title: row.analyte, meta: row.value !== null - ? // Every recorded digit, without the float noise a converted - // reading carries (the labs page prints the same figure). + ? // Every recorded digit, minus a converted reading's float noise. // eslint-disable-next-line healthlog/no-unlocalised-decimal -- the day contract's meta is wire text; the lab figure keeps the notation it was stored in - `${formatRecordedNumber(row.value, (v, digits) => v.toFixed(digits), row.sourceValue)} ${row.unit}`.trim() + `${formatRecordedNumber(row.value, (v, digits) => v.toFixed(digits), row.sourceValue)} ${labUnitText(row.unit)}`.trim() : (row.valueText ?? null), note: openText(row.noteEncrypted, "lab note"), href: row.biomarkerId ? `/labs/${row.biomarkerId}` : "/labs", diff --git a/src/lib/doctor-report-pdf/clinical-records-notes-section.ts b/src/lib/doctor-report-pdf/clinical-records-notes-section.ts index 02538766d..294399161 100644 --- a/src/lib/doctor-report-pdf/clinical-records-notes-section.ts +++ b/src/lib/doctor-report-pdf/clinical-records-notes-section.ts @@ -16,6 +16,7 @@ import { type DoctorReportPdfCursorState, type DoctorReportPdfRenderContext, } from "./render-context"; +import { withLabUnit } from "@/lib/labs/dimensionless"; export function buildClinicalRecordsNotesSection( context: DoctorReportPdfRenderContext, @@ -109,7 +110,7 @@ export function buildClinicalRecordsNotesSection( lr.panel ? `${lr.analyte} (${lr.panel})` : lr.analyte, isQualitative ? (lr.valueText ?? "") - : `${labNum(lr.value as number, lr.sourceValue)} ${lr.unit}`.trim(), + : withLabUnit(labNum(lr.value as number, lr.sourceValue), lr.unit), isQualitative ? "—" : referenceCell(lr), isQualitative ? "" @@ -166,10 +167,10 @@ export function buildClinicalRecordsNotesSection( y += 4; for (const lr of diverging) { doc.text( - `* ${lr.analyte}: ${t("doctorReport.labsCatalogRangeLabel")} ${rangeText( - lr.catalogReferenceLow, - lr.catalogReferenceHigh, - )} ${lr.unit}`.trim(), + `* ${lr.analyte}: ${t("doctorReport.labsCatalogRangeLabel")} ${withLabUnit( + rangeText(lr.catalogReferenceLow, lr.catalogReferenceHigh), + lr.unit, + )}`.trim(), margin, y, ); diff --git a/src/lib/insights/biomarker-status.ts b/src/lib/insights/biomarker-status.ts index 75c4c7f8b..0306ccf37 100644 --- a/src/lib/insights/biomarker-status.ts +++ b/src/lib/insights/biomarker-status.ts @@ -53,6 +53,7 @@ import { import { annotate } from "@/lib/logging/context"; import { userDayKey } from "@/lib/tz/resolver"; import { sanitizeForPrompt } from "@/lib/insights/sanitize"; +import { labUnitText } from "@/lib/labs/dimensionless"; export type BiomarkerStatusResult = { hasProvider: boolean; @@ -321,7 +322,7 @@ function biomarkerSnapshot(args: { // position below, so both are scrubbed exactly like the coach // labs-snapshot scrubs the same fields. const safeMarkerName = sanitizeForPrompt(marker.name, 120); - const safeMarkerUnit = sanitizeForPrompt(marker.unit, 40); + const safeMarkerUnit = sanitizeForPrompt(labUnitText(marker.unit), 40); const snapshot = { locale, diff --git a/src/lib/jobs/reaction-line-labs.ts b/src/lib/jobs/reaction-line-labs.ts index 8d71e8a37..d164e0c5a 100644 --- a/src/lib/jobs/reaction-line-labs.ts +++ b/src/lib/jobs/reaction-line-labs.ts @@ -8,6 +8,7 @@ import { fenceUserText } from "@/lib/ai/coach/data-fence"; import { prisma } from "@/lib/db"; import { isModuleEnabled } from "@/lib/modules/gate"; +import { labUnitText } from "@/lib/labs/dimensionless"; /** Up to eight results of the panel, fenced; null when there is nothing to say. */ export async function labsPanelEvidence( @@ -33,7 +34,8 @@ export async function labsPanelEvidence( lab.value === null ? fenceUserText(lab.valueText ?? "not reported") : String(lab.value); - const unit = lab.unit ? ` ${fenceUserText(lab.unit)}` : ""; + const shown = labUnitText(lab.unit); + const unit = shown ? ` ${fenceUserText(shown)}` : ""; return `- Newly arrived lab result: ${fenceUserText(lab.analyte)} ${value}${unit}.`; }) .join("\n"); diff --git a/src/lib/mcp/doctor-visit-summary.ts b/src/lib/mcp/doctor-visit-summary.ts index 6b00444d6..41c3aaaf1 100644 --- a/src/lib/mcp/doctor-visit-summary.ts +++ b/src/lib/mcp/doctor-visit-summary.ts @@ -19,6 +19,7 @@ import type { DoctorReportData } from "@/lib/doctor-report-data"; import { getMetricStatusMeta } from "@/lib/insights/metric-status-registry"; import { fenceUserText } from "@/lib/ai/coach/data-fence"; +import { labUnitText } from "@/lib/labs/dimensionless"; /** * Fallback units for the headline specialised metric types the generic @@ -91,7 +92,7 @@ export function summariseForVisit( panel: l.panel, value: l.value, valueText: l.valueText, - unit: l.unit, + unit: labUnitText(l.unit), referenceLow: l.referenceLow, referenceHigh: l.referenceHigh, takenAt: l.takenAt, diff --git a/src/lib/mcp/prompts.ts b/src/lib/mcp/prompts.ts index 9507775c2..cdba743fd 100644 --- a/src/lib/mcp/prompts.ts +++ b/src/lib/mcp/prompts.ts @@ -34,6 +34,7 @@ import { detectDerivedBriefingSignals } from "@/lib/insights/derived-briefing"; import { buildBriefingIllnessCycleContext } from "@/lib/insights/illness-cycle-briefing"; import { resolveUserTimezone } from "@/lib/tz/resolver"; import type { McpAuthContext } from "./auth"; +import { labUnitText } from "@/lib/labs/dimensionless"; const MS_PER_DAY = 24 * 60 * 60 * 1000; @@ -254,7 +255,7 @@ async function collectLabTrajectories( const history = readings.slice(0, 12).map((r) => ({ value: r.value, valueText: r.valueText, - unit: r.unit, + unit: labUnitText(r.unit), referenceLow: r.referenceLow, referenceHigh: r.referenceHigh, takenAt: r.takenAt.toISOString(), @@ -263,7 +264,7 @@ async function collectLabTrajectories( return { analyte: latest.analyte, panel: latest.panel, - unit: latest.unit, + unit: labUnitText(latest.unit), readingsOnFile: readings.length, latest: history[0], history, diff --git a/src/lib/mcp/tools/get-labs.ts b/src/lib/mcp/tools/get-labs.ts index f9c3dec33..7c233a298 100644 --- a/src/lib/mcp/tools/get-labs.ts +++ b/src/lib/mcp/tools/get-labs.ts @@ -2,12 +2,17 @@ import { z } from "zod/v4"; import { fenceUserText } from "@/lib/ai/coach/data-fence"; import { resolveMarkerForRead } from "@/lib/labs/biomarker-resolve"; -import { getLabHistory, LAB_HISTORY_MAX_LIMIT } from "@/lib/mcp/rich-reads"; +import { + getLabHistory, + LAB_HISTORY_MAX_LIMIT, + type LabHistoryResult, +} from "@/lib/mcp/rich-reads"; import { decodeOffsetCursor } from "@/lib/mcp/pagination"; import type { McpAuthContext } from "../auth"; import { coachReadOutput } from "./output-shapes"; import { runCoachTool, whenModuleOn } from "./shared"; import { READ_ONLY_ANNOTATIONS, type McpToolDefinition } from "./types"; +import { labUnitText } from "@/lib/labs/dimensionless"; /** Output schema for `get_labs` — latest-per-biomarker OR a paginated history. */ const getLabsOutput: z.ZodRawShape = { @@ -59,6 +64,19 @@ function fenceLabText(value: unknown): unknown { ); } +/** + * A value without a unit (a ratio, INR) is stored as the UCUM unity "1". The + * model is shown no unit for it, as the Coach's lab block does, so it never + * writes "3.6 1". + */ +function withShownUnits(result: LabHistoryResult): LabHistoryResult { + if (!result.readings) return result; + return { + ...result, + readings: result.readings.map((r) => ({ ...r, unit: labUnitText(r.unit) })), + }; +} + /** `get_labs` behind its module gate: latest-per-biomarker or a history page. */ function readLabs( ctx: McpAuthContext, @@ -78,7 +96,7 @@ function readLabs( analyte, offset: decodeOffsetCursor(args.cursor), limit: LAB_HISTORY_MAX_LIMIT, - }); + }).then(withShownUnits); } // Default: latest reading per biomarker via the Coach labs read. A name that // resolves to one of the user's markers (an alias or a catalog abbreviation diff --git a/src/lib/mcp/tools/render/citation-text.ts b/src/lib/mcp/tools/render/citation-text.ts index 9326d5f52..89059d6f1 100644 --- a/src/lib/mcp/tools/render/citation-text.ts +++ b/src/lib/mcp/tools/render/citation-text.ts @@ -6,6 +6,7 @@ import { fenceUserText } from "@/lib/ai/coach/data-fence"; import type { NutrientsDailyResult } from "@/lib/mcp/nutrients-read"; import type { NutrientUpperLimit } from "@/lib/nutrients/catalog"; +import { labUnitText } from "@/lib/labs/dimensionless"; /** Finite-number guard for the citation summarisers. */ function num(v: unknown): number | null { @@ -66,7 +67,9 @@ export function plainLabText(rid: string, result: unknown): string { : typeof reading.valueText === "string" ? reading.valueText : "—"; - const unit = typeof reading.unit === "string" ? reading.unit : ""; + // A value without a unit (a ratio, INR) is stored as "1"; it reads as none. + const unit = + typeof reading.unit === "string" ? labUnitText(reading.unit) : ""; const status = typeof reading.rangeStatus === "string" ? reading.rangeStatus : ""; const taken = From e605fe0ae8896f357470142fa905b6b085656ffc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 2 Nov 2026 20:28:35 +0100 Subject: [PATCH 4/4] docs(api): describe the dimensionless lab unit on every lab unit field The unit on lab results, biomarkers, the scanned-report commit and the document fact edit now states that "1" means the value has no unit, that it matches only itself, and that an empty unit stays refused. The lab CSV import description names the 1 in the unit column. Refs #1313 --- docs/api/openapi.yaml | 59 ++++++++++++++++++++++-- src/lib/openapi/routes/biomarkers.ts | 4 +- src/lib/openapi/routes/import.ts | 2 +- src/lib/openapi/routes/labs.ts | 4 +- src/lib/validations/biomarkers.ts | 6 ++- src/lib/validations/inbound-documents.ts | 4 +- src/lib/validations/labs-ocr.ts | 9 +++- src/lib/validations/labs.ts | 5 +- 8 files changed, 79 insertions(+), 14 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 202e7ec9d..4785a7a6a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -15875,10 +15875,11 @@ paths: checks an hour, the import itself the shared 5-an-hour `import:` bucket. Owner only; refused with `module.disabled` when the module is switched off. An unknown query parameter is refused with 422, so a misspelt `dryRun` never writes. Each row's name resolves to the account's marker the way a scanned report's rows do - (name, alias, catalog name); a new analyte mints one marker from its first row. A numeric result needs a unit; - one in another unit is converted when the conversion is known and keeps the printed value beside it, otherwise - the row is refused with `unit_mismatch`. Any other text in the value column is a qualitative result. A date - without a time is stored as that calendar date. At most 20 000 data rows." + (name, alias, catalog name); a new analyte mints one marker from its first row. A numeric result needs a unit: + an empty unit cell is refused with `missing_unit`, and `1` (the UCUM unity) states a value without a unit, such + as a ratio or INR. A result in another unit is converted when the conversion is known and keeps the printed + value beside it, otherwise the row is refused with `unit_mismatch`. Any other text in the value column is a + qualitative result. A date without a time is stored as that calendar date. At most 20 000 data rows." parameters: - in: query name: dryRun @@ -34146,6 +34147,11 @@ components: minLength: 1 maxLength: 120 unit: + description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means + the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a + unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real + unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is + never read as `"1"` and stays refused.' type: string minLength: 1 maxLength: 40 @@ -34215,6 +34221,11 @@ components: minLength: 1 maxLength: 120 unit: + description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means + the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a + unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real + unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is + never read as `"1"` and stays refused.' type: string minLength: 1 maxLength: 40 @@ -34309,6 +34320,11 @@ components: minLength: 1 maxLength: 120 unit: + description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means + the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice + and a unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker + kept in a real unit and the other way round. Clients show nothing beside such a value. An empty unit + on a numeric value is never read as `"1"` and stays refused.' type: string minLength: 1 maxLength: 40 @@ -34363,6 +34379,11 @@ components: type: string minLength: 1 maxLength: 40 + description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means + the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a + unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real + unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is + never read as `"1"` and stays refused.' lowerBound: type: number upperBound: @@ -34392,6 +34413,11 @@ components: minLength: 1 maxLength: 120 unit: + description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means + the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a + unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real + unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is + never read as `"1"` and stays refused.' type: string minLength: 1 maxLength: 40 @@ -35759,6 +35785,11 @@ components: maxLength: 300 - type: "null" unit: + description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means + the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and + a unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in + a real unit and the other way round. Clients show nothing beside such a value. An empty unit on a + numeric value is never read as `"1"` and stays refused.' anyOf: - type: string maxLength: 80 @@ -75275,6 +75306,11 @@ components: - "null" unit: type: string + description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means + the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a + unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real + unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is + never read as `"1"` and stays refused.' converted: type: boolean sourceValue: @@ -75524,6 +75560,11 @@ components: - "null" unit: type: string + description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means + the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a + unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real + unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is + never read as `"1"` and stays refused.' converted: type: boolean sourceValue: @@ -76503,6 +76544,11 @@ components: type: string unit: type: string + description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means + the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a + unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real + unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is + never read as `"1"` and stays refused.' lowerBound: type: - number @@ -76602,6 +76648,11 @@ components: type: string unit: type: string + description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means + the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a + unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real + unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is + never read as `"1"` and stays refused.' lowerBound: type: - number diff --git a/src/lib/openapi/routes/biomarkers.ts b/src/lib/openapi/routes/biomarkers.ts index fc850c598..893724720 100644 --- a/src/lib/openapi/routes/biomarkers.ts +++ b/src/lib/openapi/routes/biomarkers.ts @@ -14,6 +14,8 @@ import type { ZodOpenApiObject } from "zod-openapi"; import { z } from "zod/v4"; +import { LAB_UNIT_DESCRIPTION } from "@/lib/labs/dimensionless"; + import { createBiomarkerSchema, updateBiomarkerSchema, @@ -54,7 +56,7 @@ const biomarkerRow = z .object({ id: z.string(), name: z.string(), - unit: z.string(), + unit: z.string().describe(LAB_UNIT_DESCRIPTION), lowerBound: z.number().nullable(), upperBound: z.number().nullable(), panel: z.string().nullable(), diff --git a/src/lib/openapi/routes/import.ts b/src/lib/openapi/routes/import.ts index c22baff64..053cb10a8 100644 --- a/src/lib/openapi/routes/import.ts +++ b/src/lib/openapi/routes/import.ts @@ -553,7 +553,7 @@ export const importPaths: NonNullable = { post: { tags: ["Import"], summary: "Import lab results from CSV", - description: `${RECORD_IMPORT_COMMON} Each row's name resolves to the account's marker the way a scanned report's rows do (name, alias, catalog name); a new analyte mints one marker from its first row. A numeric result needs a unit; one in another unit is converted when the conversion is known and keeps the printed value beside it, otherwise the row is refused with \`unit_mismatch\`. Any other text in the value column is a qualitative result. A date without a time is stored as that calendar date. At most 20 000 data rows.`, + description: `${RECORD_IMPORT_COMMON} Each row's name resolves to the account's marker the way a scanned report's rows do (name, alias, catalog name); a new analyte mints one marker from its first row. A numeric result needs a unit: an empty unit cell is refused with \`missing_unit\`, and \`1\` (the UCUM unity) states a value without a unit, such as a ratio or INR. A result in another unit is converted when the conversion is known and keeps the printed value beside it, otherwise the row is refused with \`unit_mismatch\`. Any other text in the value column is a qualitative result. A date without a time is stored as that calendar date. At most 20 000 data rows.`, requestParams: { query: labsCsvQuerySchema }, requestBody: { required: true, diff --git a/src/lib/openapi/routes/labs.ts b/src/lib/openapi/routes/labs.ts index 8e7527928..279011802 100644 --- a/src/lib/openapi/routes/labs.ts +++ b/src/lib/openapi/routes/labs.ts @@ -11,6 +11,8 @@ import { withRelated } from "./relation-links"; import type { ZodOpenApiObject } from "zod-openapi"; import { z } from "zod/v4"; +import { LAB_UNIT_DESCRIPTION } from "@/lib/labs/dimensionless"; + import { createLabResultSchema, labOverviewQuerySchema, @@ -71,7 +73,7 @@ const labResultRow = z // exactly one of the two is non-null per row. value: z.number().nullable(), valueText: z.string().nullable(), - unit: z.string(), + unit: z.string().describe(LAB_UNIT_DESCRIPTION), converted: z.boolean(), sourceValue: z.number().nullable(), sourceUnit: z.string().nullable(), diff --git a/src/lib/validations/biomarkers.ts b/src/lib/validations/biomarkers.ts index fa42d8de9..2841b3f9e 100644 --- a/src/lib/validations/biomarkers.ts +++ b/src/lib/validations/biomarkers.ts @@ -15,6 +15,8 @@ */ import { z } from "zod/v4"; +import { LAB_UNIT_DESCRIPTION } from "@/lib/labs/dimensionless"; + const requiredText = (max: number) => z.string().trim().min(1).max(max); const lowerBound = z.number().finite().optional(); @@ -40,7 +42,7 @@ const optionalPanel = z export const createBiomarkerSchema = z .object({ name: requiredText(120), - unit: requiredText(40), + unit: requiredText(40).describe(LAB_UNIT_DESCRIPTION), lowerBound, upperBound, context: optionalContext, @@ -67,7 +69,7 @@ export type CreateBiomarkerInput = z.infer; export const updateBiomarkerSchema = z .object({ name: requiredText(120).optional(), - unit: requiredText(40).optional(), + unit: requiredText(40).optional().describe(LAB_UNIT_DESCRIPTION), lowerBound: lowerBound.or(z.null()), upperBound: upperBound.or(z.null()), context: optionalContext.or(z.null()), diff --git a/src/lib/validations/inbound-documents.ts b/src/lib/validations/inbound-documents.ts index a66239531..d6c336c26 100644 --- a/src/lib/validations/inbound-documents.ts +++ b/src/lib/validations/inbound-documents.ts @@ -25,6 +25,7 @@ */ import { z } from "zod/v4"; import { locales } from "@/lib/i18n/config"; +import { LAB_UNIT_DESCRIPTION } from "@/lib/labs/dimensionless"; import { DOCUMENT_BULK_MAX_IDS, DOCUMENT_CONTENT_INDEX_SOURCES, @@ -34,7 +35,6 @@ import { type DocumentContentIndexSourceValue, type InboundDocumentKindValue, } from "@/lib/documents/inbound-constants"; - import type { AiCapabilityState } from "@/lib/ai/capabilities/types"; import { OCR_MAX_ROWS } from "@/lib/validations/labs-ocr"; @@ -443,7 +443,7 @@ const observationEditSchema = z codeSystem: optCode, value: z.number().finite().nullable().optional(), valueText: optText(300), - unit: optText(80), + unit: optText(80).describe(LAB_UNIT_DESCRIPTION), referenceLow: z.number().finite().nullable().optional(), referenceHigh: z.number().finite().nullable().optional(), referenceText: optText(120), diff --git a/src/lib/validations/labs-ocr.ts b/src/lib/validations/labs-ocr.ts index 9e65d9a1c..8d92cafbc 100644 --- a/src/lib/validations/labs-ocr.ts +++ b/src/lib/validations/labs-ocr.ts @@ -19,6 +19,7 @@ import type { AiCapabilityState } from "@/lib/ai/capabilities/types"; import { z } from "zod/v4"; +import { LAB_UNIT_DESCRIPTION } from "@/lib/labs/dimensionless"; import type { WrittenOutcome } from "@/lib/outcome/written-outcome"; import { validateEntryInstant } from "@/lib/validations/entry-instant"; @@ -257,7 +258,13 @@ export const ocrCommitRowSchema = z .or(z.literal("").transform(() => undefined)), value: z.number().finite().optional(), valueText: z.string().trim().min(1).max(OCR_VALUE_TEXT_MAX).optional(), - unit: z.string().trim().min(1).max(OCR_UNIT_MAX).optional(), + unit: z + .string() + .trim() + .min(1) + .max(OCR_UNIT_MAX) + .optional() + .describe(LAB_UNIT_DESCRIPTION), referenceLow, referenceHigh, /** diff --git a/src/lib/validations/labs.ts b/src/lib/validations/labs.ts index d19a4bfe5..8879d2032 100644 --- a/src/lib/validations/labs.ts +++ b/src/lib/validations/labs.ts @@ -14,6 +14,7 @@ */ import { z } from "zod/v4"; +import { LAB_UNIT_DESCRIPTION } from "@/lib/labs/dimensionless"; import { SOURCE_REFERENCE_TEXT_MAX } from "@/lib/labs/parse-reference-range"; import { validateEntryInstant } from "@/lib/validations/entry-instant"; import { isoDateTime } from "@/lib/validations/datetime"; @@ -123,7 +124,7 @@ export const createLabResultSchema = z // (`value`) OR qualitative (`valueText`), never both, never neither. value: z.number().finite().optional(), valueText, - unit: requiredText(40).optional(), + unit: requiredText(40).optional().describe(LAB_UNIT_DESCRIPTION), referenceLow, referenceHigh, sourceReferenceLow, @@ -208,7 +209,7 @@ export const updateLabResultSchema = z // row's type (numeric ↔ qualitative) is intentionally not supported here — // delete and re-add — so an omitted key leaves the row's existing type. valueText: requiredText(120).optional(), - unit: requiredText(40).optional(), + unit: requiredText(40).optional().describe(LAB_UNIT_DESCRIPTION), referenceLow: referenceLow.or(z.null()), referenceHigh: referenceHigh.or(z.null()), sourceReferenceLow: sourceReferenceLow.or(z.null()),