mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge branch 'feat/unitless' into release/v1.46.6
This commit is contained in:
commit
2c6fb82326
55 files changed
+1029
-127
No files matched your search
+55
-4
@@ -15875,10 +15875,11 @@ paths:
|
||||
checks an hour, the import itself the shared 5-an-hour `import:` bucket. Owner only; refused with
|
||||
`module.disabled` when the module is switched off. An unknown query parameter is refused with 422, so a misspelt
|
||||
`dryRun` never writes. Each row's name resolves to the account's marker the way a scanned report's rows do
|
||||
(name, alias, catalog name); a new analyte mints one marker from its first row. A numeric result needs a unit;
|
||||
one in another unit is converted when the conversion is known and keeps the printed value beside it, otherwise
|
||||
the row is refused with `unit_mismatch`. Any other text in the value column is a qualitative result. A date
|
||||
without a time is stored as that calendar date. At most 20 000 data rows."
|
||||
(name, alias, catalog name); a new analyte mints one marker from its first row. A numeric result needs a unit:
|
||||
an empty unit cell is refused with `missing_unit`, and `1` (the UCUM unity) states a value without a unit, such
|
||||
as a ratio or INR. A result in another unit is converted when the conversion is known and keeps the printed
|
||||
value beside it, otherwise the row is refused with `unit_mismatch`. Any other text in the value column is a
|
||||
qualitative result. A date without a time is stored as that calendar date. At most 20 000 data rows."
|
||||
parameters:
|
||||
- in: query
|
||||
name: dryRun
|
||||
@@ -34146,6 +34147,11 @@ components:
|
||||
minLength: 1
|
||||
maxLength: 120
|
||||
unit:
|
||||
description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means
|
||||
the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a
|
||||
unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real
|
||||
unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is
|
||||
never read as `"1"` and stays refused.'
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 40
|
||||
@@ -34215,6 +34221,11 @@ components:
|
||||
minLength: 1
|
||||
maxLength: 120
|
||||
unit:
|
||||
description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means
|
||||
the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a
|
||||
unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real
|
||||
unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is
|
||||
never read as `"1"` and stays refused.'
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 40
|
||||
@@ -34309,6 +34320,11 @@ components:
|
||||
minLength: 1
|
||||
maxLength: 120
|
||||
unit:
|
||||
description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means
|
||||
the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice
|
||||
and a unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker
|
||||
kept in a real unit and the other way round. Clients show nothing beside such a value. An empty unit
|
||||
on a numeric value is never read as `"1"` and stays refused.'
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 40
|
||||
@@ -34363,6 +34379,11 @@ components:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 40
|
||||
description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means
|
||||
the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a
|
||||
unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real
|
||||
unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is
|
||||
never read as `"1"` and stays refused.'
|
||||
lowerBound:
|
||||
type: number
|
||||
upperBound:
|
||||
@@ -34392,6 +34413,11 @@ components:
|
||||
minLength: 1
|
||||
maxLength: 120
|
||||
unit:
|
||||
description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means
|
||||
the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a
|
||||
unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real
|
||||
unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is
|
||||
never read as `"1"` and stays refused.'
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 40
|
||||
@@ -35759,6 +35785,11 @@ components:
|
||||
maxLength: 300
|
||||
- type: "null"
|
||||
unit:
|
||||
description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means
|
||||
the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and
|
||||
a unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in
|
||||
a real unit and the other way round. Clients show nothing beside such a value. An empty unit on a
|
||||
numeric value is never read as `"1"` and stays refused.'
|
||||
anyOf:
|
||||
- type: string
|
||||
maxLength: 80
|
||||
@@ -75275,6 +75306,11 @@ components:
|
||||
- "null"
|
||||
unit:
|
||||
type: string
|
||||
description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means
|
||||
the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a
|
||||
unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real
|
||||
unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is
|
||||
never read as `"1"` and stays refused.'
|
||||
converted:
|
||||
type: boolean
|
||||
sourceValue:
|
||||
@@ -75524,6 +75560,11 @@ components:
|
||||
- "null"
|
||||
unit:
|
||||
type: string
|
||||
description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means
|
||||
the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a
|
||||
unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real
|
||||
unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is
|
||||
never read as `"1"` and stays refused.'
|
||||
converted:
|
||||
type: boolean
|
||||
sourceValue:
|
||||
@@ -76503,6 +76544,11 @@ components:
|
||||
type: string
|
||||
unit:
|
||||
type: string
|
||||
description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means
|
||||
the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a
|
||||
unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real
|
||||
unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is
|
||||
never read as `"1"` and stays refused.'
|
||||
lowerBound:
|
||||
type:
|
||||
- number
|
||||
@@ -76602,6 +76648,11 @@ components:
|
||||
type: string
|
||||
unit:
|
||||
type: string
|
||||
description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means
|
||||
the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a
|
||||
unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real
|
||||
unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is
|
||||
never read as `"1"` and stays refused.'
|
||||
lowerBound:
|
||||
type:
|
||||
- number
|
||||
|
||||
+9
-2
@@ -10396,7 +10396,9 @@
|
||||
"pdfNeedsAnthropic": "PDF-Scannen benötigt einen Claude-Bildanbieter; lade stattdessen ein Foto hoch.",
|
||||
"refTextLabel": "Bereich wie gedruckt",
|
||||
"refTextPlaceholder": "z. B. 3,5 - 5,0",
|
||||
"aiUnavailable": "Das Scannen eines Befunds mit KI ist hier gerade nicht verfügbar."
|
||||
"aiUnavailable": "Das Scannen eines Befunds mit KI ist hier gerade nicht verfügbar.",
|
||||
"errorUnitMismatchNoUnit": "Dieser Marker wird ohne Einheit geführt. Setz das Häkchen bei Keine Einheit, falls der Befund falsch gelesen wurde, oder wähle den Wert ab.",
|
||||
"unitDiffersBadgeNoUnit": "Ohne Einheit geführt; dieser Befund nennt {unit}"
|
||||
},
|
||||
"loadMoreReadings": "Weitere Werte laden",
|
||||
"documentsLink": "Befund-Dokumente",
|
||||
@@ -10474,6 +10476,10 @@
|
||||
"stale": "Der neueste Wert ist vom {date}, also älter als ein Jahr. Die Gerade gilt für diesen Stand, ohne Projektion."
|
||||
},
|
||||
"interval": "95-%-Intervall der jährlichen Veränderung: {lo} bis {hi} {unit}."
|
||||
},
|
||||
"unit": {
|
||||
"none": "Keine Einheit",
|
||||
"noneHint": "Für einen Quotienten oder Index wie INR oder Cholesterin/HDL."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -14093,6 +14099,7 @@
|
||||
"asNeeded": "Bei Bedarf",
|
||||
"startsOn": "Beginn",
|
||||
"endsOn": "Ende"
|
||||
}
|
||||
},
|
||||
"unitlessHint": "Ein Quotient oder Index ohne Einheit (INR, Cholesterin/HDL): Trag in der Spalte für die Einheit eine 1 ein. Eine leere Einheit bleibt ein Fehler."
|
||||
}
|
||||
}
|
||||
+9
-2
@@ -10396,7 +10396,9 @@
|
||||
"pdfNeedsAnthropic": "PDF scanning needs a Claude vision provider; upload a photo instead.",
|
||||
"refTextLabel": "Range as printed",
|
||||
"refTextPlaceholder": "e.g. 3.5 - 5.0",
|
||||
"aiUnavailable": "Scanning a report with AI is not available here right now."
|
||||
"aiUnavailable": "Scanning a report with AI is not available here right now.",
|
||||
"errorUnitMismatchNoUnit": "This marker is tracked without a unit. Tick No unit if the report was misread, or deselect this reading.",
|
||||
"unitDiffersBadgeNoUnit": "Tracked without a unit; this report says {unit}"
|
||||
},
|
||||
"loadMoreReadings": "Load more readings",
|
||||
"documentsLink": "Lab report documents",
|
||||
@@ -10474,6 +10476,10 @@
|
||||
"stale": "The newest reading is from {date}, more than a year ago. The line is shown as of then, without a projection."
|
||||
},
|
||||
"interval": "95 % interval of the yearly change: {lo} to {hi} {unit}."
|
||||
},
|
||||
"unit": {
|
||||
"none": "No unit",
|
||||
"noneHint": "For a ratio or an index such as INR or cholesterol/HDL."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -14093,6 +14099,7 @@
|
||||
"asNeeded": "As needed",
|
||||
"startsOn": "Start",
|
||||
"endsOn": "End"
|
||||
}
|
||||
},
|
||||
"unitlessHint": "A ratio or an index without a unit (INR, cholesterol/HDL): write 1 in the unit column. An empty unit cell stays an error."
|
||||
}
|
||||
}
|
||||
+9
-2
@@ -10396,7 +10396,9 @@
|
||||
"pdfNeedsAnthropic": "El escaneo de PDF necesita un proveedor de visión de Claude; sube una foto en su lugar.",
|
||||
"refTextLabel": "Rango tal como aparece",
|
||||
"refTextPlaceholder": "p. ej. 3,5 - 5,0",
|
||||
"aiUnavailable": "Escanear un informe con IA no está disponible aquí ahora mismo."
|
||||
"aiUnavailable": "Escanear un informe con IA no está disponible aquí ahora mismo.",
|
||||
"errorUnitMismatchNoUnit": "Este marcador se registra sin unidad. Marca Sin unidad si el informe se leyó mal o deselecciona este valor.",
|
||||
"unitDiffersBadgeNoUnit": "Registrado sin unidad; este informe indica {unit}"
|
||||
},
|
||||
"loadMoreReadings": "Cargar más mediciones",
|
||||
"documentsLink": "Documentos de resultados",
|
||||
@@ -10474,6 +10476,10 @@
|
||||
"stale": "El valor más reciente es del {date}, hace más de un año. La línea se muestra a esa fecha, sin proyección."
|
||||
},
|
||||
"interval": "Intervalo del 95 % del cambio anual: de {lo} a {hi} {unit}."
|
||||
},
|
||||
"unit": {
|
||||
"none": "Sin unidad",
|
||||
"noneHint": "Para un cociente o un índice como el INR o colesterol/HDL."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -14093,6 +14099,7 @@
|
||||
"asNeeded": "A demanda",
|
||||
"startsOn": "Inicio",
|
||||
"endsOn": "Fin"
|
||||
}
|
||||
},
|
||||
"unitlessHint": "Un cociente o un índice sin unidad (INR, colesterol/HDL): escribe 1 en la columna de la unidad. Una unidad vacía sigue siendo un error."
|
||||
}
|
||||
}
|
||||
+9
-2
@@ -10396,7 +10396,9 @@
|
||||
"pdfNeedsAnthropic": "Le scan PDF nécessite un fournisseur de vision Claude ; importez plutôt une photo.",
|
||||
"refTextLabel": "Plage telle qu'imprimée",
|
||||
"refTextPlaceholder": "p. ex. 3,5 - 5,0",
|
||||
"aiUnavailable": "La numérisation d'un compte rendu par IA n'est pas disponible ici pour le moment."
|
||||
"aiUnavailable": "La numérisation d'un compte rendu par IA n'est pas disponible ici pour le moment.",
|
||||
"errorUnitMismatchNoUnit": "Ce marqueur est suivi sans unité. Cochez Sans unité si le compte rendu a été mal lu, ou désélectionnez cette valeur.",
|
||||
"unitDiffersBadgeNoUnit": "Suivi sans unité ; ce compte rendu indique {unit}"
|
||||
},
|
||||
"loadMoreReadings": "Charger plus de mesures",
|
||||
"documentsLink": "Documents de résultats",
|
||||
@@ -10474,6 +10476,10 @@
|
||||
"stale": "La valeur la plus récente date du {date}, il y a plus d’un an. La droite est montrée à cette date, sans projection."
|
||||
},
|
||||
"interval": "Intervalle à 95 % de la variation annuelle : de {lo} à {hi} {unit}."
|
||||
},
|
||||
"unit": {
|
||||
"none": "Sans unité",
|
||||
"noneHint": "Pour un rapport ou un indice comme l'INR ou cholestérol/HDL."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -14093,6 +14099,7 @@
|
||||
"asNeeded": "Si besoin",
|
||||
"startsOn": "Début",
|
||||
"endsOn": "Fin"
|
||||
}
|
||||
},
|
||||
"unitlessHint": "Un rapport ou un indice sans unité (INR, cholestérol/HDL) : inscrivez 1 dans la colonne de l'unité. Une unité vide reste une erreur."
|
||||
}
|
||||
}
|
||||
+9
-2
@@ -10396,7 +10396,9 @@
|
||||
"pdfNeedsAnthropic": "La scansione PDF richiede un provider di visione Claude; carica invece una foto.",
|
||||
"refTextLabel": "Intervallo come stampato",
|
||||
"refTextPlaceholder": "es. 3,5 - 5,0",
|
||||
"aiUnavailable": "La scansione di un referto con IA non è disponibile qui al momento."
|
||||
"aiUnavailable": "La scansione di un referto con IA non è disponibile qui al momento.",
|
||||
"errorUnitMismatchNoUnit": "Questo marcatore è registrato senza unità. Spunta Nessuna unità se il referto è stato letto male, oppure deseleziona questo valore.",
|
||||
"unitDiffersBadgeNoUnit": "Registrato senza unità; questo referto indica {unit}"
|
||||
},
|
||||
"loadMoreReadings": "Carica altre misurazioni",
|
||||
"documentsLink": "Documenti dei referti",
|
||||
@@ -10474,6 +10476,10 @@
|
||||
"stale": "Il valore più recente è del {date}, più di un anno fa. La retta è mostrata a quella data, senza proiezione."
|
||||
},
|
||||
"interval": "Intervallo al 95 % della variazione annua: da {lo} a {hi} {unit}."
|
||||
},
|
||||
"unit": {
|
||||
"none": "Nessuna unità",
|
||||
"noneHint": "Per un rapporto o un indice come l'INR o colesterolo/HDL."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -14093,6 +14099,7 @@
|
||||
"asNeeded": "Al bisogno",
|
||||
"startsOn": "Inizio",
|
||||
"endsOn": "Fine"
|
||||
}
|
||||
},
|
||||
"unitlessHint": "Un rapporto o un indice senza unità (INR, colesterolo/HDL): scrivi 1 nella colonna dell'unità. Un'unità vuota resta un errore."
|
||||
}
|
||||
}
|
||||
+9
-2
@@ -10396,7 +10396,9 @@
|
||||
"pdfNeedsAnthropic": "PDF 스캔에는 Claude 비전 제공자가 필요해요. 대신 사진을 올려 주세요.",
|
||||
"refTextLabel": "적힌 그대로의 범위",
|
||||
"refTextPlaceholder": "예: 3.5 - 5.0",
|
||||
"aiUnavailable": "지금 여기에서는 AI로 결과지를 스캔할 수 없습니다."
|
||||
"aiUnavailable": "지금 여기에서는 AI로 결과지를 스캔할 수 없습니다.",
|
||||
"errorUnitMismatchNoUnit": "이 지표는 단위 없이 기록됩니다. 결과지를 잘못 읽었다면 단위 없음을 선택하거나 이 값의 선택을 해제하세요.",
|
||||
"unitDiffersBadgeNoUnit": "단위 없이 기록 중; 이 결과지는 {unit}"
|
||||
},
|
||||
"loadMoreReadings": "측정값 더 보기",
|
||||
"documentsLink": "검사 결과지 문서",
|
||||
@@ -10474,6 +10476,10 @@
|
||||
"stale": "가장 최근 값은 {date}로 1년이 넘었습니다. 직선은 그 시점 기준으로 표시하며 예측은 하지 않습니다."
|
||||
},
|
||||
"interval": "연간 변화의 95% 구간: {lo}~{hi} {unit}."
|
||||
},
|
||||
"unit": {
|
||||
"none": "단위 없음",
|
||||
"noneHint": "INR이나 콜레스테롤/HDL 같은 비율 또는 지수에 사용해요."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -14093,6 +14099,7 @@
|
||||
"asNeeded": "필요시",
|
||||
"startsOn": "시작",
|
||||
"endsOn": "종료"
|
||||
}
|
||||
},
|
||||
"unitlessHint": "단위가 없는 비율이나 지수(INR, 콜레스테롤/HDL)는 단위 열에 1을 입력하세요. 단위 칸이 비어 있으면 여전히 오류예요."
|
||||
}
|
||||
}
|
||||
+9
-2
@@ -10396,7 +10396,9 @@
|
||||
"pdfNeedsAnthropic": "Voor het scannen van PDF's is een Claude-provider met beeldherkenning nodig; upload in plaats daarvan een foto.",
|
||||
"refTextLabel": "Bereik zoals afgedrukt",
|
||||
"refTextPlaceholder": "bijv. 3,5 - 5,0",
|
||||
"aiUnavailable": "Een rapport scannen met AI is hier op dit moment niet beschikbaar."
|
||||
"aiUnavailable": "Een rapport scannen met AI is hier op dit moment niet beschikbaar.",
|
||||
"errorUnitMismatchNoUnit": "Deze marker wordt zonder eenheid bijgehouden. Vink Geen eenheid aan als het rapport verkeerd is gelezen, of deselecteer deze meting.",
|
||||
"unitDiffersBadgeNoUnit": "Zonder eenheid bijgehouden; dit rapport vermeldt {unit}"
|
||||
},
|
||||
"loadMoreReadings": "Meer metingen laden",
|
||||
"documentsLink": "Documenten met labrapporten",
|
||||
@@ -10474,6 +10476,10 @@
|
||||
"stale": "De nieuwste waarde is van {date}, meer dan een jaar geleden. De lijn staat op die datum, zonder projectie."
|
||||
},
|
||||
"interval": "95%-interval van de jaarlijkse verandering: {lo} tot {hi} {unit}."
|
||||
},
|
||||
"unit": {
|
||||
"none": "Geen eenheid",
|
||||
"noneHint": "Voor een verhouding of index zoals INR of cholesterol/HDL."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -14093,6 +14099,7 @@
|
||||
"asNeeded": "Zo nodig",
|
||||
"startsOn": "Begin",
|
||||
"endsOn": "Einde"
|
||||
}
|
||||
},
|
||||
"unitlessHint": "Een verhouding of index zonder eenheid (INR, cholesterol/HDL): vul 1 in de eenheidskolom in. Een lege eenheid blijft een fout."
|
||||
}
|
||||
}
|
||||
+9
-2
@@ -10396,7 +10396,9 @@
|
||||
"pdfNeedsAnthropic": "Skanowanie PDF wymaga dostawcy wizji Claude; prześlij zamiast tego zdjęcie.",
|
||||
"refTextLabel": "Zakres tak jak wydrukowano",
|
||||
"refTextPlaceholder": "np. 3,5 - 5,0",
|
||||
"aiUnavailable": "Skanowanie wyniku przez AI jest tu teraz niedostępne."
|
||||
"aiUnavailable": "Skanowanie wyniku przez AI jest tu teraz niedostępne.",
|
||||
"errorUnitMismatchNoUnit": "Ten wskaźnik jest prowadzony bez jednostki. Zaznacz Bez jednostki, jeśli wynik został źle odczytany, albo odznacz tę wartość.",
|
||||
"unitDiffersBadgeNoUnit": "Prowadzony bez jednostki; ten wynik podaje {unit}"
|
||||
},
|
||||
"loadMoreReadings": "Wczytaj więcej pomiarów",
|
||||
"documentsLink": "Dokumenty wyników badań",
|
||||
@@ -10474,6 +10476,10 @@
|
||||
"stale": "Najnowszy wynik pochodzi z {date}, sprzed ponad roku. Prosta jest pokazana na ten dzień, bez projekcji."
|
||||
},
|
||||
"interval": "95% przedział rocznej zmiany: od {lo} do {hi} {unit}."
|
||||
},
|
||||
"unit": {
|
||||
"none": "Bez jednostki",
|
||||
"noneHint": "Dla ilorazu lub wskaźnika, np. INR albo cholesterol/HDL."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -14093,6 +14099,7 @@
|
||||
"asNeeded": "W razie potrzeby",
|
||||
"startsOn": "Początek",
|
||||
"endsOn": "Koniec"
|
||||
}
|
||||
},
|
||||
"unitlessHint": "Iloraz lub wskaźnik bez jednostki (INR, cholesterol/HDL): wpisz 1 w kolumnie jednostki. Pusta jednostka nadal jest błędem."
|
||||
}
|
||||
}
|
||||
+9
-2
@@ -10396,7 +10396,9 @@
|
||||
"pdfNeedsAnthropic": "A digitalização de PDF precisa de um provedor de visão Claude; envie uma foto em vez disso.",
|
||||
"refTextLabel": "Faixa como impressa",
|
||||
"refTextPlaceholder": "ex.: 3,5 - 5,0",
|
||||
"aiUnavailable": "Digitalizar um laudo com IA não está disponível aqui agora."
|
||||
"aiUnavailable": "Digitalizar um laudo com IA não está disponível aqui agora.",
|
||||
"errorUnitMismatchNoUnit": "Este marcador é acompanhado sem unidade. Marque Sem unidade se o laudo foi lido errado, ou desmarque esta leitura.",
|
||||
"unitDiffersBadgeNoUnit": "Acompanhado sem unidade; este laudo diz {unit}"
|
||||
},
|
||||
"loadMoreReadings": "Carregar mais leituras",
|
||||
"documentsLink": "Documentos de laudos laboratoriais",
|
||||
@@ -10474,6 +10476,10 @@
|
||||
"stale": "O resultado mais recente é de {date}, há mais de um ano. A linha é mostrada até essa data, sem projeção."
|
||||
},
|
||||
"interval": "Intervalo de 95 % da mudança anual: {lo} a {hi} {unit}."
|
||||
},
|
||||
"unit": {
|
||||
"none": "Sem unidade",
|
||||
"noneHint": "Para uma razão ou um índice como INR ou colesterol/HDL."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -14093,6 +14099,7 @@
|
||||
"asNeeded": "Conforme necessário",
|
||||
"startsOn": "Início",
|
||||
"endsOn": "Fim"
|
||||
}
|
||||
},
|
||||
"unitlessHint": "Uma razão ou um índice sem unidade (INR, colesterol/HDL): escreva 1 na coluna da unidade. Uma unidade vazia continua sendo um erro."
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
type LeafScope,
|
||||
type Translate,
|
||||
} from "./report-sections";
|
||||
import { withLabUnit } from "@/lib/labs/dimensionless";
|
||||
|
||||
/** Join the parts of a composed value, dropping the ones with nothing in. */
|
||||
function compose(parts: Array<string | null | undefined>): string {
|
||||
@@ -73,7 +74,7 @@ export function LabResultsSection({
|
||||
const qualitative = lab.value === null;
|
||||
const reading = qualitative
|
||||
? lab.valueText
|
||||
: `${fmtLab(lab.value as number, lab.sourceValue)} ${lab.unit}`.trim();
|
||||
: withLabUnit(fmtLab(lab.value as number, lab.sourceValue), lab.unit);
|
||||
const range = (
|
||||
low: number | null,
|
||||
high: number | null,
|
||||
|
||||
@@ -37,10 +37,12 @@ import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { DateField } from "@/components/ui/date-field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { LabUnitInput } from "@/components/labs/lab-unit-input";
|
||||
import { MarkerMatchField } from "@/components/labs/marker-match-field";
|
||||
import type { BiomarkerListResponse } from "@/components/labs/types";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import type { DocumentExtractRunResult } from "@/lib/documents/ai-runs/types";
|
||||
import { withLabUnit } from "@/lib/labs/dimensionless";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -81,7 +83,7 @@ function factHeadline(fact: ExtractedFactDto): string {
|
||||
const data = fact.data as ObservationFactData;
|
||||
const value =
|
||||
typeof data.value === "number"
|
||||
? `${data.value}${data.unit ? ` ${data.unit}` : ""}`
|
||||
? withLabUnit(String(data.value), data.unit)
|
||||
: (data.valueText ?? "");
|
||||
return value ? `${data.label}: ${value}` : data.label;
|
||||
}
|
||||
@@ -412,13 +414,12 @@ export function FactRow({
|
||||
? t("documents.review.fieldUnit")
|
||||
: t("documents.review.fieldDose")}
|
||||
</Label>
|
||||
<Input
|
||||
<LabUnitInput
|
||||
id={`fact-unit-${fact.id}`}
|
||||
value={draft.unit}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({ ...d, unit: e.target.value }))
|
||||
}
|
||||
onChange={(unit) => setDraft((d) => ({ ...d, unit }))}
|
||||
maxLength={isObservation ? 80 : 120}
|
||||
allowNoUnit={isObservation}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import type { LabChange } from "@/lib/insights/labs-changes";
|
||||
import { InsightSectionCard } from "./insight-section-card";
|
||||
import { withLabUnit } from "@/lib/labs/dimensionless";
|
||||
|
||||
/**
|
||||
* v1.25 — "what changed since your last panel" card.
|
||||
@@ -81,7 +82,7 @@ export function LabsChangesCard({
|
||||
const signedDelta =
|
||||
c.direction === "flat"
|
||||
? t("insights.labsChanges.directionFlat")
|
||||
: `${c.delta > 0 ? "+" : ""}${c.delta} ${c.unit}`.trim();
|
||||
: withLabUnit(`${c.delta > 0 ? "+" : ""}${c.delta}`, c.unit);
|
||||
return (
|
||||
<div
|
||||
key={c.analyte}
|
||||
@@ -93,7 +94,7 @@ export function LabsChangesCard({
|
||||
{c.analyte}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{`${c.latest} ${c.unit}`.trim()}
|
||||
{withLabUnit(String(c.latest), c.unit)}
|
||||
{labelKey ? ` · ${t(labelKey)}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import en from "../../../../messages/en.json";
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/api/api-fetch")>()),
|
||||
apiPost: vi.fn(),
|
||||
apiPut: vi.fn(),
|
||||
}));
|
||||
|
||||
import { BiomarkerForm } from "../biomarker-form";
|
||||
import { LabUnitInput } from "../lab-unit-input";
|
||||
import type { OcrReviewRow } from "../ocr-review-types";
|
||||
import { OcrRowEditor } from "../ocr-row-editor";
|
||||
|
||||
/**
|
||||
* The explicit "no unit" choice for a ratio or an index. It is offered next to
|
||||
* every lab unit field a person fills in, it shows as chosen when the unit is
|
||||
* the UCUM unity `"1"`, and it never reads an empty unit as that choice.
|
||||
*/
|
||||
|
||||
function render(node: React.ReactNode): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const NONE = en.labs.unit.none;
|
||||
|
||||
function noneBox(html: string): string {
|
||||
const box = html.match(/<button[^>]*role="checkbox"[^>]*>/)?.[0];
|
||||
if (!box) throw new Error("no checkbox rendered");
|
||||
return box;
|
||||
}
|
||||
|
||||
describe("<LabUnitInput>", () => {
|
||||
it("offers the choice and leaves it unticked for an empty unit", () => {
|
||||
const html = render(
|
||||
<LabUnitInput id="u" value="" onChange={() => {}} required />,
|
||||
);
|
||||
expect(html).toContain(NONE);
|
||||
expect(html).toContain(en.labs.unit.noneHint);
|
||||
expect(noneBox(html)).toContain('aria-checked="false"');
|
||||
expect(html).toMatch(/<input[^>]*id="u"[^>]*required/);
|
||||
});
|
||||
|
||||
it("shows the unity as the ticked choice, not as a typed 1", () => {
|
||||
const html = render(
|
||||
<LabUnitInput id="u" value="1" onChange={() => {}} required />,
|
||||
);
|
||||
expect(noneBox(html)).toContain('aria-checked="true"');
|
||||
const input = html.match(/<input[^>]*id="u"[^>]*>/)?.[0] ?? "";
|
||||
expect(input).toContain('value=""');
|
||||
expect(input).toContain("disabled");
|
||||
expect(input).not.toContain("required");
|
||||
});
|
||||
|
||||
it("leaves a real unit as it is", () => {
|
||||
const html = render(
|
||||
<LabUnitInput id="u" value="mg/dL" onChange={() => {}} />,
|
||||
);
|
||||
expect(noneBox(html)).toContain('aria-checked="false"');
|
||||
expect(html).toContain('value="mg/dL"');
|
||||
});
|
||||
|
||||
it("is a plain field where no unit makes no sense", () => {
|
||||
const html = render(
|
||||
<LabUnitInput id="u" value="1" onChange={() => {}} allowNoUnit={false} />,
|
||||
);
|
||||
expect(html).not.toContain(NONE);
|
||||
expect(html).toContain('value="1"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("the no-unit choice on the lab forms", () => {
|
||||
it("is offered when a marker is defined", () => {
|
||||
const html = render(<BiomarkerForm />);
|
||||
expect(html).toContain(NONE);
|
||||
});
|
||||
|
||||
it("is offered on a scanned report's row", () => {
|
||||
const row: OcrReviewRow = {
|
||||
key: "INR-0",
|
||||
analyte: "INR",
|
||||
value: 1.1,
|
||||
valueText: null,
|
||||
unit: null,
|
||||
referenceLow: 0.8,
|
||||
referenceHigh: 1.2,
|
||||
referenceText: null,
|
||||
takenAt: null,
|
||||
confidence: { analyte: 1, value: 1, unit: 1, range: 1 },
|
||||
biomarkerMatch: "new",
|
||||
markerUnit: null,
|
||||
markerAnalyteKey: null,
|
||||
duplicateOf: null,
|
||||
biomarkerId: null,
|
||||
matchKind: null,
|
||||
suggestions: [],
|
||||
chosenBiomarkerId: null,
|
||||
confirmed: true,
|
||||
};
|
||||
const html = render(<OcrRowEditor row={row} onChange={() => {}} />);
|
||||
expect(html).toContain(NONE);
|
||||
});
|
||||
|
||||
it("names a marker without a unit in words where a scanned row disagrees", () => {
|
||||
const row: OcrReviewRow = {
|
||||
key: "INR-1",
|
||||
analyte: "INR",
|
||||
value: 1.1,
|
||||
valueText: null,
|
||||
unit: "s",
|
||||
referenceLow: null,
|
||||
referenceHigh: null,
|
||||
referenceText: null,
|
||||
takenAt: null,
|
||||
confidence: { analyte: 1, value: 1, unit: 1, range: 1 },
|
||||
biomarkerMatch: "existing",
|
||||
markerUnit: "1",
|
||||
markerAnalyteKey: null,
|
||||
duplicateOf: null,
|
||||
biomarkerId: "bm-inr",
|
||||
matchKind: "name",
|
||||
suggestions: [],
|
||||
chosenBiomarkerId: null,
|
||||
confirmed: true,
|
||||
};
|
||||
const html = render(
|
||||
<OcrRowEditor
|
||||
row={row}
|
||||
onChange={() => {}}
|
||||
errors={{ unitMismatch: true }}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain(en.labs.ocr.errorUnitMismatchNoUnit);
|
||||
expect(html).toContain("Tracked without a unit; this report says s");
|
||||
expect(html).not.toContain("tracked in 1");
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from "@/lib/labs/biomarker-catalog";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { LabUnitInput } from "./lab-unit-input";
|
||||
import type { BiomarkerDto } from "./types";
|
||||
|
||||
const CONTEXT_MAX_LENGTH = 2000;
|
||||
@@ -219,10 +220,10 @@ export function BiomarkerForm({
|
||||
<Label htmlFor="biomarker-unit">
|
||||
{t("labs.biomarker.form.unit")}
|
||||
</Label>
|
||||
<Input
|
||||
<LabUnitInput
|
||||
id="biomarker-unit"
|
||||
value={unit}
|
||||
onChange={(e) => setUnit(e.target.value)}
|
||||
onChange={setUnit}
|
||||
placeholder={t("labs.biomarker.form.unitPlaceholder")}
|
||||
maxLength={40}
|
||||
required
|
||||
|
||||
@@ -24,6 +24,8 @@ import { BiomarkerForm } from "./biomarker-form";
|
||||
import { BiomarkerMergeSheet } from "./biomarker-merge-sheet";
|
||||
import type { BiomarkerDto, BiomarkerListResponse } from "./types";
|
||||
import { useLabNumber } from "./use-lab-format";
|
||||
import { withLabUnit } from "@/lib/labs/dimensionless";
|
||||
import { LabUnitName } from "./lab-unit-input";
|
||||
|
||||
/**
|
||||
* v1.18.1 — the Biomarker catalog manager. v1.22 — compact editable rows.
|
||||
@@ -124,11 +126,11 @@ export function BiomarkerManager() {
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<span className="truncate text-sm font-medium">{marker.name}</span>
|
||||
<span className="text-muted-foreground text-xs">{marker.unit}</span>
|
||||
<LabUnitName unit={marker.unit} />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{range
|
||||
? `${t("labs.referenceLabel")} ${range} ${marker.unit}`
|
||||
? `${t("labs.referenceLabel")} ${withLabUnit(range, marker.unit)}`
|
||||
: t("labs.biomarker.noRange")}
|
||||
{marker.panel ? ` · ${marker.panel}` : ""}
|
||||
</p>
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import type { BiomarkerDto, BiomarkerMergeResultDto } from "./types";
|
||||
import { labUnitLabel } from "@/lib/labs/dimensionless";
|
||||
|
||||
/** The most sources one merge takes, matching the server. */
|
||||
const MAX_SOURCES = 10;
|
||||
@@ -166,7 +167,7 @@ export function BiomarkerMergeSheet({
|
||||
<p className="text-sm">
|
||||
{tCount("labs.merge.previewSummary", preview.movedReadings, {
|
||||
name: preview.target.name,
|
||||
unit: preview.target.unit,
|
||||
unit: labUnitLabel(preview.target.unit, t),
|
||||
})}
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
@@ -178,7 +179,7 @@ export function BiomarkerMergeSheet({
|
||||
<p className="text-sm font-medium break-words">
|
||||
{source.name}{" "}
|
||||
<span className="text-muted-foreground text-xs font-normal">
|
||||
{source.unit}
|
||||
{labUnitLabel(source.unit, t)}
|
||||
</span>
|
||||
</p>
|
||||
<p
|
||||
@@ -190,8 +191,8 @@ export function BiomarkerMergeSheet({
|
||||
>
|
||||
{source.conversion === "refused"
|
||||
? t("labs.merge.refused", {
|
||||
unit: source.unit,
|
||||
targetUnit: preview.target.unit,
|
||||
unit: labUnitLabel(source.unit, t),
|
||||
targetUnit: labUnitLabel(preview.target.unit, t),
|
||||
})
|
||||
: source.conversion === "converted"
|
||||
? tCount("labs.merge.converted", source.readingCount, {
|
||||
@@ -247,7 +248,7 @@ export function BiomarkerMergeSheet({
|
||||
{marker.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground shrink-0 text-xs">
|
||||
{marker.unit}
|
||||
{labUnitLabel(marker.unit, t)}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
|
||||
@@ -61,6 +61,7 @@ import type {
|
||||
LabResultDto,
|
||||
LabResultListResponse,
|
||||
} from "./types";
|
||||
import { labUnitLabel, labUnitText } from "@/lib/labs/dimensionless";
|
||||
|
||||
// v1.18.11 — defer the recharts biomarker chart through
|
||||
// `next/dynamic` so recharts is off `/labs/[biomarkerId]`'s first-load JS.
|
||||
@@ -426,7 +427,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
one description line every page header carries. */}
|
||||
{marker?.unit ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{marker.unit}
|
||||
{labUnitLabel(marker.unit, t)}
|
||||
{marker.panel ? ` · ${marker.panel}` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -492,7 +493,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
numeric readings, mirroring the metric sub-pages. */}
|
||||
<MetricStatStrip
|
||||
summary={summary}
|
||||
unit={marker?.unit ?? latest?.unit ?? ""}
|
||||
unit={labUnitText(marker?.unit ?? latest?.unit)}
|
||||
seriesLabel={marker?.name}
|
||||
icon={FlaskConical}
|
||||
/>
|
||||
@@ -500,7 +501,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
<CardContent>
|
||||
<LabBiomarkerChart
|
||||
readings={readings}
|
||||
unit={marker?.unit ?? latest?.unit ?? ""}
|
||||
unit={labUnitText(marker?.unit ?? latest?.unit)}
|
||||
lowerBound={marker?.lowerBound ?? null}
|
||||
upperBound={marker?.upperBound ?? null}
|
||||
/>
|
||||
@@ -508,7 +509,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
</Card>
|
||||
<LabTrendCard
|
||||
trend={marker?.trend}
|
||||
unit={marker?.unit ?? latest?.unit ?? ""}
|
||||
unit={labUnitText(marker?.unit ?? latest?.unit)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -39,6 +39,11 @@ import type {
|
||||
BiomarkerListResponse,
|
||||
LabResultDto,
|
||||
} from "./types";
|
||||
import {
|
||||
labUnitLabel,
|
||||
labUnitText,
|
||||
withLabUnit,
|
||||
} from "@/lib/labs/dimensionless";
|
||||
|
||||
const NOTE_MAX_LENGTH = 2000;
|
||||
const DEFINE_NEW = "__define_new__";
|
||||
@@ -330,9 +335,8 @@ export function LabForm({
|
||||
? formatReferenceRange(selected.lowerBound, selected.upperBound, labNumber)
|
||||
: "";
|
||||
|
||||
// What the typed string resolved to, shown back so the user can see whether
|
||||
// a window was read out of it before the reading is saved. When none was,
|
||||
// the hint says the text is kept as written rather than staying silent.
|
||||
// What the typed string resolved to, shown back before the save. When no
|
||||
// window was read out of it, the hint says the text stays as written.
|
||||
const parsedSourceRange = parseReferenceRange(sourceRange, selected?.unit);
|
||||
const sourceRangeHint = parsedSourceRange
|
||||
? isUnreadableRange(parsedSourceRange)
|
||||
@@ -341,7 +345,7 @@ export function LabForm({
|
||||
parsedSourceRange.low,
|
||||
parsedSourceRange.high,
|
||||
labNumber,
|
||||
)} ${selected?.unit ?? ""}`.trim()
|
||||
)} ${labUnitText(selected?.unit)}`.trim()
|
||||
: "";
|
||||
|
||||
const footerNode = (
|
||||
@@ -406,7 +410,7 @@ export function LabForm({
|
||||
<SelectContent>
|
||||
{markers.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name} · {m.unit}
|
||||
{m.name} · {labUnitLabel(m.unit, t)}
|
||||
</SelectItem>
|
||||
))}
|
||||
{!lockedBiomarkerId ? (
|
||||
@@ -457,7 +461,7 @@ export function LabForm({
|
||||
{selected ? (
|
||||
<span className="text-muted-foreground font-normal">
|
||||
{" "}
|
||||
({selected.unit})
|
||||
({labUnitLabel(selected.unit, t)})
|
||||
</span>
|
||||
) : null}
|
||||
</Label>
|
||||
@@ -515,7 +519,8 @@ export function LabForm({
|
||||
|
||||
{resultType === "numeric" && selected && referenceText ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("labs.referenceLabel")} {referenceText} {selected.unit}
|
||||
{t("labs.referenceLabel")}{" "}
|
||||
{withLabUnit(referenceText, selected.unit)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
} from "./lab-result-related-links";
|
||||
import type { LabResultDto } from "./types";
|
||||
import { useLabDate, useLabNumber } from "./use-lab-format";
|
||||
import { labUnitText } from "@/lib/labs/dimensionless";
|
||||
|
||||
const NOTE_MAX_LENGTH = 2000;
|
||||
|
||||
@@ -103,7 +104,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
|
||||
reading.value === null
|
||||
? (reading.valueText ?? "")
|
||||
: labNumber(reading.value, reading.sourceValue),
|
||||
unit: reading.value === null ? undefined : reading.unit,
|
||||
unit: reading.value === null ? undefined : labUnitText(reading.unit),
|
||||
compare: before
|
||||
? {
|
||||
label: t("day.previousReading", { date: labDate(before.takenAt) }),
|
||||
|
||||
@@ -31,6 +31,7 @@ import type {
|
||||
LabOverviewResponse,
|
||||
LabResultDto,
|
||||
} from "./types";
|
||||
import { labUnitLabel } from "@/lib/labs/dimensionless";
|
||||
|
||||
interface MarkerGroup {
|
||||
/** Stable group key: `bm:<biomarkerId>`, or `analyte:<lower>` for legacy rows. */
|
||||
@@ -39,6 +40,7 @@ interface MarkerGroup {
|
||||
biomarkerId: string | null;
|
||||
analyte: string;
|
||||
panel: string | null;
|
||||
/** The marker's unit as the card names it ("No unit" for a ratio). */
|
||||
unit: string;
|
||||
/** Every live reading of the marker, counted by the server. */
|
||||
count: number;
|
||||
@@ -53,13 +55,16 @@ interface MarkerGroup {
|
||||
* inert until the backfill links them. The server already orders by the
|
||||
* newest reading.
|
||||
*/
|
||||
function toGroups(markers: LabOverviewMarkerDto[]): MarkerGroup[] {
|
||||
function toGroups(
|
||||
markers: LabOverviewMarkerDto[],
|
||||
t: (key: string) => string,
|
||||
): MarkerGroup[] {
|
||||
return markers.map((marker) => ({
|
||||
key: marker.key,
|
||||
biomarkerId: marker.latest.biomarkerId,
|
||||
analyte: marker.latest.analyte,
|
||||
panel: marker.latest.panel,
|
||||
unit: marker.latest.unit,
|
||||
unit: labUnitLabel(marker.latest.unit, t),
|
||||
count: marker.count,
|
||||
trend: marker.trend,
|
||||
latest: marker.latest,
|
||||
@@ -152,7 +157,7 @@ export function LabList({
|
||||
});
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const base = toGroups(data?.markers ?? []);
|
||||
const base = toGroups(data?.markers ?? [], t);
|
||||
// v1.18.6 — honour the user's Labs sort choice. The server
|
||||
// order is most-recent-first; `recentAsc` reverses, the alpha
|
||||
// options sort by analyte name (#43), and `manual` applies the persisted
|
||||
@@ -173,7 +178,7 @@ export function LabList({
|
||||
return applyOrder(base, prefs.order, (g) => g.biomarkerId ?? g.key);
|
||||
}
|
||||
return base;
|
||||
}, [data?.markers, prefs.sortDir, prefs.order]);
|
||||
}, [data?.markers, prefs.sortDir, prefs.order, t]);
|
||||
|
||||
const listEmpty = !isLoading && !isError && groups.length === 0;
|
||||
useEffect(() => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RangeBar } from "@/components/targets/range-bar";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { useLabNumber } from "./use-lab-format";
|
||||
import { labUnitText, withLabUnit } from "@/lib/labs/dimensionless";
|
||||
|
||||
/**
|
||||
* v1.37.34 — compact reference-range visualization for saved lab readings.
|
||||
@@ -43,11 +44,14 @@ export function LabReferenceRangeBar({
|
||||
const scale = Math.max(Math.abs(referenceLow ?? referenceHigh!), 1);
|
||||
const min = referenceLow ?? referenceHigh! - scale;
|
||||
const max = referenceHigh ?? referenceLow! + scale;
|
||||
const rangeLabel = !hasLow
|
||||
? `≤${labNumber(referenceHigh!)} ${unit}`
|
||||
: !hasHigh
|
||||
? `≥${labNumber(referenceLow!)} ${unit}`
|
||||
: `${labNumber(referenceLow!)}–${labNumber(referenceHigh!)} ${unit}`;
|
||||
const rangeLabel = withLabUnit(
|
||||
!hasLow
|
||||
? `≤${labNumber(referenceHigh!)}`
|
||||
: !hasHigh
|
||||
? `≥${labNumber(referenceLow!)}`
|
||||
: `${labNumber(referenceLow!)}–${labNumber(referenceHigh!)}`,
|
||||
unit,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -59,7 +63,7 @@ export function LabReferenceRangeBar({
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
unit={unit}
|
||||
unit={labUnitText(unit)}
|
||||
compact
|
||||
tone="lab"
|
||||
formatValue={labNumber}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import type { BiomarkerTrendDto } from "./types";
|
||||
import { labUnitText } from "@/lib/labs/dimensionless";
|
||||
|
||||
/**
|
||||
* v1.44 — the marker's slope over all its readings, and where that straight
|
||||
@@ -35,12 +36,19 @@ export function LabTrendCard({
|
||||
: trend.slopePerYear < 0
|
||||
? "falling"
|
||||
: "flat";
|
||||
const slopeLine = t(`labs.trend.slope.${direction}`, {
|
||||
amount: per(trend.slopePerYear),
|
||||
unit,
|
||||
n: trend.n,
|
||||
since: fmt.dateShortSmartCalendar(trend.firstAt),
|
||||
});
|
||||
const shownUnit = labUnitText(unit);
|
||||
// A value without a unit leaves the {unit} slot empty; close the gap it
|
||||
// leaves rather than print a space before the full stop.
|
||||
const tidy = (text: string) =>
|
||||
shownUnit ? text : text.replace(/ +([.,])/g, "$1").replace(/ {2,}/g, " ");
|
||||
const slopeLine = tidy(
|
||||
t(`labs.trend.slope.${direction}`, {
|
||||
amount: per(trend.slopePerYear),
|
||||
unit: shownUnit,
|
||||
n: trend.n,
|
||||
since: fmt.dateShortSmartCalendar(trend.firstAt),
|
||||
}),
|
||||
);
|
||||
const projectionLine =
|
||||
trend.projection === "exits" && trend.exitDate
|
||||
? t(`labs.trend.projection.exits.${trend.exitBound ?? "high"}`, {
|
||||
@@ -85,11 +93,13 @@ export function LabTrendCard({
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("labs.trend.interval", {
|
||||
lo: fmt.number(trend.slopeCi95[0], 2),
|
||||
hi: fmt.number(trend.slopeCi95[1], 2),
|
||||
unit,
|
||||
})}
|
||||
{tidy(
|
||||
t("labs.trend.interval", {
|
||||
lo: fmt.number(trend.slopeCi95[0], 2),
|
||||
hi: fmt.number(trend.slopeCi95[1], 2),
|
||||
unit: shownUnit,
|
||||
}),
|
||||
)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useRef } from "react";
|
||||
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
DIMENSIONLESS_UNIT,
|
||||
isDimensionlessUnit,
|
||||
labUnitLabel,
|
||||
} from "@/lib/labs/dimensionless";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** A marker's unit as a muted label, "No unit" for a ratio or an index. */
|
||||
export function LabUnitName({
|
||||
unit,
|
||||
className,
|
||||
}: {
|
||||
unit: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<span className={cn("text-muted-foreground text-xs", className)}>
|
||||
{labUnitLabel(unit, t)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface LabUnitInputProps {
|
||||
id: string;
|
||||
value: string;
|
||||
onChange: (unit: string) => void;
|
||||
maxLength?: number;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
"aria-describedby"?: string;
|
||||
"aria-invalid"?: boolean;
|
||||
/**
|
||||
* Offer the explicit "no unit" choice. Off for a field that only shares the
|
||||
* input, such as a medication dose on the document review.
|
||||
*/
|
||||
allowNoUnit?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The unit of a lab value, with an explicit "no unit" choice for a ratio or an
|
||||
* index. Ticking it sets the unit to the UCUM unity `"1"`, which every write
|
||||
* path accepts as a real unit; leaving the field empty is still refused, so a
|
||||
* unit an extraction dropped is never taken for a value that has none. Typing
|
||||
* `1` by hand is the same choice and ticks the box.
|
||||
*/
|
||||
export function LabUnitInput({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
maxLength,
|
||||
placeholder,
|
||||
required,
|
||||
allowNoUnit = true,
|
||||
...aria
|
||||
}: LabUnitInputProps) {
|
||||
const { t } = useTranslations();
|
||||
const noneId = useId();
|
||||
const hintId = `${noneId}-hint`;
|
||||
const none = allowNoUnit && isDimensionlessUnit(value);
|
||||
// What was typed before the box was ticked, so unticking gives it back.
|
||||
const typed = useRef(none ? "" : value);
|
||||
|
||||
const input = (
|
||||
<Input
|
||||
id={id}
|
||||
value={none ? "" : value}
|
||||
onChange={(e) => {
|
||||
typed.current = e.target.value;
|
||||
onChange(e.target.value);
|
||||
}}
|
||||
placeholder={none ? t("labs.unit.none") : placeholder}
|
||||
maxLength={maxLength}
|
||||
required={required && !none}
|
||||
disabled={none}
|
||||
{...aria}
|
||||
/>
|
||||
);
|
||||
if (!allowNoUnit) return input;
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5" data-slot="lab-unit-input">
|
||||
{input}
|
||||
<label
|
||||
htmlFor={noneId}
|
||||
className="flex min-h-11 cursor-pointer items-start gap-2 sm:min-h-0"
|
||||
>
|
||||
<Checkbox
|
||||
id={noneId}
|
||||
checked={none}
|
||||
onCheckedChange={(state) =>
|
||||
onChange(
|
||||
state === true
|
||||
? DIMENSIONLESS_UNIT
|
||||
: isDimensionlessUnit(typed.current)
|
||||
? ""
|
||||
: typed.current,
|
||||
)
|
||||
}
|
||||
aria-describedby={hintId}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="space-y-0.5">
|
||||
<span className="block text-sm">{t("labs.unit.none")}</span>
|
||||
<span id={hintId} className="text-muted-foreground block text-xs">
|
||||
{t("labs.unit.noneHint")}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
import { BiomarkerManager } from "./biomarker-manager";
|
||||
import type { BiomarkerDto, BiomarkerListResponse } from "./types";
|
||||
import { useLabsLocalOcr, useUpdateLabsLocalOcr } from "./use-ocr-extract";
|
||||
import { labUnitLabel } from "@/lib/labs/dimensionless";
|
||||
|
||||
export function LabsSettings() {
|
||||
const { t } = useTranslations();
|
||||
@@ -61,7 +62,7 @@ export function LabsSettings() {
|
||||
const reorderItems: ReorderItem[] = ordered.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
secondary: m.panel ? `${m.unit} · ${m.panel}` : m.unit,
|
||||
secondary: [labUnitLabel(m.unit, t), m.panel].filter(Boolean).join(" · "),
|
||||
}));
|
||||
|
||||
const manual = prefs.sortDir === "manual";
|
||||
|
||||
@@ -21,6 +21,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { labUnitLabel } from "@/lib/labs/dimensionless";
|
||||
|
||||
export interface MarkerSuggestionDto {
|
||||
biomarkerId: string;
|
||||
@@ -110,7 +111,7 @@ export function MarkerMatchField({
|
||||
<option key={suggestion.biomarkerId} value={suggestion.biomarkerId}>
|
||||
{t("labs.match.suggestionOption", {
|
||||
name: suggestion.name,
|
||||
unit: suggestion.unit,
|
||||
unit: labUnitLabel(suggestion.unit, t),
|
||||
})}
|
||||
</option>
|
||||
))}
|
||||
|
||||
@@ -24,12 +24,14 @@ import { DateField } from "@/components/ui/date-field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { isDimensionlessUnit, labUnitLabel } from "@/lib/labs/dimensionless";
|
||||
import {
|
||||
OCR_ANALYTE_MAX,
|
||||
OCR_UNIT_MAX,
|
||||
OCR_VALUE_TEXT_MAX,
|
||||
} from "@/lib/validations/labs-ocr";
|
||||
|
||||
import { LabUnitInput } from "./lab-unit-input";
|
||||
import { MarkerMatchField } from "./marker-match-field";
|
||||
import { chooseMarker, type OcrReviewRow } from "./ocr-review-types";
|
||||
import {
|
||||
@@ -51,6 +53,28 @@ function RequiredMark() {
|
||||
);
|
||||
}
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string>) => string;
|
||||
|
||||
/** The badge naming a row whose unit differs from its marker's. */
|
||||
function unitDiffersText(row: OcrReviewRow, t: Translate): string {
|
||||
const unit = labUnitLabel(row.unit, t);
|
||||
return isDimensionlessUnit(row.markerUnit)
|
||||
? t("labs.ocr.unitDiffersBadgeNoUnit", { unit })
|
||||
: t("labs.ocr.unitDiffersBadge", {
|
||||
markerUnit: row.markerUnit ?? "",
|
||||
unit,
|
||||
});
|
||||
}
|
||||
|
||||
/** The message key and params of a unit the marker cannot take. */
|
||||
function unitMismatchMessage(
|
||||
row: OcrReviewRow,
|
||||
): [string, Record<string, string>?] {
|
||||
return isDimensionlessUnit(row.markerUnit)
|
||||
? ["labs.ocr.errorUnitMismatchNoUnit"]
|
||||
: ["labs.ocr.errorUnitMismatch", { markerUnit: row.markerUnit ?? "" }];
|
||||
}
|
||||
|
||||
export function OcrRowEditor({
|
||||
row,
|
||||
onChange,
|
||||
@@ -197,12 +221,7 @@ export function OcrRowEditor({
|
||||
{unitBlocked ? (
|
||||
<Badge variant="secondary" className="max-w-full">
|
||||
<Ruler aria-hidden />
|
||||
<span className="truncate">
|
||||
{t("labs.ocr.unitDiffersBadge", {
|
||||
markerUnit: row.markerUnit ?? "",
|
||||
unit: (row.unit ?? "").trim(),
|
||||
})}
|
||||
</span>
|
||||
<span className="truncate">{unitDiffersText(row, t)}</span>
|
||||
</Badge>
|
||||
) : null}
|
||||
{conversion ? (
|
||||
@@ -294,10 +313,10 @@ export function OcrRowEditor({
|
||||
{t("labs.ocr.unitLabel")}
|
||||
<RequiredMark />
|
||||
</Label>
|
||||
<Input
|
||||
<LabUnitInput
|
||||
id={`${fieldId}-unit`}
|
||||
value={row.unit ?? ""}
|
||||
onChange={(e) => onChange({ ...row, unit: e.target.value })}
|
||||
onChange={(unit) => onChange({ ...row, unit })}
|
||||
{...invalidProps(
|
||||
errors?.unitMismatch
|
||||
? "unitMismatch"
|
||||
@@ -310,9 +329,7 @@ export function OcrRowEditor({
|
||||
{fieldError("unitTooLong", "labs.ocr.errorTooLong", {
|
||||
max: String(OCR_UNIT_MAX),
|
||||
})}
|
||||
{fieldError("unitMismatch", "labs.ocr.errorUnitMismatch", {
|
||||
markerUnit: row.markerUnit ?? "",
|
||||
})}
|
||||
{fieldError("unitMismatch", ...unitMismatchMessage(row))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import type { LabResultDto } from "./types";
|
||||
import { useLabNumber } from "./use-lab-format";
|
||||
import { withLabUnit } from "@/lib/labs/dimensionless";
|
||||
|
||||
/**
|
||||
* The reference window a reading's own report printed, shown wherever that
|
||||
@@ -54,11 +55,14 @@ export function SourceRangeNote({
|
||||
// then stated from the converted bounds, in the marker's unit.
|
||||
const inForce =
|
||||
reading.sourceReferenceText ??
|
||||
`${formatReferenceRange(
|
||||
reading.referenceLow,
|
||||
reading.referenceHigh,
|
||||
labNumber,
|
||||
)} ${reading.unit}`.trim();
|
||||
withLabUnit(
|
||||
formatReferenceRange(
|
||||
reading.referenceLow,
|
||||
reading.referenceHigh,
|
||||
labNumber,
|
||||
),
|
||||
reading.unit,
|
||||
);
|
||||
|
||||
const catalog = formatReferenceRange(
|
||||
reading.catalogReferenceLow,
|
||||
@@ -77,7 +81,7 @@ export function SourceRangeNote({
|
||||
: null}
|
||||
{reading.referenceDivergesFromCatalog && catalog
|
||||
? ` · ${t("labs.sourceRange.catalogDiffers", {
|
||||
range: `${catalog} ${reading.unit}`.trim(),
|
||||
range: withLabUnit(catalog, reading.unit),
|
||||
})}`
|
||||
: null}
|
||||
</span>
|
||||
|
||||
@@ -100,6 +100,26 @@ export function RecordCsvImportCard() {
|
||||
);
|
||||
}
|
||||
|
||||
/** What the sheet says about reading the file, below the format choices. */
|
||||
function ImportNotes({ kind }: { kind: RecordImportKind }) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("recordImport.timezoneNote")}
|
||||
</p>
|
||||
{kind === "labs" ? (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="record-import-unitless-hint"
|
||||
>
|
||||
{t("recordImport.unitlessHint")}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordCsvImportSheet({
|
||||
kind,
|
||||
onOpenChange,
|
||||
@@ -421,9 +441,7 @@ function RecordCsvImportSheet({
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("recordImport.timezoneNote")}
|
||||
</p>
|
||||
<ImportNotes kind={kind} />
|
||||
|
||||
<div
|
||||
className="max-h-48 overflow-auto overscroll-contain rounded-lg border"
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
formatDayMonthYear,
|
||||
} from "./timeline-dates";
|
||||
import { seriesNameLines, type TimelineLayout } from "./timeline-geometry";
|
||||
import { withLabUnit } from "@/lib/labs/dimensionless";
|
||||
|
||||
/** "18 ng/mL", every digit the reading holds, in the reader's locale. */
|
||||
export type LabValueText = (
|
||||
@@ -50,7 +51,7 @@ export function useLabValueText(): LabValueText {
|
||||
const labNumber = useLabNumber();
|
||||
return useCallback(
|
||||
(value, sourceValue, unit) =>
|
||||
`${labNumber(value, sourceValue)} ${unit}`.trim(),
|
||||
withLabUnit(labNumber(value, sourceValue), unit),
|
||||
[labNumber],
|
||||
);
|
||||
}
|
||||
@@ -84,7 +85,7 @@ export function useLabReference(): (
|
||||
(v) => labNumber(v),
|
||||
);
|
||||
return range
|
||||
? t("timeline.labs.reference", { range: `${range} ${unit}`.trim() })
|
||||
? t("timeline.labs.reference", { range: withLabUnit(range, unit) })
|
||||
: null;
|
||||
},
|
||||
[t, labNumber],
|
||||
|
||||
@@ -67,6 +67,7 @@ import { resolveLabFields } from "@/lib/labs/serialise";
|
||||
import { sanitizeForPrompt } from "@/lib/insights/sanitize";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
import { labUnitText } from "@/lib/labs/dimensionless";
|
||||
|
||||
/** Cap on distinct biomarkers in the block (newest reading first). */
|
||||
const MAX_BIOMARKERS = 24;
|
||||
@@ -120,7 +121,7 @@ export interface CoachLabReading {
|
||||
value: number | null;
|
||||
/** Qualitative result text ("negativ" / …); null for a numeric row. */
|
||||
valueText: string | null;
|
||||
/** Resolved unit; empty for a qualitative marker. */
|
||||
/** Resolved unit; empty for a qualitative marker and for a ratio. */
|
||||
unit: string;
|
||||
referenceLow: number | null;
|
||||
referenceHigh: number | null;
|
||||
@@ -373,7 +374,9 @@ export async function buildLabsSnapshotBlock(
|
||||
: null,
|
||||
value: row.value,
|
||||
valueText: row.valueText ? sanitizeValueText(row.valueText) : null,
|
||||
unit: sanitizeForPrompt(resolved.unit, MAX_UNIT_CHARS),
|
||||
// A value without a unit is stored as "1"; the model is shown none,
|
||||
// so it never writes "3.6 1".
|
||||
unit: sanitizeForPrompt(labUnitText(resolved.unit), MAX_UNIT_CHARS),
|
||||
referenceLow: resolved.referenceLow,
|
||||
referenceHigh: resolved.referenceHigh,
|
||||
// A qualitative row has nothing to compare against the bounds →
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
readWorkoutsAroundRange,
|
||||
WORKOUT_PICK_SELECT,
|
||||
} from "@/lib/workouts/canonical-read";
|
||||
import { labUnitText } from "@/lib/labs/dimensionless";
|
||||
|
||||
/** Everything a section reader needs about the day. */
|
||||
export interface DayFrame {
|
||||
@@ -717,10 +718,9 @@ async function labsPart(frame: DayFrame): Promise<SectionPart> {
|
||||
title: row.analyte,
|
||||
meta:
|
||||
row.value !== null
|
||||
? // Every recorded digit, without the float noise a converted
|
||||
// reading carries (the labs page prints the same figure).
|
||||
? // Every recorded digit, minus a converted reading's float noise.
|
||||
// eslint-disable-next-line healthlog/no-unlocalised-decimal -- the day contract's meta is wire text; the lab figure keeps the notation it was stored in
|
||||
`${formatRecordedNumber(row.value, (v, digits) => v.toFixed(digits), row.sourceValue)} ${row.unit}`.trim()
|
||||
`${formatRecordedNumber(row.value, (v, digits) => v.toFixed(digits), row.sourceValue)} ${labUnitText(row.unit)}`.trim()
|
||||
: (row.valueText ?? null),
|
||||
note: openText(row.noteEncrypted, "lab note"),
|
||||
href: row.biomarkerId ? `/labs/${row.biomarkerId}` : "/labs",
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type DoctorReportPdfCursorState,
|
||||
type DoctorReportPdfRenderContext,
|
||||
} from "./render-context";
|
||||
import { withLabUnit } from "@/lib/labs/dimensionless";
|
||||
|
||||
export function buildClinicalRecordsNotesSection(
|
||||
context: DoctorReportPdfRenderContext,
|
||||
@@ -109,7 +110,7 @@ export function buildClinicalRecordsNotesSection(
|
||||
lr.panel ? `${lr.analyte} (${lr.panel})` : lr.analyte,
|
||||
isQualitative
|
||||
? (lr.valueText ?? "")
|
||||
: `${labNum(lr.value as number, lr.sourceValue)} ${lr.unit}`.trim(),
|
||||
: withLabUnit(labNum(lr.value as number, lr.sourceValue), lr.unit),
|
||||
isQualitative ? "—" : referenceCell(lr),
|
||||
isQualitative
|
||||
? ""
|
||||
@@ -166,10 +167,10 @@ export function buildClinicalRecordsNotesSection(
|
||||
y += 4;
|
||||
for (const lr of diverging) {
|
||||
doc.text(
|
||||
`* ${lr.analyte}: ${t("doctorReport.labsCatalogRangeLabel")} ${rangeText(
|
||||
lr.catalogReferenceLow,
|
||||
lr.catalogReferenceHigh,
|
||||
)} ${lr.unit}`.trim(),
|
||||
`* ${lr.analyte}: ${t("doctorReport.labsCatalogRangeLabel")} ${withLabUnit(
|
||||
rangeText(lr.catalogReferenceLow, lr.catalogReferenceHigh),
|
||||
lr.unit,
|
||||
)}`.trim(),
|
||||
margin,
|
||||
y,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* A value without a unit, on the document-review commit path.
|
||||
*
|
||||
* A ratio or an index is saved only when somebody chose "no unit" on the
|
||||
* review screen, which stores the UCUM unity `"1"`. A numeric value whose unit
|
||||
* is simply missing is still refused: for most analytes that means the
|
||||
* extraction dropped the unit, and saving it would hide that.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
labResult: { create: vi.fn() },
|
||||
biomarker: { updateMany: vi.fn(), findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/labs/biomarker-store", () => ({
|
||||
resolveOrMintBiomarker: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/store", () => ({
|
||||
decryptFactData: vi.fn(),
|
||||
}));
|
||||
|
||||
import type { ExtractedFact, LabResult } from "@/generated/prisma/client";
|
||||
import { commitApprovedFact } from "@/lib/documents/commit";
|
||||
import { decryptFactData } from "@/lib/documents/store";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
|
||||
import type { ObservationFactData } from "@/lib/validations/inbound-documents";
|
||||
|
||||
const FACT = {
|
||||
id: "fact-1",
|
||||
factType: "OBSERVATION",
|
||||
dataEncrypted: new Uint8Array(),
|
||||
} as unknown as ExtractedFact;
|
||||
|
||||
function stated(unit: string | null): void {
|
||||
const data: ObservationFactData = {
|
||||
label: "Cholesterol/HDL ratio",
|
||||
code: null,
|
||||
codeSystem: null,
|
||||
value: 3.6,
|
||||
valueText: null,
|
||||
unit,
|
||||
referenceLow: null,
|
||||
referenceHigh: null,
|
||||
referenceText: "< 5.0",
|
||||
effectiveDate: "2026-08-01",
|
||||
};
|
||||
vi.mocked(decryptFactData).mockReturnValue(data);
|
||||
}
|
||||
|
||||
function marker(unit: string): void {
|
||||
vi.mocked(resolveOrMintBiomarker).mockResolvedValue({
|
||||
id: "bm-ratio",
|
||||
name: "Cholesterol/HDL ratio",
|
||||
unit,
|
||||
lowerBound: null,
|
||||
upperBound: 5,
|
||||
panel: null,
|
||||
analyteKey: null,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.labResult.create).mockResolvedValue({
|
||||
id: "lr-1",
|
||||
takenAt: new Date("2026-08-01T12:00:00Z"),
|
||||
panel: null,
|
||||
} as unknown as LabResult);
|
||||
});
|
||||
|
||||
describe("commitApprovedFact — a value without a unit", () => {
|
||||
it("saves a value the reviewer marked as having no unit", async () => {
|
||||
stated("1");
|
||||
marker("1");
|
||||
const ref = await commitApprovedFact("user-1", FACT);
|
||||
expect(ref).toEqual({ recordType: "labResult", recordId: "lr-1" });
|
||||
const data = vi.mocked(prisma.labResult.create).mock.calls[0]![0].data;
|
||||
expect(data.unit).toBe("1");
|
||||
expect(data.value).toBe(3.6);
|
||||
expect(data.sourceUnit).toBeNull();
|
||||
// The minted marker carries the unity, so later readings match it.
|
||||
expect(vi.mocked(resolveOrMintBiomarker).mock.calls[0]![1].unit).toBe("1");
|
||||
});
|
||||
|
||||
it.each([null, "", " "])(
|
||||
"still refuses a numeric value whose unit is missing (%j)",
|
||||
async (unit) => {
|
||||
stated(unit);
|
||||
marker("1");
|
||||
await expect(commitApprovedFact("user-1", FACT)).rejects.toMatchObject({
|
||||
code: "observation.unitRequired",
|
||||
});
|
||||
expect(prisma.labResult.create).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("refuses no unit against a marker kept in a real unit", async () => {
|
||||
stated("1");
|
||||
marker("mg/dL");
|
||||
await expect(commitApprovedFact("user-1", FACT)).rejects.toMatchObject({
|
||||
code: "observation.unitMismatch",
|
||||
});
|
||||
expect(prisma.labResult.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a real unit against a marker without one", async () => {
|
||||
stated("mg/dL");
|
||||
marker("1");
|
||||
await expect(commitApprovedFact("user-1", FACT)).rejects.toMatchObject({
|
||||
code: "observation.unitMismatch",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -276,6 +276,16 @@ describe("lab-result LOINC + canonical UCUM coding (v1.18.8)", () => {
|
||||
expect(o?.valueQuantity?.unit).toBe("mg/dL");
|
||||
});
|
||||
|
||||
it("codes a value without a unit as the UCUM unity", () => {
|
||||
const o = labOf("Cholesterol/HDL ratio", "1", 3.6);
|
||||
expect(o?.valueQuantity).toEqual({
|
||||
value: 3.6,
|
||||
unit: "1",
|
||||
system: "http://unitsofmeasure.org",
|
||||
code: "1",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves analyte aliases to the same canonical LOINC", () => {
|
||||
// "LDL", "LDL-C", "LDL Cholesterol" all fold to 2089-1.
|
||||
for (const name of ["LDL-C", "LDL Cholesterol", "ldl_c"]) {
|
||||
|
||||
@@ -21,6 +21,10 @@ import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
import { LOINC_SYSTEM, UCUM_SYSTEM } from "@/lib/fhir/loinc-map";
|
||||
import { resolveLabCoding } from "@/lib/fhir/lab-loinc";
|
||||
import { qualitativeValueConcept } from "@/lib/fhir/lab-qualitative";
|
||||
import {
|
||||
DIMENSIONLESS_UNIT,
|
||||
isDimensionlessUnit,
|
||||
} from "@/lib/labs/dimensionless";
|
||||
|
||||
import type {
|
||||
FhirObservation,
|
||||
@@ -84,10 +88,15 @@ export function labObservations(
|
||||
// the code system while withholding the code claims a coding the bundle
|
||||
// does not carry, and leaves a receiver unable to machine-compare the
|
||||
// value it is attached to.
|
||||
// A value without a unit is the UCUM unity by definition, so it carries
|
||||
// the code even for an analyte the map does not know.
|
||||
const ucum = isDimensionlessUnit(lab.unit)
|
||||
? DIMENSIONLESS_UNIT
|
||||
: coding?.ucum;
|
||||
const quantity = (value: number) => ({
|
||||
value,
|
||||
unit: lab.unit,
|
||||
...(coding?.ucum ? { system: UCUM_SYSTEM, code: coding.ucum } : {}),
|
||||
...(ucum ? { system: UCUM_SYSTEM, code: ucum } : {}),
|
||||
});
|
||||
// The bounds are stated in the SAME unit as the value, so they carry the
|
||||
// SAME coding — a bare unit beside a coded value leaves a receiver unable
|
||||
|
||||
@@ -65,6 +65,34 @@ describe("lab rows", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reads 1 in the unit column as a value without a unit", () => {
|
||||
expect(lab(["INR", "1.1", "1", "2025-03-01", "0.8-1.2"])).toMatchObject({
|
||||
ok: true,
|
||||
row: { value: 1.1, unit: "1", sourceReferenceHigh: 1.2 },
|
||||
});
|
||||
});
|
||||
|
||||
it("still refuses a number whose unit cell is empty", () => {
|
||||
for (const unit of ["", " "]) {
|
||||
expect(lab(["INR", "1.1", unit, "2025-03-01", ""])).toEqual({
|
||||
ok: false,
|
||||
reason: "missing_unit",
|
||||
field: "unit",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps any other spelling of no unit as the text it is", () => {
|
||||
// Only the UCUM unity means "no unit"; a word or a dash stays the literal
|
||||
// unit the file wrote, so a marker that already holds it keeps matching.
|
||||
for (const unit of ["ratio", "-", "none"]) {
|
||||
expect(lab(["A/G", "1.4", unit, "2025-03-01", ""])).toMatchObject({
|
||||
ok: true,
|
||||
row: { unit },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a non-numeric result as a qualitative reading", () => {
|
||||
expect(
|
||||
lab(["Urine protein", "negative", "", "2025-03-01", ""]),
|
||||
|
||||
@@ -332,6 +332,7 @@ export const RECORD_IMPORT_TEMPLATES: Record<RecordImportKind, string> = {
|
||||
"analyte,value,unit,date,time,reference range,panel,note",
|
||||
"HbA1c,5.4,%,2025-03-01,08:15,4.0-5.6,Diabetes,",
|
||||
"Ferritin,85,ng/mL,2025-03-01,08:15,30-400,Iron,",
|
||||
"INR,1.1,1,2025-03-01,08:15,0.8-1.2,Coagulation,",
|
||||
"Urine protein,negative,,2025-03-01,,,Urine,",
|
||||
"",
|
||||
].join("\n"),
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { userDayKey } from "@/lib/tz/resolver";
|
||||
import { sanitizeForPrompt } from "@/lib/insights/sanitize";
|
||||
import { labUnitText } from "@/lib/labs/dimensionless";
|
||||
|
||||
export type BiomarkerStatusResult = {
|
||||
hasProvider: boolean;
|
||||
@@ -321,7 +322,7 @@ function biomarkerSnapshot(args: {
|
||||
// position below, so both are scrubbed exactly like the coach
|
||||
// labs-snapshot scrubs the same fields.
|
||||
const safeMarkerName = sanitizeForPrompt(marker.name, 120);
|
||||
const safeMarkerUnit = sanitizeForPrompt(marker.unit, 40);
|
||||
const safeMarkerUnit = sanitizeForPrompt(labUnitText(marker.unit), 40);
|
||||
|
||||
const snapshot = {
|
||||
locale,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { fenceUserText } from "@/lib/ai/coach/data-fence";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
import { labUnitText } from "@/lib/labs/dimensionless";
|
||||
|
||||
/** Up to eight results of the panel, fenced; null when there is nothing to say. */
|
||||
export async function labsPanelEvidence(
|
||||
@@ -33,7 +34,8 @@ export async function labsPanelEvidence(
|
||||
lab.value === null
|
||||
? fenceUserText(lab.valueText ?? "not reported")
|
||||
: String(lab.value);
|
||||
const unit = lab.unit ? ` ${fenceUserText(lab.unit)}` : "";
|
||||
const shown = labUnitText(lab.unit);
|
||||
const unit = shown ? ` ${fenceUserText(shown)}` : "";
|
||||
return `- Newly arrived lab result: ${fenceUserText(lab.analyte)} ${value}${unit}.`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DIMENSIONLESS_UNIT,
|
||||
isDimensionlessUnit,
|
||||
labUnitText,
|
||||
withLabUnit,
|
||||
} from "../dimensionless";
|
||||
import { formatLabReading } from "../format-value";
|
||||
import { readingInMarkerUnit } from "../reading-conversion";
|
||||
import { sameLabUnit } from "../unit-normalise";
|
||||
|
||||
describe("the dimensionless lab unit", () => {
|
||||
it("is the UCUM unity", () => {
|
||||
expect(DIMENSIONLESS_UNIT).toBe("1");
|
||||
});
|
||||
|
||||
it("recognises only the explicit unity, never an empty unit", () => {
|
||||
expect(isDimensionlessUnit("1")).toBe(true);
|
||||
expect(isDimensionlessUnit(" 1 ")).toBe(true);
|
||||
expect(isDimensionlessUnit("")).toBe(false);
|
||||
expect(isDimensionlessUnit(null)).toBe(false);
|
||||
expect(isDimensionlessUnit(undefined)).toBe(false);
|
||||
expect(isDimensionlessUnit("10")).toBe(false);
|
||||
expect(isDimensionlessUnit("1/min")).toBe(false);
|
||||
});
|
||||
|
||||
it("renders as nothing beside a value", () => {
|
||||
expect(labUnitText("1")).toBe("");
|
||||
expect(labUnitText("mg/dL")).toBe("mg/dL");
|
||||
expect(labUnitText(null)).toBe("");
|
||||
expect(withLabUnit("3.6", "1")).toBe("3.6");
|
||||
expect(withLabUnit("3.6", " mg/dL ")).toBe("3.6 mg/dL");
|
||||
expect(withLabUnit("3.6", "")).toBe("3.6");
|
||||
});
|
||||
|
||||
it("never prints a literal 1 after a reading", () => {
|
||||
const text = formatLabReading(
|
||||
{ value: 3.6, valueText: null, unit: "1" },
|
||||
(v) => String(v),
|
||||
);
|
||||
expect(text).toBe("3.6");
|
||||
});
|
||||
|
||||
it("is a unit of its own when compared", () => {
|
||||
expect(sameLabUnit("1", "1")).toBe(true);
|
||||
expect(sameLabUnit("1", "")).toBe(false);
|
||||
expect(sameLabUnit("1", "mg/dL")).toBe(false);
|
||||
expect(sameLabUnit("1", "%")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a dimensionless reading against a marker", () => {
|
||||
const client = {
|
||||
biomarker: { update: async () => ({}) },
|
||||
} as unknown as Parameters<typeof readingInMarkerUnit>[0];
|
||||
|
||||
it("is stored as it is under a dimensionless marker", async () => {
|
||||
const reading = await readingInMarkerUnit(client, {
|
||||
marker: { id: "m1", name: "INR", unit: "1", analyteKey: null },
|
||||
value: 1.1,
|
||||
unit: "1",
|
||||
sourceReferenceLow: 0.8,
|
||||
sourceReferenceHigh: 1.2,
|
||||
});
|
||||
expect(reading).toMatchObject({ ok: true, converted: false, value: 1.1 });
|
||||
});
|
||||
|
||||
it("is refused under a marker with a real unit", async () => {
|
||||
const reading = await readingInMarkerUnit(client, {
|
||||
marker: { id: "m2", name: "LDL", unit: "mg/dL", analyteKey: "ldl" },
|
||||
value: 3.6,
|
||||
unit: "1",
|
||||
sourceReferenceLow: null,
|
||||
sourceReferenceHigh: null,
|
||||
});
|
||||
expect(reading.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a real unit under a dimensionless marker", async () => {
|
||||
const reading = await readingInMarkerUnit(client, {
|
||||
marker: { id: "m3", name: "INR", unit: "1", analyteKey: null },
|
||||
value: 1.1,
|
||||
unit: "s",
|
||||
sourceReferenceLow: null,
|
||||
sourceReferenceHigh: null,
|
||||
});
|
||||
expect(reading.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* The unit of a lab value that has none.
|
||||
*
|
||||
* A ratio (total cholesterol over HDL, albumin over globulin) or an index
|
||||
* (INR) is a number without a unit. It is stored and sent as the UCUM unity
|
||||
* `"1"`, never as an empty unit: an empty unit on a numeric reading is what an
|
||||
* extraction leaves behind when it dropped the unit, and every write path
|
||||
* refuses it for that reason. `"1"` is a choice somebody made on purpose.
|
||||
*
|
||||
* For every comparison `"1"` is an ordinary unit. It matches only itself, so
|
||||
* a reading without a unit is refused under a marker kept in mg/dL and the
|
||||
* other way round. Only the display differs: nobody wants "3.6 1", so the
|
||||
* helpers below render the unity as nothing beside a value.
|
||||
*
|
||||
* Pure and dependency-free.
|
||||
*/
|
||||
|
||||
/** The UCUM unit of a dimensionless quantity. */
|
||||
export const DIMENSIONLESS_UNIT = "1";
|
||||
|
||||
/** Whether a stored or stated unit is the explicit "no unit" choice. */
|
||||
export function isDimensionlessUnit(unit: string | null | undefined): boolean {
|
||||
return typeof unit === "string" && unit.trim() === DIMENSIONLESS_UNIT;
|
||||
}
|
||||
|
||||
/** The unit as it reads beside a value: trimmed, and empty for the unity. */
|
||||
export function labUnitText(unit: string | null | undefined): string {
|
||||
if (typeof unit !== "string") return "";
|
||||
const trimmed = unit.trim();
|
||||
return trimmed === DIMENSIONLESS_UNIT ? "" : trimmed;
|
||||
}
|
||||
|
||||
/** A formatted number followed by its unit, or the number alone. */
|
||||
export function withLabUnit(
|
||||
number: string,
|
||||
unit: string | null | undefined,
|
||||
): string {
|
||||
const shown = labUnitText(unit);
|
||||
return shown ? `${number} ${shown}` : number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The unit where it stands on its own (a column, a marker's subtitle, a
|
||||
* sentence naming it): the localised "no unit" label for the unity, the unit
|
||||
* itself otherwise.
|
||||
*/
|
||||
export function labUnitLabel(
|
||||
unit: string | null | undefined,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
return isDimensionlessUnit(unit) ? t("labs.unit.none") : labUnitText(unit);
|
||||
}
|
||||
|
||||
/** How the API contract describes a lab unit field. */
|
||||
export const LAB_UNIT_DESCRIPTION =
|
||||
'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is never read as `"1"` and stays refused.';
|
||||
@@ -13,6 +13,7 @@
|
||||
* German reader sees "1,8 mIU/L" where an English one sees "1.8 mIU/L". This
|
||||
* module only decides the precision.
|
||||
*/
|
||||
import { withLabUnit } from "@/lib/labs/dimensionless";
|
||||
import {
|
||||
formatRecordedNumber,
|
||||
type FractionDigitsFormat,
|
||||
@@ -38,7 +39,7 @@ export function formatLabValue(
|
||||
/**
|
||||
* Render a reading's display value: a qualitative row (numeric `value` is
|
||||
* null) shows its `valueText` verbatim; a numeric row shows the formatted
|
||||
* number plus its unit. One helper so the list card, the history rows, and
|
||||
* number plus its unit (none for a dimensionless reading). One helper so the list card, the history rows, and
|
||||
* the detail header stay consistent.
|
||||
*/
|
||||
export function formatLabReading(
|
||||
@@ -53,5 +54,8 @@ export function formatLabReading(
|
||||
if (reading.value === null) {
|
||||
return reading.valueText ?? "";
|
||||
}
|
||||
return `${formatNumber(reading.value, reading.sourceValue)} ${reading.unit}`.trim();
|
||||
return withLabUnit(
|
||||
formatNumber(reading.value, reading.sourceValue),
|
||||
reading.unit,
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
import { getMetricStatusMeta } from "@/lib/insights/metric-status-registry";
|
||||
import { fenceUserText } from "@/lib/ai/coach/data-fence";
|
||||
import { labUnitText } from "@/lib/labs/dimensionless";
|
||||
|
||||
/**
|
||||
* Fallback units for the headline specialised metric types the generic
|
||||
@@ -91,7 +92,7 @@ export function summariseForVisit(
|
||||
panel: l.panel,
|
||||
value: l.value,
|
||||
valueText: l.valueText,
|
||||
unit: l.unit,
|
||||
unit: labUnitText(l.unit),
|
||||
referenceLow: l.referenceLow,
|
||||
referenceHigh: l.referenceHigh,
|
||||
takenAt: l.takenAt,
|
||||
|
||||
@@ -34,6 +34,7 @@ import { detectDerivedBriefingSignals } from "@/lib/insights/derived-briefing";
|
||||
import { buildBriefingIllnessCycleContext } from "@/lib/insights/illness-cycle-briefing";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import type { McpAuthContext } from "./auth";
|
||||
import { labUnitText } from "@/lib/labs/dimensionless";
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -254,7 +255,7 @@ async function collectLabTrajectories(
|
||||
const history = readings.slice(0, 12).map((r) => ({
|
||||
value: r.value,
|
||||
valueText: r.valueText,
|
||||
unit: r.unit,
|
||||
unit: labUnitText(r.unit),
|
||||
referenceLow: r.referenceLow,
|
||||
referenceHigh: r.referenceHigh,
|
||||
takenAt: r.takenAt.toISOString(),
|
||||
@@ -263,7 +264,7 @@ async function collectLabTrajectories(
|
||||
return {
|
||||
analyte: latest.analyte,
|
||||
panel: latest.panel,
|
||||
unit: latest.unit,
|
||||
unit: labUnitText(latest.unit),
|
||||
readingsOnFile: readings.length,
|
||||
latest: history[0],
|
||||
history,
|
||||
|
||||
@@ -2,7 +2,11 @@ import { z } from "zod/v4";
|
||||
|
||||
import { fenceUserText } from "@/lib/ai/coach/data-fence";
|
||||
import { resolveMarkerForRead } from "@/lib/labs/biomarker-resolve";
|
||||
import { getLabHistory, LAB_HISTORY_MAX_LIMIT } from "@/lib/mcp/rich-reads";
|
||||
import {
|
||||
getLabHistory,
|
||||
LAB_HISTORY_MAX_LIMIT,
|
||||
type LabHistoryResult,
|
||||
} from "@/lib/mcp/rich-reads";
|
||||
import { decodeOffsetCursor } from "@/lib/mcp/pagination";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { attachLabLinks, readLatestLabs } from "@/lib/mcp/lab-reads";
|
||||
@@ -10,6 +14,7 @@ import type { McpAuthContext } from "../auth";
|
||||
import { coachReadOutput, recordLinkSchema } from "./output-shapes";
|
||||
import { runCoachTool, whenModuleOn } from "./shared";
|
||||
import { READ_ONLY_ANNOTATIONS, type McpToolDefinition } from "./types";
|
||||
import { labUnitText } from "@/lib/labs/dimensionless";
|
||||
|
||||
/** Output schema for `get_labs` — latest-per-biomarker OR a paginated history. */
|
||||
const getLabsOutput: z.ZodRawShape = {
|
||||
@@ -67,6 +72,19 @@ function fenceLabText(value: unknown): unknown {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A value without a unit (a ratio, INR) is stored as the UCUM unity "1". The
|
||||
* model is shown no unit for it, as the Coach's lab block does, so it never
|
||||
* writes "3.6 1".
|
||||
*/
|
||||
function withShownUnits(result: LabHistoryResult): LabHistoryResult {
|
||||
if (!result.readings) return result;
|
||||
return {
|
||||
...result,
|
||||
readings: result.readings.map((r) => ({ ...r, unit: labUnitText(r.unit) })),
|
||||
};
|
||||
}
|
||||
|
||||
/** `get_labs` behind its module gate: latest-per-biomarker or a history page. */
|
||||
function readLabs(
|
||||
ctx: McpAuthContext,
|
||||
@@ -86,7 +104,7 @@ function readLabs(
|
||||
analyte,
|
||||
offset: decodeOffsetCursor(args.cursor),
|
||||
limit: LAB_HISTORY_MAX_LIMIT,
|
||||
});
|
||||
}).then(withShownUnits);
|
||||
}
|
||||
// Default: latest reading per biomarker via the Coach labs read. A name that
|
||||
// resolves to one of the user's markers (an alias or a catalog abbreviation
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { fenceUserText } from "@/lib/ai/coach/data-fence";
|
||||
import type { NutrientsDailyResult } from "@/lib/mcp/nutrients-read";
|
||||
import type { NutrientUpperLimit } from "@/lib/nutrients/catalog";
|
||||
import { labUnitText } from "@/lib/labs/dimensionless";
|
||||
|
||||
/** Finite-number guard for the citation summarisers. */
|
||||
function num(v: unknown): number | null {
|
||||
@@ -66,7 +67,9 @@ export function plainLabText(rid: string, result: unknown): string {
|
||||
: typeof reading.valueText === "string"
|
||||
? reading.valueText
|
||||
: "—";
|
||||
const unit = typeof reading.unit === "string" ? reading.unit : "";
|
||||
// A value without a unit (a ratio, INR) is stored as "1"; it reads as none.
|
||||
const unit =
|
||||
typeof reading.unit === "string" ? labUnitText(reading.unit) : "";
|
||||
const status =
|
||||
typeof reading.rangeStatus === "string" ? reading.rangeStatus : "";
|
||||
const taken =
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { LAB_UNIT_DESCRIPTION } from "@/lib/labs/dimensionless";
|
||||
|
||||
import {
|
||||
createBiomarkerSchema,
|
||||
updateBiomarkerSchema,
|
||||
@@ -54,7 +56,7 @@ const biomarkerRow = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
unit: z.string(),
|
||||
unit: z.string().describe(LAB_UNIT_DESCRIPTION),
|
||||
lowerBound: z.number().nullable(),
|
||||
upperBound: z.number().nullable(),
|
||||
panel: z.string().nullable(),
|
||||
|
||||
@@ -553,7 +553,7 @@ export const importPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
post: {
|
||||
tags: ["Import"],
|
||||
summary: "Import lab results from CSV",
|
||||
description: `${RECORD_IMPORT_COMMON} Each row's name resolves to the account's marker the way a scanned report's rows do (name, alias, catalog name); a new analyte mints one marker from its first row. A numeric result needs a unit; one in another unit is converted when the conversion is known and keeps the printed value beside it, otherwise the row is refused with \`unit_mismatch\`. Any other text in the value column is a qualitative result. A date without a time is stored as that calendar date. At most 20 000 data rows.`,
|
||||
description: `${RECORD_IMPORT_COMMON} Each row's name resolves to the account's marker the way a scanned report's rows do (name, alias, catalog name); a new analyte mints one marker from its first row. A numeric result needs a unit: an empty unit cell is refused with \`missing_unit\`, and \`1\` (the UCUM unity) states a value without a unit, such as a ratio or INR. A result in another unit is converted when the conversion is known and keeps the printed value beside it, otherwise the row is refused with \`unit_mismatch\`. Any other text in the value column is a qualitative result. A date without a time is stored as that calendar date. At most 20 000 data rows.`,
|
||||
requestParams: { query: labsCsvQuerySchema },
|
||||
requestBody: {
|
||||
required: true,
|
||||
|
||||
@@ -11,6 +11,8 @@ import { withRelated } from "./relation-links";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { LAB_UNIT_DESCRIPTION } from "@/lib/labs/dimensionless";
|
||||
|
||||
import {
|
||||
createLabResultSchema,
|
||||
labOverviewQuerySchema,
|
||||
@@ -71,7 +73,7 @@ const labResultRow = z
|
||||
// exactly one of the two is non-null per row.
|
||||
value: z.number().nullable(),
|
||||
valueText: z.string().nullable(),
|
||||
unit: z.string(),
|
||||
unit: z.string().describe(LAB_UNIT_DESCRIPTION),
|
||||
converted: z.boolean(),
|
||||
sourceValue: z.number().nullable(),
|
||||
sourceUnit: z.string().nullable(),
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { LAB_UNIT_DESCRIPTION } from "@/lib/labs/dimensionless";
|
||||
|
||||
const requiredText = (max: number) => z.string().trim().min(1).max(max);
|
||||
|
||||
const lowerBound = z.number().finite().optional();
|
||||
@@ -40,7 +42,7 @@ const optionalPanel = z
|
||||
export const createBiomarkerSchema = z
|
||||
.object({
|
||||
name: requiredText(120),
|
||||
unit: requiredText(40),
|
||||
unit: requiredText(40).describe(LAB_UNIT_DESCRIPTION),
|
||||
lowerBound,
|
||||
upperBound,
|
||||
context: optionalContext,
|
||||
@@ -67,7 +69,7 @@ export type CreateBiomarkerInput = z.infer<typeof createBiomarkerSchema>;
|
||||
export const updateBiomarkerSchema = z
|
||||
.object({
|
||||
name: requiredText(120).optional(),
|
||||
unit: requiredText(40).optional(),
|
||||
unit: requiredText(40).optional().describe(LAB_UNIT_DESCRIPTION),
|
||||
lowerBound: lowerBound.or(z.null()),
|
||||
upperBound: upperBound.or(z.null()),
|
||||
context: optionalContext.or(z.null()),
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import { locales } from "@/lib/i18n/config";
|
||||
import { LAB_UNIT_DESCRIPTION } from "@/lib/labs/dimensionless";
|
||||
import {
|
||||
DOCUMENT_BULK_MAX_IDS,
|
||||
DOCUMENT_CONTENT_INDEX_SOURCES,
|
||||
@@ -34,7 +35,6 @@ import {
|
||||
type DocumentContentIndexSourceValue,
|
||||
type InboundDocumentKindValue,
|
||||
} from "@/lib/documents/inbound-constants";
|
||||
|
||||
import type { AiCapabilityState } from "@/lib/ai/capabilities/types";
|
||||
import { OCR_MAX_ROWS } from "@/lib/validations/labs-ocr";
|
||||
|
||||
@@ -443,7 +443,7 @@ const observationEditSchema = z
|
||||
codeSystem: optCode,
|
||||
value: z.number().finite().nullable().optional(),
|
||||
valueText: optText(300),
|
||||
unit: optText(80),
|
||||
unit: optText(80).describe(LAB_UNIT_DESCRIPTION),
|
||||
referenceLow: z.number().finite().nullable().optional(),
|
||||
referenceHigh: z.number().finite().nullable().optional(),
|
||||
referenceText: optText(120),
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import type { AiCapabilityState } from "@/lib/ai/capabilities/types";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { LAB_UNIT_DESCRIPTION } from "@/lib/labs/dimensionless";
|
||||
import type { WrittenOutcome } from "@/lib/outcome/written-outcome";
|
||||
|
||||
import { validateEntryInstant } from "@/lib/validations/entry-instant";
|
||||
@@ -257,7 +258,13 @@ export const ocrCommitRowSchema = z
|
||||
.or(z.literal("").transform(() => undefined)),
|
||||
value: z.number().finite().optional(),
|
||||
valueText: z.string().trim().min(1).max(OCR_VALUE_TEXT_MAX).optional(),
|
||||
unit: z.string().trim().min(1).max(OCR_UNIT_MAX).optional(),
|
||||
unit: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(OCR_UNIT_MAX)
|
||||
.optional()
|
||||
.describe(LAB_UNIT_DESCRIPTION),
|
||||
referenceLow,
|
||||
referenceHigh,
|
||||
/**
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { LAB_UNIT_DESCRIPTION } from "@/lib/labs/dimensionless";
|
||||
import { SOURCE_REFERENCE_TEXT_MAX } from "@/lib/labs/parse-reference-range";
|
||||
import { validateEntryInstant } from "@/lib/validations/entry-instant";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
@@ -123,7 +124,7 @@ export const createLabResultSchema = z
|
||||
// (`value`) OR qualitative (`valueText`), never both, never neither.
|
||||
value: z.number().finite().optional(),
|
||||
valueText,
|
||||
unit: requiredText(40).optional(),
|
||||
unit: requiredText(40).optional().describe(LAB_UNIT_DESCRIPTION),
|
||||
referenceLow,
|
||||
referenceHigh,
|
||||
sourceReferenceLow,
|
||||
@@ -208,7 +209,7 @@ export const updateLabResultSchema = z
|
||||
// row's type (numeric ↔ qualitative) is intentionally not supported here —
|
||||
// delete and re-add — so an omitted key leaves the row's existing type.
|
||||
valueText: requiredText(120).optional(),
|
||||
unit: requiredText(40).optional(),
|
||||
unit: requiredText(40).optional().describe(LAB_UNIT_DESCRIPTION),
|
||||
referenceLow: referenceLow.or(z.null()),
|
||||
referenceHigh: referenceHigh.or(z.null()),
|
||||
sourceReferenceLow: sourceReferenceLow.or(z.null()),
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* A lab value without a unit, through the CSV import against a real database.
|
||||
*
|
||||
* `1` in the unit column is the explicit "no unit" choice (the UCUM unity): it
|
||||
* mints a marker kept in "1" and stores the reading under it. An empty unit
|
||||
* cell is still refused, and "1" stays a unit of its own in both directions:
|
||||
* it does not go under a marker kept in mg/dL, and mg/dL does not go under it.
|
||||
*/
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import { textToStream } from "@/lib/import/csv-stream";
|
||||
import { importLabsCsv } from "@/lib/import/labs-csv";
|
||||
import { labsCsvQuerySchema } from "@/lib/validations/record-import";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const USER = "unitless-lab-user";
|
||||
const TZ = "Europe/Berlin";
|
||||
|
||||
const query = labsCsvQuerySchema.parse({
|
||||
analyte: "0",
|
||||
value: "1",
|
||||
unit: "2",
|
||||
date: "3",
|
||||
referenceRange: "4",
|
||||
decimal: "comma",
|
||||
dateOrder: "dmy",
|
||||
});
|
||||
|
||||
function run(csv: string) {
|
||||
return importLabsCsv({
|
||||
userId: USER,
|
||||
tz: TZ,
|
||||
stream: textToStream(csv),
|
||||
maxBytes: 1_000_000,
|
||||
query,
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
await prisma.user.create({
|
||||
data: { id: USER, username: USER, timezone: TZ },
|
||||
});
|
||||
await prisma.biomarker.create({
|
||||
data: { userId: USER, name: "LDL", unit: "mg/dL" },
|
||||
});
|
||||
});
|
||||
|
||||
describe("lab CSV import of a value without a unit", () => {
|
||||
it("mints a marker without a unit and refuses the rows that are not one", async () => {
|
||||
const result = await run(
|
||||
[
|
||||
"Analyt;Wert;Einheit;Datum;Referenz",
|
||||
"Cholesterin/HDL-Quotient;3,6;1;01.03.2025;0 - 5",
|
||||
"INR;1,1;;01.03.2025;",
|
||||
"LDL;2,1;1;01.03.2025;",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(result.errors).toEqual([
|
||||
{ line: 3, reason: "missing_unit", field: "unit" },
|
||||
{ line: 4, reason: "unit_mismatch", field: "unit" },
|
||||
]);
|
||||
expect(result.inserted).toBe(1);
|
||||
|
||||
const marker = await prisma.biomarker.findFirstOrThrow({
|
||||
where: { userId: USER, name: "Cholesterin/HDL-Quotient" },
|
||||
});
|
||||
expect(marker.unit).toBe("1");
|
||||
const reading = await prisma.labResult.findFirstOrThrow({
|
||||
where: { userId: USER, biomarkerId: marker.id },
|
||||
});
|
||||
expect(reading.unit).toBe("1");
|
||||
expect(reading.value).toBe(3.6);
|
||||
expect(reading.sourceUnit).toBeNull();
|
||||
expect(reading.sourceReferenceHigh).toBe(5);
|
||||
});
|
||||
|
||||
it("refuses a real unit under the marker without one", async () => {
|
||||
const result = await run(
|
||||
[
|
||||
"Analyt;Wert;Einheit;Datum",
|
||||
"Cholesterin/HDL-Quotient;3,4;mg/dL;02.03.2025",
|
||||
"Cholesterin/HDL-Quotient;3,2;1;03.03.2025",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(result.errors).toEqual([
|
||||
{ line: 2, reason: "unit_mismatch", field: "unit" },
|
||||
]);
|
||||
expect(result.inserted).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user