Merge branch 'v1423/labs' into release/v1.42.3

This commit is contained in:
Marc-André Bombeck committed 2026-10-14 19:34:44 +02:00
commit 42b66b2228
72 files changed
+4115 -146

No files matched your search

+98 -13
View File
@@ -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:
+4 -1
View File
@@ -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": {
+4 -1
View File
@@ -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": {
+4 -1
View File
@@ -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": {
+4 -1
View File
@@ -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": {
+4 -1
View File
@@ -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": {
+4 -1
View File
@@ -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": {
+4 -1
View File
@@ -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": {
+1 -1
View File
@@ -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`.
@@ -722,6 +722,9 @@ const OCR_EXCEPTIONS: Readonly<Record<string, string>> = {
"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<Record<string, string>> = {
"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,
+13
View File
@@ -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
+6
View File
@@ -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,
},
});
@@ -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,
})),
);
+15 -1
View File
@@ -192,7 +192,21 @@ export const PUT = apiHandler(
const data: Record<string, unknown> = {};
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.
+60 -17
View File
@@ -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",
+57 -27
View File
@@ -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
@@ -28,6 +28,7 @@ const MARKER: BiomarkerDto = {
lowerBound: 70,
upperBound: 99,
panel: null,
analyteKey: null,
hasContext: false,
context: null,
hidden: false,
@@ -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,
@@ -0,0 +1,49 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { I18nProvider } from "@/lib/i18n/context";
import { LabConvertedNote } from "../lab-converted-note";
function render(
reading: Parameters<typeof LabConvertedNote>[0]["reading"],
locale: "en" | "de" = "en",
) {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<LabConvertedNote reading={reading} />
</I18nProvider>,
);
}
describe("<LabConvertedNote>", () => {
it("says what the report printed beside a converted reading", () => {
const html = render({
converted: true,
sourceValue: 5.6,
sourceUnit: "mmol/L",
});
expect(html).toContain('data-slot="lab-converted-note"');
expect(html).toContain("converted from 5.6 mmol/L");
});
it("formats the number for the reader's language", () => {
expect(
render({ converted: true, sourceValue: 5.6, sourceUnit: "mmol/L" }, "de"),
).toContain("umgerechnet aus 5,6 mmol/L");
});
it("trims a long fraction the way the reading itself is trimmed", () => {
expect(
render({ converted: true, sourceValue: 5.6123456, sourceUnit: "mmol/L" }),
).toContain("converted from 5.61 mmol/L");
});
it.each([
[{ converted: false, sourceValue: null, sourceUnit: null }],
[{ converted: true, sourceValue: null, sourceUnit: "mmol/L" }],
[{ converted: true, sourceValue: 5.6, sourceUnit: null }],
])("renders nothing for %j", (reading) => {
expect(render(reading)).toBe("");
});
});
@@ -29,6 +29,9 @@ function reading(over: Partial<LabResultDto>): LabResultDto {
value: 80,
valueText: null,
unit: "ng/mL",
converted: false,
sourceValue: null,
sourceUnit: null,
referenceLow: 30,
referenceHigh: 400,
catalogReferenceLow: 30,
@@ -165,4 +168,21 @@ describe("<LabList> columns", () => {
}
});
});
it("notes where a converted reading came from, and only for that reading", () => {
const html = renderList([
reading({
id: "c",
biomarkerId: "b1",
analyte: "Glucose",
unit: "mg/dL",
converted: true,
sourceValue: 5.6,
sourceUnit: "mmol/L",
}),
reading({ id: "d", biomarkerId: "b2", analyte: "Ferritin" }),
]);
expect(count(html, 'data-slot="lab-converted-note"')).toBe(1);
expect(html).toContain("converted from 5.6 mmol/L");
});
});
@@ -5,6 +5,8 @@ import { seedReviewRows } from "../ocr-review-types";
import {
collectRowErrors,
planSave,
readingConversion,
readingUnitBlocked,
readingUnitDiffers,
toCommitRow,
validateReviewRow,
@@ -24,6 +26,7 @@ function row(overrides: Partial<OcrReviewRow> = {}): OcrReviewRow {
confidence: { analyte: 1, value: 1, unit: 1, range: 1 },
biomarkerMatch: "existing",
markerUnit: "mmol/L",
markerAnalyteKey: null,
duplicateOf: null,
confirmed: true,
...overrides,
@@ -200,6 +203,7 @@ describe("a reading in another unit than its marker's", () => {
confidence: { analyte: 1, value: 1, unit: 1, range: 1 },
biomarkerMatch: "existing" as const,
markerUnit,
markerAnalyteKey: null,
duplicateOf: null,
});
const [same, other, fresh] = seedReviewRows(
@@ -212,3 +216,85 @@ describe("a reading in another unit than its marker's", () => {
expect(fresh.confirmed).toBe(true);
});
});
describe("a reading in another unit that can be converted", () => {
// Glucose stated in mmol/L against a marker kept in mg/dL, and the analyte is
// known: Save converts it, so nothing blocks and the row starts selected.
const glucose = (overrides: Partial<OcrReviewRow> = {}) =>
row({
analyte: "Glucose",
value: 5.6,
unit: "mmol/L",
markerUnit: "mg/dL",
markerAnalyteKey: "fasting-glucose",
...overrides,
});
it("previews what Save will store, beside what the report stated", () => {
const conversion = readingConversion(glucose());
expect(conversion).toMatchObject({
unit: "mg/dL",
statedValue: 5.6,
statedUnit: "mmol/L",
});
expect(conversion!.value).toBeCloseTo(5.6 * 18.0182, 9);
});
it("is not blocked, and not a validation error", () => {
expect(readingUnitDiffers(glucose())).toBe(true);
expect(readingUnitBlocked(glucose())).toBe(false);
expect(validateReviewRow(glucose())).toEqual({});
});
it("sends what the report printed, for the server to convert", () => {
const payload = toCommitRow(glucose());
expect(payload).toMatchObject({
analyte: "Glucose",
value: 5.6,
unit: "mmol/L",
});
expect(planSave([glucose()]).kind).toBe("ready");
});
it("starts selected on the review screen", () => {
const dto = {
analyte: "Glucose",
value: 5.6,
valueText: null,
unit: "mmol/L",
referenceLow: null,
referenceHigh: null,
referenceText: null,
takenAt: "2026-06-10",
confidence: { analyte: 1, value: 1, unit: 1, range: 1 },
biomarkerMatch: "existing" as const,
markerUnit: "mg/dL",
markerAnalyteKey: "fasting-glucose",
duplicateOf: null,
};
const [converts, blocked] = seedReviewRows(
[dto, { ...dto, markerAnalyteKey: null }],
null,
);
expect(converts.confirmed).toBe(true);
expect(blocked.confirmed).toBe(false);
});
it("stays blocked when the marker has no known analyte, or the pair is not in the registry", () => {
expect(readingUnitBlocked(glucose({ markerAnalyteKey: null }))).toBe(true);
expect(readingConversion(glucose({ markerAnalyteKey: null }))).toBeNull();
expect(validateReviewRow(glucose({ markerAnalyteKey: null }))).toEqual({
unitMismatch: true,
});
// The analyte is known but the unit is not one it reaches.
expect(readingUnitBlocked(glucose({ unit: "%" }))).toBe(true);
});
it("has nothing to preview when the units agree or the row is qualitative", () => {
expect(readingConversion(glucose({ unit: "mg/dL" }))).toBeNull();
expect(
readingConversion(glucose({ value: null, valueText: "negative" })),
).toBeNull();
expect(readingConversion(glucose({ value: null }))).toBeNull();
});
});
@@ -19,6 +19,7 @@ const baseRow: OcrReviewRow = {
confidence: { analyte: 1, value: 1, unit: 1, range: 1 },
biomarkerMatch: "existing",
markerUnit: "mmol/L",
markerAnalyteKey: null,
duplicateOf: null,
confirmed: true,
};
@@ -155,3 +156,54 @@ describe("<OcrRowEditor> unit differs from the marker's", () => {
expect(html).toContain("Geführt in mmol/L; dieser Befund nennt mg/dL");
});
});
describe("<OcrRowEditor> unit differs but converts", () => {
const converts: OcrReviewRow = {
...baseRow,
analyte: "Glucose",
value: 5.6,
takenAt: "2026-06-10",
unit: "mmol/L",
markerUnit: "mg/dL",
markerAnalyteKey: "fasting-glucose",
};
it("says what Save will store and what the report stated, not that it is blocked", () => {
const html = render(undefined, "en", converts);
expect(html).toContain('data-slot="ocr-row-converted-badge"');
expect(html).toContain("Saved as 100.9 mg/dL, converted from 5.6 mmol/L");
expect(html).not.toContain("Tracked in");
expect(html).not.toContain('aria-invalid="true"');
});
it("wraps the sentence instead of cutting off the printed value", () => {
const html = render(undefined, "en", converts);
const badge =
/<span[^>]*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");
});
});
@@ -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<typeof SourceRangeNote>[0]["reading"];
function render(reading: Reading) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<SourceRangeNote reading={reading} />
</I18nProvider>,
);
}
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("<SourceRangeNote>", () => {
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("");
});
});
@@ -0,0 +1,44 @@
"use client";
import { useTranslations } from "@/lib/i18n/context";
import { formatLabValue } from "@/lib/labs/format-value";
import { useLabNumber } from "./use-lab-format";
/**
* A quiet "converted from 5.6 mmol/L" beside a reading whose stored number is a
* conversion of what the report printed, so a number nobody typed can always be
* traced back. Renders nothing for a reading stored as stated.
*/
export function LabConvertedNote({
reading,
className,
}: {
reading: {
converted: boolean;
sourceValue: number | null;
sourceUnit: string | null;
};
className?: string;
}) {
const { t } = useTranslations();
const labNumber = useLabNumber();
if (
!reading.converted ||
reading.sourceValue === null ||
reading.sourceUnit === null
) {
return null;
}
return (
<span
data-slot="lab-converted-note"
className={className ?? "text-muted-foreground text-xs"}
>
{t("labs.convertedFrom", {
value: formatLabValue(reading.sourceValue, labNumber),
unit: reading.sourceUnit,
})}
</span>
);
}
+24 -4
View File
@@ -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[] }) {
<div className="min-w-0 flex-1 space-y-2">
<div className="font-semibold tabular-nums">
{formatLabReading(r, labNumber)}
<LabConvertedNote
reading={r}
className="text-muted-foreground ml-2 text-xs font-normal"
/>
</div>
<LabReferenceRangeBar
value={r.value}
@@ -453,6 +469,10 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
<TableCell className="font-semibold tabular-nums">
<div className="space-y-2">
<span>{formatLabReading(r, labNumber)}</span>
<LabConvertedNote
reading={r}
className="text-muted-foreground block text-xs font-normal"
/>
<LabReferenceRangeBar
value={r.value}
referenceLow={r.referenceLow}
+3
View File
@@ -15,6 +15,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { apiGet } from "@/lib/api/api-fetch";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { formatLabReading } from "@/lib/labs/format-value";
import { LabConvertedNote } from "./lab-converted-note";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { queryKeys } from "@/lib/query-keys";
@@ -299,6 +300,7 @@ export function LabList({
{formatLabReading(group.latest, labNumber)}
</span>
<span>{labDate(group.latest.takenAt)}</span>
<LabConvertedNote reading={group.latest} />
</div>
</div>
);
@@ -429,6 +431,7 @@ export function LabList({
<span className="text-xs">
{labDate(group.latest.takenAt)}
</span>
<LabConvertedNote reading={group.latest} />
{group.readings.length > 1 ? (
<span className="text-xs">
{t("labs.readingsCount", {
+13 -6
View File
@@ -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,
}),
}));
}
+57 -2
View File
@@ -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,
+33 -6
View File
@@ -32,7 +32,12 @@ import { Label } from "@/components/ui/label";
import { useTranslations } from "@/lib/i18n/context";
import type { OcrReviewRow } from "./ocr-review-types";
import { readingUnitDiffers, type OcrRowErrors } from "./ocr-review-validation";
import {
readingConversion,
readingUnitBlocked,
type OcrRowErrors,
} from "./ocr-review-validation";
import { useLabNumber } from "./use-lab-format";
/** Below this per-field confidence the field is flagged for a second look. */
const CONFIDENCE_THRESHOLD = 0.6;
@@ -76,10 +81,13 @@ export function OcrRowEditor({
message={t(messageKey, params)}
/>
) : null;
// Stated before Save, not only after a failed attempt: a reading in another
// unit than its marker's cannot be written, and the person should see why
// the row is unticked (or will not save) while they are still reading it.
const unitDiffers = readingUnitDiffers(row);
// Stated before Save, not only after a failed attempt. A reading in another
// unit than its marker's is either converted (the badge says what Save will
// store) or cannot be written (the person should see why the row is unticked,
// or will not save, while they are still reading it).
const unitBlocked = readingUnitBlocked(row);
const conversion = readingConversion(row);
const labNumber = useLabNumber();
const isQualitative = row.valueText !== null && row.valueText !== undefined;
const lowValueConfidence =
@@ -169,7 +177,7 @@ export function OcrRowEditor({
{t("labs.ocr.duplicateWarning")}
</Badge>
) : null}
{unitDiffers ? (
{unitBlocked ? (
<Badge variant="secondary" className="max-w-full">
<Ruler aria-hidden />
<span className="truncate">
@@ -180,6 +188,25 @@ export function OcrRowEditor({
</span>
</Badge>
) : null}
{conversion ? (
<Badge
variant="outline"
// Wraps rather than truncates: the printed value is the end
// of the sentence, exactly what a cut at 390 px would hide.
className="h-auto max-w-full justify-start rounded-md text-left whitespace-normal"
data-slot="ocr-row-converted-badge"
>
<Ruler aria-hidden />
<span className="min-w-0 break-words">
{t("labs.ocr.unitConvertedBadge", {
value: labNumber(conversion.value),
markerUnit: conversion.unit,
statedValue: labNumber(conversion.statedValue),
unit: conversion.statedUnit,
})}
</span>
</Badge>
) : null}
{lowRowConfidence ? (
<Badge variant="secondary" className="text-muted-foreground">
<AlertCircle aria-hidden />
+17 -2
View File
@@ -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 (
<span className={className}>
{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(),
+18
View File
@@ -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. */
+1
View File
@@ -143,6 +143,7 @@ export async function buildLabsSnapshotBlock(
sourceReferenceLow: true,
sourceReferenceHigh: true,
sourceReferenceText: true,
sourceUnit: true,
takenAt: true,
biomarkerId: true,
biomarker: {
@@ -56,6 +56,7 @@ export async function loadLabResults(
sourceReferenceLow: true,
sourceReferenceHigh: true,
sourceReferenceText: true,
sourceUnit: true,
takenAt: true,
},
});
+76 -8
View File
@@ -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",
+37 -12
View File
@@ -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",
@@ -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 }),
@@ -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`),
};
}
+1
View File
@@ -172,6 +172,7 @@ export async function generateBiomarkerStatus(args: {
sourceReferenceLow: true,
sourceReferenceHigh: true,
sourceReferenceText: true,
sourceUnit: true,
},
});
if (rows.length === 0) {
@@ -1072,6 +1072,7 @@ export async function fetchLabDraws(
sourceReferenceLow: true,
sourceReferenceHigh: true,
sourceReferenceText: true,
sourceUnit: true,
panel: true,
value: true,
takenAt: true,
+1
View File
@@ -256,6 +256,7 @@ export async function readLabsBriefingBlock(
sourceReferenceLow: true,
sourceReferenceHigh: true,
sourceReferenceText: true,
sourceUnit: true,
takenAt: true,
biomarkerId: true,
biomarker: {
+2
View File
@@ -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;
@@ -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);
}
});
});
@@ -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");
@@ -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<typeof vi.fn> };
}
).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",
});
});
});
@@ -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", {
+2
View File
@@ -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",
@@ -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);
});
});
+53
View File
@@ -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<PrismaClient, "biomarker">,
marker: MarkerKeyState,
): Promise<string | null> {
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 });
}
+83
View File
@@ -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<string, string> | 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<string, string>;
ambiguous: readonly string[];
} {
const claims = new Map<string, Set<string>>();
const claim = (name: string, key: string) => {
const folded = foldForMatch(name);
if (!folded) return;
const keys = claims.get(folded) ?? new Set<string>();
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<string, string>();
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;
}
+149
View File
@@ -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",
],
},
];
+4
View File
@@ -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,
});
+37 -2
View File
@@ -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,
};
}
+105
View File
@@ -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<PrismaClient, "biomarker">,
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<ReadingInMarkerUnit> {
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(),
};
}
+35 -2
View File
@@ -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,
};
}
+45 -1
View File
@@ -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<LabRow, "sourceValue" | "sourceUnit">) {
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(),
@@ -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);
}
}
}
});
});
@@ -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<string, number> = {
"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<string, number> = {
"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/);
});
});
@@ -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");
});
});
@@ -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<Partial<Record<Element, number>>>;
/** 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("");
}
+183
View File
@@ -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<number | null> = [input.low, input.high];
const converted: Array<number | null> = [];
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<string>(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
);
}
+236
View File
@@ -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<Record<string, Affine>>;
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<string, AnalyteConversion> = (() => {
const map = new Map<string, AnalyteConversion>();
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;
}
+130
View File
@@ -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<Record<string, number>> = {
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<string, readonly [FamilyName, number]> = 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);
}
+29
View File
@@ -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;
}
+1
View File
@@ -1426,6 +1426,7 @@ export async function getLabHistory(
sourceReferenceLow: true,
sourceReferenceHigh: true,
sourceReferenceText: true,
sourceUnit: true,
takenAt: true,
biomarkerId: true,
biomarker: {
+2 -1
View File
@@ -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
+6 -2
View File
@@ -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<ZodOpenApiObject["paths"]> = {
...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,
+11 -3
View File
@@ -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`.",
}),
}),
),
+6
View File
@@ -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;
}
@@ -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<typeof labObservations>[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();
+50 -29
View File
@@ -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");
+375
View File
@@ -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<string, unknown>) {
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<string, unknown>) {
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<string, unknown>) {
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");
});
});
@@ -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<T = unknown>(
route: "labs" | "ocr" | "one",
method: "GET" | "POST" | "PUT",
body?: unknown,
id?: string,
): Promise<{
status: number;
json: { data?: T; meta?: Record<string, unknown> };
}> {
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<Dto>("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<Dto>("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<Dto>("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<Dto>("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<Dto>("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<Dto>("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<Dto>(
"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<Dto>("one", "GET", undefined, converted[0].id);
expect(one.json.data!.converted).toBe(true);
});
});