Merge pull request #1328 from MBombeck/release/v1.46.6

release: v1.46.6
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-11-02 21:39:10 +01:00
commit 2c8140a502
112 files changed
+3802 -1081

No files matched your search

+82
View File
@@ -1,5 +1,87 @@
# Changelog
## [1.46.6] — 2026-11-02
### Added
- **Lab values without a unit.** Ratios and indices such as INR,
cholesterol/HDL or albumin/globulin had nowhere to go: manual entry, the
CSV import and the document review all required a unit. Entry, the
scanned-report rows and the document review now offer an explicit "No
unit" choice, stored as the UCUM unity `"1"`, and the CSV import reads
`1` in the unit column the same way. An empty unit is still refused, so
a unit the reading lost is not mistaken for none, and a document read
that answers "no unit" by itself is held for review. Such a value shows
as the number alone, a marker kept in a real unit refuses it, and the
FHIR export codes it as UCUM `1`. (#1313)
- **MCP reads follow their links.** Lab readings in `get_labs` and
`fetch lab:` list the documents and visits they are linked to, with the
linked records once per answer instead of on every reading of the same
report. `fetch med:` and `get_medication_schedule` list theirs, and
`fetch document:` now also names the lab results and medications filed
against it. Rows of `get_visits`, `get_medication_schedule` and
`get_illness_recovery` carry an id `fetch` takes. An imported document
names its source system and id, never the address of the instance it
came from. (#1323)
### Fixed
- **A refused lab value can be corrected in the review.** A value that
Finish review refused, such as a ratio printed without a unit or a unit
that disagrees with the marker, showed the reason but could only be
discarded. The row now offers the same Check control as a low-confidence
value. (#1313, #1327)
- **The doctor report prints every language.** The PDF used a built-in
font that covers Western European letters only, so Polish letters such
as ą, ł or ś, the whole Korean report, and any medication name or note in
another script printed as empty boxes. The report now embeds an open
Unicode font family covering Latin, Greek, Cyrillic and, when the report
needs it, Korean, and arrows, ≤ and ² print as themselves instead of
ASCII stand-ins. The file is compressed, so a report stays around
65 KB. Chinese, Japanese and Arabic text are not covered yet.
- **PDFs read on a Raspberry Pi 4.** The native canvas build for arm64
Alpine needs CPU features an ARMv8.0 core lacks, and loading it ended
every PDF read with an illegal instruction. The CPU check now covers that
case, and PDF reading carries on without the canvas, so the text layer
and the index work; thumbnails keep their existing fallback. (#1311)
- **The display name greets you.** The dashboard and Insights greetings,
the top bar and the sidebar showed the username even when a display name
was set. They now use the display name and fall back to the username.
(#1309)
- **Summaries count every reading right after an import.** A type whose
history was not folded yet counted as covered as soon as one day was, so
a summary could show one day of weight readings out of three. A type now
counts as covered only when its folded days reach back to its oldest
reading.
- **The repeated hour of a clock change stays two hours.** On the night
the clocks go back, a dense pulse or glucose chart folded both runs of
the repeated hour into one point. Each hour is its own point now.
- **Google Health heals a connection that lost its account id.** The
1.46.4 update cleared a placeholder id on existing connections. After a
clean sync such a connection now reads its real id back, so the
one-account-per-profile check covers it again.
- **WHOOP refuses a sign-in without a user id** instead of storing the
text "undefined" as the account.
### Changed
- Next.js 16.4.3 and the weekly dependency updates. node-forge, reached
through the APNs client, is raised to 1.4.2, which closes five high
advisories.
### Thanks
- @johnnyrepo026 for the precise report on unitless lab ratios (#1313),
with the cause already traced to the right two files, and for the pull
request that fixed the review half of it (#1327).
- @mbreitkreuz for the MCP linked-records request (#1323), a careful spec
with the real case that made date matching fail.
- @Oguz3003 for tracking the Raspberry Pi 4 crash down to the CPU and
confirming the workaround (#1311).
- @angusmaul for noticing the greeting ignored the display name (#1309).
- @mills1975 for the heart-rate sync report and the diagnostics that
pinned it to the iPhone app (#1326); the fix follows in an app update.
## [1.46.5] — 2026-10-30
### Fixed
+1 -1
View File
@@ -312,7 +312,7 @@ RUN mkdir -p /opt/prisma-cli && \
npm pkg set 'overrides.mysql2=^3.22.0' && \
npm pkg set 'overrides.@hono/node-server=^1.19.15' && \
npm pkg set 'overrides.valibot=^1.4.2' && \
npm install --omit=dev prisma@7.10.1 @prisma/engines@7.10.1 tsx@4.23.1 dotenv@18.0.8 && \
npm install --omit=dev prisma@7.10.1 @prisma/engines@7.10.1 tsx@4.23.1 dotenv@18.0.9 && \
ln -sfn /opt/prisma-cli/node_modules/.bin/tsx /usr/local/bin/healthlog-tsx && \
ln -sfn /opt/prisma-cli/node_modules/dotenv /app/node_modules/dotenv && \
ln -sfn /opt/prisma-cli/node_modules/prisma /app/node_modules/prisma && \
+56 -5
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.46.5
version: 1.46.6
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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."
}
}
+3
View File
@@ -309,6 +309,9 @@ const nextConfig: NextConfig = {
"./src/lib/documents/pdf-child.mjs",
"./src/lib/documents/pdf-engine.mjs",
"./src/lib/documents/image-engine.mjs",
// The doctor-report PDF embeds its fonts, read from disk by a path the
// tracer cannot follow (`src/lib/doctor-report-pdf/fonts.ts`).
"./src/lib/doctor-report-pdf/fonts/**",
// Document AI local text extraction and rasterization both run the
// app's one pdfjs-dist (`src/lib/documents/pdfjs.ts`) server-side; the
// text read is the provider-free fallback that keeps content search
+18 -18
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.46.5",
"version": "1.46.6",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
@@ -54,34 +54,34 @@
"mcp:stdio": "npx tsx src/cli/mcp-stdio.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1155.0",
"@aws-sdk/lib-storage": "3.1155.0",
"@aws-sdk/client-s3": "^3.1159.0",
"@aws-sdk/lib-storage": "3.1159.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@modelcontextprotocol/sdk": "^1.32.1",
"@modelcontextprotocol/sdk": "^1.32.2",
"@napi-rs/canvas": "1.0.13",
"@node-rs/argon2": "^2.2.2",
"@parse/node-apn": "^8.1.0",
"@parse/node-apn": "^8.1.1",
"@prisma/adapter-pg": "^7.10.1",
"@prisma/client": "^7.10.1",
"@simplewebauthn/browser": "^14.0.0",
"@simplewebauthn/server": "^14.0.3",
"@tanstack/react-query": "5.104.5",
"@tanstack/react-virtual": "^3.14.22",
"@tanstack/react-query": "5.105.0",
"@tanstack/react-virtual": "^3.14.25",
"@types/sax": "^1.2.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"dotenv": "^18.0.8",
"dotenv": "^18.0.9",
"fflate": "^0.8.3",
"jose": "^6.2.13",
"jose": "^6.2.14",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.9",
"lucide-react": "^1.61.0",
"lucide-react": "^1.66.0",
"mmdb-lib": "^3.0.3",
"next": "16.4.2",
"nodemailer": "10.2.1",
"next": "16.4.3",
"nodemailer": "10.3.1",
"otpauth": "^9.5.2",
"p-limit": "^7.3.3",
"pg": "^8.23.1",
@@ -107,15 +107,15 @@
},
"devDependencies": {
"@axe-core/playwright": "^4.14.0",
"@next/bundle-analyzer": "16.4.2",
"@next/bundle-analyzer": "16.4.3",
"@playwright/test": "^1.64.1",
"@stryker-mutator/core": "10.0.0",
"@stryker-mutator/core": "10.1.1",
"@stryker-mutator/vitest-runner": "10.0.0",
"@tailwindcss/postcss": "^4",
"@tesseract.js-data/deu": "1.0.0",
"@tesseract.js-data/eng": "1.0.0",
"@testcontainers/postgresql": "^12.2.1",
"@types/node": "26.6.5",
"@types/node": "26.6.6",
"@types/pg": "^8.23.1",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.3.0",
@@ -125,14 +125,14 @@
"@vitest/coverage-v8": "^5.0.5",
"ajv": "^8.20.0",
"eslint": "^9",
"eslint-config-next": "16.4.2",
"eslint-config-next": "16.4.3",
"fast-check": "4.10.2",
"knip": "^6.42.0",
"knip": "^6.44.1",
"lefthook": "^2.2.2",
"pdfjs-dist": "6.4.299",
"prettier": "^3.9.10",
"prettier-plugin-tailwindcss": "^0.8.1",
"shadcn": "^4.22.1",
"shadcn": "^4.23.0",
"tailwindcss": "^4",
"testcontainers": "^12.2.1",
"tw-animate-css": "^1.4.0",
+499 -478
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.46.5";
/* @sw-version-fallback */ "v1.46.6";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
+1
View File
@@ -31,6 +31,7 @@ typecheck.
- **`bench/`** — `seed-large-account.ts` (a synthetic ten-year account of about 1.26 million measurement rows) and `run.ts` (latency and SQL statements per request against it); see `docs/contributing/benchmarks.md`. Results land in `bench/results/`.
- **`test-notifications.ts`** — exercise the notification dispatcher + reminder check.
- **`fetch-geolite2.sh`** — download the MaxMind GeoLite2 MMDBs (needs a licence key) before `docker build`.
- **`build-pdf-fonts.py`** — rebuild the fonts the doctor-report PDF embeds (`src/lib/doctor-report-pdf/fonts/`) from the upstream Noto sources; needs `fonttools`. The source URLs are in its header.
- **`install-hooks.sh`** — install the local git hooks.
- **`generate-sw-version.mjs`**, **`print-bundle-report.mjs`** — service-worker version stamp and bundle-size report.
- **`i18n/`** — locale tooling; **`__tests__/`** — tests for the scripts above.
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""Build the fonts the doctor-report PDF embeds.
jsPDF draws one font per text run and has no glyph fallback, so every script
the report can carry has to live in one font file. No single upstream Noto
font covers what the report needs: Noto Sans has Latin, Greek and Cyrillic but
no arrows or comparison signs, Noto Sans KR has Hangul but no Latin
Extended-A (Polish). This script merges them:
ReportSans-{Regular,Bold,Italic}.ttf
Noto Sans (Latin, Latin Extended-A/B, Greek, Cyrillic, punctuation,
super-/subscripts, currency) plus the arrows and mathematical operators
of Noto Sans Math.
ReportSansKR-{Regular,Bold}.ttf
The matching ReportSans weight plus Hangul (syllables, jamo,
compatibility jamo) and CJK punctuation from Noto Sans KR, instanced
at weight 400 / 700. Loaded only for a report that contains Hangul.
All sources are SIL Open Font License 1.1; the licence texts sit beside the
output. The merged fonts carry their own family name, as the OFL asks of a
modified version. Layout tables (GSUB/GPOS/GDEF/MATH) are dropped: jsPDF
does no shaping, and precomposed Hangul syllables need none.
Requires fonttools (`pip install fonttools`). Usage:
python3 scripts/build-pdf-fonts.py <download-dir> <output-dir>
where <download-dir> holds the unhinted upstream TTFs:
NotoSans-Regular.ttf, NotoSans-Bold.ttf, NotoSans-Italic.ttf
https://github.com/notofonts/notofonts.github.io/tree/main/fonts/NotoSans/unhinted/ttf
NotoSansMath-Regular.ttf
https://github.com/notofonts/notofonts.github.io/tree/main/fonts/NotoSansMath/unhinted/ttf
NotoSansKR[wght].ttf
https://github.com/google/fonts/tree/main/ofl/notosanskr
"""
import copy
import sys
from pathlib import Path
from fontTools import subset
from fontTools.ttLib import TTFont
from fontTools.varLib import instancer
def ranges(*spans):
out = set()
for lo, hi in spans:
out.update(range(lo, hi + 1))
return out
BASE_CODEPOINTS = ranges(
(0x0020, 0x007E), # Basic Latin
(0x00A0, 0x024F), # Latin-1, Latin Extended-A/B
(0x0250, 0x02FF), # IPA, spacing modifiers
(0x0300, 0x036F), # combining diacritics
(0x0370, 0x03FF), # Greek
(0x0400, 0x052F), # Cyrillic + supplement
(0x1E00, 0x1EFF), # Latin Extended Additional
(0x2000, 0x206F), # general punctuation
(0x2070, 0x209F), # super-/subscripts
(0x20A0, 0x20CF), # currency
(0x2100, 0x214F), # letterlike (°C, №, ™)
(0x2150, 0x218F), # number forms
)
SYMBOL_CODEPOINTS = ranges(
(0x2190, 0x21FF), # arrows
(0x2200, 0x22FF), # mathematical operators (≈ ≤ ≥ ±)
)
HANGUL_CODEPOINTS = ranges(
(0x1100, 0x11FF), # Hangul jamo
(0x3000, 0x303F), # CJK symbols and punctuation
(0x3130, 0x318F), # compatibility jamo
(0xA960, 0xA97F), # jamo extended-A
(0xAC00, 0xD7A3), # syllables
(0xD7B0, 0xD7FF), # jamo extended-B
)
DROP_TABLES = ["GSUB", "GPOS", "GDEF", "MATH", "BASE", "JSTF", "DSIG", "STAT"]
def subset_font(font: TTFont, codepoints: set[int]) -> TTFont:
options = subset.Options()
options.layout_features = []
options.drop_tables += DROP_TABLES
options.hinting = False
options.notdef_outline = True
options.name_IDs = ["*"]
options.glyph_names = True
subsetter = subset.Subsetter(options)
subsetter.populate(unicodes=codepoints & set(font.getBestCmap()))
subsetter.subset(font)
return font
def copy_glyph(base: TTFont, donor: TTFont, name: str, prefix: str) -> str:
"""Copy one donor glyph (and its components) into base; return its name."""
new_name = f"{prefix}{name}"
glyf = base["glyf"]
if new_name in glyf.glyphs:
return new_name
glyph = copy.deepcopy(donor["glyf"][name])
if glyph.isComposite():
for component in glyph.components:
component.glyphName = copy_glyph(
base, donor, component.glyphName, prefix
)
glyf.glyphs[new_name] = glyph
glyf.glyphOrder.append(new_name)
base["hmtx"].metrics[new_name] = donor["hmtx"].metrics[name]
order = base.getGlyphOrder()
if new_name not in order:
order.append(new_name)
base.setGlyphOrder(order)
return new_name
def merge(base: TTFont, donor: TTFont, prefix: str) -> None:
if base["head"].unitsPerEm != donor["head"].unitsPerEm:
raise SystemExit("unitsPerEm differs between base and donor")
base_cmap = base.getBestCmap()
additions = {}
for cp, name in donor.getBestCmap().items():
if cp in base_cmap:
continue
additions[cp] = copy_glyph(base, donor, name, prefix)
for table in base["cmap"].tables:
if table.isUnicode():
if table.format == 4 and any(cp > 0xFFFF for cp in additions):
continue
table.cmap.update(additions)
base["maxp"].numGlyphs = len(base.getGlyphOrder())
base["hhea"].numberOfHMetrics = len(base.getGlyphOrder())
base["hhea"].advanceWidthMax = max(
base["hhea"].advanceWidthMax,
max(width for width, _ in donor["hmtx"].metrics.values()),
)
def finish(font: TTFont, family: str, style: str, path: Path) -> None:
"""Rename, drop glyph names, save.
jsPDF copies the whole `post` table into every PDF it writes, subset or
not, so glyph names would cost each report tens of kilobytes (hundreds
with Hangul). Format 3 carries none.
"""
font["post"].formatType = 3.0
rename(font, family, style)
font.save(path)
def rename(font: TTFont, family: str, style: str) -> None:
name = font["name"]
postscript = f"{family}-{style}"
for record in list(name.names):
if record.nameID in (1, 16):
record.string = family
elif record.nameID in (2, 17):
record.string = style
elif record.nameID == 3:
record.string = f"{postscript};HealthLog"
elif record.nameID == 4:
record.string = f"{family} {style}"
elif record.nameID == 6:
record.string = postscript
def build(src: Path, out: Path) -> None:
out.mkdir(parents=True, exist_ok=True)
math = subset_font(TTFont(src / "NotoSansMath-Regular.ttf"), SYMBOL_CODEPOINTS)
kr_source = src / "NotoSansKR[wght].ttf"
for style in ("Regular", "Bold", "Italic"):
base = subset_font(TTFont(src / f"NotoSans-{style}.ttf"), BASE_CODEPOINTS)
merge(base, math, "math.")
finish(base, "ReportSans", style, out / f"ReportSans-{style}.ttf")
if style == "Italic":
continue
weight = 400 if style == "Regular" else 700
kr = subset_font(TTFont(kr_source), HANGUL_CODEPOINTS)
kr = instancer.instantiateVariableFont(kr, {"wght": weight})
with_hangul = TTFont(out / f"ReportSans-{style}.ttf")
merge(with_hangul, kr, "kr.")
finish(with_hangul, "ReportSansKR", style, out / f"ReportSansKR-{style}.ttf")
if __name__ == "__main__":
if len(sys.argv) != 3:
raise SystemExit(__doc__)
build(Path(sys.argv[1]), Path(sys.argv[2]))
@@ -83,6 +83,11 @@ const ALLOWLIST: Record<string, { max: number; reason: string }> = {
reason:
"the discovery matrix reads from a fixed since forward; the fixture days stay inside it as now moves on",
},
"tests/integration/dense-series-dst-hour.test.ts": {
max: 1,
reason:
"the read starts at a fixed fall-back night and runs forward to now; the readings are pinned to that night, so the case cannot age",
},
};
export interface AgingFinding {
@@ -1,7 +1,7 @@
{
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 84,
"total": 94362,
"total": 94292,
"entries": {
"app/medications/page-client.tsx": 839,
"components/charts/mood-chart.tsx": 1166,
@@ -69,7 +69,7 @@
"lib/jobs/offhost-backup.ts": 1094,
"lib/jobs/reaction-line.ts": 803,
"lib/managed-profiles/handover.ts": 986,
"lib/mcp/record-search.ts": 1467,
"lib/mcp/record-search.ts": 1397,
"lib/mcp/rich-reads.ts": 1467,
"lib/measurements/dense-intraday-retention.ts": 1016,
"lib/measurements/import-apple-health-export.ts": 1722,
@@ -1,7 +1,7 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 345,
"total": 111929,
"total": 111918,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275,
@@ -45,7 +45,7 @@
"app/coach/plans/page.tsx :: CoachPlansBody": 216,
"app/insights/catalog/page.tsx :: MetricCatalogPage": 204,
"app/insights/medications/page.tsx :: InsightsMedikamentePage": 265,
"app/insights/page-client.tsx :: InsightsPageClient": 376,
"app/insights/page-client.tsx :: InsightsPageClient": 375,
"app/labs/page.tsx :: LabsPage": 228,
"app/medications/page-client.tsx :: MedicationsPageClient": 590,
"app/notifications/page.tsx :: NotificationsPage": 327,
@@ -93,7 +93,7 @@
"components/documents/document-card.tsx :: DocumentCard": 354,
"components/documents/document-detail-sheet.tsx :: DocumentDetailSheet": 325,
"components/documents/document-facts-review.tsx :: DocumentFactsSection": 242,
"components/documents/document-facts-review.tsx :: FactRow": 262,
"components/documents/document-facts-review.tsx :: FactRow": 261,
"components/documents/document-filter-bar.tsx :: DocumentFilterBar": 348,
"components/documents/document-record-links.tsx :: DocumentRecordLinks": 202,
"components/documents/document-timeline.tsx :: DocumentTimeline": 314,
@@ -139,7 +139,7 @@
"components/labs/lab-history-list.tsx :: LabHistoryList": 534,
"components/labs/lab-list.tsx :: LabList": 308,
"components/labs/ocr-review-dialog.tsx :: OcrReviewDialog": 359,
"components/labs/ocr-row-editor.tsx :: OcrRowEditor": 393,
"components/labs/ocr-row-editor.tsx :: OcrRowEditor": 386,
"components/layout/auth-shell.tsx :: AuthShell": 518,
"components/layout/bottom-nav.tsx :: BottomNav": 262,
"components/layout/capture-picker.tsx :: CapturePicker": 235,
@@ -205,7 +205,7 @@
"components/settings/import-panel/csv-import-card.tsx :: CsvImportCard": 212,
"components/settings/import-panel/dose-history-import-card.tsx :: DoseHistoryImportCard": 225,
"components/settings/import-panel/health-connect-import-card.tsx :: HealthConnectImportCard": 293,
"components/settings/import-panel/record-csv-import-card.tsx :: RecordCsvImportSheet": 460,
"components/settings/import-panel/record-csv-import-card.tsx :: RecordCsvImportSheet": 458,
"components/settings/insights-pill-order-section.tsx :: InsightsPillOrderSection": 255,
"components/settings/integrations/connections-panel.tsx :: ConnectionsPanel": 230,
"components/settings/integrations/document-sources-card.tsx :: SourceForm": 306,
@@ -1,7 +1,7 @@
{
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
"count": 2895,
"total": 9226,
"count": 2894,
"total": 9223,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 2,
"__tests__/acting-account-boundary-guard.test.ts": 7,
@@ -824,7 +824,7 @@
"components/documents/document-ai-transport.ts": 2,
"components/documents/document-bulk-bar.tsx": 1,
"components/documents/document-card.tsx": 2,
"components/documents/document-facts-review.tsx": 2,
"components/documents/document-facts-review.tsx": 1,
"components/documents/document-summary-block.tsx": 2,
"components/documents/use-content-index.ts": 1,
"components/documents/use-document-assist.ts": 1,
@@ -917,7 +917,7 @@
"components/insights/coach-panel/chat-bubble.tsx": 25,
"components/insights/coach-panel/coach-conversation.tsx": 40,
"components/insights/coach-panel/coach-drawer-body.tsx": 6,
"components/insights/coach-panel/coach-drawer.tsx": 14,
"components/insights/coach-panel/coach-drawer.tsx": 13,
"components/insights/coach-panel/coach-hero.tsx": 2,
"components/insights/coach-panel/coach-input.tsx": 12,
"components/insights/coach-panel/coach-model-picker.tsx": 1,
@@ -958,7 +958,6 @@
"components/insights/derived/coach-read-strip.tsx": 2,
"components/insights/derived/composite-score-anatomy.tsx": 6,
"components/insights/derived/coverage-meter.tsx": 1,
"components/insights/derived/index.ts": 1,
"components/insights/derived/provenance-explainer.tsx": 2,
"components/insights/derived/ring-hues.ts": 2,
"components/insights/derived/score-anatomy-view.tsx": 3,
+18 -1
View File
@@ -17,6 +17,7 @@ import { withIdempotency } from "@/lib/idempotency";
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
import { isDimensionlessUnit } from "@/lib/labs/dimensionless";
import { readingInMarkerUnit } from "@/lib/labs/reading-conversion";
import {
type ResolvedBiomarker,
@@ -270,7 +271,7 @@ async function postLabResult(request: NextRequest) {
meta: { biomarkerId: biomarker.id, reason: reading.reason },
});
return apiError(
`The reading is in ${unit}, but ${biomarker.name} is tracked in ${biomarker.unit}.`,
unitMismatchMessage(unit, biomarker.name, biomarker.unit),
422,
{
errorCode: "labs.unit.mismatch",
@@ -392,3 +393,19 @@ async function postLabResult(request: NextRequest) {
return apiSuccess(dto, 201);
}
/** The refusal for a reading whose unit differs from its marker's. */
function unitMismatchMessage(
readingUnit: string | undefined,
markerName: string,
markerUnit: string,
): string {
const readingSide =
!readingUnit || isDimensionlessUnit(readingUnit)
? "The reading has no unit"
: `The reading is in ${readingUnit}`;
const markerSide = isDimensionlessUnit(markerUnit)
? "tracked without a unit"
: `tracked in ${markerUnit}`;
return `${readingSide}, but ${markerName} is ${markerSide}.`;
}
@@ -131,7 +131,7 @@ function req(query: string): NextRequest {
* `tests/integration/series-source-ladder.test.ts`.
*/
function adaptSeriesSql(sql: string): Promise<unknown> {
if (/GROUP BY local_day|STDDEV_POP/.test(sql)) {
if (/GROUP BY (?:local_day|bucket_start)|STDDEV_POP/.test(sql)) {
return (prisma.$queryRaw as unknown as () => Promise<unknown>)();
}
const type = /'([A-Z0-9_]+)'::"measurement_type"/.exec(sql)?.[1];
@@ -914,7 +914,7 @@ describe("GET /api/measurements/series — dense-kind day-bucketing (v1.28.25)",
const bucketCall = vi
.mocked(prisma.$queryRawUnsafe)
.mock.calls.find(([sql]) =>
/GROUP BY local_day/.test(sql as string),
/GROUP BY (?:local_day|bucket_start)/.test(sql as string),
) as unknown[];
expect(bucketCall.slice(1)).toContain("hour");
});
@@ -981,7 +981,7 @@ describe("GET /api/measurements/series — dense-kind day-bucketing (v1.28.25)",
const bucketCall = vi
.mocked(prisma.$queryRawUnsafe)
.mock.calls.find(([sql]) =>
/GROUP BY local_day/.test(sql as string),
/GROUP BY (?:local_day|bucket_start)/.test(sql as string),
) as unknown[];
expect(bucketCall.slice(1)).toContain("hour");
});
+6
View File
@@ -52,6 +52,12 @@ vi.mock("@/lib/ai/coach/tools/executor", () => ({ executeCoachTool }));
vi.mock("@/lib/ai/coach/tools/inventory", () => ({
buildCoachDataInventory: vi.fn(),
}));
// `get_labs` builds its labs block first and asks the Coach only on a miss;
// a miss keeps the health-reader case on the executor it asserts.
vi.mock("@/lib/ai/coach/labs-snapshot", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/ai/coach/labs-snapshot")>()),
buildLabsSnapshotBlock: vi.fn(async () => null),
}));
vi.mock("@/lib/mcp/server", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/mcp/server")>();
return {
@@ -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,
@@ -8,7 +8,7 @@ import type {
InboundDocumentDetailDto,
} from "@/lib/validations/inbound-documents";
import { DocumentFactsSection } from "../document-facts-review";
import { DocumentFactsSection, FactRow } from "../document-facts-review";
/**
* The staged-facts block's honest states, pinned by static render:
@@ -173,3 +173,56 @@ describe("<DocumentFactsSection>", () => {
expect(html).toBe("");
});
});
describe("<FactRow>", () => {
// A confident lab value the last save refused (here a ratio printed without
// a unit) gets the same edit control as a low-confidence one: it is the only
// way to add the unit and finish the review.
const unitless = fact({
data: {
label: "Ratio",
code: null,
codeSystem: null,
value: 4.2,
valueText: null,
unit: null,
referenceLow: null,
referenceHigh: 5,
referenceText: "<5.0",
effectiveDate: "2026-08-01",
},
provenance: {
sourceText: "Ratio 4.2 <5.0",
anchored: true,
sourceOffset: 10,
page: 0,
confidence: 0.95,
},
});
const rowProps = {
documentId: "doc-1",
checked: true,
onCheckedChange: () => {},
chosenBiomarkerId: null,
onChooseBiomarker: () => {},
markerName: () => null,
};
it("offers the edit control on a confident row whose save was refused", () => {
const html = render(
<FactRow
{...rowProps}
fact={unitless}
failure="observation.unitRequired"
/>,
);
expect(html).toContain('data-slot="document-fact-verify-open"');
expect(html).toContain("A numeric value needs a unit");
});
it("leaves a confident row without a refusal free of the edit control", () => {
const html = render(<FactRow {...rowProps} fact={fact()} failure={null} />);
expect(html).not.toContain('data-slot="document-fact-verify-open"');
});
});
@@ -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;
}
@@ -204,8 +206,8 @@ function draftFor(fact: ExtractedFactDto): VerifyDraft {
};
}
/** One pending fact: include-toggle, stated values, verify-first when low-confidence. */
function FactRow({
/** One pending fact: include-toggle, stated values, and the edit control when it is low-confidence or its last save was refused. */
export function FactRow({
documentId,
fact,
checked,
@@ -221,7 +223,7 @@ function FactRow({
onCheckedChange: (checked: boolean) => void;
/** A per-fact commit refusal from the last confirm attempt, or null. */
failure: string | null;
/** v1.43 — the suggestion the person picked for a lab value, or null. */
/** The suggestion the person picked for a lab value, or null. */
chosenBiomarkerId: string | null;
onChooseBiomarker: (biomarkerId: string | null) => void;
/** The account's marker name by id, for the matched marker. */
@@ -250,8 +252,7 @@ function FactRow({
{
onSuccess: () => {
setVerifying(false);
// The refetched fact arrives with needsReview cleared; include it —
// the person just asserted these values.
// Include it: the refetched fact clears needsReview, as asserted.
onCheckedChange(true);
},
},
@@ -319,9 +320,10 @@ function FactRow({
</p>
) : null}
</div>
{fact.needsReview && !verifying ? (
{(fact.needsReview || failure !== null) && !verifying ? (
<Button
type="button"
data-slot="document-fact-verify-open"
variant="outline"
size="sm"
className="min-h-11 shrink-0 sm:min-h-8"
@@ -411,13 +413,12 @@ 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}
@@ -22,14 +22,9 @@ import { useIsMobile } from "@/hooks/use-is-mobile";
import { CoachConversation } from "./coach-conversation";
// v1.12.0 — `useResettableValue` + `nextResettableValue` moved into
// `./use-resettable-value` so the shared `<CoachConversation>` surface
// can seed its composer without importing the drawer shell. Re-exported
// here for the existing prefill-controller unit test import path.
export {
useResettableValue,
nextResettableValue,
} from "./use-resettable-value";
// `nextResettableValue` lives in `./use-resettable-value` beside its hook;
// it is re-exported here for the prefill-controller unit test's import path.
export { nextResettableValue } from "./use-resettable-value";
/**
* v1.28.52 (Documents R3) — the full-page target the maximize control hands
@@ -43,20 +43,6 @@ export const BAND_BORDER_CLASS: Record<ScoreBand, string> = {
red: "border-destructive/40",
};
/**
* The raw CSS variable per band — used where a Recharts `fill` needs an
* actual colour string rather than a Tailwind class (the score ring's
* `<RadialBar fill>`). Reads the semantic tokens so dark and Alucard light
* modes track the AA-safe light overrides automatically (the
* `--dracula-*` primitives carry no light override and would fail AA on
* the white card).
*/
export const BAND_VAR: Record<ScoreBand, string> = {
green: "var(--success)",
yellow: "var(--warning)",
red: "var(--destructive)",
};
/**
* Map a 0..100 score to its band. Mirrors the thresholds the existing
* health-score blend uses (≥ 70 green, ≥ 40 yellow, else red) so a
+3 -38
View File
@@ -1,43 +1,8 @@
/**
* v1.10.0 — the derived-metrics design-system primitives barrel.
*
* Four reusable, presentational components that extend the existing token +
* sentiment vocabulary; none introduce a new colour system. Reused across
* the dashboard, Insights pages, the Coach / briefing and the doctor/FHIR
* report. See `.planning/v1.10-build/design-sota-and-direction.md`.
* The derived-metrics surfaces the Insights page loads on demand. Their
* building blocks (score ring, coverage meter, band tokens and the rest) are
* imported from their own modules by whatever uses them.
*/
export { ScoreRing, type ScoreRingProps } from "./score-ring";
export { CoverageMeter, type CoverageMeterProps } from "./coverage-meter";
export {
SparklineDeltaTile,
type SparklineDeltaTileProps,
} from "./sparkline-delta-tile";
export {
ProvenanceExplainer,
type ProvenanceExplainerProps,
type ProvenanceStandard,
} from "./provenance-explainer";
export {
ScoreAnatomyView,
type ScoreAnatomyViewProps,
type AnatomyContributor,
} from "./score-anatomy-view";
export {
bandForScore,
clampScore,
BAND_NUMBER_CLASS,
BAND_PROGRESS_CLASS,
BAND_BORDER_CLASS,
BAND_VAR,
type ScoreBand,
} from "./band-tokens";
// ── W2b vitals dashboard surface + query hook ────────────────────────
export { VitalsDashboard } from "./vitals-dashboard";
export { WellnessScores } from "./wellness-scores";
export {
useDerivedMetric,
type DerivedMetricResponse,
type UseDerivedMetricOptions,
} from "./use-derived-metric";
@@ -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>
@@ -7,7 +7,7 @@ import { primeLocales } from "@/__tests__/helpers/prime-locales";
import { LabTrendCard } from "../lab-trend-card";
import type { BiomarkerTrendDto } from "../types";
primeLocales("de");
primeLocales("de", "ko");
const OK: BiomarkerTrendDto = {
status: "ok",
@@ -29,11 +29,12 @@ const OK: BiomarkerTrendDto = {
function render(
trend: BiomarkerTrendDto | undefined,
locale: "en" | "de" = "en",
locale: "en" | "de" | "ko" = "en",
unit = "mg/dL",
) {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<LabTrendCard trend={trend} unit="mg/dL" />
<LabTrendCard trend={trend} unit={unit} />
</I18nProvider>,
);
}
@@ -48,6 +49,16 @@ describe("<LabTrendCard>", () => {
expect(html).toContain("4.25 to 20.75 mg/dL");
});
it("leaves no gap where a value without a unit has none, in any language", () => {
const en = render(OK, "en", "1");
expect(en).toContain("Rising by about 13 a year across 4 readings");
expect(en).toContain("4.25 to 20.75.");
const ko = render(OK, "ko", "1");
expect(ko).toContain("약 13씩");
expect(ko).not.toMatch(/ 씩/);
expect(ko).not.toContain("\u0000");
});
it("speaks German with du", () => {
const html = render(OK, "de");
expect(html).toContain("deines Referenzbereichs");
@@ -0,0 +1,56 @@
import { useState } from "react";
import { describe, expect, it } from "vitest";
import { userEvent } from "vitest/browser";
import { renderWithApp } from "../../../../tests/browser/render";
import { LabUnitInput } from "../lab-unit-input";
/**
* The unit field as a form holds it, typed into key by key. A unit that
* starts with a 1 (10^9/L, 1/min) passes through "1" on the way, and that
* keystroke must not turn into the "no unit" choice.
*/
function Controlled({ initial = "" }: { initial?: string }) {
const [unit, setUnit] = useState(initial);
return (
<>
<label htmlFor="u">Unit</label>
<LabUnitInput id="u" value={unit} onChange={setUnit} />
<output data-testid="unit">{unit}</output>
</>
);
}
describe("<LabUnitInput> typed into", () => {
it("keeps a unit that starts with a 1", async () => {
const { screen } = await renderWithApp(<Controlled />);
await userEvent.type(screen.getByLabelText("Unit"), "10^9/L");
await expect
.element(screen.getByTestId("unit"))
.toHaveTextContent("10^9/L");
await expect
.element(screen.getByRole("checkbox", { name: /no unit/i }))
.not.toBeChecked();
});
it("shows a stored unity as the ticked choice and gives the field back on unticking", async () => {
const { screen } = await renderWithApp(<Controlled initial="1" />);
const box = screen.getByRole("checkbox", { name: /no unit/i });
await expect.element(box).toBeChecked();
await expect.element(screen.getByLabelText("Unit")).toBeDisabled();
await box.click();
await expect.element(screen.getByLabelText("Unit")).toBeEnabled();
await userEvent.type(screen.getByLabelText("Unit"), "mmol/L");
await expect
.element(screen.getByTestId("unit"))
.toHaveTextContent("mmol/L");
});
it("sets the unity when the box is ticked", async () => {
const { screen } = await renderWithApp(<Controlled initial="mg/dL" />);
await screen.getByRole("checkbox", { name: /no unit/i }).click();
await expect
.poll(() => screen.getByTestId("unit").element().textContent)
.toBe("1");
});
});
@@ -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");
});
});
+3 -2
View File
@@ -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
+4 -2
View File
@@ -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>
+5 -4
View File
@@ -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)}
/>
</>
)}
+12 -7
View File
@@ -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}
+2 -1
View File
@@ -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) }),
+9 -4
View File
@@ -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}
+23 -11
View File
@@ -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,21 @@ 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 fills the {unit} slot with a marker and drops it
// together with the space before it, so no language keeps the gap ("0.3 .",
// "0.3 씩").
const SLOT = "\u0000";
const tidy = (text: string) =>
shownUnit ? text : text.replace(new RegExp(` ?${SLOT}`, "g"), "");
const slopeLine = tidy(
t(`labs.trend.slope.${direction}`, {
amount: per(trend.slopePerYear),
unit: shownUnit || SLOT,
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 +95,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 || SLOT,
}),
)}
</p>
</CardContent>
</Card>
+124
View File
@@ -0,0 +1,124 @@
"use client";
import { useId, useRef, useState } 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. Only
* the box makes the choice: a unit typed key by key passes through "1" on its
* way to `10^9/L`, and that keystroke stays text.
*/
export function LabUnitInput({
id,
value,
onChange,
maxLength,
placeholder,
required,
allowNoUnit = true,
...aria
}: LabUnitInputProps) {
const { t } = useTranslations();
const noneId = useId();
const hintId = `${noneId}-hint`;
// Set when a keystroke, not the box, left the field at "1".
const [typedOne, setTypedOne] = useState(false);
const none = allowNoUnit && isDimensionlessUnit(value) && !typedOne;
// 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;
setTypedOne(isDimensionlessUnit(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) => {
setTypedOne(false);
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>
);
}
+2 -1
View File
@@ -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";
+2 -1
View File
@@ -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>
))}
+28 -11
View File
@@ -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>
</>
)}
+10 -6
View File
@@ -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"
+3 -2
View File
@@ -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],
@@ -2,7 +2,6 @@ import { describe, it, expect } from "vitest";
import {
buildDoctorReportPdfDocument,
renderDoctorReportPdfBytes,
sanitiseForPdf,
DOCTOR_REPORT_VITAL_GROUPS,
} from "../doctor-report-pdf-core";
import { measurementTypeEnum } from "../validations/measurement";
@@ -313,26 +312,10 @@ describe("buildDoctorReportPdfDocument", () => {
});
});
// ── WinAnsi sanitiser ── the rendered Helvetica is WinAnsi-encoded; glyphs
// outside it (trend arrows, superscripts) resolve to the .notdef box at a
// fallback advance width and stretch the surrounding line. The sanitiser
// maps every offender the report can emit onto a WinAnsi-safe equivalent.
describe("sanitiseForPdf", () => {
it("maps trend arrows to ASCII equivalents", () => {
expect(sanitiseForPdf("↑")).toBe("^");
expect(sanitiseForPdf("↓")).toBe("v");
expect(sanitiseForPdf("→")).toBe("->");
});
it("maps the superscript-two in kg/m² to a plain 2", () => {
expect(sanitiseForPdf("kg/m²")).toBe("kg/m2");
});
it("leaves WinAnsi-safe glyphs (umlauts, ß, em-dash) untouched", () => {
expect(sanitiseForPdf("Müller — Größe")).toBe("Müller — Größe");
});
it("renders the GLP-1 weight summary without any non-WinAnsi glyph", async () => {
// ── embedded font ── the report draws in an embedded Unicode font, so the
// GLP-1 summary's arrow separator prints as the arrow and not as ASCII.
describe("doctor-report arrows", () => {
it("renders the GLP-1 weight summary with its arrow separator", async () => {
const data = makeData({
glp1: {
weightStartKg: 92,
@@ -350,10 +333,8 @@ describe("sanitiseForPdf", () => {
now: FIXED_NOW,
});
const text = await extractText(bytes);
// The arrow separator must have been folded to ASCII; no raw arrow in
// the document text layer.
expect(text).not.toMatch(/[↑↓→²]/);
expect(text).toContain("->");
expect(text).toMatch(/92[.,]0 kg → 86[.,]0 kg/);
expect(text).not.toContain("->");
});
});
@@ -747,8 +728,7 @@ describe("doctor-report recorded precision", () => {
}),
);
expect(text).toContain("201 mg/dL");
// The PDF spells "≤" as "<=" (WinAnsi).
expect(text).toContain("<= 193");
expect(text).toContain("≤ 193");
});
it("keeps the digits of a measurement read below one", async () => {
@@ -1199,3 +1179,77 @@ describe("doctor report — the user's date order", () => {
expect(text).toContain("05/03/2026");
});
});
// ── script coverage ── the report is read in every shipped locale and carries
// free text the user typed in any script. Each string below has to come back
// out of the PDF's text layer exactly as it went in: a glyph the font lacks
// draws as a box and extracts as nothing, or as the wrong character.
describe("doctor-report script coverage", () => {
const POLISH = "Łódź, źdźbło, ężą";
const KOREAN = "혈압 측정";
const RUSSIAN = "Давление крови";
function withMedicationNames(names: string[]): DoctorReportData {
const compliance: DoctorReportData["compliance"] = {};
names.forEach((name, i) => {
compliance[`med-${i}`] = {
name,
total: 30,
taken: 28,
skipped: 1,
missed: 1,
};
});
return makeData({ compliance });
}
async function render(
locale: "en" | "pl" | "ko",
data: DoctorReportData,
): Promise<string> {
const { t } = getServerTranslator(locale);
return extractText(
renderDoctorReportPdfBytes(data, {
t,
locale,
now: FIXED_NOW,
timeFormat: "AUTO",
dateFormat: "AUTO",
}),
);
}
it("round-trips Polish, Russian and Korean free text in an English report", async () => {
const text = await render(
"en",
withMedicationNames([POLISH, RUSSIAN, KOREAN]),
);
expect(text).toContain(POLISH);
expect(text).toContain(RUSSIAN);
expect(text).toContain(KOREAN);
});
it("prints the Polish report's own wording with its diacritics", async () => {
const { t } = getServerTranslator("pl");
const text = await render("pl", withMedicationNames([POLISH]));
expect(text).toContain(t("doctorReport.title"));
expect(text).toContain(POLISH);
});
it("prints the Korean report's own wording", async () => {
const { t } = getServerTranslator("ko");
const text = await render("ko", withMedicationNames([KOREAN, RUSSIAN]));
expect(text).toContain(t("doctorReport.title"));
expect(text).toContain(KOREAN);
expect(text).toContain(RUSSIAN);
});
it("draws arrows and superscripts as themselves", async () => {
const text = await render(
"en",
withMedicationNames(["Dose ↑ 10 mg → 20 mg ↓", "kg/m² ≤ 25 × 2"]),
);
expect(text).toContain("Dose ↑ 10 mg → 20 mg ↓");
expect(text).toContain("kg/m² ≤ 25 × 2");
});
});
+13
View File
@@ -107,6 +107,8 @@ export interface CoachIllnessBlock {
bodySite?: string;
/** The side of `bodySite`, when one is stated. */
laterality?: string;
/** The episode's id; set only for a read that asks for it (`withIds`). */
id?: string;
}>;
/** Recently-resolved episodes, newest first — light history context. */
recentResolved: Array<{
@@ -116,6 +118,7 @@ export interface CoachIllnessBlock {
resolvedAt: string;
bodySite?: string;
laterality?: string;
id?: string;
}>;
/**
* v1.40 — the person's own symptoms over the last 14 days, most frequent
@@ -208,8 +211,14 @@ export async function buildIllnessSnapshotBlock(
* symptoms are history and are read from inside the limit only.
*/
reach: CoachHistoryReach = UNBOUNDED_REACH,
/**
* `withIds` carries each episode's id, so an MCP read can name the
* condition a client fetches next. The Coach never asks for it.
*/
options: { withIds?: boolean } = {},
): Promise<CoachIllnessBlock | null> {
if (!(await isIllnessEnabled(userId))) return null;
const idOf = (e: { id: string }) => (options.withIds ? { id: e.id } : {});
const floor = reachFloor(reach, now);
const [activeRows, resolvedRows, symptoms] = await Promise.all([
@@ -222,6 +231,7 @@ export async function buildIllnessSnapshotBlock(
},
orderBy: { onsetAt: "asc" },
select: {
id: true,
label: true,
type: true,
lifecycle: true,
@@ -240,6 +250,7 @@ export async function buildIllnessSnapshotBlock(
orderBy: { resolvedAt: "desc" },
take: RESOLVED_HISTORY_LIMIT,
select: {
id: true,
label: true,
type: true,
onsetAt: true,
@@ -267,6 +278,7 @@ export async function buildIllnessSnapshotBlock(
lifecycle: e.lifecycle,
onsetAt: e.onsetAt.toISOString(),
...siteFields(e),
...idOf(e),
})),
recentResolved: resolvedRows.map((e) => ({
label: sanitizeLabel(e.label),
@@ -275,6 +287,7 @@ export async function buildIllnessSnapshotBlock(
// `resolvedAt` is non-null by the query filter above.
resolvedAt: (e.resolvedAt as Date).toISOString(),
...siteFields(e),
...idOf(e),
})),
...(symptoms.length > 0 ? { symptoms } : {}),
};
+20 -2
View File
@@ -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;
@@ -128,6 +129,8 @@ export interface CoachLabReading {
rangeStatus: "in-range" | "below" | "above" | "unknown";
/** Measured date (ISO). */
takenAt: string;
/** The lab result's id; set only for a read that asks for it (`withIds`). */
id?: string;
}
/** One earlier numeric reading of a biomarker. */
@@ -136,6 +139,8 @@ export interface CoachLabHistoryPoint {
/** in-range / below / above / unknown against that reading's own bounds. */
rangeStatus: "in-range" | "below" | "above" | "unknown";
takenAt: string;
/** The lab result's id; set only for a read that asks for it (`withIds`). */
id?: string;
}
/** The readings of one calendar year (UTC), folded. */
@@ -174,6 +179,7 @@ export interface LabHistoryInput {
value: number;
takenAt: Date;
rangeStatus: CoachLabHistoryPoint["rangeStatus"];
id?: string;
}
const round2 = (value: number) => Math.round(value * 100) / 100;
@@ -216,6 +222,7 @@ export function shapeLabHistory(
value: reading.value,
rangeStatus: reading.rangeStatus,
takenAt: reading.takenAt.toISOString(),
...(reading.id ? { id: reading.id } : {}),
})),
yearly,
yearsOmitted: Math.max(0, years.length - LAB_HISTORY_MAX_YEARS),
@@ -239,6 +246,11 @@ export async function buildLabsSnapshotBlock(
* With it, each entry carries its `history`.
*/
analyte?: string;
/**
* Carry each reading's lab result id, so a caller can list what the
* reading is linked to. The Coach never asks: an id means nothing to it.
*/
withIds?: boolean;
} = {},
): Promise<CoachLabsBlock | null> {
if (!(await isModuleEnabled(userId, "labs"))) return null;
@@ -272,6 +284,7 @@ export async function buildLabsSnapshotBlock(
orderBy: { takenAt: "desc" },
take: MAX_ROWS,
select: {
id: true,
analyte: true,
panel: true,
value: true,
@@ -318,6 +331,7 @@ export async function buildLabsSnapshotBlock(
const resolved = resolveLabFields(row, row.biomarker);
if (wanted && !resolved.analyte.toLowerCase().includes(wanted)) continue;
const key = row.biomarkerId ?? `analyte:${resolved.analyte.toLowerCase()}`;
const id = options.withIds ? { id: row.id } : {};
const existing = entries.get(key);
if (existing) {
// One unit written two ways ("mg/dl", "mg/dL") is one series.
@@ -333,6 +347,7 @@ export async function buildLabsSnapshotBlock(
row.value,
resolved.effectiveRange,
),
...id,
});
}
continue;
@@ -359,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 →
@@ -372,6 +389,7 @@ export async function buildLabsSnapshotBlock(
resolved.effectiveRange,
),
takenAt: row.takenAt.toISOString(),
...id,
},
});
}
+3 -3
View File
@@ -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",
+19 -100
View File
@@ -1,16 +1,16 @@
/**
* Isomorphic PDF renderer for the doctor report.
* Server-side PDF renderer for the doctor report.
*
* Runs identically in the browser (settings page download) and in Node
* (the clinician share link's `/c/[token]/report.pdf` route). The comment here
* Callers are the clinician share link's `/c/[token]/report.pdf` route and
* the health-record export. The comment here
* named `/api/doctor-report/pdf` until 2026-08-23; that route was removed and
* the sweep behind the retired-route registry found the reference still
* standing. All locale-sensitive
* strings and number/date formatting are driven by the injected
* `{ t, locale }` so DE and EN output match the user's UI language.
* `{ t, locale }` so the output matches the user's UI language.
*
* jsPDF is fully isomorphic: `doc.output("arraybuffer")` returns a valid
* `%PDF-` byte stream in both environments.
* The text is drawn in embedded Unicode fonts read from disk
* (`doctor-report-pdf/fonts.ts`), which ties the renderer to Node.
*/
import { jsPDF } from "jspdf";
import type { MeasurementType } from "@/generated/prisma/client";
@@ -43,98 +43,14 @@ import {
pdfCursorState,
type DoctorReportPdfRenderContext,
} from "./doctor-report-pdf/render-context";
import {
registerReportFonts,
reportNeedsHangul,
} from "./doctor-report-pdf/fonts";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
type T = (key: string, params?: Record<string, string | number>) => string;
/**
* jsPDF's built-in Helvetica is WinAnsi-encoded. Latin-1 glyphs (umlauts,
* ß, em-/en-dash, typographic quotes, °, µ) carry correct metrics, but any
* code point outside WinAnsi resolves to the `.notdef` box at a single
* fallback advance width. The widths then disagree with the drawn glyph and
* the surrounding words stretch / shift — the visible "stretched line" bug.
*
* This sanitiser maps every glyph the report can emit that falls outside
* WinAnsi onto a WinAnsi-safe equivalent, applied centrally to every string
* just before it reaches `doc.text` / `doc.splitTextToSize` (see
* `patchPdfTextSanitiser`). The offenders that actually occur in the report
* are the trend arrows (↑ ↓ →) injected by `trendArrow()` + the `glp1WeightSummary`
* separator, and the superscript-two in "kg/m²". The mapping is exhaustive
* for those and degrades gracefully for any future stray symbol.
*
* Premium follow-up (documented, not a hotfix blocker): embed a Unicode TTF
* (e.g. DejaVuSans) via `doc.addFileToVFS` / `addFont` so the arrows and
* superscripts render as their true glyphs instead of ASCII equivalents.
*/
const WINANSI_REPLACEMENTS: Record<string, string> = {
// Trend arrows → ASCII so the metrics match the drawn glyph.
"↑": "^", // ↑ up
"↓": "v", // ↓ down
"→": "->", // → right (also the glp1 weight separator)
"←": "<-", // ←
"↔": "<->", // ↔
// Super-/subscripts used in "kg/m²".
"²": "2", // ²
"³": "3", // ³
"¹": "1", // ¹
// Defensive: a few maths/symbol glyphs that are outside WinAnsi but read
// fine as ASCII, in case a future string introduces them.
"≈": "~", // ≈
"≤": "<=", // ≤
"≥": ">=", // ≥
"×": "x", // ×
"€": "EUR", // €
};
const WINANSI_REPLACE_RE = new RegExp(
`[${Object.keys(WINANSI_REPLACEMENTS).join("")}]`,
"g",
);
/** Map non-WinAnsi glyphs onto safe equivalents. Pure; exported for tests. */
export function sanitiseForPdf(text: string): string {
return text.replace(
WINANSI_REPLACE_RE,
(ch) => WINANSI_REPLACEMENTS[ch] ?? ch,
);
}
/**
* Patch `doc.text` + `doc.splitTextToSize` on a single jsPDF instance so
* every drawn string (direct text, wrapped paragraphs, AND `jspdf-autotable`
* cells — which route their content through `doc.text` too) passes through
* `sanitiseForPdf` first. One choke point instead of a sanitiser call at
* every site keeps the renderer readable and guarantees coverage.
*/
function patchPdfTextSanitiser(doc: jsPDF): void {
const clean = (value: unknown): unknown =>
typeof value === "string"
? sanitiseForPdf(value)
: Array.isArray(value)
? value.map((v) => (typeof v === "string" ? sanitiseForPdf(v) : v))
: value;
const originalText = doc.text.bind(doc);
doc.text = function patchedText(
this: jsPDF,
...args: Parameters<jsPDF["text"]>
): jsPDF {
const next = [...args] as unknown[];
next[0] = clean(next[0]);
return originalText(...(next as Parameters<jsPDF["text"]>));
} as jsPDF["text"];
const originalSplit = doc.splitTextToSize.bind(doc);
doc.splitTextToSize = function patchedSplit(
this: jsPDF,
...args: Parameters<jsPDF["splitTextToSize"]>
): ReturnType<jsPDF["splitTextToSize"]> {
const next = [...args] as unknown[];
next[0] = clean(next[0]);
return originalSplit(...(next as Parameters<jsPDF["splitTextToSize"]>));
} as jsPDF["splitTextToSize"];
}
export interface DoctorReportRenderOptions {
t: T;
locale: Locale;
@@ -209,9 +125,8 @@ export const DOCTOR_REPORT_VITAL_GROUPS = REPORT_GROUPS.map((group) => ({
/**
* Render the doctor report into a `jsPDF` instance.
*
* Used internally by both the client wrapper (which calls `.save()` on the
* returned doc) and the server renderer (which calls `.output("arraybuffer")`
* via `renderDoctorReportPdfBytes`).
* `renderDoctorReportPdfBytes` serialises the returned doc; tests inspect it
* directly.
*/
export function buildDoctorReportPdfDocument(
data: DoctorReportData,
@@ -238,8 +153,13 @@ export function buildDoctorReportPdfDocument(
const unitFor = (type: string): string =>
reportStatUnit(type, glucoseUnit, t);
const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" });
patchPdfTextSanitiser(doc);
const doc = new jsPDF({
orientation: "portrait",
unit: "mm",
format: "a4",
compress: true,
});
registerReportFonts(doc, reportNeedsHangul(locale, data));
doc.setProperties({
title: t("doctorReport.title"),
subject: t("doctorReport.subtitle"),
@@ -304,7 +224,6 @@ export function buildDoctorReportPdfDocument(
/**
* Render the doctor report and return the PDF as a `Uint8Array`.
* Isomorphic — works in browser and Node.
*/
export function renderDoctorReportPdfBytes(
data: DoctorReportData,
@@ -0,0 +1,47 @@
import { jsPDF } from "jspdf";
import { readSource } from "@/__tests__/helpers/source-files";
import { describe, expect, it } from "vitest";
import {
PDF_FONT_FAMILY,
registerReportFonts,
reportNeedsHangul,
} from "../fonts";
const FONT_DIR = "src/lib/doctor-report-pdf/fonts";
describe("doctor-report fonts", () => {
it("ships the OFL licence beside the font files", () => {
const licence = readSource(
`${FONT_DIR}/OFL.txt`,
"SIL Open Font License, Version 1.1",
);
expect(licence).toContain("The Noto Project Authors");
expect(licence).toContain("Adobe");
});
it("names the font directory in the standalone trace includes", () => {
// The files are read by a path built at runtime; without the include the
// production image ships the renderer and none of its fonts.
const config = readSource("next.config.ts", "outputFileTracingIncludes");
expect(config).toContain(`"./${FONT_DIR}/**"`);
});
it("registers every style in both variants", () => {
for (const withHangul of [false, true]) {
const doc = new jsPDF();
registerReportFonts(doc, withHangul);
const styles = doc.getFontList()[PDF_FONT_FAMILY];
expect(styles?.sort()).toEqual(["bold", "italic", "normal"]);
expect(doc.getFont().fontName).toBe(PDF_FONT_FAMILY);
}
});
it("loads the Hangul font for a Korean report or Hangul in the data", () => {
expect(reportNeedsHangul("ko", { name: "Ramipril" })).toBe(true);
expect(reportNeedsHangul("en", { name: "혈압 측정" })).toBe(true);
expect(reportNeedsHangul("pl", { name: "Łódź" })).toBe(false);
expect(reportNeedsHangul("en", { name: "Давление крови" })).toBe(false);
});
});
@@ -9,6 +9,7 @@
*/
import type { jsPDF } from "jspdf";
import autoTable from "jspdf-autotable";
import { PDF_FONT_FAMILY } from "./fonts";
import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only";
import type { MeasurementType } from "@/generated/prisma/client";
import { formatEstimate } from "@/lib/medications/efficacy/segmented-format";
@@ -54,7 +55,7 @@ function heading(
const { doc, margin, ensureSpace } = context;
const top = ensureSpace(y, 6 + keepWith);
doc.setFontSize(12);
doc.setFont("helvetica", "bold");
doc.setFont(PDF_FONT_FAMILY, "bold");
doc.setTextColor(30, 30, 30);
doc.text(text, margin, top);
return top + 5;
@@ -67,7 +68,7 @@ function note(
): number {
const { doc, margin, pageWidth, ensureSpace } = context;
doc.setFontSize(8);
doc.setFont("helvetica", "normal");
doc.setFont(PDF_FONT_FAMILY, "normal");
doc.setTextColor(90, 90, 90);
let cursor = y;
for (const line of doc.splitTextToSize(text, pageWidth - 2 * margin)) {
@@ -1,3 +1,4 @@
import { PDF_FONT_FAMILY } from "./fonts";
import { lifeReportRows } from "@/lib/profile/life-report-rows";
import type { jsPDF } from "jspdf";
import autoTable from "jspdf-autotable";
@@ -16,6 +17,7 @@ import {
type DoctorReportPdfCursorState,
type DoctorReportPdfRenderContext,
} from "./render-context";
import { withLabUnit } from "@/lib/labs/dimensionless";
export function buildClinicalRecordsNotesSection(
context: DoctorReportPdfRenderContext,
@@ -42,7 +44,7 @@ export function buildClinicalRecordsNotesSection(
if (data.labResults && data.labResults.length > 0) {
y = ensureSpace(y, 6 + 18);
doc.setFontSize(14);
doc.setFont("helvetica", "bold");
doc.setFont(PDF_FONT_FAMILY, "bold");
doc.setTextColor(30, 30, 30);
doc.text(t("doctorReport.labsTitle"), margin, y);
y += 6;
@@ -109,7 +111,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
? ""
@@ -160,16 +162,16 @@ export function buildClinicalRecordsNotesSection(
if (diverging.length > 0) {
y = ensureSpace(y, 4 + diverging.length * 4);
doc.setFontSize(8);
doc.setFont("helvetica", "normal");
doc.setFont(PDF_FONT_FAMILY, "normal");
doc.setTextColor(110, 110, 110);
doc.text(t("doctorReport.labsSourceRangeNote"), margin, y);
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,
);
@@ -188,7 +190,7 @@ export function buildClinicalRecordsNotesSection(
if (data.illnessEpisodes && data.illnessEpisodes.length > 0) {
y = ensureSpace(y, 6 + 18);
doc.setFontSize(14);
doc.setFont("helvetica", "bold");
doc.setFont(PDF_FONT_FAMILY, "bold");
doc.setTextColor(30, 30, 30);
doc.text(t("doctorReport.illnessTitle"), margin, y);
y += 6;
@@ -263,7 +265,7 @@ export function buildClinicalRecordsNotesSection(
if (data.visits && data.visits.length > 0) {
y = ensureSpace(y, 6 + 18);
doc.setFontSize(14);
doc.setFont("helvetica", "bold");
doc.setFont(PDF_FONT_FAMILY, "bold");
doc.setTextColor(30, 30, 30);
doc.text(t("doctorReport.visitsTitle"), margin, y);
y += 6;
@@ -331,7 +333,7 @@ export function buildClinicalRecordsNotesSection(
if (data.surgicalHistory && data.surgicalHistory.length > 0) {
y = ensureSpace(y, 6 + 18);
doc.setFontSize(14);
doc.setFont("helvetica", "bold");
doc.setFont(PDF_FONT_FAMILY, "bold");
doc.setTextColor(30, 30, 30);
doc.text(t("doctorReport.surgicalHistoryTitle"), margin, y);
y += 6;
@@ -399,7 +401,7 @@ export function buildClinicalRecordsNotesSection(
if (data.immunizations && data.immunizations.length > 0) {
y = ensureSpace(y, 6 + 18);
doc.setFontSize(14);
doc.setFont("helvetica", "bold");
doc.setFont(PDF_FONT_FAMILY, "bold");
doc.setTextColor(30, 30, 30);
doc.text(t("doctorReport.immunizationsTitle"), margin, y);
y += 6;
@@ -473,7 +475,7 @@ export function buildClinicalRecordsNotesSection(
if (data.allergies && data.allergies.length > 0) {
y = ensureSpace(y, 6 + 18);
doc.setFontSize(14);
doc.setFont("helvetica", "bold");
doc.setFont(PDF_FONT_FAMILY, "bold");
doc.setTextColor(30, 30, 30);
doc.text(t("doctorReport.allergiesTitle"), margin, y);
y += 6;
@@ -537,7 +539,7 @@ export function buildClinicalRecordsNotesSection(
if (data.familyHistory && data.familyHistory.length > 0) {
y = ensureSpace(y, 6 + 18);
doc.setFontSize(14);
doc.setFont("helvetica", "bold");
doc.setFont(PDF_FONT_FAMILY, "bold");
doc.setTextColor(30, 30, 30);
doc.text(t("doctorReport.familyHistoryTitle"), margin, y);
y += 6;
@@ -587,7 +589,7 @@ export function buildClinicalRecordsNotesSection(
if (data.anamnesis) {
y = ensureSpace(y, 6 + 22);
doc.setFontSize(14);
doc.setFont("helvetica", "bold");
doc.setFont(PDF_FONT_FAMILY, "bold");
doc.setTextColor(30, 30, 30);
doc.text(t("doctorReport.anamnesisTitle"), margin, y);
y += 6;
@@ -1,3 +1,4 @@
import { PDF_FONT_FAMILY } from "./fonts";
import {
pdfCursorState,
type DoctorReportPdfCursorState,
@@ -41,13 +42,13 @@ export function buildEmergencyFirstPageSection(
doc.rect(margin, y, contentWidth, 12, "F");
doc.setTextColor(255, 255, 255);
doc.setFontSize(15);
doc.setFont("helvetica", "bold");
doc.setFont(PDF_FONT_FAMILY, "bold");
doc.text(t("doctorReport.emergency.title"), margin + 3, y + 8);
y += 12;
doc.setTextColor(120, 120, 120);
doc.setFontSize(8);
doc.setFont("helvetica", "normal");
doc.setFont(PDF_FONT_FAMILY, "normal");
y += 5;
doc.text(t("doctorReport.emergency.subtitle"), margin, y);
y += 6;
@@ -57,12 +58,12 @@ export function buildEmergencyFirstPageSection(
const block = (label: string, body: string, emphasise = false) => {
ensureRoom(5 + 4.8);
doc.setFontSize(10);
doc.setFont("helvetica", "bold");
doc.setFont(PDF_FONT_FAMILY, "bold");
doc.setTextColor(40, 40, 40);
doc.text(label, margin, y);
y += 5;
doc.setFontSize(emphasise ? 11 : 9.5);
doc.setFont("helvetica", emphasise ? "bold" : "normal");
doc.setFont(PDF_FONT_FAMILY, emphasise ? "bold" : "normal");
doc.setTextColor(
emphasise ? 150 : 60,
emphasise ? 20 : 60,
+98
View File
@@ -0,0 +1,98 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { jsPDF } from "jspdf";
import { applyPlugin, type UserOptions } from "jspdf-autotable";
import type { Locale } from "@/lib/i18n/config";
/**
* The font family every string in the doctor report is drawn in.
*
* jsPDF's built-in Helvetica is WinAnsi-encoded, so anything outside Latin-1
* (Polish, Cyrillic, Greek, Hangul, arrows) drew as a `.notdef` box. The
* report embeds Unicode TrueType fonts instead, built from Noto Sans, Noto
* Sans Math and Noto Sans KR by `scripts/build-pdf-fonts.py` (SIL Open Font
* License 1.1, `fonts/OFL.txt`). jsPDF has no per-glyph fallback, so each
* file carries every script it is meant for:
*
* - `ReportSans-*` (about 190 KB each): Latin incl. Extended-A/B, Greek,
* Cyrillic, punctuation, super-/subscripts, currency, arrows and
* mathematical operators. Regular, bold and italic.
* - `ReportSansKR-*` (about 2.7 MB each): the same plus Hangul. Read only
* for a report that contains Hangul, so the other locales never load it.
* Hangul has no italic; the italic style maps to the upright regular.
*
* jsPDF subsets each font to the glyphs a document uses, so the size of the
* produced PDF does not follow the size of these files.
*
* The files are read from disk at render time. The standalone build cannot
* see a path assembled at runtime, so `next.config.ts` names the directory in
* `outputFileTracingIncludes`.
*/
export const PDF_FONT_FAMILY = "ReportSans";
type FontStyle = "normal" | "bold" | "italic";
const FONT_DIR = join(process.cwd(), "src/lib/doctor-report-pdf/fonts");
const LATIN_FILES: Record<FontStyle, string> = {
normal: "ReportSans-Regular.ttf",
bold: "ReportSans-Bold.ttf",
italic: "ReportSans-Italic.ttf",
};
const HANGUL_FILES: Record<FontStyle, string> = {
normal: "ReportSansKR-Regular.ttf",
bold: "ReportSansKR-Bold.ttf",
italic: "ReportSansKR-Regular.ttf",
};
/** Jamo, compatibility jamo, jamo extended-A/B and the syllable block. */
const HANGUL_RE = /[\u1100-\u11FF\u3130-\u318F\uA960-\uA97F\uAC00-\uD7FF]/;
// The plugin form of jspdf-autotable is the one that can set per-document
// defaults, so every table in the report inherits the font without each call
// naming it.
applyPlugin(jsPDF);
type AutoTableDocument = jsPDF & {
autoTableSetDefaults(defaults: UserOptions): jsPDF;
};
/** Base64 per file, read once per process. */
const fontCache = new Map<string, string>();
function fontBase64(file: string): string {
let encoded = fontCache.get(file);
if (encoded === undefined) {
encoded = readFileSync(join(FONT_DIR, file)).toString("base64");
fontCache.set(file, encoded);
}
return encoded;
}
/**
* Whether the report needs the Hangul font: a Korean report, or any other
* whose data carries Hangul (a medication name, a note, a lab name).
*/
export function reportNeedsHangul(locale: Locale, data: unknown): boolean {
return locale === "ko" || HANGUL_RE.test(JSON.stringify(data) ?? "");
}
/**
* Register the report fonts on one jsPDF instance, make them current, and
* make them the default of every autoTable drawn on it. Callers name
* `PDF_FONT_FAMILY` in every `setFont`.
*/
export function registerReportFonts(doc: jsPDF, withHangul: boolean): void {
const files = withHangul ? HANGUL_FILES : LATIN_FILES;
for (const style of Object.keys(files) as FontStyle[]) {
const file = files[style];
doc.addFileToVFS(file, fontBase64(file));
doc.addFont(file, PDF_FONT_FAMILY, style);
}
doc.setFont(PDF_FONT_FAMILY, "normal");
(doc as AutoTableDocument).autoTableSetDefaults({
styles: { font: PDF_FONT_FAMILY },
});
}
+95
View File
@@ -0,0 +1,95 @@
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/math)
Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+2 -1
View File
@@ -1,3 +1,4 @@
import { PDF_FONT_FAMILY } from "./fonts";
import type { jsPDF } from "jspdf";
import {
pdfCursorState,
@@ -14,7 +15,7 @@ export function buildReportFooter(
const addFooter = (pageDoc: jsPDF) => {
const pageHeight = pageDoc.internal.pageSize.getHeight();
pageDoc.setFontSize(7);
pageDoc.setFont("helvetica", "italic");
pageDoc.setFont(PDF_FONT_FAMILY, "italic");
pageDoc.setTextColor(140, 140, 140);
pageDoc.text(t("doctorReport.footerDisclaimer1"), margin, pageHeight - 14);
pageDoc.text(t("doctorReport.footerDisclaimer2"), margin, pageHeight - 10);
@@ -1,3 +1,4 @@
import { PDF_FONT_FAMILY } from "./fonts";
import { buildClinicalSummaryLines } from "./clinical-summary";
import {
pdfCursorState,
@@ -25,13 +26,13 @@ export function buildHeaderProfileSection(
let y = state.y;
doc.setFontSize(22);
doc.setFont("helvetica", "bold");
doc.setFont(PDF_FONT_FAMILY, "bold");
doc.setTextColor(30, 30, 30);
doc.text(t("doctorReport.title"), margin, y);
y += 8;
doc.setFontSize(10);
doc.setFont("helvetica", "normal");
doc.setFont(PDF_FONT_FAMILY, "normal");
doc.setTextColor(100, 100, 100);
doc.text(t("doctorReport.subtitle"), margin, y);
y += 6;
@@ -42,7 +43,7 @@ export function buildHeaderProfileSection(
// one — keeps the cover compact for self-archive use.
if (data.practiceName) {
doc.setFontSize(11);
doc.setFont("helvetica", "bold");
doc.setFont(PDF_FONT_FAMILY, "bold");
doc.setTextColor(30, 30, 30);
doc.text(
`${t("doctorReport.practiceLabel")}: ${data.practiceName}`,
@@ -50,7 +51,7 @@ export function buildHeaderProfileSection(
y,
);
y += 6;
doc.setFont("helvetica", "normal");
doc.setFont(PDF_FONT_FAMILY, "normal");
}
doc.setDrawColor(200, 200, 200);
@@ -131,12 +132,12 @@ export function buildHeaderProfileSection(
// line 4.5 mm + trailing gap).
y = ensureSpace(y, 5 + 4.5 + 4);
doc.setFontSize(12);
doc.setFont("helvetica", "bold");
doc.setFont(PDF_FONT_FAMILY, "bold");
doc.setTextColor(30, 30, 30);
doc.text(t("doctorReport.summaryTitle"), margin, y);
y += 5;
doc.setFontSize(9);
doc.setFont("helvetica", "normal");
doc.setFont(PDF_FONT_FAMILY, "normal");
doc.setTextColor(60, 60, 60);
for (const line of summaryLines) {
const wrapped = doc.splitTextToSize(line, pageWidth - 2 * margin);
@@ -1,3 +1,4 @@
import { PDF_FONT_FAMILY } from "./fonts";
import type { jsPDF } from "jspdf";
import { GLUCOSE_CONTEXT_BUCKETS } from "@/lib/glucose";
import autoTable from "jspdf-autotable";
@@ -82,7 +83,7 @@ function drawSparkline(
const axisLabelHeight = 4.5;
doc.setFontSize(9);
doc.setFont("helvetica", "bold");
doc.setFont(PDF_FONT_FAMILY, "bold");
doc.setTextColor(30, 30, 30);
doc.text(label, x, y + labelHeight - 1.5);
@@ -141,7 +142,7 @@ function drawSparkline(
// Min/max labels in the right gutter.
doc.setFontSize(7);
doc.setFont("helvetica", "normal");
doc.setFont(PDF_FONT_FAMILY, "normal");
doc.setTextColor(120, 120, 120);
const unitSuffix = unit ? ` ${unit}` : "";
doc.text(`${num(max, 1)}${unitSuffix}`, x + chartWidth + 2, chartTop + 2);
@@ -243,7 +244,7 @@ function drawScreenerTranslationNotes(
if (notes.length === 0) return startY;
let y = startY;
doc.setFontSize(8);
doc.setFont("helvetica", "italic");
doc.setFont(PDF_FONT_FAMILY, "italic");
doc.setTextColor(110, 110, 110);
for (const note of notes) {
for (const line of doc.splitTextToSize(
@@ -282,7 +283,7 @@ export function buildMeasurementsChartsSection(
// Vitals heading — keep it with the start of its table.
y = ensureSpace(y, 6 + 18);
doc.setFontSize(14);
doc.setFont("helvetica", "bold");
doc.setFont(PDF_FONT_FAMILY, "bold");
doc.setTextColor(30, 30, 30);
doc.text(t("doctorReport.vitalsTitle"), margin, y);
y += 6;
@@ -429,7 +430,7 @@ export function buildMeasurementsChartsSection(
// Keep the heading with the start of its table.
y = ensureSpace(y, 6 + 14);
doc.setFontSize(12);
doc.setFont("helvetica", "bold");
doc.setFont(PDF_FONT_FAMILY, "bold");
doc.setTextColor(30, 30, 30);
doc.text(t("doctorReport.glucoseClinical.title"), margin, y);
y += 6;
@@ -472,7 +473,7 @@ export function buildMeasurementsChartsSection(
// clinician reads the panel as a direction-of-travel, not a CGM AGP.
if (clinical.isSpotEstimate) {
doc.setFontSize(8);
doc.setFont("helvetica", "italic");
doc.setFont(PDF_FONT_FAMILY, "italic");
doc.setTextColor(110, 110, 110);
const caveat = doc.splitTextToSize(
t("doctorReport.glucoseClinical.spotCaveat"),
@@ -504,7 +505,7 @@ export function buildMeasurementsChartsSection(
// Keep the heading with at least the first chart.
y = ensureSpace(y, 6 + SPARKLINE_HEIGHT + 4);
doc.setFontSize(12);
doc.setFont("helvetica", "bold");
doc.setFont(PDF_FONT_FAMILY, "bold");
doc.setTextColor(30, 30, 30);
doc.text(t("doctorReport.chartsTitle"), margin, y);
y += 6;
@@ -561,12 +562,12 @@ export function buildMeasurementsChartsSection(
if (sysStat && diaStat) {
y = ensureSpace(y, 5 + 8);
doc.setFontSize(12);
doc.setFont("helvetica", "bold");
doc.setFont(PDF_FONT_FAMILY, "bold");
doc.text(t("doctorReport.bpClassificationTitle"), margin, y);
y += 5;
doc.setFontSize(9);
doc.setFont("helvetica", "normal");
doc.setFont(PDF_FONT_FAMILY, "normal");
const bpClass = t(getBpClassificationKey(sysStat.avg, diaStat.avg));
doc.text(
`${t("doctorReport.avgBp")}: ${num(sysStat.avg, 0)}/${num(diaStat.avg, 0)} mmHg — ${t("doctorReport.classification")}: ${bpClass}`,
@@ -579,12 +580,12 @@ export function buildMeasurementsChartsSection(
if (data.bmi) {
y = ensureSpace(y, 5 + 8);
doc.setFontSize(12);
doc.setFont("helvetica", "bold");
doc.setFont(PDF_FONT_FAMILY, "bold");
doc.text(t("doctorReport.bmiTitle"), margin, y);
y += 5;
doc.setFontSize(9);
doc.setFont("helvetica", "normal");
doc.setFont(PDF_FONT_FAMILY, "normal");
doc.text(
t("doctorReport.bmiRow", {
bmi: num(data.bmi, 1),
@@ -603,11 +604,11 @@ export function buildMeasurementsChartsSection(
if (loggedGlucose.length > 0) {
y = ensureSpace(y, 5 + 5 + 3);
doc.setFontSize(12);
doc.setFont("helvetica", "bold");
doc.setFont(PDF_FONT_FAMILY, "bold");
doc.text(t("doctorReport.glucoseClassificationTitle"), margin, y);
y += 5;
doc.setFontSize(9);
doc.setFont("helvetica", "normal");
doc.setFont(PDF_FONT_FAMILY, "normal");
for (const ctx of loggedGlucose) {
const s = data.glucoseStats[ctx];
if (!s) continue;
@@ -1,3 +1,4 @@
import { PDF_FONT_FAMILY } from "./fonts";
import type { jsPDF } from "jspdf";
import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only";
import autoTable from "jspdf-autotable";
@@ -66,7 +67,7 @@ export function buildMedicationMoodWellnessSection(
);
doc.setFontSize(14);
doc.setFont("helvetica", "bold");
doc.setFont(PDF_FONT_FAMILY, "bold");
doc.text(t("doctorReport.complianceTitle"), margin, y);
y += 6;
@@ -154,12 +155,12 @@ export function buildMedicationMoodWellnessSection(
y = ensureSpace(y, 6 + 6 + 5);
doc.setFontSize(14);
doc.setFont("helvetica", "bold");
doc.setFont(PDF_FONT_FAMILY, "bold");
doc.text(t("doctorReport.glp1Title"), margin, y);
y += 6;
doc.setFontSize(9);
doc.setFont("helvetica", "normal");
doc.setFont(PDF_FONT_FAMILY, "normal");
if (
data.glp1.weightDeltaKg !== null &&
data.glp1.weightStartKg !== null &&
@@ -181,11 +182,11 @@ export function buildMedicationMoodWellnessSection(
// Keep the med name with at least its first detail line.
y = ensureSpace(y, 5 + 5);
doc.setFontSize(11);
doc.setFont("helvetica", "bold");
doc.setFont(PDF_FONT_FAMILY, "bold");
doc.text(med.name, margin, y);
y += 5;
doc.setFontSize(9);
doc.setFont("helvetica", "normal");
doc.setFont(PDF_FONT_FAMILY, "normal");
if (med.currentDose) {
doc.text(
t("doctorReport.glp1CurrentDose", {
@@ -272,7 +273,7 @@ export function buildMedicationMoodWellnessSection(
// block.
y = ensureSpace(y, 5 + estimateTableHeight(seRows.length));
doc.setFontSize(11);
doc.setFont("helvetica", "bold");
doc.setFont(PDF_FONT_FAMILY, "bold");
doc.text(t("doctorReport.glp1SideEffectsTitle"), margin, y);
y += 5;
autoTable(doc, {
@@ -316,12 +317,12 @@ export function buildMedicationMoodWellnessSection(
y = ensureSpace(y, 6 + 6 + estimateTableHeight(Math.min(moodRowCount, 1)));
doc.setFontSize(14);
doc.setFont("helvetica", "bold");
doc.setFont(PDF_FONT_FAMILY, "bold");
doc.text(t("doctorReport.moodTitle"), margin, y);
y += 6;
doc.setFontSize(9);
doc.setFont("helvetica", "normal");
doc.setFont(PDF_FONT_FAMILY, "normal");
doc.text(
t("doctorReport.moodSummary", {
avg: num(data.mood.avg, 1),
@@ -399,12 +400,12 @@ export function buildMedicationMoodWellnessSection(
y = ensureSpace(y, 6 + 6 + 6 + estimateTableHeight(1));
doc.setFontSize(14);
doc.setFont("helvetica", "bold");
doc.setFont(PDF_FONT_FAMILY, "bold");
doc.text(t("doctorReport.wellnessTitle"), margin, y);
y += 6;
doc.setFontSize(8);
doc.setFont("helvetica", "italic");
doc.setFont(PDF_FONT_FAMILY, "italic");
const wellnessDisclaimer = doc.splitTextToSize(
t("doctorReport.wellnessDisclaimer"),
pageWidth - margin * 2,
@@ -414,7 +415,7 @@ export function buildMedicationMoodWellnessSection(
y += 4;
}
y += 2;
doc.setFont("helvetica", "normal");
doc.setFont(PDF_FONT_FAMILY, "normal");
autoTable(doc, {
startY: y,
@@ -463,13 +464,13 @@ export function buildMedicationMoodWellnessSection(
y = ensureSpace(y, 6 + 6 + estimateTableHeight(1));
doc.setFontSize(14);
doc.setFont("helvetica", "bold");
doc.setFont(PDF_FONT_FAMILY, "bold");
doc.text(t("doctorReport.cycleTitle"), margin, y);
y += 6;
// Summary line: LMP, avg length ± variability, avg period, phase.
doc.setFontSize(9);
doc.setFont("helvetica", "normal");
doc.setFont(PDF_FONT_FAMILY, "normal");
const summaryBits: string[] = [];
if (cyc.lastPeriodStart) {
summaryBits.push(
@@ -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",
});
});
});
@@ -165,6 +165,34 @@ describe("runInboundExtraction — stated-status-only mapping", () => {
expect(med.statusStated).toBe("ongoing");
});
it('sends a confident value the read itself gave no unit ("1") to review', async () => {
const provider = fakeProvider(
JSON.stringify({
facts: [
{
type: "OBSERVATION",
label: "CHOL/HDLC RATIO",
value: 4,
unit: "1",
confidence: 0.95,
sourceText: "CHOL/HDLC RATIO 4.0",
},
{
type: "OBSERVATION",
label: "HDL",
value: 52,
unit: "mg/dL",
confidence: 0.95,
sourceText: "HDL 52 mg/dL",
},
],
}),
);
const result = await runInboundExtraction(oneRead(provider));
expect(result.facts.map((f) => f.needsReview)).toEqual([true, false]);
});
it("stores the document's real span, not the model's echo of it", async () => {
// The OCR text says "Hämoglobin 14.2 g/dL"; the model echoes a tidied
// rendering of it. The stored provenance must be the document's characters.
+7 -1
View File
@@ -57,6 +57,7 @@ import {
type OcrMeter,
type OcrReadSurface,
} from "@/lib/documents/ocr-read";
import { isDimensionlessUnit } from "@/lib/labs/dimensionless";
import { annotate } from "@/lib/logging/context";
import {
INBOUND_CONFIDENCE_FLOOR,
@@ -383,7 +384,12 @@ export async function runInboundExtraction(
facts.push({
factType: raw.type,
confidence,
needsReview: confidence < INBOUND_CONFIDENCE_FLOOR,
// A unit of "1" is the user's explicit no-unit choice. A read that
// states it itself goes to review rather than straight to the record,
// so a dropped unit never passes as a dimensionless value.
needsReview:
confidence < INBOUND_CONFIDENCE_FLOOR ||
(raw.type === "OBSERVATION" && isDimensionlessUnit(raw.unit)),
data,
provenance: {
sourceText: anchor.sourceText,
@@ -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"]) {
+10 -1
View File
@@ -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", ""]),
+1
View File
@@ -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"),
+2 -1
View File
@@ -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,
+2 -1
View File
@@ -29,6 +29,7 @@ import {
canonicalWorkoutsInRange,
readWorkoutsAroundRange,
} from "@/lib/workouts/canonical-read";
import { labUnitText } from "@/lib/labs/dimensionless";
import { deriveBucketedTypes } from "@/lib/signals/adapters/correlation";
import type {
AggregatedFeatures,
@@ -434,7 +435,7 @@ export async function readLabsBriefingBlock(
analyte: resolved.analyte,
value: latest.value,
valueText: latest.valueText ? sanitizeLabel(latest.valueText, 60) : null,
unit: resolved.unit,
unit: labUnitText(resolved.unit),
rangeStatus,
trend,
takenAt: latest.takenAt.toISOString(),
+3 -1
View File
@@ -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);
});
});
+56
View File
@@ -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.';
+6 -2
View File
@@ -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,
);
}
@@ -378,7 +378,7 @@
{
"name": "get_labs",
"title": "Get lab results",
"description": "Fetch the user's lab results. By default returns the latest reading per biomarker over the whole record. Filtered to one named analyte, each matching marker also carries a summarised history (the newest earlier values in full, older ones as one mean / min / max / count per year). Pass history:true with an analyte to return that analyte's reading TRAJECTORY (newest first, paginated). Each reading carries its value, unit, reference range, and in-range/below/above status. Returns { present: false } when no labs match, and { present: false, reason: \"module_disabled\" } when the labs module is switched off.",
"description": "Fetch the user's lab results. By default returns the latest reading per biomarker over the whole record. Filtered to one named analyte, each matching marker also carries a summarised history (the newest earlier values in full, older ones as one mean / min / max / count per year). Pass history:true with an analyte to return that analyte's reading TRAJECTORY (newest first, paginated). Each reading carries its value, unit, reference range, and in-range/below/above status; a ratio or an index recorded without a unit has an empty unit. Each reading (the latest, an earlier value in full, a trajectory reading; not a per-year summary) carries links: the ids of the records it is linked to (the report it was read from, the visits it is filed against), [] when none, at most 25 with linksTruncated; every linked record is described once in linkedRecords (id for fetch, kind, label, date, and for a document imported from Paperless-ngx or Papra its sourceSystem and sourceId). A document is listed only while the Documents module is on. Returns { present: false } when no labs match, and { present: false, reason: \"module_disabled\" } when the labs module is switched off.",
"inputSchema": {
"type": "object",
"properties": {
@@ -525,6 +525,15 @@
},
"takenAt": {
"type": "string"
},
"links": {
"type": "array",
"items": {
"type": "string"
}
},
"linksTruncated": {
"type": "boolean"
}
},
"required": [
@@ -534,13 +543,67 @@
"referenceLow",
"referenceHigh",
"rangeStatus",
"takenAt"
"takenAt",
"links",
"linksTruncated"
],
"additionalProperties": false
}
},
"nextCursor": {
"type": "string"
},
"linkedRecords": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": [
"string",
"null"
]
},
"kind": {
"type": "string",
"enum": [
"visit",
"condition",
"document",
"vaccination",
"lab",
"medication"
]
},
"label": {
"type": "string"
},
"date": {
"type": [
"string",
"null"
]
},
"sourceSystem": {
"type": "string",
"enum": [
"PAPERLESS",
"PAPRA",
"OTHER"
]
},
"sourceId": {
"type": "string"
}
},
"required": [
"id",
"kind",
"label",
"date"
],
"additionalProperties": false
}
}
},
"required": [
@@ -1672,7 +1735,7 @@
{
"name": "get_illness_recovery",
"title": "Get illness & recovery",
"description": "Fetch the user's illness + recovery context: rest mode, active and recently-resolved illnesses (with the body site and side when the user named one), the recovery / strain composites, and the illness retrospective (recovery-gap, nadir, red flags). Carries the rest-mode safety flag for framing. Returns { present: false } when there is nothing to report.",
"description": "Fetch the user's illness + recovery context: rest mode, active and recently-resolved illnesses (each with its id, condition:<id>, which fetch accepts and answers with the condition's linked visits and documents, and with the body site and side when the user named one), the recovery / strain composites, and the illness retrospective (recovery-gap, nadir, red flags). Carries the rest-mode safety flag for framing. Returns { present: false } when there is nothing to report.",
"inputSchema": {
"type": "object",
"properties": {}
@@ -2917,7 +2980,7 @@
{
"name": "get_medication_schedule",
"title": "Get the medication schedule",
"description": "Fetch when the user's active medications are next due, and which are overdue right now. Reuses the same server-authoritative recurrence engine the medication cards render (open overdue slots win over future ones). Returns one row per active medication: name, dose, next-due instant, an overdue flag, an as-needed (PRN) flag, and an intakeTracked flag. As-needed medications and medications whose intake tracking is off (intakeTracked: false, kept as a record only) have no due time (nextDueAt: null). Returns { present: false } when no medications are tracked, and { present: false, reason: \"module_disabled\" } when the medications module is switched off.",
"description": "Fetch when the user's active medications are next due, and which are overdue right now. Reuses the same server-authoritative recurrence engine the medication cards render (open overdue slots win over future ones). Returns one row per active medication: its id (med:<id>, which fetch accepts), name, dose, next-due instant, an overdue flag, an as-needed (PRN) flag, an intakeTracked flag, and its directly linked records (links: conditions, documents and visits, each with an id fetch accepts, a label, a date, and for a document imported from Paperless-ngx or Papra its sourceSystem and sourceId; [] when none; at most 25 with linksTruncated; documents only while the Documents module is on). As-needed medications and medications whose intake tracking is off (intakeTracked: false, kept as a record only) have no due time (nextDueAt: null). Returns { present: false } when no medications are tracked, and { present: false, reason: \"module_disabled\" } when the medications module is switched off.",
"inputSchema": {
"type": "object",
"properties": {}
@@ -2936,6 +2999,9 @@
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
@@ -2959,15 +3025,73 @@
},
"intakeTracked": {
"type": "boolean"
},
"links": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": [
"string",
"null"
]
},
"kind": {
"type": "string",
"enum": [
"visit",
"condition",
"document",
"vaccination",
"lab",
"medication"
]
},
"label": {
"type": "string"
},
"date": {
"type": [
"string",
"null"
]
},
"sourceSystem": {
"type": "string",
"enum": [
"PAPERLESS",
"PAPRA",
"OTHER"
]
},
"sourceId": {
"type": "string"
}
},
"required": [
"id",
"kind",
"label",
"date"
],
"additionalProperties": false
}
},
"linksTruncated": {
"type": "boolean"
}
},
"required": [
"id",
"name",
"dose",
"nextDueAt",
"overdue",
"asNeeded",
"intakeTracked"
"intakeTracked",
"links",
"linksTruncated"
],
"additionalProperties": false
}
@@ -3623,7 +3747,7 @@
{
"name": "get_visits",
"title": "Get past doctor visits",
"description": "Fetch the user's own past doctor visits over a bounded window (default: the last 12 months) so a question like 'when did I last see a cardiologist' is answerable from the record. Each visit carries its date, lifecycle status (DONE / CANCELLED / NO_SHOW — a no-show is not a visit that happened), kind, practitioner name + specialty, the visit's own free-text reason and outcome, the body site and side (set on procedures), and the labels of any conditions it was filed against. Optionally narrow to one practitioner by a name substring, or to one kind. kind PROCEDURE is the procedure and surgery history: without a months argument it reads the whole record, because a surgery years ago is still the answer to 'what surgeries have I had'. Newest first, bounded. Returns { present: false } when the user has never recorded a visit — distinct from a filtered read that simply matched none. With the illness module switched off the condition labels are withheld and conditionsReason is \"module_disabled\".",
"description": "Fetch the user's own past doctor visits over a bounded window (default: the last 12 months) so a question like 'when did I last see a cardiologist' is answerable from the record. Each visit carries its id (visit:<id>, which fetch accepts and answers with the visit's linked records), its date, lifecycle status (DONE / CANCELLED / NO_SHOW — a no-show is not a visit that happened), kind, practitioner name + specialty, the visit's own free-text reason and outcome, the body site and side (set on procedures), and the labels of any conditions it was filed against. Optionally narrow to one practitioner by a name substring, or to one kind. kind PROCEDURE is the procedure and surgery history: without a months argument it reads the whole record, because a surgery years ago is still the answer to 'what surgeries have I had'. Newest first, bounded. Returns { present: false } when the user has never recorded a visit — distinct from a filtered read that simply matched none. With the illness module switched off the condition labels are withheld and conditionsReason is \"module_disabled\".",
"inputSchema": {
"type": "object",
"properties": {
@@ -3673,6 +3797,9 @@
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"occurredAt": {
"type": "string"
},
@@ -3735,6 +3862,7 @@
}
},
"required": [
"id",
"occurredAt",
"status",
"kind",
@@ -3858,7 +3986,7 @@
{
"name": "fetch",
"title": "Fetch one health record",
"description": "Hydrate a single record returned by `search`, by its id (e.g. `metric:weight`, `med:<id>`, `lab:LDL`, `visit:<id>`, `condition:<id>`, `document:<id>`, `vaccination:<id>`). Returns { id, title, text, url, metadata } where `text` is a server-authoritative, plain-text prose summary suitable for citation and `url` deep-links to the specific record in HealthLog. A visit, condition, document or vaccination carries its key fields and its directly linked records (metadata.links, each with an id `fetch` accepts); a document returns its metadata and at most a bounded excerpt of its own indexed text (the words of the stored document), never the file; a document held back from AI reading returns no excerpt and metadata.reason \"ai_read_deferred\". Fields that were never recorded are null. Returns a not-found message when the id does not resolve, and metadata.reason \"module_disabled\" when the record's module is switched off.",
"description": "Hydrate a single record returned by `search`, by its id (e.g. `metric:weight`, `med:<id>`, `lab:LDL`, `visit:<id>`, `condition:<id>`, `document:<id>`, `vaccination:<id>`). Returns { id, title, text, url, metadata } where `text` is a server-authoritative, plain-text prose summary suitable for citation and `url` deep-links to the specific record in HealthLog. A visit, condition, document, vaccination, lab or medication carries its key fields and its directly linked records (metadata.links, each with an id `fetch` accepts, a label and a date, at most 25 with metadata.linksTruncated, and \"Linked records: none.\" in the text when there are none); a linked document imported from Paperless-ngx or Papra also names its sourceSystem and sourceId (never an address), and a document is listed only while the Documents module is on. A lab lists the links of its latest reading (the report it was read from, the visits it is filed against); a medication its conditions, documents and visits; a document also lists the lab results read from it and the medications filed against it; a document returns its metadata and at most a bounded excerpt of its own indexed text (the words of the stored document), never the file; a document held back from AI reading returns no excerpt and metadata.reason \"ai_read_deferred\". Fields that were never recorded are null. Returns a not-found message when the id does not resolve, and metadata.reason \"module_disabled\" when the record's module is switched off.",
"inputSchema": {
"type": "object",
"properties": {
@@ -22,6 +22,12 @@ vi.mock("@/lib/ai/coach/tools/executor", () => ({
vi.mock("@/lib/ai/coach/tools/inventory", () => ({
buildCoachDataInventory: vi.fn(),
}));
// The labs read builds its block first and asks the Coach only on a miss;
// a miss here keeps every read on the executor this sweep counts.
vi.mock("@/lib/ai/coach/labs-snapshot", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/ai/coach/labs-snapshot")>()),
buildLabsSnapshotBlock: vi.fn(async () => null),
}));
vi.mock("@/lib/mcp/rich-reads", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/mcp/rich-reads")>()),
getLabHistory: vi.fn(async () => ({ present: true, readings: [] })),
+19 -3
View File
@@ -28,6 +28,19 @@ vi.mock("@/lib/mcp/record-search", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/mcp/record-search")>()),
loadRecordCandidates: vi.fn(async () => []),
}));
// A medication or lab fetch lists its links through the link service; none
// are filed here (the links themselves are covered against Postgres in
// `tests/integration/mcp-record-links.test.ts`).
vi.mock("@/lib/links", () => ({
listTargets: vi.fn(async () => []),
listTargetsBySource: vi.fn(async () => new Map()),
}));
// The lab fetch builds its block first and asks the Coach only on a miss;
// a miss here keeps the lab prose on the executor result each test states.
vi.mock("@/lib/ai/coach/labs-snapshot", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/ai/coach/labs-snapshot")>()),
buildLabsSnapshotBlock: vi.fn(async () => null),
}));
vi.mock("@/lib/db", () => ({
prisma: {
medication: { findMany: vi.fn(), findFirst: vi.fn() },
@@ -291,9 +304,12 @@ describe("free-text fencing", () => {
// The block is fenced...
expect(result.text.startsWith(USER_TEXT_FENCE_START)).toBe(true);
expect(result.text.endsWith(USER_TEXT_FENCE_END)).toBe(true);
// ...and the payload cannot close it early: exactly one end marker, at the
// very end, so the injected sentence stays inside the data region.
// ...and the payload cannot close it early: exactly one end marker, right
// before the server's own links sentence, so the injected sentence stays
// inside the data region.
expect(
result.text.endsWith(`${USER_TEXT_FENCE_END} Linked records: none.`),
).toBe(true);
expect(result.text.split(USER_TEXT_FENCE_END)).toHaveLength(2);
expect(result.text.split(USER_TEXT_FENCE_START)).toHaveLength(2);
// The data itself is still delivered — fencing marks it, never drops it.
+11
View File
@@ -7,6 +7,17 @@ vi.mock("@/lib/ai/coach/tools/executor", () => ({
vi.mock("@/lib/ai/coach/tools/inventory", () => ({
buildCoachDataInventory: vi.fn(),
}));
// The labs read builds its block first and asks the Coach only on a miss; a
// miss here keeps the labs tests on the executor result each one states.
vi.mock("@/lib/ai/coach/labs-snapshot", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/ai/coach/labs-snapshot")>()),
buildLabsSnapshotBlock: vi.fn(async () => null),
}));
// get_illness_recovery reads the illness block again for the condition ids.
vi.mock("@/lib/ai/coach/illness-snapshot", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/ai/coach/illness-snapshot")>()),
buildIllnessSnapshotBlock: vi.fn(async () => null),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
+2 -1
View File
@@ -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,
+107
View File
@@ -0,0 +1,107 @@
/**
* The latest-per-marker labs read behind `get_labs` and `fetch lab:`, with
* what each reading is linked to.
*
* A present read builds the same block the Coach's `get_labs` returns
* (`buildLabsSnapshotBlock`, the whole record, as an MCP read has no lookback
* limit), asking it to carry each reading's lab result id. A miss falls back
* to the caller's Coach read, which owns the grounded empty answer (what the
* record holds, `analyte_not_found`).
*
* The links of a reading are the report it was read from (a document, behind
* the Documents module) and the visits it is filed against. A labs answer
* lists many readings, and one report usually holds a whole panel, so the
* answer names each linked record once, in `linkedRecords`, and a reading
* carries the ids of its links only. Every id there is one `fetch` accepts.
*/
import { buildLabsSnapshotBlock } from "@/lib/ai/coach/labs-snapshot";
import { UNBOUNDED_REACH } from "@/lib/ai/coach/history-reach";
import { boundLinks, linksBySource, type RecordLink } from "./record-links";
/** What a lab result lists as linked, in this order. */
export const LAB_LINK_TARGETS = ["document", "encounter"] as const;
/** The latest reading per marker with ids, or `fallback()` on a miss. */
export async function readLatestLabs(
userId: string,
analyte: string | undefined,
fallback: () => Promise<unknown>,
): Promise<unknown> {
const data = await buildLabsSnapshotBlock(
userId,
new Date(),
UNBOUNDED_REACH,
{ analyte, withIds: true },
);
return data ? { present: true, data } : fallback();
}
type IdReading = Record<string, unknown> & { id: string };
/** Every reading of a labs answer that carries its lab result id. */
function idReadings(result: unknown): IdReading[] {
const r = result as {
readings?: unknown[];
data?: { recent?: Array<{ history?: { earlier?: unknown[] } }> };
} | null;
const list: unknown[] = [
...(Array.isArray(r?.readings) ? r.readings : []),
...(Array.isArray(r?.data?.recent) ? r.data.recent : []).flatMap(
(entry) => [entry, ...(entry?.history?.earlier ?? [])],
),
];
return list.filter(
(x): x is IdReading =>
typeof x === "object" &&
x !== null &&
typeof (x as { id?: unknown }).id === "string",
);
}
/**
* Replace each reading's lab result id with the ids of what it links to
* (`links`, bounded, with `linksTruncated`), and add the linked records once
* each as `linkedRecords`. A reading linked to nothing carries `links: []`.
* An answer without ids (a miss) comes back unchanged. `result` must be the
* caller's own copy: its readings are rewritten in place.
*/
export async function attachLabLinks(
userId: string,
result: unknown,
): Promise<unknown> {
const readings = idReadings(result);
if (readings.length === 0) return result;
const bySource = await linksBySource(
userId,
"labResult",
[...new Set(readings.map((r) => r.id))],
LAB_LINK_TARGETS,
);
const table = new Map<string, RecordLink>();
for (const reading of readings) {
const { links, linksTruncated } = boundLinks(
bySource.get(reading.id) ?? [],
);
delete (reading as { id?: string }).id;
reading.links = links.flatMap((l) => (l.id ? [l.id] : []));
reading.linksTruncated = linksTruncated;
for (const link of links) {
if (link.id && !table.has(link.id)) table.set(link.id, link);
}
}
return { ...(result as object), linkedRecords: [...table.values()] };
}
/** The links of one lab result, bounded, for a single-record `fetch`. */
export async function labResultLinks(
userId: string,
labResultId: string,
): Promise<{ links: RecordLink[]; linksTruncated: boolean }> {
const bySource = await linksBySource(
userId,
"labResult",
[labResultId],
LAB_LINK_TARGETS,
);
return boundLinks(bySource.get(labResultId) ?? []);
}
+3 -2
View File
@@ -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,
+258
View File
@@ -0,0 +1,258 @@
/**
* The one-hop links an MCP read lists beside a record: `fetch` on a visit,
* condition, document, vaccination, lab or medication, and the lab and
* medication list reads. One builder, so every read names a linked record the
* same way: an id `fetch` accepts, a label, a date, and for an imported
* document the system it came from and its id there.
*
* Reads go through the link service only (`@/lib/links`), which narrows the
* link rows AND the targets to the caller's account, so another account's
* record cannot appear here. A target kind whose module is switched off is not
* read at all, the way `fetch` has always treated a switched-off kind.
*
* Labels are the person's own words (a document title, an analyte, a
* medication name): scrubbed of forged fence markers in structured fields, and
* fenced in the prose sentence.
*/
import type { EncounterKind } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { fenceUserText, scrubFenceMarkers } from "@/lib/ai/coach/data-fence";
import { encounterKindLabel } from "@/lib/encounters/kind-label";
import { listTargetsBySource } from "@/lib/links";
import type {
LinkSourceKind,
LinkTargetKind,
LinkedTarget,
} from "@/lib/links/link-service";
import { isModuleEnabled, type ModuleKey } from "@/lib/modules/gate";
import {
toDocumentSourceSystem,
type DocumentSourceSystemValue,
} from "@/lib/validations/inbound-documents";
/** One-hop links one record lists. */
export const MAX_LINKS = 25;
/** What a medication lists as linked, in this order. */
export const MEDICATION_LINK_TARGETS = [
"conditionEpisode",
"document",
"encounter",
] as const;
export type RecordLinkKind =
"visit" | "condition" | "document" | "vaccination" | "lab" | "medication";
export interface RecordLink {
id: string | null;
kind: RecordLinkKind;
label: string;
date: string | null;
/**
* For a document imported from Paperless-ngx or Papra: the system and the
* document's id there. Never the instance address.
*/
sourceSystem?: DocumentSourceSystemValue;
sourceId?: string;
}
/** The module each link target belongs to; a visit belongs to none. */
const TARGET_MODULE: Record<LinkTargetKind, ModuleKey | null> = {
encounter: null,
document: "inboundDocuments",
conditionEpisode: "illness",
labResult: "labs",
medication: "medications",
vaccination: "vaccinations",
};
/** The link kind and the `fetch` id prefix of each target kind. */
const TARGET_LINK: Record<
Exclude<LinkTargetKind, "labResult">,
{ kind: RecordLinkKind; prefix: string }
> = {
encounter: { kind: "visit", prefix: "visit" },
document: { kind: "document", prefix: "document" },
conditionEpisode: { kind: "condition", prefix: "condition" },
medication: { kind: "medication", prefix: "med" },
vaccination: { kind: "vaccination", prefix: "vaccination" },
};
function linkDate(iso: string | null): string | null {
return iso ? iso.slice(0, 10) : null;
}
/** A linked visit is labelled by its kind, in English on this wire. */
function visitLinkLabel(target: LinkedTarget): string {
return encounterKindLabel(target.label as EncounterKind, "en");
}
/** A lab result is fetched by its analyte, so the link id names that. */
async function labLinks(
userId: string,
targets: readonly LinkedTarget[],
): Promise<Map<string, RecordLink>> {
// Asked here as well as by every caller: an analyte name is lab data.
if (!(await isModuleEnabled(userId, "labs"))) return new Map();
const rows = await prisma.labResult.findMany({
where: { userId, id: { in: targets.map((t) => t.id) }, deletedAt: null },
select: { id: true, analyte: true },
});
const analyte = new Map(rows.map((r) => [r.id, r.analyte]));
const out = new Map<string, RecordLink>();
for (const target of targets) {
const name = analyte.get(target.id);
if (!name) continue;
out.set(target.id, {
id: `lab:${scrubFenceMarkers(name)}`,
kind: "lab",
label: scrubFenceMarkers(target.label),
date: linkDate(target.date),
});
}
return out;
}
/** An imported document says where it came from: system and id, no address. */
async function documentSources(
userId: string,
targets: readonly LinkedTarget[],
): Promise<Map<string, Pick<RecordLink, "sourceSystem" | "sourceId">>> {
const rows = await prisma.inboundDocument.findMany({
where: { userId, id: { in: targets.map((t) => t.id) } },
select: { id: true, sourceSystem: true, sourceId: true },
});
const out = new Map<string, Pick<RecordLink, "sourceSystem" | "sourceId">>();
for (const row of rows) {
const system = toDocumentSourceSystem(row.sourceSystem);
if (system && row.sourceId) {
out.set(row.id, {
sourceSystem: system,
sourceId: scrubFenceMarkers(row.sourceId),
});
}
}
return out;
}
/** Resolve one target kind's targets into links, keyed by target id. */
async function resolveLinks(
userId: string,
targetKind: LinkTargetKind,
targets: readonly LinkedTarget[],
): Promise<Map<string, RecordLink>> {
if (targets.length === 0) return new Map();
if (targetKind === "labResult") return labLinks(userId, targets);
const { kind, prefix } = TARGET_LINK[targetKind];
const sources =
targetKind === "document"
? await documentSources(userId, targets)
: new Map<string, Pick<RecordLink, "sourceSystem" | "sourceId">>();
return new Map(
targets.map((target) => [
target.id,
{
id: `${prefix}:${target.id}`,
kind,
label: scrubFenceMarkers(
targetKind === "encounter" ? visitLinkLabel(target) : target.label,
),
date: linkDate(target.date),
...sources.get(target.id),
},
]),
);
}
/** Links for targets already read by the caller, in their order. */
export async function linksFor(
userId: string,
targetKind: LinkTargetKind,
targets: readonly LinkedTarget[],
): Promise<RecordLink[]> {
const resolved = await resolveLinks(userId, targetKind, targets);
return targets.flatMap((t) => resolved.get(t.id) ?? []);
}
/** The target kinds whose module is on for this account, in order. */
export async function visibleTargetKinds(
userId: string,
kinds: readonly LinkTargetKind[],
): Promise<LinkTargetKind[]> {
const on = await Promise.all(
kinds.map((kind) => {
const owner = TARGET_MODULE[kind];
return owner ? isModuleEnabled(userId, owner) : true;
}),
);
return kinds.filter((_, i) => on[i]);
}
/**
* The links of many sources at once, keyed by source id, unbounded (bound
* them per record with {@link boundLinks}). One link read and one target read
* per target kind for the whole set, so a list of readings costs the same few
* round trips as one. A source with no link maps to an empty list.
*/
export async function linksBySource(
userId: string,
sourceKind: LinkSourceKind,
sourceIds: readonly string[],
targetKinds: readonly LinkTargetKind[],
): Promise<Map<string, RecordLink[]>> {
const out = new Map<string, RecordLink[]>(sourceIds.map((id) => [id, []]));
if (sourceIds.length === 0) return out;
const visible = await visibleTargetKinds(userId, targetKinds);
const perKind = await Promise.all(
visible.map(async (targetKind) => {
const bySource = await listTargetsBySource(prisma, {
userId,
sourceKind,
sourceIds: [...sourceIds],
targetKind,
});
const resolved = await resolveLinks(
userId,
targetKind,
[...bySource.values()].flat(),
);
return { bySource, resolved };
}),
);
for (const { bySource, resolved } of perKind) {
for (const [sourceId, targets] of bySource) {
const list = out.get(sourceId);
if (!list) continue;
list.push(...targets.flatMap((t) => resolved.get(t.id) ?? []));
}
}
return out;
}
/** The prose form of a record's links, with the same empty answer everywhere. */
export function linksSentence(links: readonly RecordLink[]): string {
if (links.length === 0) return "Linked records: none.";
return `Linked records: ${links
.map((l) => {
const detail = [
l.date,
l.sourceSystem && l.sourceId
? `${l.sourceSystem} id ${fenceUserText(l.sourceId)}`
: null,
].filter((d): d is string => d !== null);
return `${l.kind} ${fenceUserText(l.label)}${
detail.length > 0 ? ` (${detail.join(", ")})` : ""
}`;
})
.join("; ")}.`;
}
export function boundLinks(links: readonly RecordLink[]): {
links: RecordLink[];
linksTruncated: boolean;
} {
return {
links: links.slice(0, MAX_LINKS),
linksTruncated: links.length > MAX_LINKS,
};
}
+28 -98
View File
@@ -58,7 +58,13 @@ import { encounterKindLabel } from "@/lib/encounters/kind-label";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
import { listTargets } from "@/lib/links";
import type { LinkedTarget } from "@/lib/links/link-service";
import {
boundLinks,
linksBySource,
linksFor,
linksSentence,
type RecordLink,
} from "./record-links";
import { isModuleEnabled, type ModuleKey } from "@/lib/modules/gate";
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
import {
@@ -83,8 +89,6 @@ export const MAX_ROWS_PER_KIND = 500;
export const EMPTY_QUERY_PER_KIND = 10;
/** Characters of indexed document text one `fetch` returns. */
export const MAX_DOCUMENT_EXCERPT_CHARS = 1500;
/** One-hop links one `fetch` returns. */
export const MAX_LINKS = 25;
// ── Kinds and their modules ──────────────────────────────────────────
@@ -923,13 +927,6 @@ export async function loadRecordCandidates(
// ── Fetch ────────────────────────────────────────────────────────────
export interface RecordLink {
id: string | null;
kind: RecordKind | "lab";
label: string;
date: string | null;
}
export interface FetchResult {
id: string;
title: string;
@@ -938,72 +935,6 @@ export interface FetchResult {
metadata: Record<string, unknown>;
}
function linkDate(iso: string | null): string | null {
return iso ? iso.slice(0, 10) : null;
}
function toLinks(
kind: RecordKind,
targets: readonly LinkedTarget[],
label: (target: LinkedTarget) => string = (t) => t.label,
): RecordLink[] {
return targets.map((target) => ({
id: `${kind}:${target.id}`,
kind,
label: scrubFenceMarkers(label(target)),
date: linkDate(target.date),
}));
}
/** A linked visit is labelled by its kind, in English on this wire. */
function visitLinkLabel(target: LinkedTarget): string {
return encounterKindLabel(target.label as EncounterKind, "en");
}
async function labLinks(
userId: string,
targets: readonly LinkedTarget[],
): Promise<RecordLink[]> {
if (targets.length === 0) return [];
const rows = await prisma.labResult.findMany({
where: { userId, id: { in: targets.map((t) => t.id) }, deletedAt: null },
select: { id: true, analyte: true },
});
const analyte = new Map(rows.map((r) => [r.id, r.analyte]));
return targets.flatMap((target) => {
const name = analyte.get(target.id);
if (!name) return [];
return [
{
id: `lab:${name}`,
kind: "lab" as const,
label: scrubFenceMarkers(target.label),
date: linkDate(target.date),
},
];
});
}
function linksSentence(links: readonly RecordLink[]): string {
if (links.length === 0) return "Linked records: none.";
return `Linked records: ${links
.map(
(l) =>
`${l.kind} ${fenceUserText(l.label)}${l.date ? ` (${l.date})` : ""}`,
)
.join("; ")}.`;
}
function boundLinks(links: RecordLink[]): {
links: RecordLink[];
linksTruncated: boolean;
} {
return {
links: links.slice(0, MAX_LINKS),
linksTruncated: links.length > MAX_LINKS,
};
}
function moduleOff(
id: string,
kind: RecordKind,
@@ -1075,9 +1006,9 @@ async function fetchVisit(
: Promise.resolve([]),
]);
const all: RecordLink[] = [
...toLinks("condition", conditions),
...toLinks("document", documents),
...(await labLinks(userId, labs)),
...(await linksFor(userId, "conditionEpisode", conditions)),
...(await linksFor(userId, "document", documents)),
...(await linksFor(userId, "labResult", labs)),
...doses.map((dose) => ({
id: `vaccination:${dose.id}`,
kind: "vaccination" as const,
@@ -1171,8 +1102,8 @@ async function fetchCondition(
: Promise.resolve([]),
]);
const { links, linksTruncated } = boundLinks([
...toLinks("visit", visits, visitLinkLabel),
...toLinks("document", documents),
...(await linksFor(userId, "encounter", visits)),
...(await linksFor(userId, "document", documents)),
]);
const bodySite = decryptText(row.bodySiteEncrypted);
@@ -1246,12 +1177,20 @@ async function documentExcerpt(
}
}
/** What a document lists as linked, in this order. */
const DOCUMENT_LINK_TARGETS = [
"encounter",
"conditionEpisode",
"vaccination",
"labResult",
"medication",
] as const;
async function fetchDocument(
userId: string,
id: string,
rid: string,
origin: string,
kinds: ReadonlySet<RecordKind>,
): Promise<FetchResult> {
// Metadata only. The stored bytes and the model-written summary are never
// selected, so neither can reach this answer by accident.
@@ -1270,24 +1209,15 @@ async function fetchDocument(
// and still are.
const deferred = row.aiReadDeferred;
const source = { userId, sourceKind: "document" as const, sourceId: rid };
const [content, visits, conditions, doses] = await Promise.all([
// The lab results read from this document and the medications filed
// against it are listed too, each behind its own module like the rest.
const [content, linked] = await Promise.all([
deferred
? Promise.resolve({ indexed: false, excerpt: null })
: documentExcerpt(userId, rid),
listTargets(prisma, { ...source, targetKind: "encounter" }),
kinds.has("condition")
? listTargets(prisma, { ...source, targetKind: "conditionEpisode" })
: Promise.resolve([]),
kinds.has("vaccination")
? listTargets(prisma, { ...source, targetKind: "vaccination" })
: Promise.resolve([]),
]);
const { links, linksTruncated } = boundLinks([
...toLinks("visit", visits, visitLinkLabel),
...toLinks("condition", conditions),
...toLinks("vaccination", doses),
linksBySource(userId, "document", [rid], DOCUMENT_LINK_TARGETS),
]);
const { links, linksTruncated } = boundLinks(linked.get(rid) ?? []);
const kindLabel = documentKindLabel(row.kind, "en");
const name = row.title ?? row.filename ?? kindLabel;
@@ -1371,7 +1301,7 @@ async function fetchVaccination(
},
]
: []),
...toLinks("document", documents),
...(await linksFor(userId, "document", documents)),
]);
const catalog = vaccineCatalogName(row.antigenSlug, "en");
@@ -1460,7 +1390,7 @@ export async function fetchRecord(
await loadOwner(userId),
);
case "document":
return fetchDocument(userId, id, rid, origin, kinds);
return fetchDocument(userId, id, rid, origin);
case "vaccination":
return fetchVaccination(userId, id, rid, origin, kinds);
}
+8 -8
View File
@@ -1314,6 +1314,8 @@ export interface LabHistoryReading {
rangeStatus: "in-range" | "below" | "above" | "unknown";
/** Measured date (ISO). */
takenAt: string;
/** The lab result's id; `get_labs` turns it into the reading's links. */
id: string;
}
export interface LabHistoryResult {
@@ -1331,14 +1333,10 @@ export interface LabHistoryResult {
export const LAB_HISTORY_MAX_LIMIT = 50;
/**
* Return ONE analyte's reading trajectory (newest first) over the user's own
* lab record. Re-uses the SAME resolver + range classifier the latest-only labs
* snapshot and the Labs API use (`resolveLabFields` / `classifyReferenceRange`)
* — no new analytics, no fabricated value. DB-paginated by `offset` so a long
* history stays token-bounded: the page carries at most `limit` readings and a
* `nextCursor` when more exist. The encrypted `noteEncrypted` column is never
* selected, so a decrypted note can never reach the wire. `{ present: false }`
* when no reading matches the analyte.
* ONE analyte's reading trajectory (newest first), through the resolver and
* range classifier the labs snapshot and the Labs API use. Paginated by
* `offset` (at most `limit` readings, `nextCursor` when more exist); never
* selects `noteEncrypted`. `{ present: false }` when nothing matches.
*/
export async function getLabHistory(
userId: string,
@@ -1393,6 +1391,7 @@ export async function getLabHistory(
skip: offset,
take: limit + 1,
select: {
id: true,
analyte: true,
panel: true,
value: true,
@@ -1443,6 +1442,7 @@ export async function getLabHistory(
resolved.effectiveRange,
),
takenAt: row.takenAt.toISOString(),
id: row.id,
};
});
+108 -52
View File
@@ -17,7 +17,18 @@ import {
type NutrientsDailyResult,
} from "@/lib/mcp/nutrients-read";
import { fenceUserText, scrubFenceMarkers } from "@/lib/ai/coach/data-fence";
import { fetchRecord, isRecordKind } from "@/lib/mcp/record-search";
import {
fetchRecord,
isRecordKind,
type FetchResult,
} from "@/lib/mcp/record-search";
import { labResultLinks, readLatestLabs } from "@/lib/mcp/lab-reads";
import {
boundLinks,
linksBySource,
linksSentence,
MEDICATION_LINK_TARGETS,
} from "@/lib/mcp/record-links";
import {
plainClinicalText,
plainLabText,
@@ -26,6 +37,99 @@ import {
} from "./render/citation-text";
import { READ_ONLY_ANNOTATIONS, type McpToolDefinition } from "./types";
/** The lab result id of the reading `plainLabText` quotes, when it has one. */
function latestLabId(result: unknown): string | null {
const r = result as { present?: boolean; data?: { recent?: unknown[] } };
const first = r?.present && Array.isArray(r.data?.recent) && r.data.recent[0];
const id = first ? (first as { id?: unknown }).id : undefined;
return typeof id === "string" ? id : null;
}
/** `fetch lab:<analyte>`: the latest reading, with what it is linked to. */
async function fetchLab(
userId: string,
id: string,
rid: string,
origin: string,
): Promise<FetchResult> {
const result = await readLatestLabs(userId, rid, () =>
executeCoachTool({
userId,
name: "get_labs",
rawArguments: JSON.stringify({ analyte: rid }),
}),
);
// The links of the reading the text quotes, the latest one.
const latestId = latestLabId(result);
const linked = latestId ? await labResultLinks(userId, latestId) : null;
return {
id,
title: rid,
text:
plainLabText(rid, result) +
(linked ? ` ${linksSentence(linked.links)}` : ""),
// Deep-link to the labs surface filtered to this analyte.
url: `${origin}/labs?analyte=${encodeURIComponent(rid)}`,
metadata: { type: "lab", analyte: rid, ...linked },
};
}
/** `fetch med:<id>`: the medication, with what it is linked to. */
async function fetchMedication(
userId: string,
id: string,
rid: string,
origin: string,
): Promise<FetchResult> {
const med = await prisma.medication.findFirst({
where: { id: rid, userId },
select: {
name: true,
dose: true,
treatmentClass: true,
asNeeded: true,
},
});
if (!med) {
return {
id,
title: "Not found",
text: "No medication matches this id.",
url: `${origin}/medications`,
metadata: { type: "medication" },
};
}
// Plain-text prose, not a JSON blob. `name` / `dose` /
// `treatmentClass` are user-controlled free text returned as DATA,
// never interpreted — fenced so that contract is stated on the wire
// rather than only in the server instructions.
const text = fenceUserText(
`${med.name}${med.dose ? ` ${med.dose}` : ""}` +
`${med.asNeeded ? " (as needed)" : ""}` +
`${med.treatmentClass ? ` — ${med.treatmentClass}` : ""}.`,
);
const linked = await linksBySource(
userId,
"medication",
[rid],
MEDICATION_LINK_TARGETS,
);
const { links, linksTruncated } = boundLinks(linked.get(rid) ?? []);
return {
id,
title: scrubFenceMarkers(med.name),
text: `${text} ${linksSentence(links)}`,
// Per-item deep-link to the medication detail page.
url: `${origin}/medications/${encodeURIComponent(rid)}`,
metadata: {
type: "medication",
medicationId: rid,
links,
linksTruncated,
},
};
}
/**
* `fetch`, the second half of the `search` + `fetch` pair (see `search.ts`
* for the wire shapes ChatGPT mandates): hydrates one id into a plain-text
@@ -35,7 +139,7 @@ export const fetchTool: McpToolDefinition = {
name: "fetch",
title: "Fetch one health record",
description:
'Hydrate a single record returned by `search`, by its id (e.g. `metric:weight`, `med:<id>`, `lab:LDL`, `visit:<id>`, `condition:<id>`, `document:<id>`, `vaccination:<id>`). Returns { id, title, text, url, metadata } where `text` is a server-authoritative, plain-text prose summary suitable for citation and `url` deep-links to the specific record in HealthLog. A visit, condition, document or vaccination carries its key fields and its directly linked records (metadata.links, each with an id `fetch` accepts); a document returns its metadata and at most a bounded excerpt of its own indexed text (the words of the stored document), never the file; a document held back from AI reading returns no excerpt and metadata.reason \"ai_read_deferred\". Fields that were never recorded are null. Returns a not-found message when the id does not resolve, and metadata.reason "module_disabled" when the record\'s module is switched off.',
'Hydrate a single record returned by `search`, by its id (e.g. `metric:weight`, `med:<id>`, `lab:LDL`, `visit:<id>`, `condition:<id>`, `document:<id>`, `vaccination:<id>`). Returns { id, title, text, url, metadata } where `text` is a server-authoritative, plain-text prose summary suitable for citation and `url` deep-links to the specific record in HealthLog. A visit, condition, document, vaccination, lab or medication carries its key fields and its directly linked records (metadata.links, each with an id `fetch` accepts, a label and a date, at most 25 with metadata.linksTruncated, and \"Linked records: none.\" in the text when there are none); a linked document imported from Paperless-ngx or Papra also names its sourceSystem and sourceId (never an address), and a document is listed only while the Documents module is on. A lab lists the links of its latest reading (the report it was read from, the visits it is filed against); a medication its conditions, documents and visits; a document also lists the lab results read from it and the medications filed against it; a document returns its metadata and at most a bounded excerpt of its own indexed text (the words of the stored document), never the file; a document held back from AI reading returns no excerpt and metadata.reason \"ai_read_deferred\". Fields that were never recorded are null. Returns a not-found message when the id does not resolve, and metadata.reason "module_disabled" when the record\'s module is switched off.',
inputShape: { id: z.string().min(1).max(200) },
annotations: READ_ONLY_ANNOTATIONS,
outputShape: {
@@ -140,58 +244,10 @@ export const fetchTool: McpToolDefinition = {
};
}
if (kind === "lab" && rid) {
const result = await executeCoachTool({
userId: ctx.userId,
name: "get_labs",
rawArguments: JSON.stringify({ analyte: rid }),
});
return {
id,
title: rid,
text: plainLabText(rid, result),
// Deep-link to the labs surface filtered to this analyte.
url: `${origin}/labs?analyte=${encodeURIComponent(rid)}`,
metadata: { type: "lab", analyte: rid },
};
}
if (kind === "lab" && rid) return fetchLab(ctx.userId, id, rid, origin);
if (kind === "med" && rid) {
const med = await prisma.medication.findFirst({
where: { id: rid, userId: ctx.userId },
select: {
name: true,
dose: true,
treatmentClass: true,
asNeeded: true,
},
});
if (!med) {
return {
id,
title: "Not found",
text: "No medication matches this id.",
url: `${origin}/medications`,
metadata: { type: "medication" },
};
}
// Plain-text prose, not a JSON blob. `name` / `dose` /
// `treatmentClass` are user-controlled free text returned as DATA,
// never interpreted — fenced so that contract is stated on the wire
// rather than only in the server instructions.
const text = fenceUserText(
`${med.name}${med.dose ? ` ${med.dose}` : ""}` +
`${med.asNeeded ? " (as needed)" : ""}` +
`${med.treatmentClass ? ` — ${med.treatmentClass}` : ""}.`,
);
return {
id,
title: scrubFenceMarkers(med.name),
text,
// Per-item deep-link to the medication detail page.
url: `${origin}/medications/${encodeURIComponent(rid)}`,
metadata: { type: "medication", medicationId: rid },
};
return fetchMedication(ctx.userId, id, rid, origin);
}
if (isRecordKind(kind) && rid) {
+51 -2
View File
@@ -1,4 +1,6 @@
import { fenceUserText } from "@/lib/ai/coach/data-fence";
import { buildIllnessSnapshotBlock } from "@/lib/ai/coach/illness-snapshot";
import { UNBOUNDED_REACH } from "@/lib/ai/coach/history-reach";
import { coachReadOutput } from "./output-shapes";
import { runCoachTool } from "./shared";
import { READ_ONLY_ANNOTATIONS, type McpToolDefinition } from "./types";
@@ -48,17 +50,64 @@ function fenceIllnessSites(result: unknown): unknown {
};
}
type IllnessEntry = Record<string, unknown> & { id?: string };
/**
* Name each listed condition by the id `fetch` accepts (`condition:<id>`), so
* its linked visits and documents are one call away.
*
* The Coach's block carries no ids (an id means nothing to it), so the block
* is read again by the same builder, asking for them, and its two lists take
* the place of the Coach's. Should the second read find nothing (a condition
* deleted in between), the Coach's lists stay as they were.
*/
async function withConditionIds(
userId: string,
result: unknown,
): Promise<unknown> {
const r = result as {
data?: { illness?: Record<string, unknown> } & Record<string, unknown>;
};
if (!r?.data?.illness) return result;
const fresh = await buildIllnessSnapshotBlock(
userId,
new Date(),
UNBOUNDED_REACH,
{ withIds: true },
);
if (!fresh) return result;
const named = (list: IllnessEntry[]) =>
list.map(({ id, ...entry }) =>
id ? { id: `condition:${id}`, ...entry } : entry,
);
return {
...r,
data: {
...r.data,
illness: {
...r.data.illness,
restMode: fresh.restMode,
active: named(fresh.active),
recentResolved: named(fresh.recentResolved),
},
},
};
}
export const getIllnessRecoveryTool: McpToolDefinition = {
name: "get_illness_recovery",
title: "Get illness & recovery",
description:
"Fetch the user's illness + recovery context: rest mode, active and recently-resolved illnesses (with the body site and side when the user named one), the recovery / strain composites, and the illness retrospective (recovery-gap, nadir, red flags). Carries the rest-mode safety flag for framing. Returns { present: false } when there is nothing to report.",
"Fetch the user's illness + recovery context: rest mode, active and recently-resolved illnesses (each with its id, condition:<id>, which fetch accepts and answers with the condition's linked visits and documents, and with the body site and side when the user named one), the recovery / strain composites, and the illness retrospective (recovery-gap, nadir, red flags). Carries the rest-mode safety flag for framing. Returns { present: false } when there is nothing to report.",
inputShape: {},
annotations: READ_ONLY_ANNOTATIONS,
outputShape: coachReadOutput,
async run(ctx, args) {
return fenceIllnessSites(
await runCoachTool(ctx, "get_illness_recovery", args),
await withConditionIds(
ctx.userId,
await runCoachTool(ctx, "get_illness_recovery", args),
),
);
},
};
+49 -7
View File
@@ -2,12 +2,19 @@ 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";
import type { McpAuthContext } from "../auth";
import { coachReadOutput } from "./output-shapes";
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 = {
@@ -25,10 +32,16 @@ const getLabsOutput: z.ZodRawShape = {
referenceHigh: z.number().nullable(),
rangeStatus: z.enum(["in-range", "below", "above", "unknown"]),
takenAt: z.string(),
// The fetch ids of what this reading links to; each is described
// once in `linkedRecords`.
links: z.array(z.string()),
linksTruncated: z.boolean(),
}),
)
.optional(),
nextCursor: z.string().optional(),
// Both modes: every record a listed reading links to, named once.
linkedRecords: z.array(recordLinkSchema).optional(),
};
/** The free-text fields of a lab reading: a document's words, never instructions. */
@@ -59,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,
@@ -78,19 +104,35 @@ 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
// such as "GPT" for "ALT") is read as that marker's own name (v1.43).
// A present read carries each reading's lab result id (see `lab-reads.ts`);
// a miss is the Coach read's grounded answer.
return (async () => {
const asked = typeof args.analyte === "string" ? args.analyte : null;
const marker = asked?.trim()
? await resolveMarkerForRead(ctx.userId, asked)
: null;
return runCoachTool(ctx, "get_labs", {
...(asked !== null ? { analyte: marker?.name ?? asked } : {}),
const analyte = asked !== null ? (marker?.name ?? asked) : undefined;
let missed = false;
const result = await readLatestLabs(ctx.userId, analyte, () => {
missed = true;
return runCoachTool(
ctx,
"get_labs",
analyte !== undefined ? { analyte } : {},
);
});
if (!missed) {
annotate({
action: { name: "mcp.tool.invoked" },
meta: { tool: "get_labs", present: true },
});
}
return result;
})();
}
@@ -98,7 +140,7 @@ export const getLabsTool: McpToolDefinition = {
name: "get_labs",
title: "Get lab results",
description:
"Fetch the user's lab results. By default returns the latest reading per biomarker over the whole record. Filtered to one named analyte, each matching marker also carries a summarised history (the newest earlier values in full, older ones as one mean / min / max / count per year). Pass history:true with an analyte to return that analyte's reading TRAJECTORY (newest first, paginated). Each reading carries its value, unit, reference range, and in-range/below/above status. Returns { present: false } when no labs match, and { present: false, reason: \"module_disabled\" } when the labs module is switched off.",
"Fetch the user's lab results. By default returns the latest reading per biomarker over the whole record. Filtered to one named analyte, each matching marker also carries a summarised history (the newest earlier values in full, older ones as one mean / min / max / count per year). Pass history:true with an analyte to return that analyte's reading TRAJECTORY (newest first, paginated). Each reading carries its value, unit, reference range, and in-range/below/above status; a ratio or an index recorded without a unit has an empty unit. Each reading (the latest, an earlier value in full, a trajectory reading; not a per-year summary) carries links: the ids of the records it is linked to (the report it was read from, the visits it is filed against), [] when none, at most 25 with linksTruncated; every linked record is described once in linkedRecords (id for fetch, kind, label, date, and for a document imported from Paperless-ngx or Papra its sourceSystem and sourceId). A document is listed only while the Documents module is on. Returns { present: false } when no labs match, and { present: false, reason: \"module_disabled\" } when the labs module is switched off.",
inputShape: {
analyte: z.string().min(1).max(80).optional(),
history: z
@@ -119,7 +161,7 @@ export const getLabsTool: McpToolDefinition = {
outputShape: getLabsOutput,
run(ctx, args) {
return whenModuleOn(ctx, "labs", "get_labs", async () =>
fenceLabText(await readLabs(ctx, args)),
attachLabLinks(ctx.userId, fenceLabText(await readLabs(ctx, args))),
);
},
};
+30 -10
View File
@@ -15,7 +15,13 @@ import {
} from "@/lib/medications/scheduling/next-due";
import { dueSchedules } from "@/lib/medications/intake-tracking";
import { liveEraStartsByMedication } from "@/lib/medications/scheduling/live-era";
import {
boundLinks,
linksBySource,
MEDICATION_LINK_TARGETS,
} from "@/lib/mcp/record-links";
import { MODULE_DISABLED } from "./shared";
import { recordLinkSchema } from "./output-shapes";
import { READ_ONLY_ANNOTATIONS, type McpToolDefinition } from "./types";
import { MEDICATION_SCHEDULE_ORDER } from "@/lib/medications/read-order";
@@ -33,12 +39,16 @@ const getMedicationScheduleOutput: z.ZodRawShape = {
medications: z
.array(
z.object({
// `med:<id>`, the id `fetch` accepts.
id: z.string(),
name: z.string(),
dose: z.string().nullable(),
nextDueAt: z.string().nullable(),
overdue: z.boolean(),
asNeeded: z.boolean(),
intakeTracked: z.boolean(),
links: z.array(recordLinkSchema),
linksTruncated: z.boolean(),
}),
)
.optional(),
@@ -48,7 +58,7 @@ export const getMedicationScheduleTool: McpToolDefinition = {
name: "get_medication_schedule",
title: "Get the medication schedule",
description:
'Fetch when the user\'s active medications are next due, and which are overdue right now. Reuses the same server-authoritative recurrence engine the medication cards render (open overdue slots win over future ones). Returns one row per active medication: name, dose, next-due instant, an overdue flag, an as-needed (PRN) flag, and an intakeTracked flag. As-needed medications and medications whose intake tracking is off (intakeTracked: false, kept as a record only) have no due time (nextDueAt: null). Returns { present: false } when no medications are tracked, and { present: false, reason: "module_disabled" } when the medications module is switched off.',
'Fetch when the user\'s active medications are next due, and which are overdue right now. Reuses the same server-authoritative recurrence engine the medication cards render (open overdue slots win over future ones). Returns one row per active medication: its id (med:<id>, which fetch accepts), name, dose, next-due instant, an overdue flag, an as-needed (PRN) flag, an intakeTracked flag, and its directly linked records (links: conditions, documents and visits, each with an id fetch accepts, a label, a date, and for a document imported from Paperless-ngx or Papra its sourceSystem and sourceId; [] when none; at most 25 with linksTruncated; documents only while the Documents module is on). As-needed medications and medications whose intake tracking is off (intakeTracked: false, kept as a record only) have no due time (nextDueAt: null). Returns { present: false } when no medications are tracked, and { present: false, reason: "module_disabled" } when the medications module is switched off.',
inputShape: {},
annotations: READ_ONLY_ANNOTATIONS,
outputShape: getMedicationScheduleOutput,
@@ -140,15 +150,23 @@ export const getMedicationScheduleTool: McpToolDefinition = {
// Under auto-take a rolling cadence stands where the job
// will record next (see `auto-take.ts`).
const engineLastIntakeAt = await loadAutoTakeRollingAnchors(prisma, {
userId: ctx.userId,
medicationIds: medications
.filter((m) => m.autoMarkTaken)
.map((m) => m.id),
now,
userTz,
lastIntakeAt: (id) => lastTakenAtByMedId.get(id) ?? null,
});
const [engineLastIntakeAt, linksByMedId] = await Promise.all([
loadAutoTakeRollingAnchors(prisma, {
userId: ctx.userId,
medicationIds: medications
.filter((m) => m.autoMarkTaken)
.map((m) => m.id),
now,
userTz,
lastIntakeAt: (id) => lastTakenAtByMedId.get(id) ?? null,
}),
linksBySource(
ctx.userId,
"medication",
medications.map((m) => m.id),
MEDICATION_LINK_TARGETS,
),
]);
const rows = medications.map((m) => {
const display = computeDisplayDue({
@@ -168,6 +186,7 @@ export const getMedicationScheduleTool: McpToolDefinition = {
eraStart: eraStartByMedId.get(m.id) ?? null,
});
return {
id: `med:${m.id}`,
// The person's own words: fenced as data, as the server
// instructions promise for medication names.
name: fenceUserText(m.name),
@@ -176,6 +195,7 @@ export const getMedicationScheduleTool: McpToolDefinition = {
overdue: display ? display.overdue : false,
asNeeded: m.asNeeded,
intakeTracked: m.trackIntake,
...boundLinks(linksByMedId.get(m.id) ?? []),
};
});
+4 -1
View File
@@ -28,6 +28,8 @@ const getVisitsOutput: z.ZodRawShape = {
visits: z
.array(
z.object({
// `visit:<id>`, the id `fetch` accepts for the visit and its links.
id: z.string(),
occurredAt: z.string(),
status: z.string(),
kind: z.string(),
@@ -58,7 +60,7 @@ export const getVisitsTool: McpToolDefinition = {
name: "get_visits",
title: "Get past doctor visits",
description:
"Fetch the user's own past doctor visits over a bounded window (default: the last 12 months) so a question like 'when did I last see a cardiologist' is answerable from the record. Each visit carries its date, lifecycle status (DONE / CANCELLED / NO_SHOW — a no-show is not a visit that happened), kind, practitioner name + specialty, the visit's own free-text reason and outcome, the body site and side (set on procedures), and the labels of any conditions it was filed against. Optionally narrow to one practitioner by a name substring, or to one kind. kind PROCEDURE is the procedure and surgery history: without a months argument it reads the whole record, because a surgery years ago is still the answer to 'what surgeries have I had'. Newest first, bounded. Returns { present: false } when the user has never recorded a visit — distinct from a filtered read that simply matched none. With the illness module switched off the condition labels are withheld and conditionsReason is \"module_disabled\".",
"Fetch the user's own past doctor visits over a bounded window (default: the last 12 months) so a question like 'when did I last see a cardiologist' is answerable from the record. Each visit carries its id (visit:<id>, which fetch accepts and answers with the visit's linked records), its date, lifecycle status (DONE / CANCELLED / NO_SHOW — a no-show is not a visit that happened), kind, practitioner name + specialty, the visit's own free-text reason and outcome, the body site and side (set on procedures), and the labels of any conditions it was filed against. Optionally narrow to one practitioner by a name substring, or to one kind. kind PROCEDURE is the procedure and surgery history: without a months argument it reads the whole record, because a surgery years ago is still the answer to 'what surgeries have I had'. Newest first, bounded. Returns { present: false } when the user has never recorded a visit — distinct from a filtered read that simply matched none. With the illness module switched off the condition labels are withheld and conditionsReason is \"module_disabled\".",
inputShape: {
months: z
.number()
@@ -167,6 +169,7 @@ export const getVisitsTool: McpToolDefinition = {
// entry is deleted, as the web record does.
const seenBy = r.practitioner;
return {
id: `visit:${r.id}`,
occurredAt: r.occurredAt.toISOString(),
status: r.status,
kind: r.kind,
+22
View File
@@ -1,6 +1,7 @@
import { z } from "zod/v4";
import { isoDateTime } from "@/lib/validations/datetime";
import { DOCUMENT_SOURCE_SYSTEMS } from "@/lib/validations/inbound-documents";
// ── Output schemas ───────────────────────────────────────────────────
// Every field except the presence anchor (`present`) is optional so a grounded
@@ -66,3 +67,24 @@ export const dateRangeShape = z
.describe(
"An explicit inclusive date range. Use INSTEAD OF `window` for an arbitrary span (e.g. before vs after a date). Both bounds are ISO-8601 instants.",
);
/**
* One linked record, as `fetch` lists it in `metadata.links` and the lab and
* medication reads list it (see `record-links.ts`). `id` is one `fetch`
* accepts; an imported document adds its source system and its id there.
*/
export const recordLinkSchema = z.object({
id: z.string().nullable(),
kind: z.enum([
"visit",
"condition",
"document",
"vaccination",
"lab",
"medication",
]),
label: z.string(),
date: z.string().nullable(),
sourceSystem: z.enum(DOCUMENT_SOURCE_SYSTEMS).optional(),
sourceId: z.string().optional(),
});
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff. Show more