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/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/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/documents/document-facts-review.tsx b/src/components/documents/document-facts-review.tsx index c2b0f39d9..10de24b49 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; } @@ -412,13 +414,12 @@ export 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/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/__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/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/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/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/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/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/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")} -

+
- `${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 3b52a6132..deb49fd56 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; @@ -373,7 +374,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/documents/__tests__/commit-dimensionless.test.ts b/src/lib/documents/__tests__/commit-dimensionless.test.ts new file mode 100644 index 000000000..a2db4ac3a --- /dev/null +++ b/src/lib/documents/__tests__/commit-dimensionless.test.ts @@ -0,0 +1,118 @@ +/** + * A value without a unit, on the document-review commit path. + * + * A ratio or an index is saved only when somebody chose "no unit" on the + * review screen, which stores the UCUM unity `"1"`. A numeric value whose unit + * is simply missing is still refused: for most analytes that means the + * extraction dropped the unit, and saving it would hide that. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + 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/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 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/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/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/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 3439d9e13..a570e8da2 100644 --- a/src/lib/mcp/tools/get-labs.ts +++ b/src/lib/mcp/tools/get-labs.ts @@ -2,7 +2,11 @@ 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 { annotate } from "@/lib/logging/context"; import { attachLabLinks, readLatestLabs } from "@/lib/mcp/lab-reads"; @@ -10,6 +14,7 @@ import type { McpAuthContext } from "../auth"; import { coachReadOutput, recordLinkSchema } 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 = { @@ -67,6 +72,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, @@ -86,7 +104,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 = 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()), 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); + }); +});