diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 48dfedfb1..f7b395c53 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -14905,11 +14905,14 @@ paths: "403": *a11 "409": *a7 "422": - description: Nothing was written. `meta.errorCode` = `labs.create.invalid` when the body failed validation (every issue + description: "Nothing was written. `meta.errorCode` = `labs.create.invalid` when the body failed validation (every issue under `details.issues`), or `labs.unit.mismatch` when a numeric reading states a `unit` other than the one - its marker is tracked in (`meta.markerUnit`, `meta.readingUnit`). A unit is compared only when one is sent; - with a `biomarkerId` it may be omitted and the marker's unit is then the stated one. Spelling is forgiven - (`mmol/l`, `ug/L` for `µg/L`); a different unit is refused, never relabelled. + its marker is tracked in AND the pair cannot be converted (`meta.markerUnit`, `meta.readingUnit`, + `meta.reason` = `unknown_analyte` | `unknown_unit` | `not_finite`). A unit is compared only when one is + sent; with a `biomarkerId` it may be omitted and the marker's unit is then the stated one. Spelling is + forgiven (`mmol/l`, `ug/L` for `µg/L`); a different unit is converted when the marker's analyte is known and + the pair is in the unit-conversion registry (the 201 response then carries `converted: true`), and refused + otherwise, never relabelled." content: application/json: schema: @@ -67394,6 +67397,16 @@ components: - type: "null" unit: type: string + converted: + type: boolean + sourceValue: + anyOf: + - type: number + - type: "null" + sourceUnit: + anyOf: + - type: string + - type: "null" referenceLow: anyOf: - type: number @@ -67422,6 +67435,10 @@ components: anyOf: - type: string - type: "null" + printedReferenceText: + anyOf: + - type: string + - type: "null" referenceOrigin: $ref: "#/components/schemas/LabReferenceRangeOrigin" referenceDivergesFromCatalog: @@ -67446,6 +67463,9 @@ components: - value - valueText - unit + - converted + - sourceValue + - sourceUnit - referenceLow - referenceHigh - catalogReferenceLow @@ -67453,6 +67473,7 @@ components: - sourceReferenceLow - sourceReferenceHigh - sourceReferenceText + - printedReferenceText - referenceOrigin - referenceDivergesFromCatalog - takenAt @@ -67476,8 +67497,14 @@ components: numbers could be read out of it). `catalogReferenceLow` / `catalogReferenceHigh` are the Biomarker band. `referenceOrigin` names the winner and `referenceDivergesFromCatalog` is true when the report's window and the catalog band state different limits — a surface showing only one of the two is showing a partial answer. The - client renders these values and never recomputes the range. The encrypted note is never echoed in list rows; - `hasNote` flags its presence and the single-resource GET returns the decrypted `note`. + client renders these values and never recomputes the range. `converted` is true when the stored `value` was + converted into the marker's `unit` from another unit; `sourceValue` and `sourceUnit` are then what the report + printed (full precision, round for display), and the report's own window was converted with it: + `sourceReferenceLow` / `sourceReferenceHigh` are then in the marker's `unit`. The printed range text is not + converted and stays in `sourceUnit`, so for a converted reading `sourceReferenceText` is null and the verbatim + text is in `printedReferenceText` instead, never mixed with the converted numbers. A reading stored as stated + has `converted: false`, two nulls and a null `printedReferenceText`. The encrypted note is never echoed in list + rows; `hasNote` flags its presence and the single-resource GET returns the decrypted `note`. LabReferenceRangeOrigin: type: string enum: @@ -67556,6 +67583,16 @@ components: - type: "null" unit: type: string + converted: + type: boolean + sourceValue: + anyOf: + - type: number + - type: "null" + sourceUnit: + anyOf: + - type: string + - type: "null" referenceLow: anyOf: - type: number @@ -67584,6 +67621,10 @@ components: anyOf: - type: string - type: "null" + printedReferenceText: + anyOf: + - type: string + - type: "null" referenceOrigin: $ref: "#/components/schemas/LabReferenceRangeOrigin" referenceDivergesFromCatalog: @@ -67610,6 +67651,9 @@ components: - value - valueText - unit + - converted + - sourceValue + - sourceUnit - referenceLow - referenceHigh - catalogReferenceLow @@ -67617,6 +67661,7 @@ components: - sourceReferenceLow - sourceReferenceHigh - sourceReferenceText + - printedReferenceText - referenceOrigin - referenceDivergesFromCatalog - takenAt @@ -67820,7 +67865,8 @@ components: - duplicate - unit_mismatch description: "`duplicate`: the row matched a live reading at commit time. `unit_mismatch`: the row states a unit other - than the one its marker is tracked in, so it was not written." + than the one its marker is tracked in and the pair cannot be converted, so it was not written. A pair + that can be converted is written in the marker's unit and reported with `converted: true`." required: - analyte - reason @@ -67867,6 +67913,16 @@ components: - type: "null" unit: type: string + converted: + type: boolean + sourceValue: + anyOf: + - type: number + - type: "null" + sourceUnit: + anyOf: + - type: string + - type: "null" referenceLow: anyOf: - type: number @@ -67895,6 +67951,10 @@ components: anyOf: - type: string - type: "null" + printedReferenceText: + anyOf: + - type: string + - type: "null" referenceOrigin: type: string enum: @@ -67928,6 +67988,9 @@ components: - value - valueText - unit + - converted + - sourceValue + - sourceUnit - referenceLow - referenceHigh - catalogReferenceLow @@ -67935,6 +67998,7 @@ components: - sourceReferenceLow - sourceReferenceHigh - sourceReferenceText + - printedReferenceText - referenceOrigin - referenceDivergesFromCatalog - takenAt @@ -67945,6 +68009,9 @@ components: - updatedAt additionalProperties: false description: A written reading. Its reference window resolves exactly as it does on `/api/labs` — see `LabResult`. + `converted`, `sourceValue` and `sourceUnit` say when the row's unit was converted into its marker's and what the + report printed; for a converted row the printed range text is in `printedReferenceText` (in `sourceUnit`) and + `sourceReferenceText` is null. AiRunEnvelope: type: object properties: @@ -68184,8 +68251,16 @@ components: - type: string - type: "null" description: "The unit the matched marker is tracked in, or null when the marker does not exist yet (it will adopt this - row's unit). A row whose `unit` differs from it cannot be committed: the commit skips it with `reason: - unit_mismatch`, and the review screen marks it before Save." + row's unit). A row whose `unit` differs from it is converted on commit when the marker's analyte is known + and the pair is in the unit-conversion registry; otherwise the commit skips it with `reason: unit_mismatch`, + and the review screen marks it before Save." + markerAnalyteKey: + anyOf: + - type: string + - type: "null" + description: The analyte the matched marker is (the catalog slug, e.g. `fasting-glucose` or `ldl`, or a registry-only + key such as `urea`), or null when the marker does not exist yet or its name matches none. With `markerUnit` + it tells a client whether a row in another unit can be converted on commit. duplicateOf: anyOf: - type: string @@ -68202,6 +68277,7 @@ components: - confidence - biomarkerMatch - markerUnit + - markerAnalyteKey - duplicateOf additionalProperties: false description: "One proposed reading transcribed from the upload. UNTRUSTED model output annotated server-side: @@ -68333,6 +68409,10 @@ components: anyOf: - type: string - type: "null" + analyteKey: + anyOf: + - type: string + - type: "null" hasContext: type: boolean context: @@ -68352,16 +68432,21 @@ components: - lowerBound - upperBound - panel + - analyteKey - hasContext - context - hidden - createdAt - updatedAt additionalProperties: false - description: A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its - presence. `hidden` drops the marker from the active catalog list and the lab-entry pickers without deleting it - (its readings and unit/range survive). A `LabResult` linking this marker resolves its unit + reference bounds - from here. + description: "A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its + presence. `analyteKey` is the analyte the marker was recognised as: the catalog slug its name belongs to (e.g. + `fasting-glucose`, `ldl`, `hdl`), or a key of the unit-conversion registry for an analyte with no catalog seed + (e.g. `urea`, `uric-acid`), or null when its name matches none. It is derived from `name` and `unit`, never + chosen: it is set on create and re-derived (or cleared) when an update changes either. A reading in another unit + is converted only for a marker that has one. `hidden` drops the marker from the active catalog list and the + lab-entry pickers without deleting it (its readings and unit/range survive). A `LabResult` linking this marker + resolves its unit + reference bounds from here." CreateBiomarkerResponse: type: object properties: diff --git a/messages/de.json b/messages/de.json index 001763d15..6a814002e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9563,6 +9563,7 @@ }, "noteLoadFailed": "Notiz konnte nicht geladen werden; sie bleibt beim Speichern unverändert.", "showingLatestOf": "Die neuesten {shown} von {total} Messwerten werden angezeigt", + "convertedFrom": "umgerechnet aus {value} {unit}", "ocr": { "scanButton": "Befund scannen", "addScan": "Dokument scannen", @@ -9609,6 +9610,7 @@ "errorUnit": "Bitte eine Einheit angeben.", "errorUnitMismatch": "Dieser Marker wird in {markerUnit} geführt. Ändere die Einheit, falls der Befund falsch gelesen wurde, oder wähle den Wert ab.", "unitDiffersBadge": "Geführt in {markerUnit}; dieser Befund nennt {unit}", + "unitConvertedBadge": "Wird als {value} {markerUnit} gespeichert, umgerechnet aus {statedValue} {unit}", "savedPartialUnitToast": "{count} Laborwerte gespeichert. Nicht gespeichert, weil die Einheit von der des Markers abweicht: {analytes}", "savedNothingUnitToast": "Nichts gespeichert: Die Einheit weicht bei {analytes} von der des Markers ab", "saveFailed": "Werte konnten nicht gespeichert werden. Bitte erneut versuchen.", @@ -9629,7 +9631,8 @@ "notLinkedYet": "Noch keinem Biomarker zugeordnet", "sourceRange": { "fromReport": "Befundbereich {range}", - "catalogDiffers": "dein Bereich {range}" + "catalogDiffers": "dein Bereich {range}", + "printed": "auf dem Befund in {unit}: {range}" } }, "modules": { diff --git a/messages/en.json b/messages/en.json index c70292f31..356521583 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9563,6 +9563,7 @@ }, "noteLoadFailed": "Note could not be loaded; it will be kept unchanged when you save.", "showingLatestOf": "Showing the latest {shown} of {total} readings", + "convertedFrom": "converted from {value} {unit}", "ocr": { "scanButton": "Scan a report", "addScan": "Scan a document", @@ -9609,6 +9610,7 @@ "errorUnit": "Add a unit.", "errorUnitMismatch": "This marker is tracked in {markerUnit}. Change the unit if the report was misread, or deselect this reading.", "unitDiffersBadge": "Tracked in {markerUnit}; this report says {unit}", + "unitConvertedBadge": "Saved as {value} {markerUnit}, converted from {statedValue} {unit}", "savedPartialUnitToast": "{count} lab results saved. Not saved, because the unit differs from the marker's: {analytes}", "savedNothingUnitToast": "Nothing saved: the unit differs from the marker's for {analytes}", "saveFailed": "Couldn't save the readings. Please try again.", @@ -9629,7 +9631,8 @@ "notLinkedYet": "Not linked to a biomarker yet", "sourceRange": { "fromReport": "Report range {range}", - "catalogDiffers": "your range {range}" + "catalogDiffers": "your range {range}", + "printed": "as printed in {unit}: {range}" } }, "modules": { diff --git a/messages/es.json b/messages/es.json index a7b2b87ed..dfa682e77 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9563,6 +9563,7 @@ }, "noteLoadFailed": "No se pudo cargar la nota; se mantendrá sin cambios al guardar.", "showingLatestOf": "Mostrando los {shown} resultados más recientes de {total}", + "convertedFrom": "convertido desde {value} {unit}", "ocr": { "scanButton": "Escanear un informe", "addScan": "Escanear un documento", @@ -9609,6 +9610,7 @@ "errorUnit": "Añade una unidad.", "errorUnitMismatch": "Este marcador se registra en {markerUnit}. Cambia la unidad si el informe se leyó mal o deselecciona este valor.", "unitDiffersBadge": "Registrado en {markerUnit}; este informe indica {unit}", + "unitConvertedBadge": "Se guardará como {value} {markerUnit}, convertido desde {statedValue} {unit}", "savedPartialUnitToast": "{count} resultados guardados. No guardados porque la unidad difiere de la del marcador: {analytes}", "savedNothingUnitToast": "No se guardó nada: la unidad difiere de la del marcador en {analytes}", "saveFailed": "No se pudieron guardar los valores. Inténtalo de nuevo.", @@ -9629,7 +9631,8 @@ "notLinkedYet": "Aún no vinculado a un biomarcador", "sourceRange": { "fromReport": "Rango del informe {range}", - "catalogDiffers": "tu rango {range}" + "catalogDiffers": "tu rango {range}", + "printed": "impreso en {unit}: {range}" } }, "modules": { diff --git a/messages/fr.json b/messages/fr.json index b47dc29fe..587297fd3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9563,6 +9563,7 @@ }, "noteLoadFailed": "La note n’a pas pu être chargée ; elle restera inchangée à l’enregistrement.", "showingLatestOf": "Affichage des {shown} derniers résultats sur {total}", + "convertedFrom": "converti depuis {value} {unit}", "ocr": { "scanButton": "Scanner un compte rendu", "addScan": "Scanner un document", @@ -9609,6 +9610,7 @@ "errorUnit": "Indiquez une unité.", "errorUnitMismatch": "Ce marqueur est suivi en {markerUnit}. Modifiez l’unité si le compte rendu a été mal lu, ou désélectionnez cette valeur.", "unitDiffersBadge": "Suivi en {markerUnit} ; ce compte rendu indique {unit}", + "unitConvertedBadge": "Enregistré comme {value} {markerUnit}, converti depuis {statedValue} {unit}", "savedPartialUnitToast": "{count} résultats enregistrés. Non enregistrés, car l’unité diffère de celle du marqueur : {analytes}", "savedNothingUnitToast": "Rien n’a été enregistré : l’unité diffère de celle du marqueur pour {analytes}", "saveFailed": "Impossible d'enregistrer les valeurs. Veuillez réessayer.", @@ -9629,7 +9631,8 @@ "notLinkedYet": "Pas encore lié à un biomarqueur", "sourceRange": { "fromReport": "Plage du compte rendu {range}", - "catalogDiffers": "votre plage {range}" + "catalogDiffers": "votre plage {range}", + "printed": "imprimé en {unit} : {range}" } }, "modules": { diff --git a/messages/it.json b/messages/it.json index e7231fe3a..5302c2727 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9563,6 +9563,7 @@ }, "noteLoadFailed": "Impossibile caricare la nota; resterà invariata al salvataggio.", "showingLatestOf": "Visualizzazione degli ultimi {shown} risultati su {total}", + "convertedFrom": "convertito da {value} {unit}", "ocr": { "scanButton": "Scansiona un referto", "addScan": "Scansiona un documento", @@ -9609,6 +9610,7 @@ "errorUnit": "Inserisci un'unità.", "errorUnitMismatch": "Questo marcatore è registrato in {markerUnit}. Cambia l’unità se il referto è stato letto male, oppure deseleziona questo valore.", "unitDiffersBadge": "Registrato in {markerUnit}; questo referto indica {unit}", + "unitConvertedBadge": "Verrà salvato come {value} {markerUnit}, convertito da {statedValue} {unit}", "savedPartialUnitToast": "{count} risultati salvati. Non salvati perché l’unità è diversa da quella del marcatore: {analytes}", "savedNothingUnitToast": "Nulla salvato: l’unità è diversa da quella del marcatore per {analytes}", "saveFailed": "Impossibile salvare i valori. Riprova.", @@ -9629,7 +9631,8 @@ "notLinkedYet": "Non ancora collegato a un biomarcatore", "sourceRange": { "fromReport": "Intervallo del referto {range}", - "catalogDiffers": "il tuo intervallo {range}" + "catalogDiffers": "il tuo intervallo {range}", + "printed": "stampato in {unit}: {range}" } }, "modules": { diff --git a/messages/ko.json b/messages/ko.json index 292fd4013..fe1370a61 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -9563,6 +9563,7 @@ }, "noteLoadFailed": "메모를 불러오지 못했어요. 저장해도 메모는 그대로 유지돼요.", "showingLatestOf": "측정값 {total}개 중 최근 {shown}개를 보여주고 있어요", + "convertedFrom": "{value} {unit}에서 변환됨", "ocr": { "scanButton": "결과지 스캔", "addScan": "문서 스캔", @@ -9609,6 +9610,7 @@ "errorUnit": "단위를 입력하세요.", "errorUnitMismatch": "이 지표는 {markerUnit} 단위로 기록됩니다. 결과지를 잘못 읽었다면 단위를 바꾸거나 이 값의 선택을 해제하세요.", "unitDiffersBadge": "{markerUnit} 단위로 기록 중; 이 결과지는 {unit}", + "unitConvertedBadge": "{value} {markerUnit}(으)로 저장됩니다. {statedValue} {unit}에서 변환됨", "savedPartialUnitToast": "검사 결과 {count}개를 저장했습니다. 단위가 지표와 달라 저장하지 않은 항목: {analytes}", "savedNothingUnitToast": "저장된 항목이 없습니다: {analytes}의 단위가 지표와 다릅니다", "saveFailed": "측정값을 저장하지 못했어요. 다시 시도해 주세요.", @@ -9629,7 +9631,8 @@ "notLinkedYet": "아직 바이오마커에 연결되지 않았어요", "sourceRange": { "fromReport": "결과지 범위 {range}", - "catalogDiffers": "내 범위 {range}" + "catalogDiffers": "내 범위 {range}", + "printed": "결과지 원문({unit}): {range}" } }, "modules": { diff --git a/messages/pl.json b/messages/pl.json index c22982d53..7a3e8b972 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9563,6 +9563,7 @@ }, "noteLoadFailed": "Nie udało się wczytać notatki; pozostanie niezmieniona po zapisaniu.", "showingLatestOf": "Wyświetlanie najnowszych {shown} z {total} wyników", + "convertedFrom": "przeliczono z {value} {unit}", "ocr": { "scanButton": "Zeskanuj wynik", "addScan": "Zeskanuj dokument", @@ -9609,6 +9610,7 @@ "errorUnit": "Podaj jednostkę.", "errorUnitMismatch": "Ten wskaźnik jest prowadzony w {markerUnit}. Zmień jednostkę, jeśli wynik został źle odczytany, albo odznacz tę wartość.", "unitDiffersBadge": "Prowadzony w {markerUnit}; ten wynik podaje {unit}", + "unitConvertedBadge": "Zostanie zapisane jako {value} {markerUnit}, przeliczone z {statedValue} {unit}", "savedPartialUnitToast": "Zapisano {count} wyników. Nie zapisano, bo jednostka różni się od jednostki wskaźnika: {analytes}", "savedNothingUnitToast": "Nic nie zapisano: jednostka różni się od jednostki wskaźnika dla {analytes}", "saveFailed": "Nie udało się zapisać wyników. Spróbuj ponownie.", @@ -9629,7 +9631,8 @@ "notLinkedYet": "Jeszcze niepowiązane z biomarkerem", "sourceRange": { "fromReport": "Zakres z wyniku {range}", - "catalogDiffers": "twój zakres {range}" + "catalogDiffers": "twój zakres {range}", + "printed": "wydrukowano w {unit}: {range}" } }, "modules": { diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9925b8ecf..ee6de1b3c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -6166,7 +6166,7 @@ model LabResult { sourceReferenceText String? @map("source_reference_text") /// @pending-consumer(#1095) v1.42 — the value as printed on the source /// report, kept when a write converted it into the biomarker's canonical - /// unit. NULL when no conversion happened. A later edit of `value` clears it. + /// unit. NULL when no conversion happened. An edit that changes `value` clears it. sourceValue Float? @map("source_value") /// @pending-consumer(#1095) v1.42 — the unit the source report printed, /// paired with `sourceValue`. diff --git a/src/__tests__/written-outcome-response-consumer-guard.test.ts b/src/__tests__/written-outcome-response-consumer-guard.test.ts index 335b152f5..bc9948329 100644 --- a/src/__tests__/written-outcome-response-consumer-guard.test.ts +++ b/src/__tests__/written-outcome-response-consumer-guard.test.ts @@ -722,6 +722,9 @@ const OCR_EXCEPTIONS: Readonly> = { "inserted.value": OCR_ROW_EXCEPTION, "inserted.valueText": OCR_ROW_EXCEPTION, "inserted.unit": OCR_ROW_EXCEPTION, + "inserted.converted": OCR_ROW_EXCEPTION, + "inserted.sourceValue": OCR_ROW_EXCEPTION, + "inserted.sourceUnit": OCR_ROW_EXCEPTION, "inserted.referenceLow": OCR_ROW_EXCEPTION, "inserted.referenceHigh": OCR_ROW_EXCEPTION, "inserted.catalogReferenceLow": OCR_ROW_EXCEPTION, @@ -729,6 +732,7 @@ const OCR_EXCEPTIONS: Readonly> = { "inserted.sourceReferenceLow": OCR_ROW_EXCEPTION, "inserted.sourceReferenceHigh": OCR_ROW_EXCEPTION, "inserted.sourceReferenceText": OCR_ROW_EXCEPTION, + "inserted.printedReferenceText": OCR_ROW_EXCEPTION, "inserted.referenceOrigin": OCR_ROW_EXCEPTION, "inserted.referenceDivergesFromCatalog": OCR_ROW_EXCEPTION, "inserted.takenAt": OCR_ROW_EXCEPTION, diff --git a/src/app/api/biomarkers/[id]/route.ts b/src/app/api/biomarkers/[id]/route.ts index a17d0b015..506333ad3 100644 --- a/src/app/api/biomarkers/[id]/route.ts +++ b/src/app/api/biomarkers/[id]/route.ts @@ -17,6 +17,7 @@ import { decryptContextFromBytes, encryptContextToBytes, } from "@/lib/labs/biomarker-store"; +import { matchAnalyteKey } from "@/lib/labs/analyte-match"; import { annotate } from "@/lib/logging/context"; import { updateBiomarkerSchema } from "@/lib/validations/biomarkers"; import { effectiveBound, isInvertedRange } from "@/lib/validations/labs"; @@ -41,6 +42,7 @@ function serialiseBiomarker(row: { lowerBound: number | null; upperBound: number | null; panel: string | null; + analyteKey: string | null; hidden: boolean; contextEncrypted: Uint8Array | null; createdAt: Date; @@ -53,6 +55,7 @@ function serialiseBiomarker(row: { lowerBound: row.lowerBound, upperBound: row.upperBound, panel: row.panel, + analyteKey: row.analyteKey, hasContext: row.contextEncrypted !== null, context: row.contextEncrypted ? decryptContextFromBytes(row.contextEncrypted) @@ -174,6 +177,16 @@ export const PUT = apiHandler( if (d.upperBound !== undefined) data.upperBound = d.upperBound; if (d.panel !== undefined) data.panel = d.panel; if (d.hidden !== undefined) data.hidden = d.hidden; + // The analyte a marker is follows from its name and unit, the same match + // the creation paths use. A rename must move it, or a marker renamed from + // "Glucose" to "Cholesterol" would keep converting readings with the + // glucose factor; a name that now matches nothing clears it. + if (d.name !== undefined || d.unit !== undefined) { + data.analyteKey = matchAnalyteKey({ + name: d.name ?? existing.name, + unit: d.unit ?? existing.unit, + }); + } // Inverted-range guard for a PARTIAL bound update. The schema refine only // fires when both bounds arrive together; moving a single bound past the diff --git a/src/app/api/biomarkers/route.ts b/src/app/api/biomarkers/route.ts index 8de993f70..8736ab76d 100644 --- a/src/app/api/biomarkers/route.ts +++ b/src/app/api/biomarkers/route.ts @@ -17,6 +17,7 @@ import { decryptContextSoft, encryptContextToBytes, } from "@/lib/labs/biomarker-store"; +import { matchAnalyteKey } from "@/lib/labs/analyte-match"; import { annotate } from "@/lib/logging/context"; import { createBiomarkerSchema } from "@/lib/validations/biomarkers"; @@ -51,6 +52,7 @@ function serialiseBiomarker(row: { lowerBound: number | null; upperBound: number | null; panel: string | null; + analyteKey: string | null; hidden: boolean; contextEncrypted: Uint8Array | null; createdAt: Date; @@ -63,6 +65,7 @@ function serialiseBiomarker(row: { lowerBound: row.lowerBound, upperBound: row.upperBound, panel: row.panel, + analyteKey: row.analyteKey, hasContext: row.contextEncrypted !== null, context: decryptContextSoft(row.contextEncrypted), hidden: row.hidden, @@ -158,6 +161,9 @@ async function postBiomarker(request: NextRequest) { lowerBound: lowerBound ?? null, upperBound: upperBound ?? null, panel: panel ?? null, + // Which analyte it is, for the unit conversion; null when the name is no + // analyte the registry knows. + analyteKey: matchAnalyteKey({ name, unit }), contextEncrypted: context ? encryptContextToBytes(context) : null, }, }); diff --git a/src/app/api/insights/labs-changes/route.ts b/src/app/api/insights/labs-changes/route.ts index 6969ac69d..d48a6b69f 100644 --- a/src/app/api/insights/labs-changes/route.ts +++ b/src/app/api/insights/labs-changes/route.ts @@ -52,6 +52,7 @@ export const GET = apiHandler(async () => { sourceReferenceLow: true, sourceReferenceHigh: true, sourceReferenceText: true, + sourceUnit: true, takenAt: true, }, orderBy: { takenAt: "desc" }, @@ -67,6 +68,7 @@ export const GET = apiHandler(async () => { sourceReferenceLow: r.sourceReferenceLow, sourceReferenceHigh: r.sourceReferenceHigh, sourceReferenceText: r.sourceReferenceText, + sourceUnit: r.sourceUnit, takenAt: r.takenAt, })), ); diff --git a/src/app/api/labs/[id]/route.ts b/src/app/api/labs/[id]/route.ts index fa2c7b326..e8363b6e5 100644 --- a/src/app/api/labs/[id]/route.ts +++ b/src/app/api/labs/[id]/route.ts @@ -192,7 +192,21 @@ export const PUT = apiHandler( const data: Record = {}; if (d.panel !== undefined) data.panel = d.panel; if (d.analyte !== undefined) data.analyte = d.analyte; - if (d.value !== undefined) data.value = d.value; + if (d.value !== undefined) { + data.value = d.value; + // A number the person typed is no longer the conversion of what the + // report printed, so the "converted from" record would now be wrong. Only + // a CHANGED value says so: a client that sends the whole form back with + // the value untouched (the web edit dialog does) must not erase the + // record. Both sides are plain numbers (`value` is a Float column). + if ( + d.value !== existing.value && + (existing.sourceValue !== null || existing.sourceUnit !== null) + ) { + data.sourceValue = null; + data.sourceUnit = null; + } + } // v1.18.9 — qualitative edit. A row stays single-typed: editing `value` on a // qualitative row (or `valueText` on a numeric row) crosses the row's type // and is rejected below. An edit of the matching field updates in place. diff --git a/src/app/api/labs/ocr/commit/route.ts b/src/app/api/labs/ocr/commit/route.ts index 8a8647108..1066edb69 100644 --- a/src/app/api/labs/ocr/commit/route.ts +++ b/src/app/api/labs/ocr/commit/route.ts @@ -33,6 +33,7 @@ import { withIdempotency } from "@/lib/idempotency"; import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; import { emitDataArrival } from "@/lib/arrivals/emit-shared"; import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store"; +import { readingInMarkerUnit } from "@/lib/labs/reading-conversion"; import { sameLabUnit } from "@/lib/labs/unit-normalise"; import { parseReferenceRange } from "@/lib/labs/parse-reference-range"; import { serialiseLabResult } from "@/lib/labs/serialise"; @@ -75,7 +76,13 @@ async function isDuplicate( analyte: { equals: row.analyte.trim(), mode: "insensitive" }, takenAt: labReadingDaySearchRange(day), }, - select: { value: true, valueText: true, takenAt: true }, + select: { + value: true, + valueText: true, + sourceValue: true, + sourceUnit: true, + takenAt: true, + }, take: 100, }); const candidates = rows.filter((c) => labReadingDay(c.takenAt, tz) === day); @@ -83,6 +90,19 @@ async function isDuplicate( if (row.value !== undefined && c.value !== null && c.value === row.value) { return true; } + // A reading stored converted holds the value in the marker's unit, so the + // same scan committed again no longer equals it. What the report printed is + // kept beside it: the same number in the same unit is the same reading. + if ( + row.value !== undefined && + row.unit !== undefined && + c.sourceValue !== null && + c.sourceValue === row.value && + c.sourceUnit !== null && + sameLabUnit(c.sourceUnit, row.unit) + ) { + return true; + } if ( row.valueText !== undefined && c.valueText !== null && @@ -179,19 +199,40 @@ async function commitOcrRows(request: NextRequest) { panel: row.panel ?? null, }); - // The marker's unit is the one the reading is stored under, so a row that - // states another unit cannot be written: the number would be kept under a - // unit it is not in. The review screen marks such a row before Save; this - // is the backstop for a client that did not, and for a marker whose unit - // changed between the scan and the commit. A freshly minted marker adopted - // this row's unit, so it cannot disagree. - if ( - !isQualitative && - row.unit !== undefined && - !sameLabUnit(row.unit, biomarker.unit) - ) { - skipped.push({ analyte: row.analyte.trim(), reason: "unit_mismatch" }); - continue; + // The marker's unit is the one the reading is stored under. A row that + // states another unit is converted when the marker's analyte is known and + // the registry has the pair, with what the report printed kept beside it; + // otherwise it cannot be written: the number would be kept under a unit it + // is not in. The review screen marks such a row before Save; this is the + // backstop for a client that did not, and for a marker whose unit changed + // between the scan and the commit. A freshly minted marker adopted this + // row's unit, so it cannot disagree. + let storedValue = row.value ?? null; + let storedSourceLow = isQualitative ? null : sourceLow; + let storedSourceHigh = isQualitative ? null : sourceHigh; + let convertedFrom: { sourceValue: number; sourceUnit: string } | null = + null; + if (!isQualitative && row.value !== undefined) { + const reading = await readingInMarkerUnit(prisma, { + marker: biomarker, + value: row.value, + unit: row.unit, + sourceReferenceLow: storedSourceLow, + sourceReferenceHigh: storedSourceHigh, + }); + if (!reading.ok) { + skipped.push({ analyte: row.analyte.trim(), reason: "unit_mismatch" }); + continue; + } + storedValue = reading.value; + storedSourceLow = reading.sourceReferenceLow; + storedSourceHigh = reading.sourceReferenceHigh; + if (reading.converted) { + convertedFrom = { + sourceValue: reading.sourceValue, + sourceUnit: reading.sourceUnit, + }; + } } // Field-by-field — never spread the parsed row. The row stamps the resolved @@ -202,13 +243,15 @@ async function commitOcrRows(request: NextRequest) { biomarkerId: biomarker.id, panel: biomarker.panel, analyte: biomarker.name, - value: row.value ?? null, + value: storedValue, valueText: row.valueText ?? null, unit: biomarker.unit, + sourceValue: convertedFrom?.sourceValue ?? null, + sourceUnit: convertedFrom?.sourceUnit ?? null, referenceLow: biomarker.lowerBound, referenceHigh: biomarker.upperBound, - sourceReferenceLow: isQualitative ? null : sourceLow, - sourceReferenceHigh: isQualitative ? null : sourceHigh, + sourceReferenceLow: storedSourceLow, + sourceReferenceHigh: storedSourceHigh, sourceReferenceText: isQualitative ? null : sourceText, takenAt: row.takenAt, source: "OCR", diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index bd463890a..e18282a76 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -18,7 +18,7 @@ import { withIdempotency } from "@/lib/idempotency"; import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; import { emitDataArrival } from "@/lib/arrivals/emit-shared"; import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store"; -import { sameLabUnit } from "@/lib/labs/unit-normalise"; +import { readingInMarkerUnit } from "@/lib/labs/reading-conversion"; import { type ResolvedBiomarker, serialiseLabResult, @@ -232,6 +232,7 @@ async function postLabResult(request: NextRequest) { lowerBound: true, upperBound: true, panel: true, + analyteKey: true, }, }); if (!found) { @@ -253,30 +254,56 @@ async function postLabResult(request: NextRequest) { }); } - // The marker's unit is the one every reading is stored under, so a reading - // that states another unit cannot be written: the number would be kept and - // the label swapped, and nothing downstream could tell. Compared only when a - // unit was actually sent: on the `biomarkerId` path it may be omitted, and - // then the marker's unit is the stated one. A freshly minted marker adopted - // the reading's own unit, so it cannot disagree with it. - if ( - !isQualitative && - unit !== undefined && - !sameLabUnit(unit, biomarker.unit) - ) { - annotate({ - action: { name: "labs.create.unit-mismatch" }, - meta: { biomarkerId: biomarker.id }, + // The marker's unit is the one every reading is stored under. A reading that + // states another unit is converted when the marker's analyte is known and the + // registry has the pair (the value, and the report's own window with it, with + // what the report printed kept beside it); otherwise it cannot be written: + // the number would be kept and the label swapped, and nothing downstream + // could tell. Compared only when a unit was actually sent: on the + // `biomarkerId` path it may be omitted, and then the marker's unit is the + // stated one. A freshly minted marker adopted the reading's own unit, so it + // cannot disagree with it. + let storedValue = value ?? null; + let storedSourceLow = isQualitative ? null : (sourceReferenceLow ?? null); + let storedSourceHigh = isQualitative ? null : (sourceReferenceHigh ?? null); + let convertedFrom: { sourceValue: number; sourceUnit: string } | null = null; + if (!isQualitative && value !== undefined) { + const reading = await readingInMarkerUnit(prisma, { + marker: biomarker, + value, + unit, + sourceReferenceLow: storedSourceLow, + sourceReferenceHigh: storedSourceHigh, }); - return apiError( - `The reading is in ${unit}, but ${biomarker.name} is tracked in ${biomarker.unit}.`, - 422, - { - errorCode: "labs.unit.mismatch", - markerUnit: biomarker.unit, - readingUnit: unit, - }, - ); + if (!reading.ok) { + annotate({ + action: { name: "labs.create.unit-mismatch" }, + meta: { biomarkerId: biomarker.id, reason: reading.reason }, + }); + return apiError( + `The reading is in ${unit}, but ${biomarker.name} is tracked in ${biomarker.unit}.`, + 422, + { + errorCode: "labs.unit.mismatch", + markerUnit: biomarker.unit, + readingUnit: unit, + reason: reading.reason, + }, + ); + } + storedValue = reading.value; + storedSourceLow = reading.sourceReferenceLow; + storedSourceHigh = reading.sourceReferenceHigh; + if (reading.converted) { + convertedFrom = { + sourceValue: reading.sourceValue, + sourceUnit: reading.sourceUnit, + }; + annotate({ + action: { name: "labs.create.unit-converted" }, + meta: { biomarkerId: biomarker.id }, + }); + } } // Field-by-field assignment — never spread `parsed.data`. The row stamps the @@ -290,17 +317,20 @@ async function postLabResult(request: NextRequest) { panel: biomarker.panel, analyte: biomarker.name, // Exactly one of value / valueText is set (the Zod XOR refine). - value: value ?? null, + value: storedValue, valueText: valueText ?? null, unit: biomarker.unit, + // What the report printed, when the value above is a conversion of it. + sourceValue: convertedFrom?.sourceValue ?? null, + sourceUnit: convertedFrom?.sourceUnit ?? null, referenceLow: biomarker.lowerBound, referenceHigh: biomarker.upperBound, // The window this reading's own report printed, kept beside the catalog // stamp rather than folded into it: it governs THIS reading's verdict, // and the catalog band stays the net for every reading without one. A // qualitative reading has no window to record. - sourceReferenceLow: isQualitative ? null : (sourceReferenceLow ?? null), - sourceReferenceHigh: isQualitative ? null : (sourceReferenceHigh ?? null), + sourceReferenceLow: storedSourceLow, + sourceReferenceHigh: storedSourceHigh, sourceReferenceText: isQualitative ? null : (sourceReferenceText ?? null), takenAt, // v1.25 (iOS #36) — provenance: the on-device-OCR path posts diff --git a/src/components/labs/__tests__/biomarker-form.test.tsx b/src/components/labs/__tests__/biomarker-form.test.tsx index 43d77f898..50333598e 100644 --- a/src/components/labs/__tests__/biomarker-form.test.tsx +++ b/src/components/labs/__tests__/biomarker-form.test.tsx @@ -28,6 +28,7 @@ const MARKER: BiomarkerDto = { lowerBound: 70, upperBound: 99, panel: null, + analyteKey: null, hasContext: false, context: null, hidden: false, diff --git a/src/components/labs/__tests__/lab-biomarker-detail.test.tsx b/src/components/labs/__tests__/lab-biomarker-detail.test.tsx index 9a969fcc0..f2f966b9b 100644 --- a/src/components/labs/__tests__/lab-biomarker-detail.test.tsx +++ b/src/components/labs/__tests__/lab-biomarker-detail.test.tsx @@ -51,6 +51,7 @@ const MARKER: BiomarkerDto = { lowerBound: 0, upperBound: 100, panel: null, + analyteKey: null, hasContext: false, context: null, hidden: false, @@ -67,6 +68,9 @@ function reading(id: string, value: number, takenAt: string): LabResultDto { value, valueText: null, unit: "mg/dL", + converted: false, + sourceValue: null, + sourceUnit: null, referenceLow: 0, referenceHigh: 100, catalogReferenceLow: 0, @@ -74,6 +78,7 @@ function reading(id: string, value: number, takenAt: string): LabResultDto { sourceReferenceLow: null, sourceReferenceHigh: null, sourceReferenceText: null, + printedReferenceText: null, referenceOrigin: "catalog", referenceDivergesFromCatalog: false, takenAt, diff --git a/src/components/labs/__tests__/lab-converted-note.test.tsx b/src/components/labs/__tests__/lab-converted-note.test.tsx new file mode 100644 index 000000000..2f0c79c49 --- /dev/null +++ b/src/components/labs/__tests__/lab-converted-note.test.tsx @@ -0,0 +1,49 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { I18nProvider } from "@/lib/i18n/context"; + +import { LabConvertedNote } from "../lab-converted-note"; + +function render( + reading: Parameters[0]["reading"], + locale: "en" | "de" = "en", +) { + return renderToStaticMarkup( + + + , + ); +} + +describe("", () => { + it("says what the report printed beside a converted reading", () => { + const html = render({ + converted: true, + sourceValue: 5.6, + sourceUnit: "mmol/L", + }); + expect(html).toContain('data-slot="lab-converted-note"'); + expect(html).toContain("converted from 5.6 mmol/L"); + }); + + it("formats the number for the reader's language", () => { + expect( + render({ converted: true, sourceValue: 5.6, sourceUnit: "mmol/L" }, "de"), + ).toContain("umgerechnet aus 5,6 mmol/L"); + }); + + it("trims a long fraction the way the reading itself is trimmed", () => { + expect( + render({ converted: true, sourceValue: 5.6123456, sourceUnit: "mmol/L" }), + ).toContain("converted from 5.61 mmol/L"); + }); + + it.each([ + [{ converted: false, sourceValue: null, sourceUnit: null }], + [{ converted: true, sourceValue: null, sourceUnit: "mmol/L" }], + [{ converted: true, sourceValue: 5.6, sourceUnit: null }], + ])("renders nothing for %j", (reading) => { + expect(render(reading)).toBe(""); + }); +}); diff --git a/src/components/labs/__tests__/lab-list-columns.test.tsx b/src/components/labs/__tests__/lab-list-columns.test.tsx index 21e133d8a..44c7505b6 100644 --- a/src/components/labs/__tests__/lab-list-columns.test.tsx +++ b/src/components/labs/__tests__/lab-list-columns.test.tsx @@ -29,6 +29,9 @@ function reading(over: Partial): LabResultDto { value: 80, valueText: null, unit: "ng/mL", + converted: false, + sourceValue: null, + sourceUnit: null, referenceLow: 30, referenceHigh: 400, catalogReferenceLow: 30, @@ -165,4 +168,21 @@ describe(" columns", () => { } }); }); + + it("notes where a converted reading came from, and only for that reading", () => { + const html = renderList([ + reading({ + id: "c", + biomarkerId: "b1", + analyte: "Glucose", + unit: "mg/dL", + converted: true, + sourceValue: 5.6, + sourceUnit: "mmol/L", + }), + reading({ id: "d", biomarkerId: "b2", analyte: "Ferritin" }), + ]); + expect(count(html, 'data-slot="lab-converted-note"')).toBe(1); + expect(html).toContain("converted from 5.6 mmol/L"); + }); }); diff --git a/src/components/labs/__tests__/ocr-review-validation.test.ts b/src/components/labs/__tests__/ocr-review-validation.test.ts index ac47ea7a9..234d18a59 100644 --- a/src/components/labs/__tests__/ocr-review-validation.test.ts +++ b/src/components/labs/__tests__/ocr-review-validation.test.ts @@ -5,6 +5,8 @@ import { seedReviewRows } from "../ocr-review-types"; import { collectRowErrors, planSave, + readingConversion, + readingUnitBlocked, readingUnitDiffers, toCommitRow, validateReviewRow, @@ -24,6 +26,7 @@ function row(overrides: Partial = {}): OcrReviewRow { confidence: { analyte: 1, value: 1, unit: 1, range: 1 }, biomarkerMatch: "existing", markerUnit: "mmol/L", + markerAnalyteKey: null, duplicateOf: null, confirmed: true, ...overrides, @@ -200,6 +203,7 @@ describe("a reading in another unit than its marker's", () => { confidence: { analyte: 1, value: 1, unit: 1, range: 1 }, biomarkerMatch: "existing" as const, markerUnit, + markerAnalyteKey: null, duplicateOf: null, }); const [same, other, fresh] = seedReviewRows( @@ -212,3 +216,85 @@ describe("a reading in another unit than its marker's", () => { expect(fresh.confirmed).toBe(true); }); }); + +describe("a reading in another unit that can be converted", () => { + // Glucose stated in mmol/L against a marker kept in mg/dL, and the analyte is + // known: Save converts it, so nothing blocks and the row starts selected. + const glucose = (overrides: Partial = {}) => + row({ + analyte: "Glucose", + value: 5.6, + unit: "mmol/L", + markerUnit: "mg/dL", + markerAnalyteKey: "fasting-glucose", + ...overrides, + }); + + it("previews what Save will store, beside what the report stated", () => { + const conversion = readingConversion(glucose()); + expect(conversion).toMatchObject({ + unit: "mg/dL", + statedValue: 5.6, + statedUnit: "mmol/L", + }); + expect(conversion!.value).toBeCloseTo(5.6 * 18.0182, 9); + }); + + it("is not blocked, and not a validation error", () => { + expect(readingUnitDiffers(glucose())).toBe(true); + expect(readingUnitBlocked(glucose())).toBe(false); + expect(validateReviewRow(glucose())).toEqual({}); + }); + + it("sends what the report printed, for the server to convert", () => { + const payload = toCommitRow(glucose()); + expect(payload).toMatchObject({ + analyte: "Glucose", + value: 5.6, + unit: "mmol/L", + }); + expect(planSave([glucose()]).kind).toBe("ready"); + }); + + it("starts selected on the review screen", () => { + const dto = { + analyte: "Glucose", + value: 5.6, + valueText: null, + unit: "mmol/L", + referenceLow: null, + referenceHigh: null, + referenceText: null, + takenAt: "2026-06-10", + confidence: { analyte: 1, value: 1, unit: 1, range: 1 }, + biomarkerMatch: "existing" as const, + markerUnit: "mg/dL", + markerAnalyteKey: "fasting-glucose", + duplicateOf: null, + }; + const [converts, blocked] = seedReviewRows( + [dto, { ...dto, markerAnalyteKey: null }], + null, + ); + expect(converts.confirmed).toBe(true); + expect(blocked.confirmed).toBe(false); + }); + + it("stays blocked when the marker has no known analyte, or the pair is not in the registry", () => { + expect(readingUnitBlocked(glucose({ markerAnalyteKey: null }))).toBe(true); + expect(readingConversion(glucose({ markerAnalyteKey: null }))).toBeNull(); + expect(validateReviewRow(glucose({ markerAnalyteKey: null }))).toEqual({ + unitMismatch: true, + }); + // The analyte is known but the unit is not one it reaches. + expect(readingUnitBlocked(glucose({ unit: "%" }))).toBe(true); + }); + + it("has nothing to preview when the units agree or the row is qualitative", () => { + expect(readingConversion(glucose({ unit: "mg/dL" }))).toBeNull(); + expect( + readingConversion(glucose({ value: null, valueText: "negative" })), + ).toBeNull(); + expect(readingConversion(glucose({ value: null }))).toBeNull(); + }); +}); diff --git a/src/components/labs/__tests__/ocr-row-editor.test.tsx b/src/components/labs/__tests__/ocr-row-editor.test.tsx index 0079dfafd..e28f27093 100644 --- a/src/components/labs/__tests__/ocr-row-editor.test.tsx +++ b/src/components/labs/__tests__/ocr-row-editor.test.tsx @@ -19,6 +19,7 @@ const baseRow: OcrReviewRow = { confidence: { analyte: 1, value: 1, unit: 1, range: 1 }, biomarkerMatch: "existing", markerUnit: "mmol/L", + markerAnalyteKey: null, duplicateOf: null, confirmed: true, }; @@ -155,3 +156,54 @@ describe(" unit differs from the marker's", () => { expect(html).toContain("Geführt in mmol/L; dieser Befund nennt mg/dL"); }); }); + +describe(" unit differs but converts", () => { + const converts: OcrReviewRow = { + ...baseRow, + analyte: "Glucose", + value: 5.6, + takenAt: "2026-06-10", + unit: "mmol/L", + markerUnit: "mg/dL", + markerAnalyteKey: "fasting-glucose", + }; + + it("says what Save will store and what the report stated, not that it is blocked", () => { + const html = render(undefined, "en", converts); + expect(html).toContain('data-slot="ocr-row-converted-badge"'); + expect(html).toContain("Saved as 100.9 mg/dL, converted from 5.6 mmol/L"); + expect(html).not.toContain("Tracked in"); + expect(html).not.toContain('aria-invalid="true"'); + }); + + it("wraps the sentence instead of cutting off the printed value", () => { + const html = render(undefined, "en", converts); + const badge = + /]*data-slot="ocr-row-converted-badge"[^>]*>([\s\S]*?)<\/span><\/span>/.exec( + html, + ); + expect(badge).not.toBeNull(); + const tag = badge![0].slice(0, badge![0].indexOf(">")); + expect(tag).toContain("whitespace-normal"); + expect(badge![0]).not.toContain("truncate"); + }); + + it("speaks German too", () => { + const html = render(undefined, "de", converts); + expect(html).toContain("umgerechnet aus 5,6 mmol/L"); + }); + + it("shows nothing when the units agree", () => { + const html = render(undefined, "en", { ...converts, unit: "mg/dL" }); + expect(html).not.toContain("ocr-row-converted-badge"); + }); + + it("still says it is blocked when the analyte is not known", () => { + const html = render(undefined, "en", { + ...converts, + markerAnalyteKey: null, + }); + expect(html).toContain("Tracked in mg/dL; this report says mmol/L"); + expect(html).not.toContain("ocr-row-converted-badge"); + }); +}); diff --git a/src/components/labs/__tests__/source-range-note.test.tsx b/src/components/labs/__tests__/source-range-note.test.tsx new file mode 100644 index 000000000..e77072e45 --- /dev/null +++ b/src/components/labs/__tests__/source-range-note.test.tsx @@ -0,0 +1,73 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { I18nProvider } from "@/lib/i18n/context"; + +import { SourceRangeNote } from "../source-range-note"; + +type Reading = Parameters[0]["reading"]; + +function render(reading: Reading) { + return renderToStaticMarkup( + + + , + ); +} + +const STATED: Reading = { + unit: "mg/dL", + referenceLow: 70, + referenceHigh: 99, + catalogReferenceLow: 70, + catalogReferenceHigh: 99, + sourceReferenceText: "70 - 99", + printedReferenceText: null, + sourceUnit: null, + referenceOrigin: "source", + referenceDivergesFromCatalog: false, +}; + +// 5.6 mmol/L stored as 100.9 mg/dL, with the report's 3.9 to 5.5 mmol/L window +// converted alongside it. +const CONVERTED: Reading = { + unit: "mg/dL", + referenceLow: 70.27, + referenceHigh: 99.1, + catalogReferenceLow: 70, + catalogReferenceHigh: 100, + sourceReferenceText: null, + printedReferenceText: "3.9 - 5.5", + sourceUnit: "mmol/L", + referenceOrigin: "source", + referenceDivergesFromCatalog: true, +}; + +describe("", () => { + it("names a stated reading's window verbatim", () => { + const html = render(STATED); + expect(html).toContain("Report range 70 - 99"); + expect(html).not.toContain("as printed"); + }); + + it("states a converted reading's window in the marker's unit", () => { + const html = render(CONVERTED); + expect(html).toContain("Report range 70.27–99.1 mg/dL"); + }); + + it("keeps the printed text as evidence, labelled with the unit it is in", () => { + const html = render(CONVERTED); + expect(html).toContain("as printed in mmol/L: 3.9 - 5.5"); + }); + + it("never puts the printed numbers in the statement about the stored unit", () => { + const html = render(CONVERTED); + // The window in force is followed by the marker's unit, not the printed text. + expect(html).not.toContain("Report range 3.9 - 5.5"); + expect(html).toContain("your range 70–100 mg/dL"); + }); + + it("renders nothing when the window in force is not the report's", () => { + expect(render({ ...CONVERTED, referenceOrigin: "catalog" })).toBe(""); + }); +}); diff --git a/src/components/labs/lab-converted-note.tsx b/src/components/labs/lab-converted-note.tsx new file mode 100644 index 000000000..dfbdc9e85 --- /dev/null +++ b/src/components/labs/lab-converted-note.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useTranslations } from "@/lib/i18n/context"; +import { formatLabValue } from "@/lib/labs/format-value"; + +import { useLabNumber } from "./use-lab-format"; + +/** + * A quiet "converted from 5.6 mmol/L" beside a reading whose stored number is a + * conversion of what the report printed, so a number nobody typed can always be + * traced back. Renders nothing for a reading stored as stated. + */ +export function LabConvertedNote({ + reading, + className, +}: { + reading: { + converted: boolean; + sourceValue: number | null; + sourceUnit: string | null; + }; + className?: string; +}) { + const { t } = useTranslations(); + const labNumber = useLabNumber(); + if ( + !reading.converted || + reading.sourceValue === null || + reading.sourceUnit === null + ) { + return null; + } + return ( + + {t("labs.convertedFrom", { + value: formatLabValue(reading.sourceValue, labNumber), + unit: reading.sourceUnit, + })} + + ); +} diff --git a/src/components/labs/lab-history-list.tsx b/src/components/labs/lab-history-list.tsx index 1e5348cd9..109d32de6 100644 --- a/src/components/labs/lab-history-list.tsx +++ b/src/components/labs/lab-history-list.tsx @@ -30,7 +30,9 @@ import { useTableSort } from "@/hooks/use-table-sort"; import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch"; import { localizedApiError } from "@/lib/api/localized-error"; import { formatLabReading } from "@/lib/labs/format-value"; +import { LabConvertedNote } from "./lab-converted-note"; import { resolveNoteForUpdate } from "@/lib/labs/note-update"; +import { resolveValueForUpdate, valueEditSeed } from "@/lib/labs/value-update"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -176,7 +178,8 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) { const updateMutation = useMutation({ mutationFn: (body: { id: string; - // Exactly one of value / valueText is set, matching the row's type. + // At most one of value / valueText is set, matching the row's type; + // null leaves the field out of the PUT (an untouched value). value: number | null; valueText: string | null; takenAt: string; @@ -218,7 +221,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) { async function openEdit(reading: LabResultDto) { const seed = { - value: reading.value !== null ? String(reading.value) : "", + value: reading.value !== null ? valueEditSeed(reading.value) : "", valueText: reading.valueText ?? "", takenAt: toDateTimeLocal(reading.takenAt), note: "", @@ -274,11 +277,20 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) { } qualitativeValue = trimmed; } else { - numericValue = parseDecimal(editValue); - if (numericValue === null) { + const parsed = parseDecimal(editValue); + if (parsed === null) { setEditError(t("labs.form.requiredError")); return; } + // An untouched field is left out of the PUT (null here), so the stored + // full-precision value and a conversion record survive a date or note + // edit. + numericValue = + resolveValueForUpdate({ + seed: editSeed.value, + editorValue: editValue, + parsed, + }) ?? null; } const takenDate = new Date(editTakenAt); if (Number.isNaN(takenDate.getTime())) { @@ -381,6 +393,10 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
{formatLabReading(r, labNumber)} +
{formatLabReading(r, labNumber)} + {labDate(group.latest.takenAt)} +
); @@ -429,6 +431,7 @@ export function LabList({ {labDate(group.latest.takenAt)} + {group.readings.length > 1 ? ( {t("labs.readingsCount", { diff --git a/src/components/labs/ocr-review-types.ts b/src/components/labs/ocr-review-types.ts index 7def77782..9a167ff2f 100644 --- a/src/components/labs/ocr-review-types.ts +++ b/src/components/labs/ocr-review-types.ts @@ -7,7 +7,7 @@ */ import type { OcrExtractedRowDto } from "@/lib/validations/labs-ocr"; -import { readingUnitDiffers } from "./ocr-review-validation"; +import { readingUnitBlocked } from "./ocr-review-validation"; export interface OcrReviewRow { /** Stable client key for the list (the analyte+index at seed time). */ @@ -26,9 +26,12 @@ export interface OcrReviewRow { biomarkerMatch: "new" | "existing"; /** * The unit the matched marker is tracked in; null for a marker that does not - * exist yet. A numeric row whose `unit` differs from it cannot be saved. + * exist yet. A numeric row whose `unit` differs from it is converted on + * Save when it can be (see `readingConversion`) and cannot be saved when not. */ markerUnit: string | null; + /** Which analyte the matched marker is (catalog slug or registry-only key), or null. */ + markerAnalyteKey: string | null; /** Non-null when the server flagged a likely duplicate of a live reading. */ duplicateOf: string | null; /** Whether the row is selected for saving. Defaults false for duplicates. */ @@ -53,17 +56,21 @@ export function seedReviewRows( confidence: row.confidence, biomarkerMatch: row.biomarkerMatch, markerUnit: row.markerUnit, + markerAnalyteKey: row.markerAnalyteKey, duplicateOf: row.duplicateOf, // A flagged duplicate starts unchecked, and so does a reading in another - // unit than its marker's (it carries a badge saying why, and ticking it - // marks the unit field until it is fixed); everything else starts - // confirmed. + // unit than its marker's that cannot be converted (it carries a badge + // saying why, and ticking it marks the unit field until it is fixed); a + // reading that converts starts confirmed, with a badge saying what Save + // will store; everything else starts confirmed. confirmed: row.duplicateOf === null && - !readingUnitDiffers({ + !readingUnitBlocked({ + value: row.value, valueText: row.valueText, unit: row.unit, markerUnit: row.markerUnit, + markerAnalyteKey: row.markerAnalyteKey, }), })); } diff --git a/src/components/labs/ocr-review-validation.ts b/src/components/labs/ocr-review-validation.ts index a2ab7fea2..339fbe992 100644 --- a/src/components/labs/ocr-review-validation.ts +++ b/src/components/labs/ocr-review-validation.ts @@ -7,6 +7,7 @@ * build the payload: it returns null exactly when `validateReviewRow` reports * at least one field. */ +import { convertLabValue } from "@/lib/labs/unit-conversion/convert"; import { sameLabUnit } from "@/lib/labs/unit-normalise"; import type { OcrReviewRow } from "./ocr-review-types"; @@ -46,6 +47,60 @@ export function readingUnitDiffers(row: { return !sameLabUnit(stated, row.markerUnit); } +/** + * What Save will store for a numeric row whose unit differs from its marker's + * and can be converted: the value in the marker's unit, beside what the report + * stated. Null when the units agree, the row is qualitative, or the pair has no + * conversion. The commit does the same conversion on the server; this only lets + * the screen say so before Save. + */ +export function readingConversion(row: { + value: number | null | undefined; + valueText: string | null | undefined; + unit: string | null | undefined; + markerUnit: string | null | undefined; + markerAnalyteKey: string | null | undefined; +}): { + value: number; + unit: string; + statedValue: number; + statedUnit: string; +} | null { + if (!readingUnitDiffers(row)) return null; + if (row.value === null || row.value === undefined) return null; + if (!Number.isFinite(row.value)) return null; + const statedUnit = (row.unit ?? "").trim(); + const converted = convertLabValue({ + analyteKey: row.markerAnalyteKey, + value: row.value, + from: statedUnit, + to: row.markerUnit as string, + }); + if (!converted.ok) return null; + return { + value: converted.value, + unit: row.markerUnit as string, + statedValue: row.value, + statedUnit, + }; +} + +/** + * A numeric reading in another unit than its marker's that cannot be + * converted: the one case that still cannot be saved. A row with no number yet + * has nothing to convert, so it is blocked here as well as on `value`. + */ +export function readingUnitBlocked(row: { + value: number | null | undefined; + valueText: string | null | undefined; + unit: string | null | undefined; + markerUnit: string | null | undefined; + markerAnalyteKey: string | null | undefined; +}): boolean { + if (!readingUnitDiffers(row)) return false; + return readingConversion(row) === null; +} + export function validateReviewRow(row: OcrReviewRow): OcrRowErrors { const errors: OcrRowErrors = {}; if (!row.analyte.trim()) errors.analyte = true; @@ -56,7 +111,7 @@ export function validateReviewRow(row: OcrReviewRow): OcrRowErrors { } else { if (row.value === null || !Number.isFinite(row.value)) errors.value = true; if (!(row.unit ?? "").trim()) errors.unit = true; - if (readingUnitDiffers(row)) errors.unitMismatch = true; + if (readingUnitBlocked(row)) errors.unitMismatch = true; } return errors; } @@ -80,7 +135,7 @@ export function toCommitRow(row: OcrReviewRow): OcrCommitRowInput | null { if (row.value === null || !Number.isFinite(row.value)) return null; const unit = (row.unit ?? "").trim(); if (!unit) return null; - if (readingUnitDiffers(row)) return null; + if (readingUnitBlocked(row)) return null; return { analyte, value: row.value, diff --git a/src/components/labs/ocr-row-editor.tsx b/src/components/labs/ocr-row-editor.tsx index b103f09f7..221906e5f 100644 --- a/src/components/labs/ocr-row-editor.tsx +++ b/src/components/labs/ocr-row-editor.tsx @@ -32,7 +32,12 @@ import { Label } from "@/components/ui/label"; import { useTranslations } from "@/lib/i18n/context"; import type { OcrReviewRow } from "./ocr-review-types"; -import { readingUnitDiffers, type OcrRowErrors } from "./ocr-review-validation"; +import { + readingConversion, + readingUnitBlocked, + type OcrRowErrors, +} from "./ocr-review-validation"; +import { useLabNumber } from "./use-lab-format"; /** Below this per-field confidence the field is flagged for a second look. */ const CONFIDENCE_THRESHOLD = 0.6; @@ -76,10 +81,13 @@ export function OcrRowEditor({ message={t(messageKey, params)} /> ) : null; - // Stated before Save, not only after a failed attempt: a reading in another - // unit than its marker's cannot be written, and the person should see why - // the row is unticked (or will not save) while they are still reading it. - const unitDiffers = readingUnitDiffers(row); + // Stated before Save, not only after a failed attempt. A reading in another + // unit than its marker's is either converted (the badge says what Save will + // store) or cannot be written (the person should see why the row is unticked, + // or will not save, while they are still reading it). + const unitBlocked = readingUnitBlocked(row); + const conversion = readingConversion(row); + const labNumber = useLabNumber(); const isQualitative = row.valueText !== null && row.valueText !== undefined; const lowValueConfidence = @@ -169,7 +177,7 @@ export function OcrRowEditor({ {t("labs.ocr.duplicateWarning")} ) : null} - {unitDiffers ? ( + {unitBlocked ? ( @@ -180,6 +188,25 @@ export function OcrRowEditor({ ) : null} + {conversion ? ( + + + + {t("labs.ocr.unitConvertedBadge", { + value: labNumber(conversion.value), + markerUnit: conversion.unit, + statedValue: labNumber(conversion.statedValue), + unit: conversion.statedUnit, + })} + + + ) : null} {lowRowConfidence ? ( diff --git a/src/components/labs/source-range-note.tsx b/src/components/labs/source-range-note.tsx index d9b4bcb87..d5e3dde2c 100644 --- a/src/components/labs/source-range-note.tsx +++ b/src/components/labs/source-range-note.tsx @@ -18,6 +18,11 @@ import { useLabNumber } from "./use-lab-format"; * the same number, so a surface that shows one silently is showing a partial * answer. * + * A reading converted into its marker's unit carries bounds that were converted + * with it, but the report's own text is still in the unit it printed. The note + * then states the window in the marker's unit and adds the printed text as + * evidence, labelled with its unit, so no single statement mixes two units. + * * Neutral by construction: no tint, no icon, muted text. The in/out verdict is * the badge's job — this element states where the window came from, nothing * about whether the value is good news. @@ -34,6 +39,8 @@ export function SourceRangeNote({ | "catalogReferenceLow" | "catalogReferenceHigh" | "sourceReferenceText" + | "printedReferenceText" + | "sourceUnit" | "referenceOrigin" | "referenceDivergesFromCatalog" >; @@ -43,7 +50,9 @@ export function SourceRangeNote({ const labNumber = useLabNumber(); if (reading.referenceOrigin !== "source") return null; - const printed = + // `sourceReferenceText` is null for a converted reading, so the window is + // then stated from the converted bounds, in the marker's unit. + const inForce = reading.sourceReferenceText ?? `${formatReferenceRange( reading.referenceLow, @@ -59,7 +68,13 @@ export function SourceRangeNote({ return ( - {t("labs.sourceRange.fromReport", { range: printed })} + {t("labs.sourceRange.fromReport", { range: inForce })} + {reading.printedReferenceText && reading.sourceUnit + ? ` · ${t("labs.sourceRange.printed", { + range: reading.printedReferenceText, + unit: reading.sourceUnit, + })}` + : null} {reading.referenceDivergesFromCatalog && catalog ? ` · ${t("labs.sourceRange.catalogDiffers", { range: `${catalog} ${reading.unit}`.trim(), diff --git a/src/components/labs/types.ts b/src/components/labs/types.ts index 2f7eae4a4..90e7f60fb 100644 --- a/src/components/labs/types.ts +++ b/src/components/labs/types.ts @@ -15,6 +15,14 @@ export interface LabResultDto { /** v1.18.9 — qualitative result text ("negativ" / …); null for a numeric row. */ valueText: string | null; unit: string; + /** + * True when the stored number was converted into the marker's unit from + * another one; `sourceValue` and `sourceUnit` are then what the report + * printed. A reading stored as stated has `converted: false` and two nulls. + */ + converted: boolean; + sourceValue: number | null; + sourceUnit: string | null; /** * The window this reading is judged against and the one `rangeStatus` was * computed from: the range its own report printed when it carries one, the @@ -30,6 +38,11 @@ export interface LabResultDto { sourceReferenceLow: number | null; sourceReferenceHigh: number | null; sourceReferenceText: string | null; + /** + * A converted reading's printed range text, verbatim and in `sourceUnit`; + * `sourceReferenceText` is then null. Null for every other reading. + */ + printedReferenceText: string | null; /** Which window is in force. */ referenceOrigin: ReferenceRangeOrigin; /** True when the report's window and the catalog band state different limits. */ @@ -60,6 +73,11 @@ export interface BiomarkerDto { lowerBound: number | null; upperBound: number | null; panel: string | null; + /** + * Which analyte the marker is, for converting a reading in another unit + * (the catalog slug, e.g. `ldl`, or a registry-only key such as `urea`), or null when it is none that is known. + */ + analyteKey: string | null; hasContext: boolean; context: string | null; /** v1.22 — a hidden marker drops from the active list + lab-entry pickers. */ diff --git a/src/lib/ai/coach/labs-snapshot.ts b/src/lib/ai/coach/labs-snapshot.ts index 90b9105ad..119c299e0 100644 --- a/src/lib/ai/coach/labs-snapshot.ts +++ b/src/lib/ai/coach/labs-snapshot.ts @@ -143,6 +143,7 @@ export async function buildLabsSnapshotBlock( sourceReferenceLow: true, sourceReferenceHigh: true, sourceReferenceText: true, + sourceUnit: true, takenAt: true, biomarkerId: true, biomarker: { diff --git a/src/lib/doctor-report/clinical-records.ts b/src/lib/doctor-report/clinical-records.ts index b22921a3f..423d42f19 100644 --- a/src/lib/doctor-report/clinical-records.ts +++ b/src/lib/doctor-report/clinical-records.ts @@ -56,6 +56,7 @@ export async function loadLabResults( sourceReferenceLow: true, sourceReferenceHigh: true, sourceReferenceText: true, + sourceUnit: true, takenAt: true, }, }); diff --git a/src/lib/documents/__tests__/commit.test.ts b/src/lib/documents/__tests__/commit.test.ts index 5563913eb..591ebae47 100644 --- a/src/lib/documents/__tests__/commit.test.ts +++ b/src/lib/documents/__tests__/commit.test.ts @@ -44,22 +44,25 @@ beforeEach(() => { describe("commitApprovedFact — OBSERVATION unit integrity", () => { it("rejects a numeric observation whose stated unit differs from an existing marker's unit", async () => { + // A pair the registry has no conversion for: ferritin in pmol/L against a + // marker stored in ng/mL (protein molecular weights vary by source). vi.mocked(decryptFactData).mockReturnValue({ - label: "Glucose", - value: 6.1, - unit: "mmol/L", + label: "Ferritin", + value: 450, + unit: "pmol/L", referenceLow: null, referenceHigh: null, effectiveDate: "2025-01-01", } as never); - // Existing marker stored in mg/dL — the resolver returns it as-is. + // Existing marker stored in ng/mL — the resolver returns it as-is. vi.mocked(resolveOrMintBiomarker).mockResolvedValue({ id: "bm-1", - name: "Glucose", - unit: "mg/dL", - lowerBound: 70, - upperBound: 100, + name: "Ferritin", + unit: "ng/mL", + lowerBound: 30, + upperBound: 400, panel: null, + analyteKey: null, }); await expect( @@ -71,6 +74,69 @@ describe("commitApprovedFact — OBSERVATION unit integrity", () => { expect(prisma.labResult.create).not.toHaveBeenCalled(); }); + it("converts a unit the marker's analyte can reach, keeping what the document stated", async () => { + vi.mocked(decryptFactData).mockReturnValue({ + label: "Glucose", + value: 6.1, + unit: "mmol/L", + referenceLow: 3.9, + referenceHigh: 5.6, + effectiveDate: "2025-01-01", + } as never); + vi.mocked(resolveOrMintBiomarker).mockResolvedValue({ + id: "bm-1", + name: "Glucose", + unit: "mg/dL", + lowerBound: 70, + upperBound: 100, + panel: null, + analyteKey: "fasting-glucose", + }); + vi.mocked(prisma.labResult.create).mockResolvedValue({ + id: "lr-conv", + } as never); + + const ref = await commitApprovedFact("user-1", observationFact()); + expect(ref).toEqual({ recordType: "labResult", recordId: "lr-conv" }); + const data = vi.mocked(prisma.labResult.create).mock.calls[0]![0].data; + expect(data.value).toBeCloseTo(6.1 * 18.0182, 9); + expect(data.unit).toBe("mg/dL"); + expect(data.sourceValue).toBe(6.1); + expect(data.sourceUnit).toBe("mmol/L"); + // The report's own window moved with the value. + expect(data.sourceReferenceLow).toBeCloseTo(3.9 * 18.0182, 9); + expect(data.sourceReferenceHigh).toBeCloseTo(5.6 * 18.0182, 9); + }); + + it("stores a reading in the marker's own unit without a conversion record", async () => { + vi.mocked(decryptFactData).mockReturnValue({ + label: "Glucose", + value: 95, + unit: "mg/dL", + referenceLow: null, + referenceHigh: null, + effectiveDate: "2025-01-01", + } as never); + vi.mocked(resolveOrMintBiomarker).mockResolvedValue({ + id: "bm-1", + name: "Glucose", + unit: "mg/dL", + lowerBound: null, + upperBound: null, + panel: null, + analyteKey: "fasting-glucose", + }); + vi.mocked(prisma.labResult.create).mockResolvedValue({ + id: "lr-same", + } as never); + + await commitApprovedFact("user-1", observationFact()); + const data = vi.mocked(prisma.labResult.create).mock.calls[0]![0].data; + expect(data.value).toBe(95); + expect(data.sourceValue).toBeNull(); + expect(data.sourceUnit).toBeNull(); + }); + it("commits when the stated unit is the marker's unit spelled another way", async () => { vi.mocked(decryptFactData).mockReturnValue({ label: "Glucose", @@ -87,6 +153,7 @@ describe("commitApprovedFact — OBSERVATION unit integrity", () => { lowerBound: 70, upperBound: 100, panel: null, + analyteKey: null, }); vi.mocked(prisma.labResult.create).mockResolvedValue({ id: "lr-1", @@ -124,6 +191,7 @@ describe("commitApprovedFact — OBSERVATION unit integrity", () => { lowerBound: null, upperBound: null, panel: null, + analyteKey: null, }); vi.mocked(prisma.labResult.create).mockResolvedValue({ id: "lr-2", diff --git a/src/lib/documents/commit.ts b/src/lib/documents/commit.ts index 4645acc0f..a29b4ddc6 100644 --- a/src/lib/documents/commit.ts +++ b/src/lib/documents/commit.ts @@ -28,7 +28,7 @@ import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; import { emitDataArrival } from "@/lib/arrivals/emit-shared"; import { decryptFactData } from "@/lib/documents/store"; import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store"; -import { sameLabUnit } from "@/lib/labs/unit-normalise"; +import { readingInMarkerUnit } from "@/lib/labs/reading-conversion"; import { isUnreadableRange, parseReferenceRange, @@ -128,22 +128,45 @@ async function commitObservation( referenceHigh: isQualitative ? null : sourceHigh, panel: null, }); - // Fail closed on a unit mismatch against an EXISTING marker. A freshly - // minted marker adopts the document's unit, so this only bites when the - // stated unit disagrees with the catalog's authoritative one (e.g. a - // document states "6.1 mmol/L" against a marker stored in mg/dL). Writing - // the stated number under the catalog's unit would corrupt the reading AND - // break the "transcribe verbatim" contract — so reject the fact and let the - // user reconcile on the review screen, exactly like the `unitRequired` gate. + // A unit that differs from an EXISTING marker's is converted when the + // marker's analyte is known and the registry has the pair (a document states + // "6.1 mmol/L" against a marker stored in mg/dL), keeping what the document + // stated beside the stored number. Otherwise it fails closed: writing the + // stated number under the catalog's unit would corrupt the reading AND break + // the "transcribe verbatim" contract — so reject the fact and let the user + // reconcile on the review screen, exactly like the `unitRequired` gate. A + // freshly minted marker adopts the document's unit, so it cannot disagree. // Spelling is compared through the shared normaliser, the same one the // manual and scan paths use: `ug/L` is `µg/L`, but `MIU/L` is not `mIU/L`. + let storedValue: number | null = isQualitative + ? null + : (data.value as number); + let storedSourceLow = isQualitative ? null : sourceLow; + let storedSourceHigh = isQualitative ? null : sourceHigh; + let convertedFrom: { sourceValue: number; sourceUnit: string } | null = null; if (!isQualitative) { - if (!sameLabUnit(data.unit as string, biomarker.unit)) { + const reading = await readingInMarkerUnit(prisma, { + marker: biomarker, + value: data.value as number, + unit: data.unit as string, + sourceReferenceLow: storedSourceLow, + sourceReferenceHigh: storedSourceHigh, + }); + if (!reading.ok) { throw new FactCommitError( "observation.unitMismatch", `The stated unit (${(data.unit as string).trim()}) does not match the saved unit for ${biomarker.name} (${biomarker.unit || "none"}). Reconcile the unit before saving.`, ); } + storedValue = reading.value; + storedSourceLow = reading.sourceReferenceLow; + storedSourceHigh = reading.sourceReferenceHigh; + if (reading.converted) { + convertedFrom = { + sourceValue: reading.sourceValue, + sourceUnit: reading.sourceUnit, + }; + } } const created = await prisma.labResult.create({ data: { @@ -151,15 +174,17 @@ async function commitObservation( biomarkerId: biomarker.id, panel: biomarker.panel, analyte: biomarker.name, - value: isQualitative ? null : (data.value as number), + value: storedValue, valueText: isQualitative ? data.valueText : null, unit: biomarker.unit, + sourceValue: convertedFrom?.sourceValue ?? null, + sourceUnit: convertedFrom?.sourceUnit ?? null, referenceLow: biomarker.lowerBound, referenceHigh: biomarker.upperBound, // The report's own window for THIS reading. A qualitative result has no // window to record. - sourceReferenceLow: isQualitative ? null : sourceLow, - sourceReferenceHigh: isQualitative ? null : sourceHigh, + sourceReferenceLow: storedSourceLow, + sourceReferenceHigh: storedSourceHigh, sourceReferenceText: isQualitative ? null : sourceText, takenAt: statedDateOrNow(data.effectiveDate), source: "DOCUMENT", diff --git a/src/lib/fhir/__tests__/lab-observations.test.ts b/src/lib/fhir/__tests__/lab-observations.test.ts index 8ad312ffc..049958c06 100644 --- a/src/lib/fhir/__tests__/lab-observations.test.ts +++ b/src/lib/fhir/__tests__/lab-observations.test.ts @@ -165,6 +165,47 @@ describe("lab-result FHIR Observations", () => { expect(crp?.referenceRange).toBeUndefined(); }); + it("states a converted reading's window only in the value's unit", () => { + // A reading converted from mmol/L: the resolved entry carries the bounds + // in mg/dL and no text (the printed text is in mmol/L and would disagree + // with low and high), so the element carries the two bounds alone. + const bundle = buildFhirDocumentBundle( + makeData({ + labResults: [ + { + panel: null, + analyte: "Glucose", + value: 100.9, + valueText: null, + unit: "mg/dL", + referenceLow: 70.27, + referenceHigh: 99.1, + catalogReferenceLow: 70, + catalogReferenceHigh: 100, + sourceReferenceText: null, + referenceOrigin: "source" as const, + referenceDivergesFromCatalog: true, + takenAt: "2026-04-10T08:00:00.000Z", + count: 1, + }, + ], + }), + { insuranceNumber: "A123456780" }, + FIXED_NOW, + ); + const [obs] = observationsOf(bundle).filter((o) => + o.category?.some((c) => + c.coding?.some((code) => code.code === "laboratory"), + ), + ); + expect(obs.referenceRange).toEqual([ + { + low: expect.objectContaining({ value: 70.27, unit: "mg/dL" }), + high: expect.objectContaining({ value: 99.1, unit: "mg/dL" }), + }, + ]); + }); + it("emits no lab Observations when labResults is null", () => { const bundle = buildFhirDocumentBundle( makeData({ labResults: null }), diff --git a/src/lib/insights/__tests__/labs-changes.test.ts b/src/lib/insights/__tests__/labs-changes.test.ts index 0c3d8085c..71d50807a 100644 --- a/src/lib/insights/__tests__/labs-changes.test.ts +++ b/src/lib/insights/__tests__/labs-changes.test.ts @@ -37,6 +37,7 @@ function row( sourceReferenceLow: opts.sourceLow ?? null, sourceReferenceHigh: opts.sourceHigh ?? null, sourceReferenceText: opts.sourceText ?? null, + sourceUnit: null, takenAt: new Date(`${day}T08:00:00.000Z`), }; } diff --git a/src/lib/insights/biomarker-status.ts b/src/lib/insights/biomarker-status.ts index 58ba09cf4..20b9e1887 100644 --- a/src/lib/insights/biomarker-status.ts +++ b/src/lib/insights/biomarker-status.ts @@ -172,6 +172,7 @@ export async function generateBiomarkerStatus(args: { sourceReferenceLow: true, sourceReferenceHigh: true, sourceReferenceText: true, + sourceUnit: true, }, }); if (rows.length === 0) { diff --git a/src/lib/insights/correlation-channel-series.ts b/src/lib/insights/correlation-channel-series.ts index df3795b3d..a0f6d8fce 100644 --- a/src/lib/insights/correlation-channel-series.ts +++ b/src/lib/insights/correlation-channel-series.ts @@ -1072,6 +1072,7 @@ export async function fetchLabDraws( sourceReferenceLow: true, sourceReferenceHigh: true, sourceReferenceText: true, + sourceUnit: true, panel: true, value: true, takenAt: true, diff --git a/src/lib/insights/feature-blocks.ts b/src/lib/insights/feature-blocks.ts index f5f8ef356..68837ef9d 100644 --- a/src/lib/insights/feature-blocks.ts +++ b/src/lib/insights/feature-blocks.ts @@ -256,6 +256,7 @@ export async function readLabsBriefingBlock( sourceReferenceLow: true, sourceReferenceHigh: true, sourceReferenceText: true, + sourceUnit: true, takenAt: true, biomarkerId: true, biomarker: { diff --git a/src/lib/jobs/lab-biomarker-backfill.ts b/src/lib/jobs/lab-biomarker-backfill.ts index 61ffd162f..8481adae3 100644 --- a/src/lib/jobs/lab-biomarker-backfill.ts +++ b/src/lib/jobs/lab-biomarker-backfill.ts @@ -28,6 +28,7 @@ import { prisma } from "@/lib/db"; import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { integrationBackfillSourceOptions } from "@/lib/jobs/integration-backfill-admission"; +import { matchAnalyteKey } from "@/lib/labs/analyte-match"; import { annotate } from "@/lib/logging/context"; export const LAB_BIOMARKER_BACKFILL_QUEUE = "lab-biomarker-backfill"; @@ -161,6 +162,7 @@ export async function runLabBiomarkerBackfillForUser( lowerBound: group.referenceLow, upperBound: group.referenceHigh, panel: group.panel, + analyteKey: matchAnalyteKey({ name: group.name, unit: group.unit }), }, }); markers += 1; diff --git a/src/lib/labs/__tests__/analyte-match.test.ts b/src/lib/labs/__tests__/analyte-match.test.ts new file mode 100644 index 000000000..56668108d --- /dev/null +++ b/src/lib/labs/__tests__/analyte-match.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from "vitest"; + +import { foldForMatch } from "@/lib/i18n/fold-for-match"; + +import { matchAnalyteKey, analyteNameIndex } from "../analyte-match"; +import { ANALYTE_ALIASES, BIOMARKER_CATALOG } from "../biomarker-catalog"; +import { analyteAcceptsUnit } from "../unit-conversion/convert"; +import { + allAnalyteConversions, + analyteConversionFor, +} from "../unit-conversion/registry"; + +const match = (name: string, unit: string) => matchAnalyteKey({ name, unit }); + +describe("the catalog's own names, in every language", () => { + it.each([ + ["Fasting glucose", "mg/dL", "fasting-glucose"], + ["Nüchternglukose", "mg/dL", "fasting-glucose"], + ["Glucosa en ayunas", "mg/dL", "fasting-glucose"], + ["Glycémie à jeun", "mmol/L", "fasting-glucose"], + ["Glicemia a digiuno", "mg/dL", "fasting-glucose"], + ["공복혈당", "mg/dL", "fasting-glucose"], + ["Glukoza na czczo", "mg/dL", "fasting-glucose"], + ["HbA1c", "%", "hba1c"], + ["Total cholesterol", "mg/dL", "total-cholesterol"], + ["Gesamtcholesterin", "mg/dL", "total-cholesterol"], + ["Cholestérol total", "mmol/L", "total-cholesterol"], + ["LDL cholesterol", "mg/dL", "ldl"], + ["LDL-Cholesterin", "mg/dL", "ldl"], + ["Colesterolo HDL", "mg/dL", "hdl"], + ["Vitamin D (25-OH)", "ng/mL", "vitamin-d"], + ["Créatinine", "mg/dL", "creatinine"], + ["Kreatynina", "µmol/L", "creatinine"], + ])("%s (%s) is %s", (name, unit, key) => { + // The key is the catalog slug the name belongs to, so LDL and HDL keep + // their own identity even though they convert with the cholesterol entry. + expect(match(name, unit)).toBe(key); + }); + + it("resolves an LDL or HDL key to the cholesterol entry for the conversion", () => { + expect(match("LDL cholesterol", "mg/dL")).toBe("ldl"); + expect(analyteConversionFor("ldl")?.key).toBe("total-cholesterol"); + expect(analyteConversionFor("hdl")?.key).toBe("total-cholesterol"); + expect(analyteAcceptsUnit("ldl", "mmol/L")).toBe(true); + }); + + it("recognises a seed typed without the app's gloss", () => { + expect(match("Vitamin D", "ng/mL")).toBe("vitamin-d"); + }); +}); + +describe("typed names", () => { + it.each([ + ["Glucose", "mg/dL", "fasting-glucose"], + ["Blutzucker", "mg/dL", "fasting-glucose"], + ["blood sugar", "mmol/L", "fasting-glucose"], + ["Cholesterin", "mg/dL", "total-cholesterol"], + ["Serum Creatinine", "mg/dL", "creatinine"], + ["Urea", "mmol/L", "urea"], + ["Harnstoff", "mg/dL", "urea"], + ["BUN", "mg/dL", "blood-urea-nitrogen"], + ["Blood Urea Nitrogen", "mg/dL", "blood-urea-nitrogen"], + ["Harnsäure", "µmol/L", "uric-acid"], + ["Uric acid", "mg/dL", "uric-acid"], + ["Kalzium", "mmol/L", "calcium-total"], + ["Calcium", "mg/dL", "calcium-total"], + ["Gesamtbilirubin", "µmol/L", "bilirubin-total"], + ["Bilirubin total", "mg/dL", "bilirubin-total"], + ])("%s (%s) is %s", (name, unit, key) => { + expect(match(name, unit)).toBe(key); + }); + + it("folds case, accents, hyphens and spacing the same on both sides", () => { + expect(match(" HARNSÄURE ", "mg/dL")).toBe("uric-acid"); + expect(match("ldl-c", "mg/dL")).toBe("ldl"); + expect(match("blood urea-nitrogen", "mg/dL")).toBe("blood-urea-nitrogen"); + }); +}); + +describe("a name that is not the whole analyte name stays unlinked", () => { + it.each([ + ["Calcium (ionisiert)", "mmol/L"], + ["Ionised calcium", "mmol/L"], + ["Calcium ionisiert", "mmol/L"], + ["Urea nitrogen ratio", "mg/dL"], + ["Glucose (urine)", "mg/dL"], + ["Glucose tolerance", "mg/dL"], + ["Non-HDL cholesterol", "mg/dL"], + ["Cholesterol ratio", "mg/dL"], + ["Direct bilirubin", "mg/dL"], + ["Free thyroxine", "pmol/L"], + ["", "mg/dL"], + ])("%s", (name, unit) => { + expect(match(name, unit)).toBeNull(); + }); + + it("keeps urea and blood urea nitrogen apart", () => { + expect(match("Urea", "mmol/L")).toBe("urea"); + expect(match("Urea nitrogen", "mg/dL")).toBe("blood-urea-nitrogen"); + }); +}); + +describe("the marker's unit must be one the analyte reaches", () => { + it("links a unit the entry lists and one that differs by a power of ten", () => { + expect(match("Glucose", "mg/dL")).toBe("fasting-glucose"); + expect(match("Glucose", "mmol/L")).toBe("fasting-glucose"); + expect(match("Glucose", "g/L")).toBe("fasting-glucose"); + expect(match("Creatinine", "mmol/L")).toBe("creatinine"); + }); + + it("leaves a unit the registry cannot reach unlinked", () => { + expect(match("Glucose", "mEq/L")).toBeNull(); + expect(match("Glucose", "%")).toBeNull(); + expect(match("HbA1c", "mg/dL")).toBeNull(); + expect(match("Calcium", "furlongs")).toBeNull(); + }); + + it("reads a spelling of the unit through the normaliser", () => { + expect(match("Glucose", "MG/DL")).toBe("fasting-glucose"); + expect(match("Creatinine", "umol/l")).toBe("creatinine"); + }); +}); + +describe("the alias list, as a guard", () => { + it("names only analytes the registry knows", () => { + for (const { key } of ANALYTE_ALIASES) { + expect(analyteConversionFor(key), key).not.toBeNull(); + } + }); + + it("writes every name already folded, so what is read is what is matched", () => { + for (const { key, names } of ANALYTE_ALIASES) { + for (const name of names) { + expect(foldForMatch(name), `${key}: ${name}`).toBe(name); + } + } + }); + + it("has no name that two analytes claim", () => { + expect(analyteNameIndex().ambiguous).toEqual([]); + }); + + it("gives every registry entry at least one name to be found by", () => { + const { index } = analyteNameIndex(); + const reached = new Set(index.values()); + for (const entry of allAnalyteConversions()) { + expect(reached.has(entry.key), entry.key).toBe(true); + } + }); + + it("covers every catalog seed the registry knows, in all seven languages", () => { + const { index } = analyteNameIndex(); + const seeds = BIOMARKER_CATALOG.filter((seed) => + analyteConversionFor(seed.slug), + ); + expect(seeds.length).toBeGreaterThanOrEqual(6); + for (const seed of seeds) { + const key = analyteConversionFor(seed.slug)!.key; + const names = [...index.entries()].filter(([, k]) => k === key); + expect(names.length, seed.slug).toBeGreaterThan(0); + } + }); + + it("links each seed at its own catalog unit", () => { + for (const seed of BIOMARKER_CATALOG) { + const entry = analyteConversionFor(seed.slug); + if (!entry) continue; + expect(analyteAcceptsUnit(entry.key, seed.unit), seed.slug).toBe(true); + } + }); +}); diff --git a/src/lib/labs/__tests__/effective-reference-range.test.ts b/src/lib/labs/__tests__/effective-reference-range.test.ts index 92dc9721b..97bcd4a8b 100644 --- a/src/lib/labs/__tests__/effective-reference-range.test.ts +++ b/src/lib/labs/__tests__/effective-reference-range.test.ts @@ -9,6 +9,7 @@ const NO_SOURCE = { sourceReferenceLow: null, sourceReferenceHigh: null, sourceReferenceText: null, + sourceUnit: null, }; describe("resolveEffectiveReferenceRange", () => { @@ -36,6 +37,7 @@ describe("resolveEffectiveReferenceRange", () => { sourceReferenceLow: 3.9, sourceReferenceHigh: 5.4, sourceReferenceText: "3,9 - 5,4", + sourceUnit: null, }); expect(eff).toMatchObject({ low: 3.9, @@ -53,6 +55,7 @@ describe("resolveEffectiveReferenceRange", () => { sourceReferenceLow: 3.5, sourceReferenceHigh: 5, sourceReferenceText: "3,5 - 5,0", + sourceUnit: null, }); expect(eff.origin).toBe("source"); expect(eff.divergesFromCatalog).toBe(false); @@ -65,6 +68,7 @@ describe("resolveEffectiveReferenceRange", () => { sourceReferenceLow: null, sourceReferenceHigh: 4.2, sourceReferenceText: "< 4,2", + sourceUnit: null, }); expect(eff).toMatchObject({ low: null, @@ -81,6 +85,7 @@ describe("resolveEffectiveReferenceRange", () => { sourceReferenceLow: null, sourceReferenceHigh: null, sourceReferenceText: "negativ", + sourceUnit: null, }); expect(eff).toMatchObject({ low: 3.5, @@ -96,12 +101,57 @@ describe("resolveEffectiveReferenceRange", () => { sourceReferenceLow: 3.9, sourceReferenceHigh: 5.4, sourceReferenceText: "3,9 - 5,4", + sourceUnit: null, }); expect(eff.origin).toBe("source"); expect(eff.divergesFromCatalog).toBe(false); }); }); +describe("a converted reading's printed text", () => { + // 5.6 mmol/L stored as mg/dL, with the printed 3.9 to 5.5 mmol/L window + // converted alongside; the text stays as the report printed it. + const CONVERTED = { + sourceReferenceLow: 3.9 * 18.0182, + sourceReferenceHigh: 5.5 * 18.0182, + sourceReferenceText: "3,9 - 5,5", + sourceUnit: "mmol/L", + }; + + it("is never the window's text, so no statement mixes two units", () => { + const eff = resolveEffectiveReferenceRange(70, 100, CONVERTED); + expect(eff.origin).toBe("source"); + expect(eff.low).toBeCloseTo(70.27, 2); + expect(eff.sourceText).toBeNull(); + }); + + it("is kept as evidence with the unit it is written in", () => { + const eff = resolveEffectiveReferenceRange(70, 100, CONVERTED); + expect(eff.printed).toEqual({ text: "3,9 - 5,5", unit: "mmol/L" }); + }); + + it("stays the window's text for a reading stored as stated", () => { + const eff = resolveEffectiveReferenceRange(70, 100, { + ...CONVERTED, + sourceUnit: null, + }); + expect(eff.sourceText).toBe("3,9 - 5,5"); + expect(eff.printed).toBeNull(); + }); + + it("moves an unreadable printed text the same way", () => { + const eff = resolveEffectiveReferenceRange(70, 100, { + sourceReferenceLow: null, + sourceReferenceHigh: null, + sourceReferenceText: "siehe Befund", + sourceUnit: "mmol/L", + }); + expect(eff.origin).toBe("catalog"); + expect(eff.sourceText).toBeNull(); + expect(eff.printed).toEqual({ text: "siehe Befund", unit: "mmol/L" }); + }); +}); + describe("classifyAgainstEffectiveRange", () => { it("judges a reading by the source window, not the catalog band", () => { // 5.2 sits inside the catalog band (3.5–5.0 would call it high) but the @@ -110,6 +160,7 @@ describe("classifyAgainstEffectiveRange", () => { sourceReferenceLow: 3.9, sourceReferenceHigh: 5.4, sourceReferenceText: "3,9 - 5,4", + sourceUnit: null, }); expect(classifyAgainstEffectiveRange(5.2, eff)).toBe("in-range"); expect(classifyAgainstEffectiveRange(3.7, eff)).toBe("below"); diff --git a/src/lib/labs/__tests__/reading-conversion.test.ts b/src/lib/labs/__tests__/reading-conversion.test.ts new file mode 100644 index 000000000..1c8823b30 --- /dev/null +++ b/src/lib/labs/__tests__/reading-conversion.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it, vi } from "vitest"; + +import { readingInMarkerUnit, type ReadingMarker } from "../reading-conversion"; + +/** A client whose `updateMany` records what the fill wrote. */ +function client(current: string | null = null) { + return { + biomarker: { + updateMany: vi.fn(async () => ({ count: 1 })), + findUnique: vi.fn(async () => ({ analyteKey: current })), + }, + } as never; +} + +const glucose: ReadingMarker = { + id: "bm-1", + name: "Glucose", + unit: "mg/dL", + analyteKey: "fasting-glucose", +}; + +const read = ( + marker: ReadingMarker, + value: number, + unit: string | undefined, + low: number | null = null, + high: number | null = null, + c = client(), +) => + readingInMarkerUnit(c, { + marker, + value, + unit, + sourceReferenceLow: low, + sourceReferenceHigh: high, + }); + +describe("a reading in the marker's own unit", () => { + it("is stored as stated, with its window untouched", async () => { + expect(await read(glucose, 95, "mg/dL", 70, 100)).toEqual({ + ok: true, + converted: false, + value: 95, + sourceReferenceLow: 70, + sourceReferenceHigh: 100, + }); + }); + + it("is the same when the unit is spelled another way or not sent", async () => { + expect(await read(glucose, 95, " mg/DL ")).toMatchObject({ + ok: true, + converted: false, + value: 95, + }); + expect(await read(glucose, 95, undefined)).toMatchObject({ + ok: true, + converted: false, + value: 95, + }); + }); + + it("does not touch the database", async () => { + const c = client(); + await read({ ...glucose, analyteKey: null }, 95, "mg/dL", null, null, c); + expect( + (c as never as { biomarker: { updateMany: unknown } }).biomarker + .updateMany, + ).not.toHaveBeenCalled(); + }); +}); + +describe("a reading in another unit", () => { + it("is converted into the marker's unit, keeping what the report printed", async () => { + const result = await read(glucose, 5.6, "mmol/L"); + expect(result).toMatchObject({ + ok: true, + converted: true, + sourceValue: 5.6, + sourceUnit: "mmol/L", + }); + if (!result.ok) return; + expect(result.value).toBeCloseTo(5.6 * 18.0182, 9); + }); + + it("stores full precision and trims the printed unit", async () => { + const result = await read(glucose, 5.6, " mmol/L "); + if (!result.ok || !result.converted) throw new Error("not converted"); + expect(result.sourceUnit).toBe("mmol/L"); + expect(result.value).not.toBe(Math.round(result.value)); + }); + + it("moves the report's own window with the value", async () => { + const result = await read(glucose, 5.6, "mmol/L", 3.9, 5.6); + if (!result.ok) throw new Error("refused"); + expect(result.sourceReferenceLow).toBeCloseTo(3.9 * 18.0182, 9); + expect(result.sourceReferenceHigh).toBeCloseTo(5.6 * 18.0182, 9); + expect(result.sourceReferenceLow!).toBeLessThan( + result.sourceReferenceHigh!, + ); + }); + + it("leaves an open bound open", async () => { + const result = await read(glucose, 5, "mmol/L", null, 5.6); + if (!result.ok) throw new Error("refused"); + expect(result.sourceReferenceLow).toBeNull(); + expect(result.sourceReferenceHigh).toBeCloseTo(5.6 * 18.0182, 9); + }); + + it("converts an affine pair: 47.5 mmol/mol is 6.5 % HbA1c", async () => { + const hba1c: ReadingMarker = { + id: "bm-2", + name: "HbA1c", + unit: "%", + analyteKey: "hba1c", + }; + const result = await read(hba1c, 47.5, "mmol/mol", 20, 42); + if (!result.ok) throw new Error("refused"); + expect(result.value).toBeCloseTo(6.5, 1); + expect(result.sourceReferenceLow!).toBeLessThan( + result.sourceReferenceHigh!, + ); + }); + + it("converts by scale alone, for a marker with no analyte", async () => { + const unknown: ReadingMarker = { + id: "bm-3", + name: "Some marker", + unit: "mg/dL", + analyteKey: null, + }; + const result = await read(unknown, 1, "g/L"); + expect(result).toMatchObject({ ok: true, converted: true, value: 100 }); + }); +}); + +describe("the analyte is filled on first use", () => { + it("links a marker that had no key yet, then converts", async () => { + const c = client(); + const result = await read( + { id: "bm-1", name: "Blutzucker", unit: "mg/dL", analyteKey: null }, + 5.6, + "mmol/L", + null, + null, + c, + ); + expect(result).toMatchObject({ ok: true, converted: true }); + const updateMany = ( + c as never as { + biomarker: { updateMany: ReturnType }; + } + ).biomarker.updateMany; + expect(updateMany).toHaveBeenCalledWith({ + where: { id: "bm-1", analyteKey: null }, + data: { analyteKey: "fasting-glucose" }, + }); + }); +}); + +describe("a refusal", () => { + it("is returned for a marker with no analyte and a different kind of unit", async () => { + const free: ReadingMarker = { + id: "bm-4", + name: "My own marker", + unit: "mg/dL", + analyteKey: null, + }; + expect(await read(free, 5.6, "mmol/L")).toEqual({ + ok: false, + reason: "unknown_analyte", + }); + }); + + it("is returned for a pair the analyte's entry does not reach", async () => { + expect(await read(glucose, 5.6, "%")).toEqual({ + ok: false, + reason: "unknown_unit", + }); + }); + + it("is returned for a value that is not finite", async () => { + expect(await read(glucose, Number.NaN, "mmol/L")).toEqual({ + ok: false, + reason: "not_finite", + }); + }); +}); diff --git a/src/lib/labs/__tests__/resolve-or-mint-biomarker.test.ts b/src/lib/labs/__tests__/resolve-or-mint-biomarker.test.ts index e11976666..d6b1d3719 100644 --- a/src/lib/labs/__tests__/resolve-or-mint-biomarker.test.ts +++ b/src/lib/labs/__tests__/resolve-or-mint-biomarker.test.ts @@ -38,6 +38,7 @@ describe("resolveOrMintBiomarker", () => { lowerBound: null, upperBound: 100, panel: "lipids", + analyteKey: null, }); const out = await resolveOrMintBiomarker("u1", { @@ -65,6 +66,7 @@ describe("resolveOrMintBiomarker", () => { lowerBound: null, upperBound: 116, panel: "lipids", + analyteKey: null, }); const out = await resolveOrMintBiomarker("u1", baseInput); @@ -90,6 +92,7 @@ describe("resolveOrMintBiomarker", () => { lowerBound: 30, upperBound: 400, panel: null, + analyteKey: null, }); await resolveOrMintBiomarker("u1", { diff --git a/src/lib/labs/__tests__/serialise.test.ts b/src/lib/labs/__tests__/serialise.test.ts index 4ee846f49..26968217e 100644 --- a/src/lib/labs/__tests__/serialise.test.ts +++ b/src/lib/labs/__tests__/serialise.test.ts @@ -20,6 +20,8 @@ const baseRow: LabRow = { sourceReferenceLow: null, sourceReferenceHigh: null, sourceReferenceText: null, + sourceValue: null, + sourceUnit: null, takenAt: new Date("2026-01-01T08:00:00.000Z"), source: "MANUAL", biomarkerId: "bm_1", diff --git a/src/lib/labs/__tests__/value-update.test.ts b/src/lib/labs/__tests__/value-update.test.ts new file mode 100644 index 000000000..dcc98d4c8 --- /dev/null +++ b/src/lib/labs/__tests__/value-update.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; + +import { resolveValueForUpdate, valueEditSeed } from "../value-update"; + +describe("valueEditSeed", () => { + it("opens a converted reading at display precision, not its stored tail", () => { + // 5.6 mmol/L of glucose, stored in mg/dL. + expect(valueEditSeed(100.90191999999999)).toBe("100.9"); + }); + + it("keeps whole numbers bare and at most two decimals", () => { + expect(valueEditSeed(110)).toBe("110"); + expect(valueEditSeed(4.567)).toBe("4.57"); + }); +}); + +describe("resolveValueForUpdate", () => { + it("leaves an untouched value out of the update", () => { + expect( + resolveValueForUpdate({ + seed: "100.9", + editorValue: "100.9", + parsed: 100.9, + }), + ).toBeUndefined(); + }); + + it("sends a changed value", () => { + expect( + resolveValueForUpdate({ + seed: "100.9", + editorValue: "101,5", + parsed: 101.5, + }), + ).toBe(101.5); + }); + + it("sends a value typed back to the same number in another spelling", () => { + // "100,9" is a deliberate re-entry; the stored tail goes, as for any edit. + expect( + resolveValueForUpdate({ + seed: "100.9", + editorValue: "100,9", + parsed: 100.9, + }), + ).toBe(100.9); + }); +}); diff --git a/src/lib/labs/analyte-key-store.ts b/src/lib/labs/analyte-key-store.ts new file mode 100644 index 000000000..e2b1a12bc --- /dev/null +++ b/src/lib/labs/analyte-key-store.ts @@ -0,0 +1,53 @@ +/** + * Link a marker to its analyte for the unit conversion. + * + * There is no manual choice of analyte: the key is a function of the marker's + * name and unit (`matchAnalyteKey`). It is set when a marker is created and + * re-derived whenever its name or unit changes, and this step brings a stored + * key back in line with that function the first time a reading needs it. So a + * marker that never had a key gets one, and a key that no longer agrees with + * the name (written before a rename re-derived it, or in an older form) is + * replaced, or cleared when the name now matches nothing. A key that agrees is + * left alone, so the step is idempotent. + * + * The write is gated on the column still holding what the caller read, so two + * concurrent writers converge, and a caller holding a stale copy re-reads the + * row rather than writing over a newer value. + */ +import type { PrismaClient } from "@/generated/prisma/client"; + +import { matchAnalyteKey } from "./analyte-match"; + +interface MarkerKeyState { + id: string; + name: string; + unit: string; + analyteKey?: string | null; +} + +export async function ensureAnalyteKey( + client: Pick, + marker: MarkerKeyState, +): Promise { + let current: MarkerKeyState = marker; + // Two attempts: the caller's copy, then the row as it stands if that copy was + // stale. A third writer racing both is vanishingly rare; the derived key is + // still the right answer for the name this call saw. + for (let attempt = 0; attempt < 2; attempt++) { + const derived = matchAnalyteKey({ name: current.name, unit: current.unit }); + const stored = current.analyteKey ?? null; + if (stored === derived) return derived; + const { count } = await client.biomarker.updateMany({ + where: { id: current.id, analyteKey: stored }, + data: { analyteKey: derived }, + }); + if (count > 0) return derived; + const row = await client.biomarker.findUnique({ + where: { id: current.id }, + select: { id: true, name: true, unit: true, analyteKey: true }, + }); + if (!row) return null; + current = row; + } + return matchAnalyteKey({ name: current.name, unit: current.unit }); +} diff --git a/src/lib/labs/analyte-match.ts b/src/lib/labs/analyte-match.ts new file mode 100644 index 000000000..2e7338ad4 --- /dev/null +++ b/src/lib/labs/analyte-match.ts @@ -0,0 +1,83 @@ +/** + * Which analyte a marker is, for the unit conversion. + * + * `matchAnalyteKey` answers from two places: the localised names of the + * catalog seeds the registry knows (so a marker created from the "pick a common + * marker" list is recognised in every language the app ships) and the alias + * list in the catalog. The name must equal one of them as a WHOLE, folded the + * same way on both sides; a part of a name never matches. + * + * Two safeguards keep a link from being wrong: + * - a folded name that two analytes claim matches neither; + * - the marker's own unit must be one the analyte reaches, so a marker called + * "Glucose" in a unit the registry cannot convert is left unlinked. + * + * Returns the catalog slug the name was recognised as (`ldl`, `hdl`, + * `fasting-glucose`), or the registry-only key for an analyte with no seed + * (`urea`, `uric-acid`), or null. LDL stays `ldl` even though the registry + * converts it with the cholesterol entry: the key says which marker this is, + * and `analyteConversionFor` resolves it to its entry. Pure apart from reading + * the message bundles once. + */ +import { foldForMatch } from "@/lib/i18n/fold-for-match"; +import { localisedValues } from "@/lib/i18n/shared-resolve"; + +import { ANALYTE_ALIASES, BIOMARKER_CATALOG } from "./biomarker-catalog"; +import { analyteAcceptsUnit } from "./unit-conversion/convert"; +import { analyteConversionFor } from "./unit-conversion/registry"; + +/** The folded name without a trailing parenthetical gloss: "Vitamin D (25-OH)" -> "vitamin d". */ +function withoutGloss(folded: string): string { + return folded.replace(/\s*\([^)]*\)/gu, "").trim(); +} + +let cache: ReadonlyMap | null = null; + +/** + * folded name -> analyte key, with every name two keys claim removed. + * Exported for the guard test, which asserts nothing was dropped silently. + */ +export function analyteNameIndex(): { + index: ReadonlyMap; + ambiguous: readonly string[]; +} { + const claims = new Map>(); + const claim = (name: string, key: string) => { + const folded = foldForMatch(name); + if (!folded) return; + const keys = claims.get(folded) ?? new Set(); + keys.add(key); + claims.set(folded, keys); + }; + + for (const seed of BIOMARKER_CATALOG) { + if (!analyteConversionFor(seed.slug)) continue; + for (const name of localisedValues(`labs.catalog.${seed.slug}`)) { + claim(name, seed.slug); + // The bare name too, for a marker typed without the app's gloss. + claim(withoutGloss(foldForMatch(name)), seed.slug); + } + } + for (const { key, names } of ANALYTE_ALIASES) { + if (!analyteConversionFor(key)) continue; + for (const name of names) claim(name, key); + } + + const index = new Map(); + const ambiguous: string[] = []; + for (const [folded, keys] of claims) { + if (keys.size === 1) index.set(folded, [...keys][0]); + else ambiguous.push(folded); + } + return { index, ambiguous }; +} + +export function matchAnalyteKey(input: { + name: string; + unit: string; +}): string | null { + cache ??= analyteNameIndex().index; + const key = cache.get(foldForMatch(input.name)); + if (!key) return null; + return analyteAcceptsUnit(key, input.unit) ? key : null; +} diff --git a/src/lib/labs/biomarker-catalog.ts b/src/lib/labs/biomarker-catalog.ts index 6b33f8569..376a63b6a 100644 --- a/src/lib/labs/biomarker-catalog.ts +++ b/src/lib/labs/biomarker-catalog.ts @@ -276,3 +276,152 @@ export const BIOMARKER_PANELS = [ "electrolytes", "bloodCount", ] as const; + +/** + * Names a marker may carry for an analyte the unit-conversion registry knows + * (`src/lib/labs/unit-conversion/registry.ts`), keyed by the analyte key a + * marker stores: the catalog slug (`ldl`, `hdl`, `fasting-glucose`), or a + * registry-only key for an analyte with no seed. + * + * A `Biomarker` row keeps only its name, not the seed it came from, so this is + * how a marker is told which analyte it is: its folded name equals one of these, + * or one of the localised names of a seed the registry knows (`ldl`, `hba1c`, + * `fasting-glucose`, ...). The match is the WHOLE name, never a part of it, so + * "Calcium (ionisiert)" is not "Calcium" and "Urea nitrogen" is not "Urea". A + * name that two analytes claim is left out of the match, and a marker named in + * some other way stays unlinked, which is the safe default. + * + * Names are compared folded (lower case, no accents, hyphens and slashes as + * spaces), so "Harnsäure" is written "harnsaure" here and "Nüchtern-Glukose" + * needs no entry of its own. The registry-only keys (urea, blood urea + * nitrogen, uric acid, total calcium, total bilirubin) have no seed, so their + * names live only here. + */ +export interface AnalyteAliases { + /** The analyte key: a catalog slug, or a registry-only key. */ + key: string; + names: readonly string[]; +} + +export const ANALYTE_ALIASES: readonly AnalyteAliases[] = [ + { + key: "fasting-glucose", + names: [ + "glucose", + "blood glucose", + "blood sugar", + "plasma glucose", + "fasting blood glucose", + "fasting blood sugar", + "fasting plasma glucose", + "glukose", + "blutglukose", + "blutzucker", + "nuchternblutzucker", + "glucosa", + "glicemia", + "glycemie", + "glucosio", + "glukoza", + "glikemia", + ], + }, + { + key: "hba1c", + names: [ + "hb a1c", + "a1c", + "hemoglobin a1c", + "glycated hemoglobin", + "glycosylated hemoglobin", + "glykohamoglobin", + "hamoglobin a1c", + ], + }, + { + key: "total-cholesterol", + names: [ + "cholesterol", + "cholesterin", + "cholesterol total", + "cholesterin gesamt", + "cholesterol gesamt", + "colesterol", + "colesterolo", + "cholesterol calkowity", + ], + }, + { key: "ldl", names: ["ldl", "ldl c", "ldl chol", "ldl cholesterin"] }, + { key: "hdl", names: ["hdl", "hdl c", "hdl chol", "hdl cholesterin"] }, + { + key: "creatinine", + names: [ + "serum creatinine", + "serumkreatinin", + "kreatinin im serum", + "creatinine serique", + ], + }, + { + key: "vitamin-d", + names: [ + "25 oh vitamin d", + "25 hydroxy vitamin d", + "25 hydroxyvitamin d", + "25 oh d", + "calcidiol", + "vitamin d 25 oh", + ], + }, + { key: "urea", names: ["urea", "harnstoff", "uree", "urea serum"] }, + { + key: "blood-urea-nitrogen", + names: [ + "bun", + "blood urea nitrogen", + "urea nitrogen", + "harnstoff stickstoff", + "stickstoff harnstoff", + "azote ureique", + ], + }, + { + key: "uric-acid", + names: [ + "uric acid", + "urate", + "harnsaure", + "acide urique", + "acido urico", + "kwas moczowy", + ], + }, + { + key: "calcium-total", + names: [ + "calcium", + "total calcium", + "calcium total", + "calcium gesamt", + "gesamtcalcium", + "kalzium", + "kalzium gesamt", + "gesamtkalzium", + "calcio", + "wapn", + ], + }, + { + key: "bilirubin-total", + names: [ + "bilirubin", + "total bilirubin", + "bilirubin total", + "bilirubin gesamt", + "gesamtbilirubin", + "bilirrubina", + "bilirubine", + "bilirubina", + ], + }, +]; diff --git a/src/lib/labs/biomarker-store.ts b/src/lib/labs/biomarker-store.ts index f1e11411e..c6ca11e3a 100644 --- a/src/lib/labs/biomarker-store.ts +++ b/src/lib/labs/biomarker-store.ts @@ -13,6 +13,7 @@ import { Buffer } from "node:buffer"; import { decrypt, encrypt } from "@/lib/crypto"; import { prisma } from "@/lib/db"; +import { matchAnalyteKey } from "@/lib/labs/analyte-match"; import { getEvent } from "@/lib/logging/context"; /** Encrypt a UTF-8 context note into the `Bytes` payload the schema stores. */ @@ -64,6 +65,7 @@ export interface MintedBiomarker { lowerBound: number | null; upperBound: number | null; panel: string | null; + analyteKey: string | null; } /** @@ -101,6 +103,7 @@ export async function resolveOrMintBiomarker( lowerBound: true, upperBound: true, panel: true, + analyteKey: true, } as const; // Case-insensitive match on the catalog identity. Postgres is the store; @@ -120,6 +123,7 @@ export async function resolveOrMintBiomarker( lowerBound: input.referenceLow, upperBound: input.referenceHigh, panel: input.panel, + analyteKey: matchAnalyteKey({ name, unit: input.unit }), }, select, }); diff --git a/src/lib/labs/ocr-extract.ts b/src/lib/labs/ocr-extract.ts index f217f2657..59740c81a 100644 --- a/src/lib/labs/ocr-extract.ts +++ b/src/lib/labs/ocr-extract.ts @@ -28,6 +28,8 @@ import { isUnreadableRange, parseReferenceRange, } from "@/lib/labs/parse-reference-range"; +import { matchAnalyteKey } from "@/lib/labs/analyte-match"; +import { sameLabUnit } from "@/lib/labs/unit-normalise"; import { annotate } from "@/lib/logging/context"; import { extractedLabsSchema, @@ -144,8 +146,19 @@ export async function annotateRow( // Biomarker match — case-insensitive on the catalog identity, presence-only. const existingBiomarker = await prisma.biomarker.findFirst({ where: { userId, name: { equals: analyte, mode: "insensitive" } }, - select: { id: true, unit: true }, + select: { id: true, name: true, unit: true }, }); + // The analyte the marker is, so the review can say whether a row in another + // unit converts. Derived from the name and unit exactly as the commit derives + // it (a stored key is only that derivation, written down), so the review and + // the commit cannot disagree. Read only: the key is stored when a reading is + // written. + const markerAnalyteKey = existingBiomarker + ? matchAnalyteKey({ + name: existingBiomarker.name, + unit: existingBiomarker.unit, + }) + : null; // Duplicate check: a live lab_results row with the same analyte (fuzzy, // case-insensitive), the same calendar day, and the same value/valueText is @@ -161,7 +174,14 @@ export async function annotateRow( analyte: { equals: analyte, mode: "insensitive" }, takenAt: labReadingDaySearchRange(takenAt), }, - select: { id: true, value: true, valueText: true, takenAt: true }, + select: { + id: true, + value: true, + valueText: true, + sourceValue: true, + sourceUnit: true, + takenAt: true, + }, take: 100, }); const candidates = rows.filter( @@ -172,6 +192,20 @@ export async function annotateRow( duplicateOf = c.id; break; } + // A reading stored converted holds the number in its marker's unit; what + // the report printed is beside it. The same number in the same unit is + // the same reading (the commit applies the same rule). + if ( + row.value !== null && + row.unit !== null && + c.sourceValue !== null && + c.sourceValue === row.value && + c.sourceUnit !== null && + sameLabUnit(c.sourceUnit, row.unit) + ) { + duplicateOf = c.id; + break; + } if ( row.valueText !== null && c.valueText !== null && @@ -199,6 +233,7 @@ export async function annotateRow( confidence: row.confidence, biomarkerMatch: existingBiomarker ? "existing" : "new", markerUnit: existingBiomarker?.unit ?? null, + markerAnalyteKey, duplicateOf, }; } diff --git a/src/lib/labs/reading-conversion.ts b/src/lib/labs/reading-conversion.ts new file mode 100644 index 000000000..105650d4d --- /dev/null +++ b/src/lib/labs/reading-conversion.ts @@ -0,0 +1,105 @@ +/** + * A lab reading, written in its marker's unit. + * + * Every reading is stored under the unit of its marker. A reading that states + * the same unit (spelling aside) goes in as it is. One that states another is + * converted when the marker's analyte is known and the registry has the pair, + * keeping what the report printed beside it (`sourceValue`, `sourceUnit`) so a + * number nobody typed can always be traced back. Anything else is refused, + * exactly as before: the number is never kept under a unit it is not in. + * + * The report's own reference window is in the unit the report printed, so it is + * converted with the value; a verdict that compared a converted value with an + * unconverted window would be wrong. Full precision is stored, rounding is for + * display. + */ +import type { PrismaClient } from "@/generated/prisma/client"; + +import { ensureAnalyteKey } from "./analyte-key-store"; +import { + convertLabRange, + convertLabValue, + type ConversionRefusal, +} from "./unit-conversion/convert"; +import { sameLabUnit } from "./unit-normalise"; + +export interface ReadingMarker { + id: string; + name: string; + unit: string; + analyteKey?: string | null; +} + +export type ReadingInMarkerUnit = + | { + ok: true; + converted: false; + value: number; + sourceReferenceLow: number | null; + sourceReferenceHigh: number | null; + } + | { + ok: true; + converted: true; + value: number; + sourceReferenceLow: number | null; + sourceReferenceHigh: number | null; + /** The value as the report printed it. */ + sourceValue: number; + /** The unit as the report printed it, trimmed. */ + sourceUnit: string; + } + | { ok: false; reason: ConversionRefusal }; + +export async function readingInMarkerUnit( + client: Pick, + input: { + marker: ReadingMarker; + value: number; + /** The unit the reading states; undefined means the marker's own. */ + unit: string | undefined; + sourceReferenceLow: number | null; + sourceReferenceHigh: number | null; + }, +): Promise { + const { marker, value, unit, sourceReferenceLow, sourceReferenceHigh } = + input; + if (unit === undefined || sameLabUnit(unit, marker.unit)) { + return { + ok: true, + converted: false, + value, + sourceReferenceLow, + sourceReferenceHigh, + }; + } + + const analyteKey = await ensureAnalyteKey(client, marker); + const converted = convertLabValue({ + analyteKey, + value, + from: unit, + to: marker.unit, + }); + if (!converted.ok) return { ok: false, reason: converted.reason }; + + // The printed window is in the printed unit; move it with the value. + const range = convertLabRange({ + analyteKey, + low: sourceReferenceLow, + high: sourceReferenceHigh, + from: unit, + to: marker.unit, + }); + if (!range.ok) return { ok: false, reason: range.reason }; + + return { + ok: true, + converted: true, + value: converted.value, + sourceReferenceLow: range.low, + sourceReferenceHigh: range.high, + sourceValue: value, + sourceUnit: unit.trim(), + }; +} diff --git a/src/lib/labs/reference-range.ts b/src/lib/labs/reference-range.ts index 8d7403091..45c23e655 100644 --- a/src/lib/labs/reference-range.ts +++ b/src/lib/labs/reference-range.ts @@ -65,11 +65,23 @@ export function formatReferenceRange( /** * The reference range a lab report printed beside ONE reading, as stored on * `LabResult.sourceReference*`. + * + * `sourceUnit` is set when the reading was converted into its marker's unit: + * the bounds were converted with the value, but the text is the report's own + * and stays in the unit the report printed. It is required, so a reader cannot + * forget to select it and present that text beside numbers in another unit. */ export interface SourceReferenceRange { sourceReferenceLow: number | null; sourceReferenceHigh: number | null; sourceReferenceText: string | null; + sourceUnit: string | null; +} + +/** A converted reading's printed range text, with the unit it is written in. */ +export interface PrintedReferenceRange { + text: string; + unit: string; } /** Where the bounds a reading is judged against came from. */ @@ -84,8 +96,19 @@ export interface EffectiveReferenceRange { /** The catalog band, kept alongside so a surface can show both. */ catalogLow: number | null; catalogHigh: number | null; - /** The printed window, kept verbatim. Null when the report stated none. */ + /** + * The printed window, kept verbatim, in the reading's unit. Null when the + * report stated none, and null for a converted reading: its text is in the + * unit the report printed, not the unit of the value and bounds beside it, + * and moves to `printed`. + */ sourceText: string | null; + /** + * For a converted reading only: the window as the report printed it and the + * unit it is in. Evidence, never a statement about the stored numbers; a + * surface that shows it names the unit with it. + */ + printed: PrintedReferenceRange | null; /** * True when the source window is in force AND the catalog states a window * that differs from it. This is the case a reader must be told about: the @@ -132,7 +155,15 @@ export function resolveEffectiveReferenceRange( const cHigh = catalogHigh ?? null; const sLow = source?.sourceReferenceLow ?? null; const sHigh = source?.sourceReferenceHigh ?? null; - const sourceText = source?.sourceReferenceText ?? null; + const verbatim = source?.sourceReferenceText ?? null; + const sourceUnit = source?.sourceUnit ?? null; + // A converted reading's text is in the printed unit; it is never presented as + // the window of the converted value. + const sourceText = sourceUnit === null ? verbatim : null; + const printed: PrintedReferenceRange | null = + sourceUnit !== null && verbatim !== null + ? { text: verbatim, unit: sourceUnit } + : null; if (sLow !== null || sHigh !== null) { return { @@ -142,6 +173,7 @@ export function resolveEffectiveReferenceRange( catalogLow: cLow, catalogHigh: cHigh, sourceText, + printed, divergesFromCatalog: (cLow !== null || cHigh !== null) && !sameWindow(sLow, sHigh, cLow, cHigh), @@ -155,6 +187,7 @@ export function resolveEffectiveReferenceRange( catalogLow: cLow, catalogHigh: cHigh, sourceText, + printed, divergesFromCatalog: false, }; } diff --git a/src/lib/labs/serialise.ts b/src/lib/labs/serialise.ts index 2dc6f6ced..20d93ed7d 100644 --- a/src/lib/labs/serialise.ts +++ b/src/lib/labs/serialise.ts @@ -25,7 +25,11 @@ * - `catalogReferenceLow` / `catalogReferenceHigh` — the catalog band, so a * surface can paint both and show where they part. * - `sourceReferenceLow` / `sourceReferenceHigh` / `sourceReferenceText` — - * what the report printed, the last verbatim. + * what the report printed, the last verbatim. For a reading converted into + * its marker's unit the bounds were converted with the value, but the text + * is still in the unit the report printed, so it is never sent as + * `sourceReferenceText` (that would put two units in one statement): it + * travels as `printedReferenceText`, in `sourceUnit`. * * `referenceOrigin` names which window is in force and * `referenceDivergesFromCatalog` flags the case a reader must be told about. @@ -53,6 +57,8 @@ export interface ResolvedBiomarker { lowerBound: number | null; upperBound: number | null; panel: string | null; + /** Which analyte the marker is (catalog slug or registry-only key), if known. */ + analyteKey?: string | null; } /** The lab-result row shape the resolver reads (Prisma row subset). */ @@ -74,6 +80,12 @@ export interface LabRow { takenAt: Date; source: string; biomarkerId: string | null; + /** + * When the reading was converted into the marker's unit: the value and unit + * as the report printed them. Both null for a reading stored as stated. + */ + sourceValue: number | null; + sourceUnit: string | null; noteEncrypted: Uint8Array | null; createdAt: Date; updatedAt: Date; @@ -89,6 +101,7 @@ export type LabFieldRow = Pick< | "sourceReferenceLow" | "sourceReferenceHigh" | "sourceReferenceText" + | "sourceUnit" | "panel" | "biomarkerId" >; @@ -107,7 +120,13 @@ export interface ResolvedLabFields { /** What the source report printed for this reading. */ sourceReferenceLow: number | null; sourceReferenceHigh: number | null; + /** Null for a converted reading; see `printedReferenceText`. */ sourceReferenceText: string | null; + /** + * A converted reading's printed range text, verbatim, in `sourceUnit` (the + * unit the report printed); null for every other reading. + */ + printedReferenceText: string | null; referenceOrigin: ReferenceRangeOrigin; referenceDivergesFromCatalog: boolean; /** The resolved window as one value, for callers that pass it on. */ @@ -164,6 +183,7 @@ export function resolveLabFields( sourceReferenceLow: row.sourceReferenceLow, sourceReferenceHigh: row.sourceReferenceHigh, sourceReferenceText: effectiveRange.sourceText, + printedReferenceText: effectiveRange.printed?.text ?? null, referenceOrigin: effectiveRange.origin, referenceDivergesFromCatalog: effectiveRange.divergesFromCatalog, effectiveRange, @@ -179,6 +199,7 @@ export function serialiseLabResult( biomarker?: ResolvedBiomarker | null, ) { const resolved = resolveLabFields(row, biomarker); + const conversion = conversionFields(row); return { id: row.id, biomarkerId: row.biomarkerId, @@ -187,6 +208,9 @@ export function serialiseLabResult( value: row.value, valueText: row.valueText, unit: resolved.unit, + converted: conversion.converted, + sourceValue: conversion.sourceValue, + sourceUnit: conversion.sourceUnit, referenceLow: resolved.referenceLow, referenceHigh: resolved.referenceHigh, catalogReferenceLow: resolved.catalogReferenceLow, @@ -194,6 +218,7 @@ export function serialiseLabResult( sourceReferenceLow: resolved.sourceReferenceLow, sourceReferenceHigh: resolved.sourceReferenceHigh, sourceReferenceText: resolved.sourceReferenceText, + printedReferenceText: resolved.printedReferenceText, referenceOrigin: resolved.referenceOrigin, referenceDivergesFromCatalog: resolved.referenceDivergesFromCatalog, takenAt: row.takenAt.toISOString(), @@ -210,6 +235,20 @@ export function serialiseLabResult( }; } +/** + * Whether the stored number is a conversion, and what the report printed. A + * reading stored as stated reports `converted: false` and two nulls. + */ +function conversionFields(row: Pick) { + const sourceValue = row.sourceValue ?? null; + const sourceUnit = row.sourceUnit ?? null; + return { + converted: sourceValue !== null && sourceUnit !== null, + sourceValue, + sourceUnit, + }; +} + /** * Serialise a lab row to the detail DTO — same as the list DTO but carries the * decrypted `note` instead of the `hasNote` flag. @@ -220,6 +259,7 @@ export function serialiseLabResultDetail( note: string | null, ) { const resolved = resolveLabFields(row, biomarker); + const conversion = conversionFields(row); return { id: row.id, biomarkerId: row.biomarkerId, @@ -228,6 +268,9 @@ export function serialiseLabResultDetail( value: row.value, valueText: row.valueText, unit: resolved.unit, + converted: conversion.converted, + sourceValue: conversion.sourceValue, + sourceUnit: conversion.sourceUnit, referenceLow: resolved.referenceLow, referenceHigh: resolved.referenceHigh, catalogReferenceLow: resolved.catalogReferenceLow, @@ -235,6 +278,7 @@ export function serialiseLabResultDetail( sourceReferenceLow: resolved.sourceReferenceLow, sourceReferenceHigh: resolved.sourceReferenceHigh, sourceReferenceText: resolved.sourceReferenceText, + printedReferenceText: resolved.printedReferenceText, referenceOrigin: resolved.referenceOrigin, referenceDivergesFromCatalog: resolved.referenceDivergesFromCatalog, takenAt: row.takenAt.toISOString(), diff --git a/src/lib/labs/unit-conversion/__tests__/convert.test.ts b/src/lib/labs/unit-conversion/__tests__/convert.test.ts new file mode 100644 index 000000000..762b60ba5 --- /dev/null +++ b/src/lib/labs/unit-conversion/__tests__/convert.test.ts @@ -0,0 +1,380 @@ +import { describe, expect, it } from "vitest"; + +import { allAnalyteConversions } from "../registry"; +import { + convertLabRange, + convertLabValue, + conversionsFor, + type ConvertedValue, +} from "../convert"; + +const ok = (result: ConvertedValue) => { + if (!result.ok) throw new Error(`refused: ${result.reason}`); + return result; +}; + +const convert = ( + analyteKey: string | null | undefined, + value: number, + from: string, + to: string, +) => convertLabValue({ analyteKey, value, from, to }); + +describe("reference pairs, checked by hand", () => { + it("5.6 mmol/L glucose is 100.9 mg/dL", () => { + const r = ok(convert("fasting-glucose", 5.6, "mmol/L", "mg/dL")); + expect(r.value).toBeCloseTo(100.9, 1); + expect(r.via).toBe("analyte"); + }); + + it("1.0 mg/dL creatinine is 88.4 µmol/L", () => { + expect(ok(convert("creatinine", 1, "mg/dL", "µmol/L")).value).toBeCloseTo( + 88.4, + 1, + ); + }); + + it("6.5 % HbA1c is 47.5 mmol/mol, and back", () => { + const ifcc = ok(convert("hba1c", 6.5, "%", "mmol/mol")).value; + expect(ifcc).toBeCloseTo(47.5, 1); + expect(ok(convert("hba1c", ifcc, "mmol/mol", "%")).value).toBeCloseTo( + 6.5, + 9, + ); + }); + + it("30 ng/mL vitamin D is 74.9 nmol/L", () => { + expect(ok(convert("vitamin-d", 30, "ng/mL", "nmol/L")).value).toBeCloseTo( + 74.9, + 1, + ); + }); + + it("5.2 mmol/L total cholesterol is 201 mg/dL, and LDL goes the same way", () => { + expect( + ok(convert("total-cholesterol", 5.2, "mmol/L", "mg/dL")).value, + ).toBeCloseTo(201.1, 1); + expect(ok(convert("ldl", 3, "mmol/L", "mg/dL")).value).toBeCloseTo(116, 0); + }); + + it("5 mmol/L urea is 30.0 mg/dL, and 5 mmol/L is 14.0 mg/dL as BUN", () => { + expect(ok(convert("urea", 5, "mmol/L", "mg/dL")).value).toBeCloseTo( + 30.03, + 2, + ); + expect( + ok(convert("blood-urea-nitrogen", 5, "mmol/L", "mg/dL")).value, + ).toBeCloseTo(14.007, 2); + }); + + it("2.4 mmol/L total calcium is 9.6 mg/dL", () => { + expect( + ok(convert("calcium-total", 2.4, "mmol/L", "mg/dL")).value, + ).toBeCloseTo(9.62, 1); + }); + + it("300 µmol/L uric acid is 5.0 mg/dL and 17.1 µmol/L bilirubin is 1.0 mg/dL", () => { + expect(ok(convert("uric-acid", 300, "µmol/L", "mg/dL")).value).toBeCloseTo( + 5.04, + 2, + ); + expect( + ok(convert("bilirubin-total", 17.1, "µmol/L", "mg/dL")).value, + ).toBeCloseTo(1.0, 2); + }); +}); + +describe("the same unit written two ways", () => { + it("returns the value untouched", () => { + expect(ok(convert(null, 5.6, "mmol/l", "mmol/L")).value).toBe(5.6); + expect(ok(convert(null, 5.6, "MG/DL", "mg/dl")).value).toBe(5.6); + expect(ok(convert("fasting-glucose", 5.6, "mg/dl", "mg/dL")).value).toBe( + 5.6, + ); + }); + + it("reads a spelling of the unit through the normaliser", () => { + const a = ok(convert("creatinine", 88.4, "umol/l", "mg/dl")); + const b = ok(convert("creatinine", 88.4, "µmol/L", "mg/dL")); + expect(a.value).toBe(b.value); + }); +}); + +describe("a pair of scale units needs no analyte", () => { + it.each([ + [1, "g/L", "mg/L", 1000], + [1, "mg/dL", "mg/L", 10], + [1, "g/dL", "g/L", 10], + [1, "ng/mL", "µg/L", 1], + [1, "ng/mL", "pg/mL", 1000], + [1, "ng/dL", "ng/L", 10], + [1, "mmol/L", "µmol/L", 1000], + [1, "mol/L", "pmol/L", 1e12], + [1, "mIU/L", "µIU/mL", 1], + [1, "IU/L", "mIU/L", 1000], + [4.5, "10³/µL", "10⁹/L", 4.5], + [1, "10⁶/µL", "10¹²/L", 1], + [1, "10⁶/µL", "10³/µL", 1000], + [1, "G/L", "10³/µL", 1], + ])("%s %s is %s %s", (value, from, to, expected) => { + const r = ok(convert(null, value, from, to)); + expect(r.via).toBe("scale"); + expect(r.value).toBeCloseTo(expected, 9); + }); + + it("is exact where a power of ten is exactly representable", () => { + expect(ok(convert(null, 5, "g/L", "mg/L")).value).toBe(5000); + expect(ok(convert(null, 5, "mg/L", "g/L")).value).toBe(0.005); + }); + + it("works for an analyte's own mass units too: glucose in g/L", () => { + expect(ok(convert("fasting-glucose", 1, "g/L", "mg/dL")).value).toBe(100); + }); +}); + +describe("an analyte's units reach each other across a scale", () => { + it("glucose in g/L to mmol/L", () => { + // 1 g/L = 100 mg/dL = 100 / 18.0182 mmol/L + expect( + ok(convert("fasting-glucose", 1, "g/L", "mmol/L")).value, + ).toBeCloseTo(100 / 18.0182, 9); + }); + + it("creatinine in mmol/L to mg/dL (the entry lists µmol/L)", () => { + expect( + ok(convert("creatinine", 0.0884, "mmol/L", "mg/dL")).value, + ).toBeCloseTo(1.0, 3); + }); + + it("vitamin D in µg/L to nmol/L (the entry lists ng/mL)", () => { + expect(ok(convert("vitamin-d", 30, "µg/L", "nmol/L")).value).toBeCloseTo( + 74.9, + 1, + ); + }); +}); + +describe("a round trip returns the start, for every unit of every analyte", () => { + for (const entry of allAnalyteConversions()) { + const units = Object.keys(entry.units); + for (const from of units) { + for (const to of units) { + it(`${entry.key}: ${from} -> ${to} -> ${from}`, () => { + for (const start of [0.5, 1, 7.3, 120.45]) { + const there = ok(convert(entry.key, start, from, to)).value; + const back = ok(convert(entry.key, there, to, from)).value; + expect(Math.abs(back - start) / start).toBeLessThan(1e-12); + } + }); + } + } + } + + it("holds for the scale pairs", () => { + const units = ["g/L", "mg/dL", "µg/L", "ng/mL", "pg/mL", "ng/dL", "g/dL"]; + for (const from of units) { + for (const to of units) { + const there = ok(convert(null, 7.3, from, to)).value; + const back = ok(convert(null, there, to, from)).value; + expect(Math.abs(back - 7.3) / 7.3).toBeLessThan(1e-12); + } + } + }); +}); + +describe("a refusal, never a guess", () => { + it("refuses a unit nobody knows", () => { + expect(convert("fasting-glucose", 5, "mmol/L", "furlongs")).toEqual({ + ok: false, + reason: "unknown_unit", + }); + expect(convert(null, 5, "foo", "bar")).toEqual({ + ok: false, + reason: "unknown_unit", + }); + }); + + it("refuses an analyte it has no entry for", () => { + expect(convert("sodium", 140, "mmol/L", "mg/dL")).toEqual({ + ok: false, + reason: "unknown_analyte", + }); + expect(convert("triglycerides", 1.7, "mmol/L", "mg/dL")).toEqual({ + ok: false, + reason: "unknown_analyte", + }); + }); + + it("refuses mass to amount when no analyte key is given", () => { + expect(convert(null, 100, "mg/dL", "mmol/L")).toEqual({ + ok: false, + reason: "unknown_analyte", + }); + expect(convert(undefined, 100, "mg/dL", "mmol/L")).toEqual({ + ok: false, + reason: "unknown_analyte", + }); + }); + + it("refuses a unit the entry does not reach", () => { + // HbA1c knows % and mmol/mol; g/L is neither, and not a scale of either. + expect(convert("hba1c", 6.5, "%", "g/L")).toEqual({ + ok: false, + reason: "unknown_unit", + }); + }); + + it.each([Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY])( + "refuses %s", + (value) => { + expect(convert("fasting-glucose", value, "mmol/L", "mg/dL")).toEqual({ + ok: false, + reason: "not_finite", + }); + expect(convert(null, value, "g/L", "mg/L")).toEqual({ + ok: false, + reason: "not_finite", + }); + }, + ); + + it("never turns the lookalike pairs into a conversion", () => { + // Different measurements, not different units of one. + expect(convert("urea", 5, "mmol/L", "mg/dL").ok).toBe(true); + expect(convert("calcium-total", 2.4, "mmol/L", "mg/dL").ok).toBe(true); + expect(convert("calcium-ionised", 1.2, "mmol/L", "mg/dL").ok).toBe(false); + }); +}); + +describe("a reference range", () => { + it("converts both bounds together and keeps them in order", () => { + const r = convertLabRange({ + analyteKey: "fasting-glucose", + low: 3.9, + high: 5.6, + from: "mmol/L", + to: "mg/dL", + }); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.low).toBeCloseTo(70.3, 1); + expect(r.high).toBeCloseTo(100.9, 1); + expect(r.low!).toBeLessThan(r.high!); + expect(r.via).toBe("analyte"); + }); + + it("keeps the order for HbA1c, whose map has an offset", () => { + const r = convertLabRange({ + analyteKey: "hba1c", + low: 4, + high: 5.6, + from: "%", + to: "mmol/mol", + }); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.low!).toBeLessThan(r.high!); + expect(r.low).toBeCloseTo(20.2, 1); + expect(r.high).toBeCloseTo(37.7, 1); + }); + + it("leaves an open bound open", () => { + expect( + convertLabRange({ + analyteKey: "fasting-glucose", + low: null, + high: 5.6, + from: "mmol/L", + to: "mg/dL", + }), + ).toMatchObject({ ok: true, low: null }); + expect( + convertLabRange({ + analyteKey: "fasting-glucose", + low: 3.9, + high: null, + from: "mmol/L", + to: "mg/dL", + }), + ).toMatchObject({ ok: true, high: null }); + }); + + it("refuses the whole range when the units do not convert", () => { + expect( + convertLabRange({ + analyteKey: null, + low: 3.9, + high: 5.6, + from: "mmol/L", + to: "mg/dL", + }), + ).toEqual({ ok: false, reason: "unknown_analyte" }); + }); + + it("refuses a range of two open bounds when the units do not convert either", () => { + expect( + convertLabRange({ + analyteKey: null, + low: null, + high: null, + from: "mmol/L", + to: "mg/dL", + }), + ).toEqual({ ok: false, reason: "unknown_analyte" }); + }); + + it("is the unit spelled another way: no conversion, bounds as they are", () => { + expect( + convertLabRange({ + analyteKey: null, + low: 3.9, + high: 5.6, + from: "mmol/l", + to: "mmol/L", + }), + ).toEqual({ ok: true, low: 3.9, high: 5.6, via: "scale" }); + }); +}); + +describe("conversionsFor", () => { + it("lists what glucose in mg/dL can arrive as, without mg/dL itself", () => { + const units = conversionsFor({ + analyteKey: "fasting-glucose", + to: "mg/dL", + }); + expect(units).toContain("mmol/L"); + expect(units).toContain("g/L"); + expect(units).toContain("µmol/L"); // the amount family, scaled + expect(units).not.toContain("mg/dL"); + expect(units).not.toContain("mg/dl"); + }); + + it("lists only the scale family for a marker with no entry", () => { + const units = conversionsFor({ analyteKey: null, to: "mg/dL" }); + expect(units).toContain("g/L"); + expect(units).not.toContain("mmol/L"); + }); + + it("lists nothing for a unit that has no family and no entry", () => { + expect(conversionsFor({ analyteKey: null, to: "%" })).toEqual([]); + }); + + it("lists HbA1c's two units", () => { + expect(conversionsFor({ analyteKey: "hba1c", to: "%" })).toEqual([ + "mmol/mol", + ]); + }); + + it("every listed unit really converts", () => { + for (const entry of allAnalyteConversions()) { + for (const to of Object.keys(entry.units)) { + for (const from of conversionsFor({ analyteKey: entry.key, to })) { + expect( + convert(entry.key, 1, from, to).ok, + `${entry.key}: ${from} -> ${to}`, + ).toBe(true); + } + } + } + }); +}); diff --git a/src/lib/labs/unit-conversion/__tests__/registry.test.ts b/src/lib/labs/unit-conversion/__tests__/registry.test.ts new file mode 100644 index 000000000..21c9d3d33 --- /dev/null +++ b/src/lib/labs/unit-conversion/__tests__/registry.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from "vitest"; + +import { MGDL_PER_MMOL } from "@/lib/glucose"; + +import { molarMass } from "../atomic-weights"; +import { + allAnalyteConversions, + analyteConversionFor, + defineAnalyteConversion, + type AnalyteConversion, +} from "../registry"; + +/** + * The guard over the registry. Every entry is checked for what makes a + * conversion safe to ship: a named source, a hub that is one of its units, a + * finite positive slope, a key nobody else claims, and, for the molar-mass + * entries, a factor that equals one recomputed here from the molar mass written + * out by hand, not by the code that built the entry. + */ + +/** g/mol, summed by hand from H 1.008, C 12.011, N 14.007, O 15.999, Ca 40.078. */ +const MOLAR_MASS: Record = { + "fasting-glucose": 180.156, // C6H12O6 + "total-cholesterol": 386.664, // C27H46O + creatinine: 113.12, // C4H7N3O + urea: 60.056, // CH4N2O + "blood-urea-nitrogen": 28.014, // N2 + "uric-acid": 168.112, // C5H4N4O3 + "calcium-total": 40.078, // Ca + "bilirubin-total": 584.673, // C33H36N4O6 + "vitamin-d": 400.647, // C27H44O2, 25-hydroxyvitamin D3 (calcifediol) +}; + +/** Of the entry's amount unit: how many of its mass unit one of it is, per g/mol. */ +const POWER_OF_TEN: Record = { + "fasting-glucose": 1 / 10, // mmol/L -> mg/dL + "total-cholesterol": 1 / 10, + creatinine: 1 / 10_000, // µmol/L -> mg/dL + urea: 1 / 10, + "blood-urea-nitrogen": 1 / 10, + "uric-acid": 1 / 10_000, + "calcium-total": 1 / 10, + "bilirubin-total": 1 / 10_000, + "vitamin-d": 1 / 1_000, // nmol/L -> ng/mL +}; + +describe("the vitamin D entry", () => { + const entry = analyteConversionFor("vitamin-d")!; + + it("names the molecule its factor comes from: calcifediol, not vitamin D3", () => { + // Cholecalciferol is C27H44O; the factor uses C27H44O2, which is the + // 25-hydroxy form a lab reports. The note must say which one it is. + expect(entry.source).toContain("calcifediol"); + expect(entry.source).toContain("C27H44O2"); + expect(entry.source).not.toMatch(/cholecalciferol \(C27H44O2\)/); + }); + + it("converts 1 ng/mL to 2.496 nmol/L", () => { + // ng/mL is the hub; nmol/L maps to it by 400.647 / 1000. + expect(1 / entry.units["nmol/L"].a).toBeCloseTo(2.496, 3); + }); +}); + +describe("the registry's entries", () => { + const entries = allAnalyteConversions(); + + it("holds the starter set", () => { + expect(entries.map((entry) => entry.key).sort()).toEqual( + [ + "blood-urea-nitrogen", + "bilirubin-total", + "calcium-total", + "creatinine", + "fasting-glucose", + "hba1c", + "total-cholesterol", + "uric-acid", + "urea", + "vitamin-d", + ].sort(), + ); + }); + + it.each(entries.map((entry) => [entry.key, entry] as const))( + "%s: names its source, has a hub among its units, and a finite positive slope", + (_key, entry) => { + expect(entry.source.trim().length).toBeGreaterThan(10); + expect(Object.keys(entry.units)).toContain(entry.hub); + for (const { a, b } of Object.values(entry.units)) { + expect(Number.isFinite(a) && a > 0).toBe(true); + expect(Number.isFinite(b)).toBe(true); + } + expect(entry.units[entry.hub]).toEqual({ a: 1, b: 0 }); + }, + ); + + it("never lists a key or an alias twice, across entries", () => { + const keys = entries.flatMap((entry) => [entry.key, ...entry.aliases]); + expect(new Set(keys).size).toBe(keys.length); + }); + + it("finds an analyte by its alias: LDL and HDL are cholesterol", () => { + expect(analyteConversionFor("ldl")?.key).toBe("total-cholesterol"); + expect(analyteConversionFor("hdl")?.key).toBe("total-cholesterol"); + expect(analyteConversionFor("nope")).toBeNull(); + expect(analyteConversionFor(null)).toBeNull(); + }); + + it("keeps urea and blood urea nitrogen, and the two calciums, apart", () => { + expect(analyteConversionFor("urea")).not.toBe( + analyteConversionFor("blood-urea-nitrogen"), + ); + expect(analyteConversionFor("calcium-ionised")).toBeNull(); + }); + + describe("the molar-mass entries", () => { + const molar = entries.filter((entry) => entry.kind === "molar-mass"); + + it("are exactly the ones this table knows", () => { + expect(molar.map((entry) => entry.key).sort()).toEqual( + Object.keys(MOLAR_MASS).sort(), + ); + }); + + it.each(molar.map((entry) => [entry.key, entry] as const))( + "%s: the factor is the molar mass moved by its powers of ten", + (key, entry) => { + const [amountUnit] = Object.keys(entry.units).filter( + (unit) => unit !== entry.hub, + ); + const { a } = entry.units[amountUnit]; + const expected = MOLAR_MASS[key] * POWER_OF_TEN[key]; + if (key === "fasting-glucose") { + // The constant the rest of the app uses. + expect(a).toBe(MGDL_PER_MMOL); + } else { + expect(a).toBeCloseTo(expected, 9); + } + }, + ); + + it("has glucose agree with the derived factor to within 0.02 %", () => { + const derived = molarMass({ C: 6, H: 12, O: 6 }) / 10; + expect(derived).toBeCloseTo(18.0156, 4); + expect(Math.abs(MGDL_PER_MMOL - derived) / derived).toBeLessThan(0.0002); + }); + }); + + it("has HbA1c as the IFCC-NGSP master equation, affine and not a factor", () => { + const hba1c = analyteConversionFor("hba1c")!; + expect(hba1c.kind).toBe("standard"); + expect(hba1c.units["mmol/mol"]).toEqual({ a: 0.09148, b: 2.152 }); + expect(hba1c.source).toContain("Hoelzel"); + }); + + it("has no convention-based entry until its source is cited (triglycerides)", () => { + expect(entries.filter((entry) => entry.kind === "convention")).toEqual([]); + expect(analyteConversionFor("triglycerides")).toBeNull(); + }); +}); + +describe("an entry that is not safe cannot be defined", () => { + const valid: AnalyteConversion = { + key: "x", + aliases: [], + hub: "mg/dL", + units: { "mg/dL": { a: 1, b: 0 }, "mmol/L": { a: 10, b: 0 } }, + kind: "molar-mass", + source: "A cited source.", + }; + + it("accepts a valid entry", () => { + expect(defineAnalyteConversion(valid).key).toBe("x"); + }); + + it("refuses an entry with no source", () => { + expect(() => defineAnalyteConversion({ ...valid, source: " " })).toThrow( + /no source/, + ); + }); + + it("refuses a hub that is not one of its units", () => { + expect(() => defineAnalyteConversion({ ...valid, hub: "g/L" })).toThrow( + /hub/, + ); + }); + + it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY])( + "refuses a slope of %s", + (a) => { + expect(() => + defineAnalyteConversion({ + ...valid, + units: { ...valid.units, "mmol/L": { a, b: 0 } }, + }), + ).toThrow(/finite positive/); + }, + ); + + it("refuses a unit that is not in its normalised spelling", () => { + expect(() => + defineAnalyteConversion({ + ...valid, + units: { ...valid.units, "mmol/l": { a: 10, b: 0 } }, + }), + ).toThrow(/normalised/); + }); +}); diff --git a/src/lib/labs/unit-conversion/__tests__/scale.test.ts b/src/lib/labs/unit-conversion/__tests__/scale.test.ts new file mode 100644 index 000000000..8fe7e1ac9 --- /dev/null +++ b/src/lib/labs/unit-conversion/__tests__/scale.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; + +import { molarMass, formulaLabel } from "../atomic-weights"; +import { + convertByScale, + scaleUnitOf, + scaleUnitsInFamilyOf, + shiftPowerOfTen, +} from "../scale"; + +describe("scale families", () => { + it("puts each unit in the family of its quantity", () => { + expect(scaleUnitOf("mg/dL")?.family).toBe("mass"); + expect(scaleUnitOf("pmol/L")?.family).toBe("amount"); + expect(scaleUnitOf("mIU/L")?.family).toBe("activity"); + expect(scaleUnitOf("10³/µL")?.family).toBe("count"); + }); + + it("derives the exponent from the prefix and the volume", () => { + // Relative to g/L. + expect(scaleUnitOf("g/L")?.exponent).toBe(0); + expect(scaleUnitOf("g/dL")?.exponent).toBe(1); + expect(scaleUnitOf("mg/dL")?.exponent).toBe(-2); + expect(scaleUnitOf("ng/mL")?.exponent).toBe(-6); + expect(scaleUnitOf("ng/dL")?.exponent).toBe(-8); + expect(scaleUnitOf("pg/mL")?.exponent).toBe(-9); + }); + + it("knows a unit by any spelling the normaliser resolves", () => { + expect(scaleUnitOf("mg/dl")).toEqual(scaleUnitOf("mg/dL")); + expect(scaleUnitOf("ug/L")).toEqual(scaleUnitOf("µg/L")); + expect(scaleUnitOf("umol/l")).toEqual(scaleUnitOf("µmol/L")); + }); + + it("knows no family for a unit that is not a plain scale", () => { + expect(scaleUnitOf("%")).toBeNull(); + expect(scaleUnitOf("mmol/mol")).toBeNull(); + expect(scaleUnitOf("furlongs")).toBeNull(); + // A lone capital M is mega, not milli: not read as a scale unit at all. + expect(scaleUnitOf("MIU/L")).toBeNull(); + }); + + it("does not make mass to amount a scale pair", () => { + expect(convertByScale(1, "mg/dL", "mmol/L")).toBeNull(); + expect(convertByScale(1, "g/L", "mol/L")).toBeNull(); + }); + + it("keeps the 10^9/L of a blood count apart from g/L", () => { + expect(scaleUnitOf("G/L")?.family).toBe("count"); + expect(scaleUnitOf("g/L")?.family).toBe("mass"); + expect(convertByScale(1, "G/L", "g/L")).toBeNull(); + }); + + it("lists every unit of a family, in its own spelling", () => { + const amount = scaleUnitsInFamilyOf("mmol/L"); + expect(amount).toEqual( + expect.arrayContaining(["mol/L", "mmol/L", "µmol/L", "nmol/L", "pmol/L"]), + ); + expect(amount).not.toContain("mg/dL"); + expect(scaleUnitsInFamilyOf("%")).toEqual([]); + }); +}); + +describe("shiftPowerOfTen", () => { + it("multiplies for a positive power and divides for a negative one", () => { + expect(shiftPowerOfTen(5, 3)).toBe(5000); + expect(shiftPowerOfTen(5, -3)).toBe(0.005); + expect(shiftPowerOfTen(5, 0)).toBe(5); + }); + + it("is exact where multiplying by 10^-n is not", () => { + // 3 * 1e-3 is 0.003 with a stray last digit; 3 / 1000 is exact. + expect(shiftPowerOfTen(3, -3)).toBe(0.003); + }); +}); + +describe("atomic weights", () => { + it("sums a molar mass from the table, without float noise", () => { + expect(molarMass({ C: 6, H: 12, O: 6 })).toBe(180.156); + expect(molarMass({ Ca: 1 })).toBe(40.078); + expect(molarMass({ N: 2 })).toBe(28.014); + }); + + it("writes a formula in a stable order", () => { + expect(formulaLabel({ O: 6, H: 12, C: 6 })).toBe("C6H12O6"); + expect(formulaLabel({ Ca: 1 })).toBe("Ca"); + expect(formulaLabel({ C: 1, H: 4, N: 2, O: 1 })).toBe("CH4N2O"); + }); +}); diff --git a/src/lib/labs/unit-conversion/atomic-weights.ts b/src/lib/labs/unit-conversion/atomic-weights.ts new file mode 100644 index 000000000..9c0fe807d --- /dev/null +++ b/src/lib/labs/unit-conversion/atomic-weights.ts @@ -0,0 +1,50 @@ +/** + * The atomic weights the molar-mass conversions are derived from. + * + * Conventional atomic weights as published by the IUPAC Commission on Isotopic + * Abundances and Atomic Weights (CIAAW), "Standard atomic weights". Only the + * elements a conversion in the registry needs are listed; one more element is + * one more line here, with a conversion that uses it. + * + * A molar mass is summed from these, never typed, so a long decimal cannot be + * copied wrong into a factor that then touches every reading of a marker. + */ +export const ATOMIC_WEIGHTS_SOURCE = + "IUPAC CIAAW, Standard atomic weights (conventional values)"; + +export const ATOMIC_WEIGHTS = { + H: 1.008, + C: 12.011, + N: 14.007, + O: 15.999, + Ca: 40.078, +} as const; + +export type Element = keyof typeof ATOMIC_WEIGHTS; + +/** Atoms per molecule, by element: urea is `{ C: 1, H: 4, N: 2, O: 1 }`. */ +export type Formula = Readonly>>; + +/** g/mol of a formula, summed from `ATOMIC_WEIGHTS`. */ +export function molarMass(formula: Formula): number { + let total = 0; + for (const [element, count] of Object.entries(formula) as Array< + [Element, number] + >) { + total += ATOMIC_WEIGHTS[element] * count; + } + // Summing decimals leaves float noise past the sixth place (180.15600000000001). + return Math.round(total * 1e6) / 1e6; +} + +/** `C6H12O6` for `{ C: 6, H: 12, O: 6 }`: for a citation, in a stable order. */ +export function formulaLabel(formula: Formula): string { + const order: Element[] = ["C", "H", "N", "O", "Ca"]; + return order + .filter((element) => (formula[element] ?? 0) > 0) + .map((element) => { + const count = formula[element]!; + return count === 1 ? element : `${element}${count}`; + }) + .join(""); +} diff --git a/src/lib/labs/unit-conversion/convert.ts b/src/lib/labs/unit-conversion/convert.ts new file mode 100644 index 000000000..1c79b5e2a --- /dev/null +++ b/src/lib/labs/unit-conversion/convert.ts @@ -0,0 +1,183 @@ +/** + * Convert a lab value, or a reference range, between units. Pure, no I/O. + * + * The rules, in order: + * + * 1. The same unit written two ways (`mmol/l`, `mmol/L`) is the same unit and + * the value is returned as it is. + * 2. Two units in one scale family (`mg/L`, `g/dL`) convert by their power of + * ten, for any analyte, with no key. This is `via: "scale"`. + * 3. Anything else needs the analyte's entry in the registry (`via: + * "analyte"`): each unit is moved to a unit the entry lists by scale where + * needed, then unit -> hub -> unit. + * + * Everything else is a refusal, never a guess: an unknown unit, an analyte with + * no entry, a value that is not finite. A mass to amount pair asked for without + * an analyte key is an `unknown_analyte` refusal: it is never a scale pair. + */ +import { normaliseLabUnit } from "../unit-normalise"; +import { + analyteConversionFor, + type Affine, + type AnalyteConversion, +} from "./registry"; +import { scaleUnitOf, scaleUnitsInFamilyOf, shiftPowerOfTen } from "./scale"; + +export type ConversionRefusal = + "unknown_analyte" | "unknown_unit" | "not_finite"; + +export type ConvertedValue = + | { ok: true; value: number; via: "analyte" | "scale" } + | { ok: false; reason: ConversionRefusal }; + +export interface ConvertLabValueInput { + /** Catalog slug or registry key of the marker's analyte; null when it has none. */ + analyteKey: string | null | undefined; + value: number; + from: string; + to: string; +} + +/** `unit` written as one of the entry's own units, by scale where it is not one. */ +function intoEntryUnit( + entry: AnalyteConversion, + unit: string, +): { entryUnit: string; affine: Affine; shift: number } | null { + const norm = normaliseLabUnit(unit); + const direct = entry.units[norm]; + if (direct) return { entryUnit: norm, affine: direct, shift: 0 }; + const own = scaleUnitOf(norm); + if (!own) return null; + for (const [entryUnit, affine] of Object.entries(entry.units)) { + const candidate = scaleUnitOf(entryUnit); + if (candidate && candidate.family === own.family) { + return { entryUnit, affine, shift: own.exponent - candidate.exponent }; + } + } + return null; +} + +export function convertLabValue(input: ConvertLabValueInput): ConvertedValue { + const { analyteKey, value, from, to } = input; + if (!Number.isFinite(value)) return { ok: false, reason: "not_finite" }; + + const fromNorm = normaliseLabUnit(from); + const toNorm = normaliseLabUnit(to); + if (fromNorm === toNorm) return { ok: true, value, via: "scale" }; + + const a = scaleUnitOf(fromNorm); + const b = scaleUnitOf(toNorm); + if (a && b && a.family === b.family) { + return { + ok: true, + value: shiftPowerOfTen(value, a.exponent - b.exponent), + via: "scale", + }; + } + + const entry = analyteConversionFor(analyteKey); + if (!entry) { + // Both units are real but of different families: it needs an analyte. + return { + ok: false, + reason: a && b ? "unknown_analyte" : "unknown_unit", + }; + } + + const source = intoEntryUnit(entry, fromNorm); + const target = intoEntryUnit(entry, toNorm); + if (!source || !target) return { ok: false, reason: "unknown_unit" }; + + const inEntryUnit = shiftPowerOfTen(value, source.shift); + const hub = source.affine.a * inEntryUnit + source.affine.b; + const inTargetEntryUnit = (hub - target.affine.b) / target.affine.a; + // `target.shift` moves the entry's unit to the one asked for: the reverse of + // the way `from` was moved in. + const result = shiftPowerOfTen(inTargetEntryUnit, -target.shift); + return Number.isFinite(result) + ? { ok: true, value: result, via: "analyte" } + : { ok: false, reason: "not_finite" }; +} + +export type ConvertedRange = + | { + ok: true; + low: number | null; + high: number | null; + via: "analyte" | "scale"; + } + | { ok: false; reason: ConversionRefusal }; + +/** + * Both bounds of a reference range, or the refusal. An open bound (null) stays + * open. Every conversion has a positive slope, so the bounds keep their order. + */ +export function convertLabRange(input: { + analyteKey: string | null | undefined; + low: number | null; + high: number | null; + from: string; + to: string; +}): ConvertedRange { + const { analyteKey, from, to } = input; + const bounds: Array = [input.low, input.high]; + const converted: Array = []; + let via: "analyte" | "scale" = "scale"; + for (const bound of bounds) { + if (bound === null) { + converted.push(null); + continue; + } + const result = convertLabValue({ analyteKey, value: bound, from, to }); + if (!result.ok) return result; + if (result.via === "analyte") via = "analyte"; + converted.push(result.value); + } + // A range of nothing but open bounds still has to name a way: the units must + // convert at all. + if (input.low === null && input.high === null) { + const probe = convertLabValue({ analyteKey, value: 1, from, to }); + if (!probe.ok) return probe; + via = probe.via; + } + return { ok: true, low: converted[0], high: converted[1], via }; +} + +/** + * The units a reading can arrive in and be converted to `to` for this marker: + * every unit in the scale family of `to`, and, when the analyte has an entry, + * every unit of that entry along with their scale families. `to` itself is not + * listed. + */ +export function conversionsFor(input: { + analyteKey: string | null | undefined; + to: string; +}): string[] { + const to = normaliseLabUnit(input.to); + const found = new Set(scaleUnitsInFamilyOf(to)); + const entry = analyteConversionFor(input.analyteKey); + if (entry && (to in entry.units || intoEntryUnit(entry, to))) { + for (const unit of Object.keys(entry.units)) { + found.add(unit); + for (const sibling of scaleUnitsInFamilyOf(unit)) found.add(sibling); + } + } + found.delete(to); + return [...found].filter((unit) => normaliseLabUnit(unit) !== to); +} + +/** + * Whether the analyte's entry reaches `unit`: the unit is one it lists, or + * differs from one only by a power of ten. A marker is linked to an analyte only + * when its own unit passes this, so a marker called "Glucose" tracked in some + * unit the registry cannot reach is not given a conversion it cannot make. + */ +export function analyteAcceptsUnit( + analyteKey: string | null | undefined, + unit: string, +): boolean { + const entry = analyteConversionFor(analyteKey); + return ( + entry !== null && intoEntryUnit(entry, normaliseLabUnit(unit)) !== null + ); +} diff --git a/src/lib/labs/unit-conversion/registry.ts b/src/lib/labs/unit-conversion/registry.ts new file mode 100644 index 000000000..675c175e7 --- /dev/null +++ b/src/lib/labs/unit-conversion/registry.ts @@ -0,0 +1,236 @@ +/** + * Which analytes can be moved between units, and how. + * + * Each analyte lists the units it knows as an affine map to one hub unit: + * `hub = a * value + b`. Any pair then goes unit -> hub -> unit, so adding a + * unit to an analyte is one line and nothing lists pairs. + * + * Three kinds of entry, by where the factor comes from: + * + * - `molar-mass`: mass concentration to amount concentration, which needs the + * analyte's molar mass. The factor is DERIVED from the formula and the + * atomic weights, never typed. + * - `standard`: an equation a standards body publishes (HbA1c, IFCC to NGSP). + * - `convention`: a factor that rests on an assumed value (triglycerides take + * an average molecular weight). None is in the registry yet: a conversion + * that cannot cite a primary source stays a refusal. + * + * Every entry names its source, and an entry that does not, or whose hub is not + * one of its units, or whose factor is not a finite positive number, makes this + * module throw when it loads. A wrong factor is worse than no conversion, so an + * uncited one cannot be added quietly. + * + * Left out on purpose, so the refusal stays: haemoglobin in mmol/L (the figure + * depends on whether the monomer or the tetramer is meant, a factor of four), + * ferritin and vitamin B12 in pmol/L (large-protein molecular weights that vary + * by source), enzymes in µkat/L, and anything without a source. Urea and blood + * urea nitrogen are separate analytes (about 2.14 apart), and so are total and + * ionised calcium; only the total is here. + */ +import { MGDL_PER_MMOL } from "@/lib/glucose"; + +import { normaliseLabUnit } from "../unit-normalise"; +import { + ATOMIC_WEIGHTS_SOURCE, + formulaLabel, + molarMass, + type Formula, +} from "./atomic-weights"; +import { scaleUnitOf, shiftPowerOfTen } from "./scale"; + +export interface Affine { + a: number; + b: number; +} + +export type ConversionKind = "molar-mass" | "standard" | "convention"; + +export interface AnalyteConversion { + /** Stable identity: the catalog slug where one exists, a registry-only key otherwise. */ + key: string; + /** Other catalog slugs that are this same analyte (LDL and HDL are cholesterol). */ + aliases: readonly string[]; + /** The unit every other unit of this analyte maps to. */ + hub: string; + /** Units by their normalised spelling, each as an affine map to the hub. */ + units: Readonly>; + kind: ConversionKind; + /** Where the factor comes from; required. */ + source: string; +} + +export function defineAnalyteConversion( + entry: AnalyteConversion, +): AnalyteConversion { + const where = `unit-conversion registry entry "${entry.key}"`; + if (!entry.source.trim()) throw new Error(`${where} has no source`); + const hub = normaliseLabUnit(entry.hub); + if (!(hub in entry.units)) { + throw new Error(`${where}: hub "${entry.hub}" is not one of its units`); + } + for (const [unit, { a, b }] of Object.entries(entry.units)) { + if (normaliseLabUnit(unit) !== unit) { + throw new Error(`${where}: unit "${unit}" is not in its normalised form`); + } + if (!Number.isFinite(a) || a <= 0 || !Number.isFinite(b)) { + throw new Error(`${where}: unit "${unit}" has no finite positive factor`); + } + } + return { ...entry, hub }; +} + +/** + * A mass concentration (the hub) and one amount concentration of the same + * analyte. The factor is the molar mass moved by the powers of ten between the + * two units: 1 mmol/L of glucose is 180.156 mg/L, which is 18.0156 mg/dL. + */ +function molarMassEntry(spec: { + key: string; + aliases?: readonly string[]; + formula: Formula; + /** The mass concentration the analyte is tracked in; the hub. */ + mass: string; + /** The amount concentration it is also printed in. */ + amount: string; + /** Overrides the derived mmol/L factor with a constant the app already has. */ + factor?: number; + note?: string; +}): AnalyteConversion { + const mass = scaleUnitOf(spec.mass); + const amount = scaleUnitOf(spec.amount); + if (mass?.family !== "mass" || amount?.family !== "amount") { + throw new Error( + `molar-mass entry "${spec.key}" needs a mass and an amount unit`, + ); + } + const grams = molarMass(spec.formula); + // mol/L -> g/L is x molar mass; g/L -> the mass unit and the amount unit's + // own prefix are powers of ten. + const derived = shiftPowerOfTen(grams, amount.exponent - mass.exponent); + return defineAnalyteConversion({ + key: spec.key, + aliases: spec.aliases ?? [], + hub: spec.mass, + units: { + [normaliseLabUnit(spec.mass)]: { a: 1, b: 0 }, + [normaliseLabUnit(spec.amount)]: { a: spec.factor ?? derived, b: 0 }, + }, + kind: "molar-mass", + source: + `Molar mass of ${formulaLabel(spec.formula)} (${grams} g/mol), summed from ` + + `${ATOMIC_WEIGHTS_SOURCE}.` + + (spec.note ? ` ${spec.note}` : ""), + }); +} + +const ENTRIES: readonly AnalyteConversion[] = [ + molarMassEntry({ + key: "fasting-glucose", + formula: { C: 6, H: 12, O: 6 }, + mass: "mg/dL", + amount: "mmol/L", + // The factor the glucose screens and the CGM import already use, so the two + // can never disagree; the guard test checks it against the derived one. + factor: MGDL_PER_MMOL, + note: "The factor is MGDL_PER_MMOL (src/lib/glucose.ts).", + }), + molarMassEntry({ + key: "total-cholesterol", + aliases: ["ldl", "hdl"], + formula: { C: 27, H: 46, O: 1 }, + mass: "mg/dL", + amount: "mmol/L", + note: "LDL and HDL cholesterol are the same molecule.", + }), + molarMassEntry({ + key: "creatinine", + formula: { C: 4, H: 7, N: 3, O: 1 }, + mass: "mg/dL", + amount: "µmol/L", + }), + molarMassEntry({ + key: "urea", + formula: { C: 1, H: 4, N: 2, O: 1 }, + mass: "mg/dL", + amount: "mmol/L", + note: "Urea itself, not urea nitrogen.", + }), + molarMassEntry({ + key: "blood-urea-nitrogen", + // Only the two nitrogen atoms of urea count: BUN is mg of nitrogen per dL. + formula: { N: 2 }, + mass: "mg/dL", + amount: "mmol/L", + note: "BUN in mg/dL against urea in mmol/L; two nitrogen atoms per urea molecule.", + }), + molarMassEntry({ + key: "uric-acid", + formula: { C: 5, H: 4, N: 4, O: 3 }, + mass: "mg/dL", + amount: "µmol/L", + }), + molarMassEntry({ + key: "calcium-total", + formula: { Ca: 1 }, + mass: "mg/dL", + amount: "mmol/L", + note: "Total calcium; ionised calcium is a different measurement.", + }), + molarMassEntry({ + key: "bilirubin-total", + formula: { C: 33, H: 36, N: 4, O: 6 }, + mass: "mg/dL", + amount: "µmol/L", + }), + molarMassEntry({ + key: "vitamin-d", + formula: { C: 27, H: 44, O: 2 }, + mass: "ng/mL", + amount: "nmol/L", + // What a lab measures and reports is 25-hydroxyvitamin D (calcifediol), + // so its molar mass sets the factor: 1 ng/mL is 2.496 nmol/L. Vitamin D3 + // itself (cholecalciferol, C27H44O) is lighter by the one oxygen atom. + note: + "25-hydroxyvitamin D3 (calcifediol, C27H44O2), the form the assay reports; " + + "1 ng/mL = 2.496 nmol/L.", + }), + defineAnalyteConversion({ + key: "hba1c", + aliases: [], + hub: "%", + units: { + "%": { a: 1, b: 0 }, + // NGSP(%) = 0.09148 x IFCC(mmol/mol) + 2.152. Affine, not a factor. + "mmol/mol": { a: 0.09148, b: 2.152 }, + }, + kind: "standard", + source: + "IFCC-NGSP master equation, Hoelzel W et al., Clin Chem 2004;50(1):166-174, " + + "as published by the NGSP: NGSP (%) = 0.09148 x IFCC (mmol/mol) + 2.152.", + }), +]; + +const BY_KEY: ReadonlyMap = (() => { + const map = new Map(); + for (const entry of ENTRIES) { + for (const key of [entry.key, ...entry.aliases]) { + if (map.has(key)) { + throw new Error(`unit-conversion registry: "${key}" appears twice`); + } + map.set(key, entry); + } + } + return map; +})(); + +/** The conversion entry for an analyte key (or one of its aliases), if any. */ +export function analyteConversionFor( + analyteKey: string | null | undefined, +): AnalyteConversion | null { + return analyteKey ? (BY_KEY.get(analyteKey) ?? null) : null; +} + +/** Every entry, once each, for the guard test and the analyte key matching. */ +export function allAnalyteConversions(): readonly AnalyteConversion[] { + return ENTRIES; +} diff --git a/src/lib/labs/unit-conversion/scale.ts b/src/lib/labs/unit-conversion/scale.ts new file mode 100644 index 000000000..987f07b36 --- /dev/null +++ b/src/lib/labs/unit-conversion/scale.ts @@ -0,0 +1,130 @@ +/** + * Units that differ only by a power of ten. + * + * A pair of units inside one family cannot change the quantity, only the + * prefix, so it needs no analyte: `mg/L` to `g/dL` is the same for every + * marker. A pair across families (mass to amount, `mg/dL` to `mmol/L`) is NOT a + * scale pair, because it needs the analyte's molar mass; those live only in the + * registry, per analyte. + * + * Every unit is stored as its power of ten relative to its family's base unit, + * computed from the prefix and the volume, not typed. + */ +import { normaliseLabUnit } from "../unit-normalise"; + +type FamilyName = "mass" | "amount" | "activity" | "count"; + +const MASS_PREFIX: ReadonlyArray<[string, number]> = [ + ["g", 0], + ["mg", -3], + ["µg", -6], + ["ng", -9], + ["pg", -12], +]; +const AMOUNT_PREFIX: ReadonlyArray<[string, number]> = [ + ["mol", 0], + ["mmol", -3], + ["µmol", -6], + ["nmol", -9], + ["pmol", -12], +]; +/** Litres per volume unit, as a power of ten. */ +const VOLUME: Readonly> = { + L: 0, + dL: -1, + mL: -3, + µL: -6, +}; + +/** The mass-concentration units a lab prints, with the volumes they print on. */ +const MASS_UNITS: ReadonlyArray<[string, string]> = [ + ["g", "L"], + ["g", "dL"], + ["mg", "L"], + ["mg", "dL"], + ["µg", "L"], + ["µg", "dL"], + ["ng", "L"], + ["ng", "mL"], + ["ng", "dL"], + ["pg", "mL"], +]; + +const exponent = (table: ReadonlyArray<[string, number]>, prefix: string) => + table.find(([name]) => name === prefix)![1]; + +/** unit -> [family, power of ten relative to the family's base unit]. */ +const SCALE_UNITS: ReadonlyMap = new Map< + string, + readonly [FamilyName, number] +>([ + // Base: g/L. + ...MASS_UNITS.map( + ([prefix, volume]) => + [ + `${prefix}/${volume}`, + ["mass", exponent(MASS_PREFIX, prefix) - VOLUME[volume]], + ] as const, + ), + // Base: mol/L. + ...AMOUNT_PREFIX.map( + ([prefix, power]) => [`${prefix}/L`, ["amount", power - VOLUME.L]] as const, + ), + // Base: IU/L. `µIU/mL` is `mIU/L`. + ["IU/L", ["activity", 0]] as const, + ["mIU/L", ["activity", -3]] as const, + ["µIU/mL", ["activity", -6 - VOLUME.mL]] as const, + // Base: 10^9/L. A blood count is printed either way. + ["10⁹/L", ["count", 9]] as const, + ["10^9/L", ["count", 9]] as const, + ["G/L", ["count", 9]] as const, + ["10³/µL", ["count", 3 - VOLUME.µL]] as const, + ["10^3/µL", ["count", 3 - VOLUME.µL]] as const, + ["10¹²/L", ["count", 12]] as const, + ["10^12/L", ["count", 12]] as const, + ["T/L", ["count", 12]] as const, + ["10⁶/µL", ["count", 6 - VOLUME.µL]] as const, + ["10^6/µL", ["count", 6 - VOLUME.µL]] as const, +]); + +export interface ScaleUnit { + family: FamilyName; + /** Power of ten of one of this unit, in the family's base unit. */ + exponent: number; +} + +/** The family a unit belongs to, or null for a unit that has none. */ +export function scaleUnitOf(unit: string): ScaleUnit | null { + const found = SCALE_UNITS.get(normaliseLabUnit(unit)); + return found ? { family: found[0], exponent: found[1] } : null; +} + +/** Multiply by `10^power`, dividing for a negative power: `10^-3` is not exact. */ +export function shiftPowerOfTen(value: number, power: number): number { + if (power === 0) return value; + return power > 0 ? value * 10 ** power : value / 10 ** -power; +} + +/** + * `value` in `from`, written in `to`, when both are in the same family; + * null when either is in none or they are in different families. + */ +export function convertByScale( + value: number, + from: string, + to: string, +): number | null { + const a = scaleUnitOf(from); + const b = scaleUnitOf(to); + if (!a || !b || a.family !== b.family) return null; + return shiftPowerOfTen(value, a.exponent - b.exponent); +} + +/** Every unit that has a family, in its spelling here. */ +export function scaleUnitsInFamilyOf(unit: string): string[] { + const own = scaleUnitOf(unit); + if (!own) return []; + return [...SCALE_UNITS.entries()] + .filter(([, [family]]) => family === own.family) + .map(([spelling]) => spelling); +} diff --git a/src/lib/labs/value-update.ts b/src/lib/labs/value-update.ts new file mode 100644 index 000000000..5cbbad305 --- /dev/null +++ b/src/lib/labs/value-update.ts @@ -0,0 +1,29 @@ +import { formatLabValue } from "./format-value"; + +/** + * The numeric value of a lab reading in the edit sheet. + * + * A converted reading is stored at full precision (5.6 mmol/L of glucose is + * 100.90191999999999 mg/dL), and the sheet must not open with that tail. The + * field starts from the value as every lab surface shows it: whole numbers + * bare, at most two decimals. Written with a dot, which the sheet's parser + * reads, rather than in the reader's locale, which an input cannot round-trip. + */ +export function valueEditSeed(value: number): string { + return formatLabValue(value, String); +} + +/** + * What the edit PUT sends for the value. A field the person did not touch is + * omitted (`undefined`), so the stored full-precision number stays as it is and + * a converted reading keeps its "converted from" record: sending the rounded + * start value back would both lose precision and read to the server as a new + * number. A changed field sends the parsed number. + */ +export function resolveValueForUpdate(args: { + seed: string; + editorValue: string; + parsed: number; +}): number | undefined { + return args.editorValue.trim() === args.seed.trim() ? undefined : args.parsed; +} diff --git a/src/lib/mcp/rich-reads.ts b/src/lib/mcp/rich-reads.ts index a0f9efcaa..398d20559 100644 --- a/src/lib/mcp/rich-reads.ts +++ b/src/lib/mcp/rich-reads.ts @@ -1426,6 +1426,7 @@ export async function getLabHistory( sourceReferenceLow: true, sourceReferenceHigh: true, sourceReferenceText: true, + sourceUnit: true, takenAt: true, biomarkerId: true, biomarker: { diff --git a/src/lib/openapi/routes/biomarkers.ts b/src/lib/openapi/routes/biomarkers.ts index d210b0b84..9b8fabf2b 100644 --- a/src/lib/openapi/routes/biomarkers.ts +++ b/src/lib/openapi/routes/biomarkers.ts @@ -53,6 +53,7 @@ const biomarkerRow = z lowerBound: z.number().nullable(), upperBound: z.number().nullable(), panel: z.string().nullable(), + analyteKey: z.string().nullable(), hasContext: z.boolean(), context: z.string().nullable(), hidden: z.boolean(), @@ -62,7 +63,7 @@ const biomarkerRow = z .meta({ id: "Biomarker", description: - "A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its presence. `hidden` drops the marker from the active catalog list and the lab-entry pickers without deleting it (its readings and unit/range survive). A `LabResult` linking this marker resolves its unit + reference bounds from here.", + "A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its presence. `analyteKey` is the analyte the marker was recognised as: the catalog slug its name belongs to (e.g. `fasting-glucose`, `ldl`, `hdl`), or a key of the unit-conversion registry for an analyte with no catalog seed (e.g. `urea`, `uric-acid`), or null when its name matches none. It is derived from `name` and `unit`, never chosen: it is set on create and re-derived (or cleared) when an update changes either. A reading in another unit is converted only for a marker that has one. `hidden` drops the marker from the active catalog list and the lab-entry pickers without deleting it (its readings and unit/range survive). A `LabResult` linking this marker resolves its unit + reference bounds from here.", }); const listResponse = z diff --git a/src/lib/openapi/routes/labs.ts b/src/lib/openapi/routes/labs.ts index ca0b92046..3fcc7e5ca 100644 --- a/src/lib/openapi/routes/labs.ts +++ b/src/lib/openapi/routes/labs.ts @@ -70,6 +70,9 @@ const labResultRow = z value: z.number().nullable(), valueText: z.string().nullable(), unit: z.string(), + converted: z.boolean(), + sourceValue: z.number().nullable(), + sourceUnit: z.string().nullable(), referenceLow: z.number().nullable(), referenceHigh: z.number().nullable(), catalogReferenceLow: z.number().nullable(), @@ -77,6 +80,7 @@ const labResultRow = z sourceReferenceLow: z.number().nullable(), sourceReferenceHigh: z.number().nullable(), sourceReferenceText: z.string().nullable(), + printedReferenceText: z.string().nullable(), referenceOrigin: referenceOriginEnum, referenceDivergesFromCatalog: z.boolean(), takenAt: z.string(), @@ -89,7 +93,7 @@ const labResultRow = z .meta({ id: "LabResult", description: - "A stored lab result. Exactly one of `value` (numeric) or `valueText` (qualitative, e.g. \"negativ\") is non-null; `rangeStatus` is always `unknown` for a qualitative row (no number to classify). `biomarkerId` links the user-scoped catalog marker (null for legacy free-text rows); when linked, `analyte` / `unit` are RESOLVED server-side from the Biomarker.\n\nThree reference windows ride on the row and the server says which one applies. `referenceLow` / `referenceHigh` are the window IN FORCE and the one `rangeStatus` was computed from — read these to render the range and you are correct without doing any resolution of your own. `sourceReferenceLow` / `sourceReferenceHigh` / `sourceReferenceText` are the range this reading's own report printed (the text verbatim, kept even when no numbers could be read out of it). `catalogReferenceLow` / `catalogReferenceHigh` are the Biomarker band. `referenceOrigin` names the winner and `referenceDivergesFromCatalog` is true when the report's window and the catalog band state different limits — a surface showing only one of the two is showing a partial answer. The client renders these values and never recomputes the range. The encrypted note is never echoed in list rows; `hasNote` flags its presence and the single-resource GET returns the decrypted `note`.", + "A stored lab result. Exactly one of `value` (numeric) or `valueText` (qualitative, e.g. \"negativ\") is non-null; `rangeStatus` is always `unknown` for a qualitative row (no number to classify). `biomarkerId` links the user-scoped catalog marker (null for legacy free-text rows); when linked, `analyte` / `unit` are RESOLVED server-side from the Biomarker.\n\nThree reference windows ride on the row and the server says which one applies. `referenceLow` / `referenceHigh` are the window IN FORCE and the one `rangeStatus` was computed from — read these to render the range and you are correct without doing any resolution of your own. `sourceReferenceLow` / `sourceReferenceHigh` / `sourceReferenceText` are the range this reading's own report printed (the text verbatim, kept even when no numbers could be read out of it). `catalogReferenceLow` / `catalogReferenceHigh` are the Biomarker band. `referenceOrigin` names the winner and `referenceDivergesFromCatalog` is true when the report's window and the catalog band state different limits — a surface showing only one of the two is showing a partial answer. The client renders these values and never recomputes the range. `converted` is true when the stored `value` was converted into the marker's `unit` from another unit; `sourceValue` and `sourceUnit` are then what the report printed (full precision, round for display), and the report's own window was converted with it: `sourceReferenceLow` / `sourceReferenceHigh` are then in the marker's `unit`. The printed range text is not converted and stays in `sourceUnit`, so for a converted reading `sourceReferenceText` is null and the verbatim text is in `printedReferenceText` instead, never mixed with the converted numbers. A reading stored as stated has `converted: false`, two nulls and a null `printedReferenceText`. The encrypted note is never echoed in list rows; `hasNote` flags its presence and the single-resource GET returns the decrypted `note`.", }); const labResultDetail = labResultRow @@ -164,7 +168,7 @@ export const labsPaths: NonNullable = { ...stdResponses, "422": { description: - "Nothing was written. `meta.errorCode` = `labs.create.invalid` when the body failed validation (every issue under `details.issues`), or `labs.unit.mismatch` when a numeric reading states a `unit` other than the one its marker is tracked in (`meta.markerUnit`, `meta.readingUnit`). A unit is compared only when one is sent; with a `biomarkerId` it may be omitted and the marker's unit is then the stated one. Spelling is forgiven (`mmol/l`, `ug/L` for `µg/L`); a different unit is refused, never relabelled.", + "Nothing was written. `meta.errorCode` = `labs.create.invalid` when the body failed validation (every issue under `details.issues`), or `labs.unit.mismatch` when a numeric reading states a `unit` other than the one its marker is tracked in AND the pair cannot be converted (`meta.markerUnit`, `meta.readingUnit`, `meta.reason` = `unknown_analyte` | `unknown_unit` | `not_finite`). A unit is compared only when one is sent; with a `biomarkerId` it may be omitted and the marker's unit is then the stated one. Spelling is forgiven (`mmol/l`, `ug/L` for `µg/L`); a different unit is converted when the marker's analyte is known and the pair is in the unit-conversion registry (the 201 response then carries `converted: true`), and refused otherwise, never relabelled.", content: { "application/json": { schema: errorEnvelope } }, }, ...recordWriteRateLimitResponse, diff --git a/src/lib/openapi/routes/ocr.ts b/src/lib/openapi/routes/ocr.ts index 9de42ea6d..493078b4f 100644 --- a/src/lib/openapi/routes/ocr.ts +++ b/src/lib/openapi/routes/ocr.ts @@ -75,7 +75,11 @@ const extractedRow = z biomarkerMatch: z.enum(["new", "existing"]), markerUnit: z.string().nullable().meta({ description: - "The unit the matched marker is tracked in, or null when the marker does not exist yet (it will adopt this row's unit). A row whose `unit` differs from it cannot be committed: the commit skips it with `reason: unit_mismatch`, and the review screen marks it before Save.", + "The unit the matched marker is tracked in, or null when the marker does not exist yet (it will adopt this row's unit). A row whose `unit` differs from it is converted on commit when the marker's analyte is known and the pair is in the unit-conversion registry; otherwise the commit skips it with `reason: unit_mismatch`, and the review screen marks it before Save.", + }), + markerAnalyteKey: z.string().nullable().meta({ + description: + "The analyte the matched marker is (the catalog slug, e.g. `fasting-glucose` or `ldl`, or a registry-only key such as `urea`), or null when the marker does not exist yet or its name matches none. With `markerUnit` it tells a client whether a row in another unit can be converted on commit.", }), duplicateOf: z.string().nullable(), }) @@ -115,6 +119,9 @@ const committedRow = z value: z.number().nullable(), valueText: z.string().nullable(), unit: z.string(), + converted: z.boolean(), + sourceValue: z.number().nullable(), + sourceUnit: z.string().nullable(), referenceLow: z.number().nullable(), referenceHigh: z.number().nullable(), catalogReferenceLow: z.number().nullable(), @@ -122,6 +129,7 @@ const committedRow = z sourceReferenceLow: z.number().nullable(), sourceReferenceHigh: z.number().nullable(), sourceReferenceText: z.string().nullable(), + printedReferenceText: z.string().nullable(), referenceOrigin: z.enum(["source", "catalog", "none"]), referenceDivergesFromCatalog: z.boolean(), takenAt: z.string(), @@ -134,7 +142,7 @@ const committedRow = z .meta({ id: "OcrCommittedLabResult", description: - "A written reading. Its reference window resolves exactly as it does on `/api/labs` — see `LabResult`.", + "A written reading. Its reference window resolves exactly as it does on `/api/labs` — see `LabResult`. `converted`, `sourceValue` and `sourceUnit` say when the row's unit was converted into its marker's and what the report printed; for a converted row the printed range text is in `printedReferenceText` (in `sourceUnit`) and `sourceReferenceText` is null.", }); const commitResponse = z @@ -145,7 +153,7 @@ const commitResponse = z analyte: z.string(), reason: z.enum(["duplicate", "unit_mismatch"]).meta({ description: - "`duplicate`: the row matched a live reading at commit time. `unit_mismatch`: the row states a unit other than the one its marker is tracked in, so it was not written.", + "`duplicate`: the row matched a live reading at commit time. `unit_mismatch`: the row states a unit other than the one its marker is tracked in and the pair cannot be converted, so it was not written. A pair that can be converted is written in the marker's unit and reported with `converted: true`.", }), }), ), diff --git a/src/lib/validations/labs-ocr.ts b/src/lib/validations/labs-ocr.ts index 59c076f61..8e11aa7d9 100644 --- a/src/lib/validations/labs-ocr.ts +++ b/src/lib/validations/labs-ocr.ts @@ -131,6 +131,12 @@ export interface OcrExtractedRowDto { * so before Save when the scanned unit differs. */ markerUnit: string | null; + /** + * Which analyte the matched marker is (catalog slug or registry-only key), or null + * when the marker is new or none is known. With `markerUnit` it tells the + * review screen whether a row in another unit can be converted on Save. + */ + markerAnalyteKey: string | null; duplicateOf: string | null; } diff --git a/tests/integration/lab-source-reference-range.test.ts b/tests/integration/lab-source-reference-range.test.ts index b4aaaaad6..57e50c028 100644 --- a/tests/integration/lab-source-reference-range.test.ts +++ b/tests/integration/lab-source-reference-range.test.ts @@ -225,6 +225,78 @@ describe("a printed reference range, end to end", () => { }); }); + it("keeps a converted reading's printed text as evidence, never as the window of the converted value", async () => { + const prisma = getPrismaClient(); + // A glucose marker tracked in mg/dL; the report prints mmol/L. + const marker = await prisma.biomarker.create({ + data: { + userId: USER_ID, + name: "Glucose", + unit: "mg/dL", + lowerBound: 70, + upperBound: 100, + }, + }); + const PRINTED_MMOL = "3,9 - 5,5 mmol/l"; + const { POST } = await import("@/app/api/labs/route"); + const res = await POST( + postReq({ + biomarkerId: marker.id, + value: 5.6, + unit: "mmol/L", + sourceReferenceLow: 3.9, + sourceReferenceHigh: 5.5, + sourceReferenceText: PRINTED_MMOL, + takenAt: "2026-07-03T08:00:00.000Z", + }), + ); + expect(res.status).toBe(201); + + // The record keeps what the report printed, verbatim. + const row = await prisma.labResult.findFirstOrThrow({ + where: { userId: USER_ID }, + }); + expect(row.sourceReferenceText).toBe(PRINTED_MMOL); + expect(row.sourceUnit).toBe("mmol/L"); + + // The DTO states the window in force in mg/dL only, and hands the printed + // text over separately, beside the unit it is in. + const dto = await readDto("Glucose"); + expect(dto.converted).toBe(true); + expect(dto.unit).toBe("mg/dL"); + expect(dto.referenceOrigin).toBe("source"); + expect(dto.referenceLow).toBeCloseTo(3.9 * 18.0182, 6); + expect(dto.referenceHigh).toBeCloseTo(5.5 * 18.0182, 6); + expect(dto.sourceReferenceText).toBeNull(); + expect(dto.printedReferenceText).toBe(PRINTED_MMOL); + expect(dto.sourceUnit).toBe("mmol/L"); + + // The doctor report has no mmol/L text to print beside mg/dL numbers. + const { loadLabResults } = + await import("@/lib/doctor-report/clinical-records"); + const labs = await loadLabResults( + USER_ID, + new Date("2026-01-01T00:00:00.000Z"), + new Date("2026-12-31T23:59:59.000Z"), + ); + expect(labs?.[0].sourceReferenceText).toBeNull(); + expect(labs?.[0].referenceLow).toBeCloseTo(3.9 * 18.0182, 6); + + // FHIR: the text, when present, must agree with low and high. Here it is + // left out, and the bounds carry the marker's unit like the value. + const { labObservations } = await import("@/lib/fhir/resources/labs"); + let seq = 0; + const observations = labObservations( + { labResults: labs } as Parameters[0], + () => `obs-${++seq}`, + ); + const range = observations[0].referenceRange?.[0]; + expect(range?.text).toBeUndefined(); + expect(range?.low?.unit).toBe("mg/dL"); + expect(range?.high?.value).toBeCloseTo(5.5 * 18.0182, 6); + expect(observations[0].valueQuantity?.unit).toBe("mg/dL"); + }); + it("keeps a window it cannot read as text and leaves the catalog in force", async () => { const prisma = getPrismaClient(); await seedMarker(); diff --git a/tests/integration/lab-unit-mismatch.test.ts b/tests/integration/lab-unit-mismatch.test.ts index 0596330b7..06a926202 100644 --- a/tests/integration/lab-unit-mismatch.test.ts +++ b/tests/integration/lab-unit-mismatch.test.ts @@ -9,6 +9,13 @@ * not: a different spelling of the same unit, a qualitative reading, a * reading with no unit on the structured path and a brand-new marker must all * still write. + * + * "Another unit" is refused only where the pair cannot be converted. Ferritin + * in pmol/L against a marker kept in ng/mL is the standing example: protein + * molecular weights vary by source, so the registry has no conversion and the + * reading stays refused. Where the marker's analyte is known and the pair is in + * the registry (glucose in mmol/L against mg/dL) the reading is converted + * instead; that is `labs-unit-conversion.test.ts`. */ import { NextRequest } from "next/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -74,6 +81,19 @@ async function seedGlucose() { }); } +/** A marker whose analyte the registry has no conversion for. */ +async function seedFerritin() { + return getPrismaClient().biomarker.create({ + data: { + userId: USER_ID, + name: "Ferritin", + unit: "ng/mL", + lowerBound: 30, + upperBound: 400, + }, + }); +} + function jsonReq(path: string, body: unknown): NextRequest { return new NextRequest(`http://localhost${path}`, { method: "POST", @@ -103,27 +123,28 @@ const labCount = () => describe("POST /api/labs", () => { it("refuses a reading in another unit than its marker's and writes nothing", async () => { - await seedGlucose(); + await seedFerritin(); const { status, body } = await postLab({ - analyte: "Glucose", - value: 5.6, - unit: "mmol/L", + analyte: "Ferritin", + value: 450, + unit: "pmol/L", }); expect(status).toBe(422); expect(body.meta).toMatchObject({ errorCode: "labs.unit.mismatch", - markerUnit: "mg/dL", - readingUnit: "mmol/L", + markerUnit: "ng/mL", + readingUnit: "pmol/L", + reason: "unknown_analyte", }); expect(await labCount()).toBe(0); }); it("refuses it on the structured path too, when a unit is sent", async () => { - const marker = await seedGlucose(); + const marker = await seedFerritin(); const { status, body } = await postLab({ biomarkerId: marker.id, - value: 5.6, - unit: "mmol/L", + value: 450, + unit: "pmol/L", }); expect(status).toBe(422); expect(body.meta.errorCode).toBe("labs.unit.mismatch"); @@ -166,30 +187,30 @@ describe("POST /api/labs", () => { it("lets a new marker adopt the unit of its first reading", async () => { const { status, body } = await postLab({ - analyte: "Creatinine", + analyte: "Ferritin", value: 80, - unit: "µmol/L", + unit: "ng/mL", }); expect(status).toBe(201); - expect(body.data.unit).toBe("µmol/L"); + expect(body.data.unit).toBe("ng/mL"); // …and then holds the next reading in another unit to it. const next = await postLab({ - analyte: "Creatinine", - value: 0.9, - unit: "mg/dL", + analyte: "Ferritin", + value: 180, + unit: "pmol/L", }); expect(next.status).toBe(422); expect(await labCount()).toBe(1); }); it("never judges the refused value against the marker's range", async () => { - // The case from the report: 5.6 mmol/L is an ordinary glucose, and 5.6 - // mg/dL would read as far below range. - await seedGlucose(); - await postLab({ analyte: "Glucose", value: 5.6, unit: "mmol/L" }); + // The case from the report, for a pair that is refused: the number must + // never be kept under a unit it is not in and judged against that band. + await seedFerritin(); + await postLab({ analyte: "Ferritin", value: 450, unit: "pmol/L" }); const { GET } = await import("@/app/api/labs/route"); const res = await GET( - new NextRequest("http://localhost/api/labs?analyte=Glucose"), + new NextRequest("http://localhost/api/labs?analyte=Ferritin"), ); expect((await res.json()).data.results).toEqual([]); }); @@ -197,30 +218,30 @@ describe("POST /api/labs", () => { describe("POST /api/labs/ocr/commit", () => { it("writes the matching row, skips the one in another unit, and reports a partial outcome", async () => { - await seedGlucose(); + await seedFerritin(); const { status, body } = await commitRows([ - { analyte: "Glucose", value: 95, unit: "mg/dL" }, + { analyte: "Ferritin", value: 95, unit: "ng/mL" }, { - analyte: "Glucose", - value: 5.6, - unit: "mmol/L", + analyte: "Ferritin", + value: 450, + unit: "pmol/L", takenAt: "2026-08-10T12:00:00.000Z", }, ]); expect(status).toBe(200); expect(body.data.inserted).toHaveLength(1); - expect(body.data.inserted[0]).toMatchObject({ value: 95, unit: "mg/dL" }); + expect(body.data.inserted[0]).toMatchObject({ value: 95, unit: "ng/mL" }); expect(body.data.skipped).toEqual([ - { analyte: "Glucose", reason: "unit_mismatch" }, + { analyte: "Ferritin", reason: "unit_mismatch" }, ]); expect(body.data.outcome).toBe("partial"); expect(await labCount()).toBe(1); }); it("reports a failed outcome when every row is in another unit", async () => { - await seedGlucose(); + await seedFerritin(); const { body } = await commitRows([ - { analyte: "Glucose", value: 5.6, unit: "mmol/L" }, + { analyte: "Ferritin", value: 450, unit: "pmol/L" }, ]); expect(body.data.inserted).toHaveLength(0); expect(body.data.outcome).toBe("failed"); diff --git a/tests/integration/labs-analyte-key.test.ts b/tests/integration/labs-analyte-key.test.ts new file mode 100644 index 000000000..0e17970ff --- /dev/null +++ b/tests/integration/labs-analyte-key.test.ts @@ -0,0 +1,375 @@ +/** + * A marker is linked to its analyte, and the link follows its name and unit. + * + * `Biomarker.analyteKey` says which analyte a marker is, so a reading in another + * unit can be converted (#1095). There is no manual choice: the key is derived + * from the name and unit. It is set when a marker is created, by all three + * creation paths, re-derived when a rename or unit change moves it, and, for a + * marker that already exists, brought in line the first time it is needed. + * Against a real Postgres: + * - a marker created by name is linked when the name is a known analyte and + * its unit is one the analyte reaches, and left null otherwise; + * - the key reaches the API response; + * - a rename moves the key, or clears it, and the next reading in another + * unit converts with the new analyte's factor; + * - `ensureAnalyteKey` fills a null key, replaces one that disagrees with the + * name, and two concurrent calls converge; + * - the mint path used by the lab writes and the legacy backfill set it too. + */ +import { NextRequest } from "next/server"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; +import { runLabBiomarkerBackfillForUser } from "@/lib/jobs/lab-biomarker-backfill"; +import { ensureAnalyteKey } from "@/lib/labs/analyte-key-store"; +import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const prisma = getPrismaClient(); +const USER = "analyte-key-user"; + +beforeAll(async () => { + await truncateAllTables(prisma); + await prisma.user.create({ data: { id: USER, username: USER } }); +}); + +async function post(body: Record) { + const s = await prisma.session.create({ + data: { userId: USER, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.clear(); + cookieJar.set("healthlog_session", s.id); + const { POST } = await import("@/app/api/biomarkers/route"); + return POST( + new NextRequest("http://localhost/api/biomarkers", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ); +} + +describe("POST /api/biomarkers", () => { + it("links a marker whose name is a known analyte, and returns the key", async () => { + const res = await post({ name: "Blutzucker", unit: "mg/dL" }); + expect(res.status).toBe(201); + const { data } = (await res.json()) as { data: { analyteKey: string } }; + expect(data.analyteKey).toBe("fasting-glucose"); + const row = await prisma.biomarker.findFirst({ + where: { userId: USER, name: "Blutzucker" }, + }); + expect(row?.analyteKey).toBe("fasting-glucose"); + }); + + it("links a catalog seed by its localised name", async () => { + const res = await post({ name: "Gesamtcholesterin", unit: "mg/dL" }); + const { data } = (await res.json()) as { data: { analyteKey: string } }; + expect(data.analyteKey).toBe("total-cholesterol"); + }); + + it("stores the catalog slug for LDL and HDL, and converts them as cholesterol", async () => { + for (const [name, key] of [ + ["LDL cholesterol", "ldl"], + ["HDL-Cholesterin", "hdl"], + ]) { + const res = await post({ name, unit: "mg/dL" }); + const { data } = (await res.json()) as { data: { analyteKey: string } }; + expect(data.analyteKey, name).toBe(key); + } + const ldl = await prisma.biomarker.findFirstOrThrow({ + where: { userId: USER, name: "LDL cholesterol" }, + }); + const reading = await postReading({ + biomarkerId: ldl.id, + value: 3, + unit: "mmol/L", + takenAt: "2026-05-04T08:00:00.000Z", + }); + expect(reading.status).toBe(201); + const { data: dto } = (await reading.json()) as { + data: { value: number; converted: boolean }; + }; + expect(dto.converted).toBe(true); + // 3 mmol/L x 38.6664 (C27H46O, 386.664 g/mol). + expect(dto.value.toFixed(2)).toBe("116.00"); + }); + + it("leaves a free name, and a known name in an unreachable unit, unlinked", async () => { + for (const [name, unit] of [ + ["My own marker", "mg/dL"], + ["Glucose", "mEq/L"], + ["Calcium (ionisiert)", "mmol/L"], + ]) { + const res = await post({ name, unit }); + expect(res.status).toBe(201); + const { data } = (await res.json()) as { + data: { analyteKey: string | null }; + }; + expect(data.analyteKey, name).toBeNull(); + } + }); +}); + +async function patch(id: string, body: Record) { + const s = await prisma.session.create({ + data: { userId: USER, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.clear(); + cookieJar.set("healthlog_session", s.id); + const { PUT } = await import("@/app/api/biomarkers/[id]/route"); + return PUT( + new NextRequest(`http://localhost/api/biomarkers/${id}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ id }) }, + ); +} + +async function postReading(body: Record) { + const s = await prisma.session.create({ + data: { userId: USER, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.clear(); + cookieJar.set("healthlog_session", s.id); + const { POST } = await import("@/app/api/labs/route"); + return POST( + new NextRequest("http://localhost/api/labs", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ); +} + +describe("PUT /api/biomarkers/[id]", () => { + it("moves the key with a rename, and the next reading converts with the new analyte", async () => { + const created = await post({ name: "Blood sugar", unit: "mg/dL" }); + const { data: marker } = (await created.json()) as { + data: { id: string; analyteKey: string | null }; + }; + expect(marker.analyteKey).toBe("fasting-glucose"); + + const res = await patch(marker.id, { name: "Cholesterol" }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { analyteKey: string | null }; + }; + expect(body.data.analyteKey).toBe("total-cholesterol"); + expect( + (await prisma.biomarker.findUnique({ where: { id: marker.id } })) + ?.analyteKey, + ).toBe("total-cholesterol"); + + // 5.2 mmol/L of cholesterol is 201.07 mg/dL (C27H46O, 386.664 g/mol). With + // the glucose key left over it would have been stored as 93.7. + const reading = await postReading({ + biomarkerId: marker.id, + value: 5.2, + unit: "mmol/L", + takenAt: "2026-05-03T08:00:00.000Z", + }); + expect(reading.status).toBe(201); + const { data: dto } = (await reading.json()) as { + data: { value: number; unit: string; converted: boolean }; + }; + expect(dto.converted).toBe(true); + expect(dto.unit).toBe("mg/dL"); + expect(dto.value.toFixed(2)).toBe("201.07"); + }); + + it("clears the key when the new name matches no analyte", async () => { + const created = await post({ name: "Harnstoff", unit: "mg/dL" }); + const { data: marker } = (await created.json()) as { + data: { id: string; analyteKey: string | null }; + }; + expect(marker.analyteKey).toBe("urea"); + const res = await patch(marker.id, { name: "Harnstoff (eigene Notiz)" }); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { analyteKey: string | null } }; + expect(body.data.analyteKey).toBeNull(); + }); + + it("re-derives the key when the unit changes on a marker without readings", async () => { + const created = await post({ name: "Glukose", unit: "mEq/L" }); + const { data: marker } = (await created.json()) as { + data: { id: string; analyteKey: string | null }; + }; + expect(marker.analyteKey).toBeNull(); + const res = await patch(marker.id, { unit: "mg/dL" }); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { analyteKey: string | null } }; + expect(body.data.analyteKey).toBe("fasting-glucose"); + }); + + it("leaves the key alone on an edit that touches neither name nor unit", async () => { + const created = await post({ name: "Kalzium gesamt", unit: "mmol/L" }); + const { data: marker } = (await created.json()) as { data: { id: string } }; + const res = await patch(marker.id, { lowerBound: 2.1 }); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { analyteKey: string | null } }; + expect(body.data.analyteKey).toBe("calcium-total"); + }); +}); + +describe("ensureAnalyteKey", () => { + it("fills a null key once", async () => { + const marker = await prisma.biomarker.create({ + data: { userId: USER, name: "Harnsäure", unit: "µmol/L" }, + }); + expect(marker.analyteKey).toBeNull(); + expect(await ensureAnalyteKey(prisma, marker)).toBe("uric-acid"); + expect( + (await prisma.biomarker.findUnique({ where: { id: marker.id } })) + ?.analyteKey, + ).toBe("uric-acid"); + }); + + // This used to pin the opposite: a set key was never replaced. With no + // manual choice of analyte, a key that disagrees with the name can only be + // left over from a rename (or written in an older form), and keeping it + // converted readings with the wrong analyte's factor. So the key follows the + // name. + it("replaces a key that disagrees with the marker's name", async () => { + const marker = await prisma.biomarker.create({ + data: { + userId: USER, + name: "Blood glucose", + unit: "mg/dL", + analyteKey: "hba1c", + }, + }); + expect(await ensureAnalyteKey(prisma, marker)).toBe("fasting-glucose"); + expect( + (await prisma.biomarker.findUnique({ where: { id: marker.id } })) + ?.analyteKey, + ).toBe("fasting-glucose"); + }); + + it("clears a key when the name matches no analyte", async () => { + const marker = await prisma.biomarker.create({ + data: { + userId: USER, + name: "Some other marker", + unit: "mg/dL", + analyteKey: "fasting-glucose", + }, + }); + expect(await ensureAnalyteKey(prisma, marker)).toBeNull(); + expect( + (await prisma.biomarker.findUnique({ where: { id: marker.id } })) + ?.analyteKey, + ).toBeNull(); + }); + + it("re-reads the row when the caller's copy is stale", async () => { + const marker = await prisma.biomarker.create({ + data: { + userId: USER, + name: "Urea", + unit: "mg/dL", + analyteKey: "urea", + }, + }); + // A copy read before the key was set: the gated write misses, the row as it + // stands already agrees, and nothing is overwritten. + expect( + await ensureAnalyteKey(prisma, { ...marker, analyteKey: null }), + ).toBe("urea"); + expect( + (await prisma.biomarker.findUnique({ where: { id: marker.id } })) + ?.analyteKey, + ).toBe("urea"); + }); + + it("corrects a key stored as the registry entry rather than the catalog slug", async () => { + // Before the slug was stored, an LDL marker got the cholesterol entry's + // key. Same conversion, wrong identity; the fill step rewrites it once. + const marker = await prisma.biomarker.create({ + data: { + userId: USER, + name: "LDL-Cholesterin", + unit: "mg/dL", + analyteKey: "total-cholesterol", + }, + }); + expect(await ensureAnalyteKey(prisma, marker)).toBe("ldl"); + const row = await prisma.biomarker.findUnique({ where: { id: marker.id } }); + expect(row?.analyteKey).toBe("ldl"); + // And a second run changes nothing. + expect(await ensureAnalyteKey(prisma, row!)).toBe("ldl"); + }); + + it("converges when two callers fill the same marker", async () => { + const marker = await prisma.biomarker.create({ + data: { userId: USER, name: "Kreatinin", unit: "mg/dL" }, + }); + const [a, b] = await Promise.all([ + ensureAnalyteKey(prisma, marker), + ensureAnalyteKey(prisma, marker), + ]); + expect(a).toBe("creatinine"); + expect(b).toBe("creatinine"); + }); + + it("leaves a marker with no analyte null", async () => { + const marker = await prisma.biomarker.create({ + data: { userId: USER, name: "Ferritin", unit: "ng/mL" }, + }); + expect(await ensureAnalyteKey(prisma, marker)).toBeNull(); + }); +}); + +describe("the other creation paths", () => { + it("links a marker minted by a lab write", async () => { + const minted = await resolveOrMintBiomarker(USER, { + analyte: "Gesamtbilirubin", + unit: "µmol/L", + referenceLow: null, + referenceHigh: null, + panel: null, + }); + const row = await prisma.biomarker.findUnique({ where: { id: minted.id } }); + expect(row?.analyteKey).toBe("bilirubin-total"); + }); + + it("links a marker the legacy backfill mints", async () => { + await prisma.labResult.create({ + data: { + userId: USER, + analyte: "Kalzium", + value: 2.4, + unit: "mmol/L", + takenAt: new Date("2026-05-01T08:00:00Z"), + source: "MANUAL", + }, + }); + await runLabBiomarkerBackfillForUser(USER); + const row = await prisma.biomarker.findFirst({ + where: { userId: USER, name: "Kalzium" }, + }); + expect(row?.analyteKey).toBe("calcium-total"); + }); +}); diff --git a/tests/integration/labs-unit-conversion.test.ts b/tests/integration/labs-unit-conversion.test.ts new file mode 100644 index 000000000..89af81ce8 --- /dev/null +++ b/tests/integration/labs-unit-conversion.test.ts @@ -0,0 +1,373 @@ +/** + * A lab reading in another unit is converted into its marker's unit (#1095). + * + * Until now a reading that stated a unit other than its marker's was refused. + * When the marker's analyte is known and the registry has the pair, the three + * write paths now convert it, keep what the report printed beside it, and move + * the report's own reference window with the value. Against a real Postgres: + * + * - POST /api/labs converts, stores full precision and the printed value and + * unit, converts the printed window, and says so in the response; + * - a pair that cannot be converted is still the 422 it always was, now with + * the reason; + * - the verdict is judged against the converted window, so a value in range in + * the printed unit is in range after conversion; + * - the OCR commit converts, and the same scan committed again is a duplicate + * (the stored number is converted, so the printed one is what matches); + * - changing the value of a converted reading clears the conversion record, + * and an edit that sends the value back unchanged keeps it; + * - the list returns the fields. + */ +import { NextRequest } from "next/server"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const prisma = getPrismaClient(); +const USER = "lab-conversion-user"; +const MGDL_PER_MMOL = 18.0182; + +beforeAll(async () => { + await truncateAllTables(prisma); + await prisma.user.create({ data: { id: USER, username: USER } }); + // A catalog marker the user tracks in mg/dL, with a catalog band. + await prisma.biomarker.create({ + data: { + id: "bm-glucose", + userId: USER, + name: "Glucose", + unit: "mg/dL", + lowerBound: 70, + upperBound: 100, + }, + }); + // A marker the registry knows nothing about. + await prisma.biomarker.create({ + data: { + id: "bm-free", + userId: USER, + name: "My own marker", + unit: "mg/dL", + }, + }); +}); + +async function call( + route: "labs" | "ocr" | "one", + method: "GET" | "POST" | "PUT", + body?: unknown, + id?: string, +): Promise<{ + status: number; + json: { data?: T; meta?: Record }; +}> { + const s = await prisma.session.create({ + data: { userId: USER, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.clear(); + cookieJar.set("healthlog_session", s.id); + const url = + route === "labs" + ? "http://localhost/api/labs" + : route === "ocr" + ? "http://localhost/api/labs/ocr/commit" + : `http://localhost/api/labs/${id}`; + const init = { + method, + headers: { "content-type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }; + const req = new NextRequest(url, init); + let res: Response; + if (route === "labs") { + const mod = await import("@/app/api/labs/route"); + res = await (method === "GET" ? mod.GET(req) : mod.POST(req)); + } else if (route === "ocr") { + const mod = await import("@/app/api/labs/ocr/commit/route"); + res = await mod.POST(req); + } else { + const mod = await import("@/app/api/labs/[id]/route"); + res = await (method === "PUT" + ? mod.PUT(req, { params: Promise.resolve({ id: id! }) }) + : mod.GET(req, { params: Promise.resolve({ id: id! }) })); + } + return { status: res.status, json: (await res.json()) as never }; +} + +type Dto = { + id: string; + value: number; + unit: string; + converted: boolean; + sourceValue: number | null; + sourceUnit: string | null; + referenceLow: number | null; + referenceHigh: number | null; + sourceReferenceLow: number | null; + sourceReferenceHigh: number | null; + rangeStatus: string; +}; + +describe("POST /api/labs", () => { + it("converts a reading in another unit and keeps what the report printed", async () => { + const { status, json } = await call("labs", "POST", { + biomarkerId: "bm-glucose", + value: 5.6, + unit: "mmol/L", + sourceReferenceLow: 3.9, + sourceReferenceHigh: 6.0, + takenAt: "2026-05-01T08:00:00.000Z", + }); + expect(status).toBe(201); + const dto = json.data!; + expect(dto.converted).toBe(true); + expect(dto.unit).toBe("mg/dL"); + expect(dto.value).toBeCloseTo(5.6 * MGDL_PER_MMOL, 9); + expect(dto.sourceValue).toBe(5.6); + expect(dto.sourceUnit).toBe("mmol/L"); + // The report's own window moved with the value. + expect(dto.sourceReferenceLow).toBeCloseTo(3.9 * MGDL_PER_MMOL, 9); + expect(dto.sourceReferenceHigh).toBeCloseTo(6.0 * MGDL_PER_MMOL, 9); + // And the verdict is judged against it: 5.6 of 3.9-6.0 is in range. + expect(dto.rangeStatus).toBe("in-range"); + + const row = await prisma.labResult.findUniqueOrThrow({ + where: { id: dto.id }, + }); + expect(row.value).toBeCloseTo(5.6 * MGDL_PER_MMOL, 9); + expect(row.sourceValue).toBe(5.6); + expect(row.sourceUnit).toBe("mmol/L"); + }); + + it("flags a converted reading outside the printed window as such", async () => { + const { json } = await call("labs", "POST", { + biomarkerId: "bm-glucose", + value: 7.2, + unit: "mmol/L", + sourceReferenceLow: 3.9, + sourceReferenceHigh: 6.0, + takenAt: "2026-05-02T08:00:00.000Z", + }); + expect(json.data!.rangeStatus).toBe("above"); + }); + + it("stores a reading in the marker's unit as stated, with no conversion record", async () => { + const { json } = await call("labs", "POST", { + biomarkerId: "bm-glucose", + value: 95, + unit: "mg/dL", + takenAt: "2026-05-03T08:00:00.000Z", + }); + const dto = json.data!; + expect(dto.converted).toBe(false); + expect(dto.value).toBe(95); + expect(dto.sourceValue).toBeNull(); + expect(dto.sourceUnit).toBeNull(); + }); + + it("links a marker that had no key yet, the first time it is needed", async () => { + const before = await prisma.biomarker.findUniqueOrThrow({ + where: { id: "bm-glucose" }, + }); + // The marker was created without a key in this test; the first conversion + // above has already filled it. + expect(before.analyteKey).toBe("fasting-glucose"); + }); + + it("still refuses a pair that cannot be converted, now with the reason", async () => { + const { status, json } = await call("labs", "POST", { + biomarkerId: "bm-free", + value: 5.6, + unit: "mmol/L", + takenAt: "2026-05-04T08:00:00.000Z", + }); + expect(status).toBe(422); + expect(json.meta).toMatchObject({ + errorCode: "labs.unit.mismatch", + markerUnit: "mg/dL", + readingUnit: "mmol/L", + reason: "unknown_analyte", + }); + expect( + await prisma.labResult.count({ where: { biomarkerId: "bm-free" } }), + ).toBe(0); + }); +}); + +describe("the OCR commit", () => { + const row = { + analyte: "Glucose", + value: 5.0, + unit: "mmol/L", + referenceText: "3.9 - 5.6", + takenAt: "2026-06-01T08:00:00.000Z", + }; + + it("converts the row and reports it", async () => { + const { status, json } = await call<{ + inserted: Dto[]; + skipped: unknown[]; + }>("ocr", "POST", { rows: [row] }); + expect(status).toBe(200); + expect(json.data!.skipped).toEqual([]); + const [inserted] = json.data!.inserted; + expect(inserted.converted).toBe(true); + expect(inserted.unit).toBe("mg/dL"); + expect(inserted.value).toBeCloseTo(5.0 * MGDL_PER_MMOL, 9); + expect(inserted.sourceValue).toBe(5.0); + expect(inserted.sourceUnit).toBe("mmol/L"); + expect(inserted.sourceReferenceLow).toBeCloseTo(3.9 * MGDL_PER_MMOL, 9); + expect(inserted.sourceReferenceHigh).toBeCloseTo(5.6 * MGDL_PER_MMOL, 9); + }); + + it("treats the same scan committed again as a duplicate", async () => { + const { json } = await call<{ + inserted: unknown[]; + skipped: Array<{ reason: string }>; + }>("ocr", "POST", { rows: [row] }); + expect(json.data!.inserted).toEqual([]); + expect(json.data!.skipped.map((s) => s.reason)).toEqual(["duplicate"]); + }); + + it("tells the scan review which analyte the marker is, and flags a re-scan as a duplicate", async () => { + const { annotateRow } = await import("@/lib/labs/ocr-extract"); + const scanned = { + analyte: "Glucose", + value: 5.0, + valueText: null, + unit: "mmol/L", + referenceLow: null, + referenceHigh: null, + referenceText: null, + takenAt: "2026-06-01", + confidence: { analyte: 1, value: 1, unit: 1, range: 1 }, + }; + const annotated = await annotateRow(USER, scanned, "2026-06-01", "UTC"); + expect(annotated).toMatchObject({ + biomarkerMatch: "existing", + markerUnit: "mg/dL", + markerAnalyteKey: "fasting-glucose", + }); + // The converted reading from the commit above holds 90.09 mg/dL; the scan + // says 5.0 mmol/L, which is what the report printed for it. + expect(annotated.duplicateOf).not.toBeNull(); + + // A different value is not a duplicate. + const other = await annotateRow( + USER, + { ...scanned, value: 6.5 }, + "2026-06-01", + "UTC", + ); + expect(other.duplicateOf).toBeNull(); + + // A marker the registry knows nothing about reports no analyte. + const free = await annotateRow( + USER, + { ...scanned, analyte: "My own marker", unit: "mg/dL" }, + "2026-06-01", + "UTC", + ); + expect(free.markerAnalyteKey).toBeNull(); + }); + + it("skips a row whose unit cannot be converted, as before", async () => { + const { json } = await call<{ + inserted: unknown[]; + skipped: Array<{ reason: string }>; + }>("ocr", "POST", { + rows: [ + { + analyte: "My own marker", + value: 3, + unit: "mmol/L", + takenAt: "2026-06-02T08:00:00.000Z", + }, + ], + }); + expect(json.data!.inserted).toEqual([]); + expect(json.data!.skipped.map((s) => s.reason)).toEqual(["unit_mismatch"]); + }); +}); + +describe("editing and reading back", () => { + it("clears the conversion record when the value is edited", async () => { + const created = await call("labs", "POST", { + biomarkerId: "bm-glucose", + value: 6.0, + unit: "mmol/L", + takenAt: "2026-07-01T08:00:00.000Z", + }); + const id = created.json.data!.id; + expect(created.json.data!.converted).toBe(true); + + const edited = await call("one", "PUT", { value: 110 }, id); + expect(edited.status).toBe(200); + expect(edited.json.data!.value).toBe(110); + expect(edited.json.data!.converted).toBe(false); + expect(edited.json.data!.sourceValue).toBeNull(); + expect(edited.json.data!.sourceUnit).toBeNull(); + }); + + it("keeps the conversion record when only the note is edited", async () => { + const created = await call("labs", "POST", { + biomarkerId: "bm-glucose", + value: 6.1, + unit: "mmol/L", + takenAt: "2026-07-02T08:00:00.000Z", + }); + const id = created.json.data!.id; + const storedValue = created.json.data!.value; + // The web edit dialog sends the form back whole, the unchanged value + // included; only a changed value clears the record. + const edited = await call( + "one", + "PUT", + { + value: storedValue, + takenAt: "2026-07-02T09:00:00.000Z", + note: "fasting", + }, + id, + ); + expect(edited.status).toBe(200); + expect(edited.json.data!.value).toBe(storedValue); + expect(edited.json.data!.converted).toBe(true); + expect(edited.json.data!.sourceValue).toBe(6.1); + expect(edited.json.data!.sourceUnit).toBe("mmol/L"); + }); + + it("returns the fields on the list and on the single reading", async () => { + const list = await call<{ results: Dto[] }>("labs", "GET"); + const converted = list.json.data!.results.filter((r) => r.converted); + expect(converted.length).toBeGreaterThan(0); + for (const r of converted) { + expect(r.sourceValue).not.toBeNull(); + expect(r.sourceUnit).not.toBeNull(); + } + const one = await call("one", "GET", undefined, converted[0].id); + expect(one.json.data!.converted).toBe(true); + }); +});