mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
Merge branch 'v1423/labs' into release/v1.42.3
This commit is contained in:
commit
42b66b2228
72 files changed
+4115
-146
No files matched your search
+98
-13
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { formatReferenceRange } from "@/lib/labs/reference-range";
|
||||
import { formatLabReading } from "@/lib/labs/format-value";
|
||||
import { LabConvertedNote } from "./lab-converted-note";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -299,6 +300,7 @@ export function LabList({
|
||||
{formatLabReading(group.latest, labNumber)}
|
||||
</span>
|
||||
<span>{labDate(group.latest.takenAt)}</span>
|
||||
<LabConvertedNote reading={group.latest} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -429,6 +431,7 @@ export function LabList({
|
||||
<span className="text-xs">
|
||||
{labDate(group.latest.takenAt)}
|
||||
</span>
|
||||
<LabConvertedNote reading={group.latest} />
|
||||
{group.readings.length > 1 ? (
|
||||
<span className="text-xs">
|
||||
{t("labs.readingsCount", {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
import type { OcrExtractedRowDto } from "@/lib/validations/labs-ocr";
|
||||
|
||||
import { readingUnitDiffers } from "./ocr-review-validation";
|
||||
import { readingUnitBlocked } from "./ocr-review-validation";
|
||||
|
||||
export interface OcrReviewRow {
|
||||
/** Stable client key for the list (the analyte+index at seed time). */
|
||||
@@ -26,9 +26,12 @@ export interface OcrReviewRow {
|
||||
biomarkerMatch: "new" | "existing";
|
||||
/**
|
||||
* The unit the matched marker is tracked in; null for a marker that does not
|
||||
* exist yet. A numeric row whose `unit` differs from it cannot be saved.
|
||||
* exist yet. A numeric row whose `unit` differs from it is converted on
|
||||
* Save when it can be (see `readingConversion`) and cannot be saved when not.
|
||||
*/
|
||||
markerUnit: string | null;
|
||||
/** Which analyte the matched marker is (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,
|
||||
}),
|
||||
}));
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
* build the payload: it returns null exactly when `validateReviewRow` reports
|
||||
* at least one field.
|
||||
*/
|
||||
import { convertLabValue } from "@/lib/labs/unit-conversion/convert";
|
||||
import { sameLabUnit } from "@/lib/labs/unit-normalise";
|
||||
|
||||
import type { OcrReviewRow } from "./ocr-review-types";
|
||||
@@ -46,6 +47,60 @@ export function readingUnitDiffers(row: {
|
||||
return !sameLabUnit(stated, row.markerUnit);
|
||||
}
|
||||
|
||||
/**
|
||||
* What Save will store for a numeric row whose unit differs from its marker's
|
||||
* and can be converted: the value in the marker's unit, beside what the report
|
||||
* stated. Null when the units agree, the row is qualitative, or the pair has no
|
||||
* conversion. The commit does the same conversion on the server; this only lets
|
||||
* the screen say so before Save.
|
||||
*/
|
||||
export function readingConversion(row: {
|
||||
value: number | null | undefined;
|
||||
valueText: string | null | undefined;
|
||||
unit: string | null | undefined;
|
||||
markerUnit: string | null | undefined;
|
||||
markerAnalyteKey: string | null | undefined;
|
||||
}): {
|
||||
value: number;
|
||||
unit: string;
|
||||
statedValue: number;
|
||||
statedUnit: string;
|
||||
} | null {
|
||||
if (!readingUnitDiffers(row)) return null;
|
||||
if (row.value === null || row.value === undefined) return null;
|
||||
if (!Number.isFinite(row.value)) return null;
|
||||
const statedUnit = (row.unit ?? "").trim();
|
||||
const converted = convertLabValue({
|
||||
analyteKey: row.markerAnalyteKey,
|
||||
value: row.value,
|
||||
from: statedUnit,
|
||||
to: row.markerUnit as string,
|
||||
});
|
||||
if (!converted.ok) return null;
|
||||
return {
|
||||
value: converted.value,
|
||||
unit: row.markerUnit as string,
|
||||
statedValue: row.value,
|
||||
statedUnit,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A numeric reading in another unit than its marker's that cannot be
|
||||
* converted: the one case that still cannot be saved. A row with no number yet
|
||||
* has nothing to convert, so it is blocked here as well as on `value`.
|
||||
*/
|
||||
export function readingUnitBlocked(row: {
|
||||
value: number | null | undefined;
|
||||
valueText: string | null | undefined;
|
||||
unit: string | null | undefined;
|
||||
markerUnit: string | null | undefined;
|
||||
markerAnalyteKey: string | null | undefined;
|
||||
}): boolean {
|
||||
if (!readingUnitDiffers(row)) return false;
|
||||
return readingConversion(row) === null;
|
||||
}
|
||||
|
||||
export function validateReviewRow(row: OcrReviewRow): OcrRowErrors {
|
||||
const errors: OcrRowErrors = {};
|
||||
if (!row.analyte.trim()) errors.analyte = true;
|
||||
@@ -56,7 +111,7 @@ export function validateReviewRow(row: OcrReviewRow): OcrRowErrors {
|
||||
} else {
|
||||
if (row.value === null || !Number.isFinite(row.value)) errors.value = true;
|
||||
if (!(row.unit ?? "").trim()) errors.unit = true;
|
||||
if (readingUnitDiffers(row)) errors.unitMismatch = true;
|
||||
if (readingUnitBlocked(row)) errors.unitMismatch = true;
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
@@ -80,7 +135,7 @@ export function toCommitRow(row: OcrReviewRow): OcrCommitRowInput | null {
|
||||
if (row.value === null || !Number.isFinite(row.value)) return null;
|
||||
const unit = (row.unit ?? "").trim();
|
||||
if (!unit) return null;
|
||||
if (readingUnitDiffers(row)) return null;
|
||||
if (readingUnitBlocked(row)) return null;
|
||||
return {
|
||||
analyte,
|
||||
value: row.value,
|
||||
|
||||
@@ -32,7 +32,12 @@ import { Label } from "@/components/ui/label";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import type { OcrReviewRow } from "./ocr-review-types";
|
||||
import { readingUnitDiffers, type OcrRowErrors } from "./ocr-review-validation";
|
||||
import {
|
||||
readingConversion,
|
||||
readingUnitBlocked,
|
||||
type OcrRowErrors,
|
||||
} from "./ocr-review-validation";
|
||||
import { useLabNumber } from "./use-lab-format";
|
||||
|
||||
/** Below this per-field confidence the field is flagged for a second look. */
|
||||
const CONFIDENCE_THRESHOLD = 0.6;
|
||||
@@ -76,10 +81,13 @@ export function OcrRowEditor({
|
||||
message={t(messageKey, params)}
|
||||
/>
|
||||
) : null;
|
||||
// Stated before Save, not only after a failed attempt: a reading in another
|
||||
// unit than its marker's cannot be written, and the person should see why
|
||||
// the row is unticked (or will not save) while they are still reading it.
|
||||
const unitDiffers = readingUnitDiffers(row);
|
||||
// Stated before Save, not only after a failed attempt. A reading in another
|
||||
// unit than its marker's is either converted (the badge says what Save will
|
||||
// store) or cannot be written (the person should see why the row is unticked,
|
||||
// or will not save, while they are still reading it).
|
||||
const unitBlocked = readingUnitBlocked(row);
|
||||
const conversion = readingConversion(row);
|
||||
const labNumber = useLabNumber();
|
||||
|
||||
const isQualitative = row.valueText !== null && row.valueText !== undefined;
|
||||
const lowValueConfidence =
|
||||
@@ -169,7 +177,7 @@ export function OcrRowEditor({
|
||||
{t("labs.ocr.duplicateWarning")}
|
||||
</Badge>
|
||||
) : null}
|
||||
{unitDiffers ? (
|
||||
{unitBlocked ? (
|
||||
<Badge variant="secondary" className="max-w-full">
|
||||
<Ruler aria-hidden />
|
||||
<span className="truncate">
|
||||
@@ -180,6 +188,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 />
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
@@ -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`),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -256,6 +256,7 @@ export async function readLabsBriefingBlock(
|
||||
sourceReferenceLow: true,
|
||||
sourceReferenceHigh: true,
|
||||
sourceReferenceText: true,
|
||||
sourceUnit: true,
|
||||
takenAt: true,
|
||||
biomarkerId: true,
|
||||
biomarker: {
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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("");
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1426,6 +1426,7 @@ export async function getLabHistory(
|
||||
sourceReferenceLow: true,
|
||||
sourceReferenceHigh: true,
|
||||
sourceReferenceText: true,
|
||||
sourceUnit: true,
|
||||
takenAt: true,
|
||||
biomarkerId: true,
|
||||
biomarker: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`.",
|
||||
}),
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user