mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge main v1.46.6 into feat/ru-locale
- baselines: take main's entries, keep the two ru-driven line counts (coach-prose-grounding 1091, insight-generator 1115) and regenerate the function baseline. - messages/ru.json: translate the five unit-less markers keys the release added (labs.unit.none/noneHint, labs.ocr.errorUnitMismatchNoUnit, labs.ocr.unitDiffersBadgeNoUnit, recordImport.unitlessHint) — parity is 11 159 keys again.
This commit is contained in:
commit
c6372bde94
113 files changed
+3808
-1080
No files matched your search
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -15883,10 +15883,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
|
||||
@@ -34160,6 +34161,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
|
||||
@@ -34229,6 +34235,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
|
||||
@@ -34323,6 +34334,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
|
||||
@@ -34377,6 +34393,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:
|
||||
@@ -34406,6 +34427,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
|
||||
@@ -35774,6 +35800,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
|
||||
@@ -75294,6 +75325,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:
|
||||
@@ -75543,6 +75579,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:
|
||||
@@ -76522,6 +76563,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
|
||||
@@ -76621,6 +76667,11 @@ components:
|
||||
type: string
|
||||
unit:
|
||||
type: string
|
||||
description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means
|
||||
the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a
|
||||
unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real
|
||||
unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is
|
||||
never read as `"1"` and stays refused.'
|
||||
lowerBound:
|
||||
type:
|
||||
- number
|
||||
|
||||
+9
-2
@@ -10396,7 +10396,9 @@
|
||||
"pdfNeedsAnthropic": "PDF-Scannen benötigt einen Claude-Bildanbieter; lade stattdessen ein Foto hoch.",
|
||||
"refTextLabel": "Bereich wie gedruckt",
|
||||
"refTextPlaceholder": "z. B. 3,5 - 5,0",
|
||||
"aiUnavailable": "Das Scannen eines Befunds mit KI ist hier gerade nicht verfügbar."
|
||||
"aiUnavailable": "Das Scannen eines Befunds mit KI ist hier gerade nicht verfügbar.",
|
||||
"errorUnitMismatchNoUnit": "Dieser Marker wird ohne Einheit geführt. Setz das Häkchen bei Keine Einheit, falls der Befund falsch gelesen wurde, oder wähle den Wert ab.",
|
||||
"unitDiffersBadgeNoUnit": "Ohne Einheit geführt; dieser Befund nennt {unit}"
|
||||
},
|
||||
"loadMoreReadings": "Weitere Werte laden",
|
||||
"documentsLink": "Befund-Dokumente",
|
||||
@@ -10474,6 +10476,10 @@
|
||||
"stale": "Der neueste Wert ist vom {date}, also älter als ein Jahr. Die Gerade gilt für diesen Stand, ohne Projektion."
|
||||
},
|
||||
"interval": "95-%-Intervall der jährlichen Veränderung: {lo} bis {hi} {unit}."
|
||||
},
|
||||
"unit": {
|
||||
"none": "Keine Einheit",
|
||||
"noneHint": "Für einen Quotienten oder Index wie INR oder Cholesterin/HDL."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -14093,6 +14099,7 @@
|
||||
"asNeeded": "Bei Bedarf",
|
||||
"startsOn": "Beginn",
|
||||
"endsOn": "Ende"
|
||||
}
|
||||
},
|
||||
"unitlessHint": "Ein Quotient oder Index ohne Einheit (INR, Cholesterin/HDL): Trag in der Spalte für die Einheit eine 1 ein. Eine leere Einheit bleibt ein Fehler."
|
||||
}
|
||||
}
|
||||
+9
-2
@@ -10396,7 +10396,9 @@
|
||||
"pdfNeedsAnthropic": "PDF scanning needs a Claude vision provider; upload a photo instead.",
|
||||
"refTextLabel": "Range as printed",
|
||||
"refTextPlaceholder": "e.g. 3.5 - 5.0",
|
||||
"aiUnavailable": "Scanning a report with AI is not available here right now."
|
||||
"aiUnavailable": "Scanning a report with AI is not available here right now.",
|
||||
"errorUnitMismatchNoUnit": "This marker is tracked without a unit. Tick No unit if the report was misread, or deselect this reading.",
|
||||
"unitDiffersBadgeNoUnit": "Tracked without a unit; this report says {unit}"
|
||||
},
|
||||
"loadMoreReadings": "Load more readings",
|
||||
"documentsLink": "Lab report documents",
|
||||
@@ -10474,6 +10476,10 @@
|
||||
"stale": "The newest reading is from {date}, more than a year ago. The line is shown as of then, without a projection."
|
||||
},
|
||||
"interval": "95 % interval of the yearly change: {lo} to {hi} {unit}."
|
||||
},
|
||||
"unit": {
|
||||
"none": "No unit",
|
||||
"noneHint": "For a ratio or an index such as INR or cholesterol/HDL."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -14093,6 +14099,7 @@
|
||||
"asNeeded": "As needed",
|
||||
"startsOn": "Start",
|
||||
"endsOn": "End"
|
||||
}
|
||||
},
|
||||
"unitlessHint": "A ratio or an index without a unit (INR, cholesterol/HDL): write 1 in the unit column. An empty unit cell stays an error."
|
||||
}
|
||||
}
|
||||
+9
-2
@@ -10396,7 +10396,9 @@
|
||||
"pdfNeedsAnthropic": "El escaneo de PDF necesita un proveedor de visión de Claude; sube una foto en su lugar.",
|
||||
"refTextLabel": "Rango tal como aparece",
|
||||
"refTextPlaceholder": "p. ej. 3,5 - 5,0",
|
||||
"aiUnavailable": "Escanear un informe con IA no está disponible aquí ahora mismo."
|
||||
"aiUnavailable": "Escanear un informe con IA no está disponible aquí ahora mismo.",
|
||||
"errorUnitMismatchNoUnit": "Este marcador se registra sin unidad. Marca Sin unidad si el informe se leyó mal o deselecciona este valor.",
|
||||
"unitDiffersBadgeNoUnit": "Registrado sin unidad; este informe indica {unit}"
|
||||
},
|
||||
"loadMoreReadings": "Cargar más mediciones",
|
||||
"documentsLink": "Documentos de resultados",
|
||||
@@ -10474,6 +10476,10 @@
|
||||
"stale": "El valor más reciente es del {date}, hace más de un año. La línea se muestra a esa fecha, sin proyección."
|
||||
},
|
||||
"interval": "Intervalo del 95 % del cambio anual: de {lo} a {hi} {unit}."
|
||||
},
|
||||
"unit": {
|
||||
"none": "Sin unidad",
|
||||
"noneHint": "Para un cociente o un índice como el INR o colesterol/HDL."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -14093,6 +14099,7 @@
|
||||
"asNeeded": "A demanda",
|
||||
"startsOn": "Inicio",
|
||||
"endsOn": "Fin"
|
||||
}
|
||||
},
|
||||
"unitlessHint": "Un cociente o un índice sin unidad (INR, colesterol/HDL): escribe 1 en la columna de la unidad. Una unidad vacía sigue siendo un error."
|
||||
}
|
||||
}
|
||||
+9
-2
@@ -10396,7 +10396,9 @@
|
||||
"pdfNeedsAnthropic": "Le scan PDF nécessite un fournisseur de vision Claude ; importez plutôt une photo.",
|
||||
"refTextLabel": "Plage telle qu'imprimée",
|
||||
"refTextPlaceholder": "p. ex. 3,5 - 5,0",
|
||||
"aiUnavailable": "La numérisation d'un compte rendu par IA n'est pas disponible ici pour le moment."
|
||||
"aiUnavailable": "La numérisation d'un compte rendu par IA n'est pas disponible ici pour le moment.",
|
||||
"errorUnitMismatchNoUnit": "Ce marqueur est suivi sans unité. Cochez Sans unité si le compte rendu a été mal lu, ou désélectionnez cette valeur.",
|
||||
"unitDiffersBadgeNoUnit": "Suivi sans unité ; ce compte rendu indique {unit}"
|
||||
},
|
||||
"loadMoreReadings": "Charger plus de mesures",
|
||||
"documentsLink": "Documents de résultats",
|
||||
@@ -10474,6 +10476,10 @@
|
||||
"stale": "La valeur la plus récente date du {date}, il y a plus d’un an. La droite est montrée à cette date, sans projection."
|
||||
},
|
||||
"interval": "Intervalle à 95 % de la variation annuelle : de {lo} à {hi} {unit}."
|
||||
},
|
||||
"unit": {
|
||||
"none": "Sans unité",
|
||||
"noneHint": "Pour un rapport ou un indice comme l'INR ou cholestérol/HDL."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -14093,6 +14099,7 @@
|
||||
"asNeeded": "Si besoin",
|
||||
"startsOn": "Début",
|
||||
"endsOn": "Fin"
|
||||
}
|
||||
},
|
||||
"unitlessHint": "Un rapport ou un indice sans unité (INR, cholestérol/HDL) : inscrivez 1 dans la colonne de l'unité. Une unité vide reste une erreur."
|
||||
}
|
||||
}
|
||||
+9
-2
@@ -10396,7 +10396,9 @@
|
||||
"pdfNeedsAnthropic": "La scansione PDF richiede un provider di visione Claude; carica invece una foto.",
|
||||
"refTextLabel": "Intervallo come stampato",
|
||||
"refTextPlaceholder": "es. 3,5 - 5,0",
|
||||
"aiUnavailable": "La scansione di un referto con IA non è disponibile qui al momento."
|
||||
"aiUnavailable": "La scansione di un referto con IA non è disponibile qui al momento.",
|
||||
"errorUnitMismatchNoUnit": "Questo marcatore è registrato senza unità. Spunta Nessuna unità se il referto è stato letto male, oppure deseleziona questo valore.",
|
||||
"unitDiffersBadgeNoUnit": "Registrato senza unità; questo referto indica {unit}"
|
||||
},
|
||||
"loadMoreReadings": "Carica altre misurazioni",
|
||||
"documentsLink": "Documenti dei referti",
|
||||
@@ -10474,6 +10476,10 @@
|
||||
"stale": "Il valore più recente è del {date}, più di un anno fa. La retta è mostrata a quella data, senza proiezione."
|
||||
},
|
||||
"interval": "Intervallo al 95 % della variazione annua: da {lo} a {hi} {unit}."
|
||||
},
|
||||
"unit": {
|
||||
"none": "Nessuna unità",
|
||||
"noneHint": "Per un rapporto o un indice come l'INR o colesterolo/HDL."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -14093,6 +14099,7 @@
|
||||
"asNeeded": "Al bisogno",
|
||||
"startsOn": "Inizio",
|
||||
"endsOn": "Fine"
|
||||
}
|
||||
},
|
||||
"unitlessHint": "Un rapporto o un indice senza unità (INR, colesterolo/HDL): scrivi 1 nella colonna dell'unità. Un'unità vuota resta un errore."
|
||||
}
|
||||
}
|
||||
+9
-2
@@ -10396,7 +10396,9 @@
|
||||
"pdfNeedsAnthropic": "PDF 스캔에는 Claude 비전 제공자가 필요해요. 대신 사진을 올려 주세요.",
|
||||
"refTextLabel": "적힌 그대로의 범위",
|
||||
"refTextPlaceholder": "예: 3.5 - 5.0",
|
||||
"aiUnavailable": "지금 여기에서는 AI로 결과지를 스캔할 수 없습니다."
|
||||
"aiUnavailable": "지금 여기에서는 AI로 결과지를 스캔할 수 없습니다.",
|
||||
"errorUnitMismatchNoUnit": "이 지표는 단위 없이 기록됩니다. 결과지를 잘못 읽었다면 단위 없음을 선택하거나 이 값의 선택을 해제하세요.",
|
||||
"unitDiffersBadgeNoUnit": "단위 없이 기록 중; 이 결과지는 {unit}"
|
||||
},
|
||||
"loadMoreReadings": "측정값 더 보기",
|
||||
"documentsLink": "검사 결과지 문서",
|
||||
@@ -10474,6 +10476,10 @@
|
||||
"stale": "가장 최근 값은 {date}로 1년이 넘었습니다. 직선은 그 시점 기준으로 표시하며 예측은 하지 않습니다."
|
||||
},
|
||||
"interval": "연간 변화의 95% 구간: {lo}~{hi} {unit}."
|
||||
},
|
||||
"unit": {
|
||||
"none": "단위 없음",
|
||||
"noneHint": "INR이나 콜레스테롤/HDL 같은 비율 또는 지수에 사용해요."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -14093,6 +14099,7 @@
|
||||
"asNeeded": "필요시",
|
||||
"startsOn": "시작",
|
||||
"endsOn": "종료"
|
||||
}
|
||||
},
|
||||
"unitlessHint": "단위가 없는 비율이나 지수(INR, 콜레스테롤/HDL)는 단위 열에 1을 입력하세요. 단위 칸이 비어 있으면 여전히 오류예요."
|
||||
}
|
||||
}
|
||||
+9
-2
@@ -10396,7 +10396,9 @@
|
||||
"pdfNeedsAnthropic": "Voor het scannen van PDF's is een Claude-provider met beeldherkenning nodig; upload in plaats daarvan een foto.",
|
||||
"refTextLabel": "Bereik zoals afgedrukt",
|
||||
"refTextPlaceholder": "bijv. 3,5 - 5,0",
|
||||
"aiUnavailable": "Een rapport scannen met AI is hier op dit moment niet beschikbaar."
|
||||
"aiUnavailable": "Een rapport scannen met AI is hier op dit moment niet beschikbaar.",
|
||||
"errorUnitMismatchNoUnit": "Deze marker wordt zonder eenheid bijgehouden. Vink Geen eenheid aan als het rapport verkeerd is gelezen, of deselecteer deze meting.",
|
||||
"unitDiffersBadgeNoUnit": "Zonder eenheid bijgehouden; dit rapport vermeldt {unit}"
|
||||
},
|
||||
"loadMoreReadings": "Meer metingen laden",
|
||||
"documentsLink": "Documenten met labrapporten",
|
||||
@@ -10474,6 +10476,10 @@
|
||||
"stale": "De nieuwste waarde is van {date}, meer dan een jaar geleden. De lijn staat op die datum, zonder projectie."
|
||||
},
|
||||
"interval": "95%-interval van de jaarlijkse verandering: {lo} tot {hi} {unit}."
|
||||
},
|
||||
"unit": {
|
||||
"none": "Geen eenheid",
|
||||
"noneHint": "Voor een verhouding of index zoals INR of cholesterol/HDL."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -14093,6 +14099,7 @@
|
||||
"asNeeded": "Zo nodig",
|
||||
"startsOn": "Begin",
|
||||
"endsOn": "Einde"
|
||||
}
|
||||
},
|
||||
"unitlessHint": "Een verhouding of index zonder eenheid (INR, cholesterol/HDL): vul 1 in de eenheidskolom in. Een lege eenheid blijft een fout."
|
||||
}
|
||||
}
|
||||
+9
-2
@@ -10396,7 +10396,9 @@
|
||||
"pdfNeedsAnthropic": "Skanowanie PDF wymaga dostawcy wizji Claude; prześlij zamiast tego zdjęcie.",
|
||||
"refTextLabel": "Zakres tak jak wydrukowano",
|
||||
"refTextPlaceholder": "np. 3,5 - 5,0",
|
||||
"aiUnavailable": "Skanowanie wyniku przez AI jest tu teraz niedostępne."
|
||||
"aiUnavailable": "Skanowanie wyniku przez AI jest tu teraz niedostępne.",
|
||||
"errorUnitMismatchNoUnit": "Ten wskaźnik jest prowadzony bez jednostki. Zaznacz Bez jednostki, jeśli wynik został źle odczytany, albo odznacz tę wartość.",
|
||||
"unitDiffersBadgeNoUnit": "Prowadzony bez jednostki; ten wynik podaje {unit}"
|
||||
},
|
||||
"loadMoreReadings": "Wczytaj więcej pomiarów",
|
||||
"documentsLink": "Dokumenty wyników badań",
|
||||
@@ -10474,6 +10476,10 @@
|
||||
"stale": "Najnowszy wynik pochodzi z {date}, sprzed ponad roku. Prosta jest pokazana na ten dzień, bez projekcji."
|
||||
},
|
||||
"interval": "95% przedział rocznej zmiany: od {lo} do {hi} {unit}."
|
||||
},
|
||||
"unit": {
|
||||
"none": "Bez jednostki",
|
||||
"noneHint": "Dla ilorazu lub wskaźnika, np. INR albo cholesterol/HDL."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -14093,6 +14099,7 @@
|
||||
"asNeeded": "W razie potrzeby",
|
||||
"startsOn": "Początek",
|
||||
"endsOn": "Koniec"
|
||||
}
|
||||
},
|
||||
"unitlessHint": "Iloraz lub wskaźnik bez jednostki (INR, cholesterol/HDL): wpisz 1 w kolumnie jednostki. Pusta jednostka nadal jest błędem."
|
||||
}
|
||||
}
|
||||
+9
-2
@@ -10396,7 +10396,9 @@
|
||||
"pdfNeedsAnthropic": "A digitalização de PDF precisa de um provedor de visão Claude; envie uma foto em vez disso.",
|
||||
"refTextLabel": "Faixa como impressa",
|
||||
"refTextPlaceholder": "ex.: 3,5 - 5,0",
|
||||
"aiUnavailable": "Digitalizar um laudo com IA não está disponível aqui agora."
|
||||
"aiUnavailable": "Digitalizar um laudo com IA não está disponível aqui agora.",
|
||||
"errorUnitMismatchNoUnit": "Este marcador é acompanhado sem unidade. Marque Sem unidade se o laudo foi lido errado, ou desmarque esta leitura.",
|
||||
"unitDiffersBadgeNoUnit": "Acompanhado sem unidade; este laudo diz {unit}"
|
||||
},
|
||||
"loadMoreReadings": "Carregar mais leituras",
|
||||
"documentsLink": "Documentos de laudos laboratoriais",
|
||||
@@ -10474,6 +10476,10 @@
|
||||
"stale": "O resultado mais recente é de {date}, há mais de um ano. A linha é mostrada até essa data, sem projeção."
|
||||
},
|
||||
"interval": "Intervalo de 95 % da mudança anual: {lo} a {hi} {unit}."
|
||||
},
|
||||
"unit": {
|
||||
"none": "Sem unidade",
|
||||
"noneHint": "Para uma razão ou um índice como INR ou colesterol/HDL."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -14093,6 +14099,7 @@
|
||||
"asNeeded": "Conforme necessário",
|
||||
"startsOn": "Início",
|
||||
"endsOn": "Fim"
|
||||
}
|
||||
},
|
||||
"unitlessHint": "Uma razão ou um índice sem unidade (INR, colesterol/HDL): escreva 1 na coluna da unidade. Uma unidade vazia continua sendo um erro."
|
||||
}
|
||||
}
|
||||
@@ -10381,7 +10381,9 @@
|
||||
"errorResult": "Укажите результат.",
|
||||
"errorUnit": "Укажите единицу.",
|
||||
"errorUnitMismatch": "Этот показатель отслеживается в {markerUnit}. Измените единицу, если отчёт прочитан неверно, или снимите выбор с этого измерения.",
|
||||
"errorUnitMismatchNoUnit": "Этот показатель отслеживается без единицы. Поставьте галочку «Без единицы», если отчёт прочитан неверно, или снимите выбор с этого измерения.",
|
||||
"unitDiffersBadge": "Отслеживается в {markerUnit}; в этом отчёте указано {unit}",
|
||||
"unitDiffersBadgeNoUnit": "Отслеживается без единицы; в этом отчёте указано {unit}",
|
||||
"unitConvertedBadge": "Сохранено как {value} {markerUnit}, пересчитано из {statedValue} {unit}",
|
||||
"savedPartialUnitToast": "Сохранено результатов анализа: {count}. Не сохранено из-за несовпадения единицы с показателем: {analytes}",
|
||||
"savedNothingUnitToast": "Ничего не сохранено: единица не совпадает с показателем для {analytes}",
|
||||
@@ -10474,6 +10476,10 @@
|
||||
"stale": "Самое новое измерение — от {date}, более года назад. Линия показана по состоянию на тот момент, без прогноза."
|
||||
},
|
||||
"interval": "95 % интервал годового изменения: от {lo} до {hi} {unit}."
|
||||
},
|
||||
"unit": {
|
||||
"none": "Без единицы",
|
||||
"noneHint": "Для коэффициента или индекса, например МНО или холестерин/ЛПВП."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -14028,6 +14034,7 @@
|
||||
"dot": "Точка (5.4)",
|
||||
"comma": "Запятая (5,4)"
|
||||
},
|
||||
"unitlessHint": "Коэффициент или индекс без единицы (МНО, холестерин/ЛПВП): напишите 1 в столбце единицы. Пустая ячейка единицы остаётся ошибкой.",
|
||||
"dateOrder": "Формат даты",
|
||||
"dateOrderOption": {
|
||||
"iso": "2025-03-01",
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Generated
+499
-478
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -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/*`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": 94379,
|
||||
"total": 94309,
|
||||
"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": 111928,
|
||||
"total": 111918,
|
||||
"entries": {
|
||||
"app/achievements/page.tsx :: AchievementsPage": 206,
|
||||
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275,
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from "@/lib/labs/biomarker-catalog";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { LabUnitInput } from "./lab-unit-input";
|
||||
import type { BiomarkerDto } from "./types";
|
||||
|
||||
const CONTEXT_MAX_LENGTH = 2000;
|
||||
@@ -219,10 +220,10 @@ export function BiomarkerForm({
|
||||
<Label htmlFor="biomarker-unit">
|
||||
{t("labs.biomarker.form.unit")}
|
||||
</Label>
|
||||
<Input
|
||||
<LabUnitInput
|
||||
id="biomarker-unit"
|
||||
value={unit}
|
||||
onChange={(e) => setUnit(e.target.value)}
|
||||
onChange={setUnit}
|
||||
placeholder={t("labs.biomarker.form.unitPlaceholder")}
|
||||
maxLength={40}
|
||||
required
|
||||
|
||||
@@ -24,6 +24,8 @@ import { BiomarkerForm } from "./biomarker-form";
|
||||
import { BiomarkerMergeSheet } from "./biomarker-merge-sheet";
|
||||
import type { BiomarkerDto, BiomarkerListResponse } from "./types";
|
||||
import { useLabNumber } from "./use-lab-format";
|
||||
import { withLabUnit } from "@/lib/labs/dimensionless";
|
||||
import { LabUnitName } from "./lab-unit-input";
|
||||
|
||||
/**
|
||||
* v1.18.1 — the Biomarker catalog manager. v1.22 — compact editable rows.
|
||||
@@ -124,11 +126,11 @@ export function BiomarkerManager() {
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<span className="truncate text-sm font-medium">{marker.name}</span>
|
||||
<span className="text-muted-foreground text-xs">{marker.unit}</span>
|
||||
<LabUnitName unit={marker.unit} />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{range
|
||||
? `${t("labs.referenceLabel")} ${range} ${marker.unit}`
|
||||
? `${t("labs.referenceLabel")} ${withLabUnit(range, marker.unit)}`
|
||||
: t("labs.biomarker.noRange")}
|
||||
{marker.panel ? ` · ${marker.panel}` : ""}
|
||||
</p>
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import type { BiomarkerDto, BiomarkerMergeResultDto } from "./types";
|
||||
import { labUnitLabel } from "@/lib/labs/dimensionless";
|
||||
|
||||
/** The most sources one merge takes, matching the server. */
|
||||
const MAX_SOURCES = 10;
|
||||
@@ -166,7 +167,7 @@ export function BiomarkerMergeSheet({
|
||||
<p className="text-sm">
|
||||
{tCount("labs.merge.previewSummary", preview.movedReadings, {
|
||||
name: preview.target.name,
|
||||
unit: preview.target.unit,
|
||||
unit: labUnitLabel(preview.target.unit, t),
|
||||
})}
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
@@ -178,7 +179,7 @@ export function BiomarkerMergeSheet({
|
||||
<p className="text-sm font-medium break-words">
|
||||
{source.name}{" "}
|
||||
<span className="text-muted-foreground text-xs font-normal">
|
||||
{source.unit}
|
||||
{labUnitLabel(source.unit, t)}
|
||||
</span>
|
||||
</p>
|
||||
<p
|
||||
@@ -190,8 +191,8 @@ export function BiomarkerMergeSheet({
|
||||
>
|
||||
{source.conversion === "refused"
|
||||
? t("labs.merge.refused", {
|
||||
unit: source.unit,
|
||||
targetUnit: preview.target.unit,
|
||||
unit: labUnitLabel(source.unit, t),
|
||||
targetUnit: labUnitLabel(preview.target.unit, t),
|
||||
})
|
||||
: source.conversion === "converted"
|
||||
? tCount("labs.merge.converted", source.readingCount, {
|
||||
@@ -247,7 +248,7 @@ export function BiomarkerMergeSheet({
|
||||
{marker.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground shrink-0 text-xs">
|
||||
{marker.unit}
|
||||
{labUnitLabel(marker.unit, t)}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
|
||||
@@ -61,6 +61,7 @@ import type {
|
||||
LabResultDto,
|
||||
LabResultListResponse,
|
||||
} from "./types";
|
||||
import { labUnitLabel, labUnitText } from "@/lib/labs/dimensionless";
|
||||
|
||||
// v1.18.11 — defer the recharts biomarker chart through
|
||||
// `next/dynamic` so recharts is off `/labs/[biomarkerId]`'s first-load JS.
|
||||
@@ -426,7 +427,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
one description line every page header carries. */}
|
||||
{marker?.unit ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{marker.unit}
|
||||
{labUnitLabel(marker.unit, t)}
|
||||
{marker.panel ? ` · ${marker.panel}` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -492,7 +493,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
numeric readings, mirroring the metric sub-pages. */}
|
||||
<MetricStatStrip
|
||||
summary={summary}
|
||||
unit={marker?.unit ?? latest?.unit ?? ""}
|
||||
unit={labUnitText(marker?.unit ?? latest?.unit)}
|
||||
seriesLabel={marker?.name}
|
||||
icon={FlaskConical}
|
||||
/>
|
||||
@@ -500,7 +501,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
<CardContent>
|
||||
<LabBiomarkerChart
|
||||
readings={readings}
|
||||
unit={marker?.unit ?? latest?.unit ?? ""}
|
||||
unit={labUnitText(marker?.unit ?? latest?.unit)}
|
||||
lowerBound={marker?.lowerBound ?? null}
|
||||
upperBound={marker?.upperBound ?? null}
|
||||
/>
|
||||
@@ -508,7 +509,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
</Card>
|
||||
<LabTrendCard
|
||||
trend={marker?.trend}
|
||||
unit={marker?.unit ?? latest?.unit ?? ""}
|
||||
unit={labUnitText(marker?.unit ?? latest?.unit)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -39,6 +39,11 @@ import type {
|
||||
BiomarkerListResponse,
|
||||
LabResultDto,
|
||||
} from "./types";
|
||||
import {
|
||||
labUnitLabel,
|
||||
labUnitText,
|
||||
withLabUnit,
|
||||
} from "@/lib/labs/dimensionless";
|
||||
|
||||
const NOTE_MAX_LENGTH = 2000;
|
||||
const DEFINE_NEW = "__define_new__";
|
||||
@@ -330,9 +335,8 @@ export function LabForm({
|
||||
? formatReferenceRange(selected.lowerBound, selected.upperBound, labNumber)
|
||||
: "";
|
||||
|
||||
// What the typed string resolved to, shown back so the user can see whether
|
||||
// a window was read out of it before the reading is saved. When none was,
|
||||
// the hint says the text is kept as written rather than staying silent.
|
||||
// What the typed string resolved to, shown back before the save. When no
|
||||
// window was read out of it, the hint says the text stays as written.
|
||||
const parsedSourceRange = parseReferenceRange(sourceRange, selected?.unit);
|
||||
const sourceRangeHint = parsedSourceRange
|
||||
? isUnreadableRange(parsedSourceRange)
|
||||
@@ -341,7 +345,7 @@ export function LabForm({
|
||||
parsedSourceRange.low,
|
||||
parsedSourceRange.high,
|
||||
labNumber,
|
||||
)} ${selected?.unit ?? ""}`.trim()
|
||||
)} ${labUnitText(selected?.unit)}`.trim()
|
||||
: "";
|
||||
|
||||
const footerNode = (
|
||||
@@ -406,7 +410,7 @@ export function LabForm({
|
||||
<SelectContent>
|
||||
{markers.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name} · {m.unit}
|
||||
{m.name} · {labUnitLabel(m.unit, t)}
|
||||
</SelectItem>
|
||||
))}
|
||||
{!lockedBiomarkerId ? (
|
||||
@@ -457,7 +461,7 @@ export function LabForm({
|
||||
{selected ? (
|
||||
<span className="text-muted-foreground font-normal">
|
||||
{" "}
|
||||
({selected.unit})
|
||||
({labUnitLabel(selected.unit, t)})
|
||||
</span>
|
||||
) : null}
|
||||
</Label>
|
||||
@@ -515,7 +519,8 @@ export function LabForm({
|
||||
|
||||
{resultType === "numeric" && selected && referenceText ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("labs.referenceLabel")} {referenceText} {selected.unit}
|
||||
{t("labs.referenceLabel")}{" "}
|
||||
{withLabUnit(referenceText, selected.unit)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
} from "./lab-result-related-links";
|
||||
import type { LabResultDto } from "./types";
|
||||
import { useLabDate, useLabNumber } from "./use-lab-format";
|
||||
import { labUnitText } from "@/lib/labs/dimensionless";
|
||||
|
||||
const NOTE_MAX_LENGTH = 2000;
|
||||
|
||||
@@ -103,7 +104,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
|
||||
reading.value === null
|
||||
? (reading.valueText ?? "")
|
||||
: labNumber(reading.value, reading.sourceValue),
|
||||
unit: reading.value === null ? undefined : reading.unit,
|
||||
unit: reading.value === null ? undefined : labUnitText(reading.unit),
|
||||
compare: before
|
||||
? {
|
||||
label: t("day.previousReading", { date: labDate(before.takenAt) }),
|
||||
|
||||
@@ -31,6 +31,7 @@ import type {
|
||||
LabOverviewResponse,
|
||||
LabResultDto,
|
||||
} from "./types";
|
||||
import { labUnitLabel } from "@/lib/labs/dimensionless";
|
||||
|
||||
interface MarkerGroup {
|
||||
/** Stable group key: `bm:<biomarkerId>`, or `analyte:<lower>` for legacy rows. */
|
||||
@@ -39,6 +40,7 @@ interface MarkerGroup {
|
||||
biomarkerId: string | null;
|
||||
analyte: string;
|
||||
panel: string | null;
|
||||
/** The marker's unit as the card names it ("No unit" for a ratio). */
|
||||
unit: string;
|
||||
/** Every live reading of the marker, counted by the server. */
|
||||
count: number;
|
||||
@@ -53,13 +55,16 @@ interface MarkerGroup {
|
||||
* inert until the backfill links them. The server already orders by the
|
||||
* newest reading.
|
||||
*/
|
||||
function toGroups(markers: LabOverviewMarkerDto[]): MarkerGroup[] {
|
||||
function toGroups(
|
||||
markers: LabOverviewMarkerDto[],
|
||||
t: (key: string) => string,
|
||||
): MarkerGroup[] {
|
||||
return markers.map((marker) => ({
|
||||
key: marker.key,
|
||||
biomarkerId: marker.latest.biomarkerId,
|
||||
analyte: marker.latest.analyte,
|
||||
panel: marker.latest.panel,
|
||||
unit: marker.latest.unit,
|
||||
unit: labUnitLabel(marker.latest.unit, t),
|
||||
count: marker.count,
|
||||
trend: marker.trend,
|
||||
latest: marker.latest,
|
||||
@@ -152,7 +157,7 @@ export function LabList({
|
||||
});
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const base = toGroups(data?.markers ?? []);
|
||||
const base = toGroups(data?.markers ?? [], t);
|
||||
// v1.18.6 — honour the user's Labs sort choice. The server
|
||||
// order is most-recent-first; `recentAsc` reverses, the alpha
|
||||
// options sort by analyte name (#43), and `manual` applies the persisted
|
||||
@@ -173,7 +178,7 @@ export function LabList({
|
||||
return applyOrder(base, prefs.order, (g) => g.biomarkerId ?? g.key);
|
||||
}
|
||||
return base;
|
||||
}, [data?.markers, prefs.sortDir, prefs.order]);
|
||||
}, [data?.markers, prefs.sortDir, prefs.order, t]);
|
||||
|
||||
const listEmpty = !isLoading && !isError && groups.length === 0;
|
||||
useEffect(() => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RangeBar } from "@/components/targets/range-bar";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { useLabNumber } from "./use-lab-format";
|
||||
import { labUnitText, withLabUnit } from "@/lib/labs/dimensionless";
|
||||
|
||||
/**
|
||||
* v1.37.34 — compact reference-range visualization for saved lab readings.
|
||||
@@ -43,11 +44,14 @@ export function LabReferenceRangeBar({
|
||||
const scale = Math.max(Math.abs(referenceLow ?? referenceHigh!), 1);
|
||||
const min = referenceLow ?? referenceHigh! - scale;
|
||||
const max = referenceHigh ?? referenceLow! + scale;
|
||||
const rangeLabel = !hasLow
|
||||
? `≤${labNumber(referenceHigh!)} ${unit}`
|
||||
: !hasHigh
|
||||
? `≥${labNumber(referenceLow!)} ${unit}`
|
||||
: `${labNumber(referenceLow!)}–${labNumber(referenceHigh!)} ${unit}`;
|
||||
const rangeLabel = withLabUnit(
|
||||
!hasLow
|
||||
? `≤${labNumber(referenceHigh!)}`
|
||||
: !hasHigh
|
||||
? `≥${labNumber(referenceLow!)}`
|
||||
: `${labNumber(referenceLow!)}–${labNumber(referenceHigh!)}`,
|
||||
unit,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -59,7 +63,7 @@ export function LabReferenceRangeBar({
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
unit={unit}
|
||||
unit={labUnitText(unit)}
|
||||
compact
|
||||
tone="lab"
|
||||
formatValue={labNumber}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import type { BiomarkerTrendDto } from "./types";
|
||||
import { labUnitText } from "@/lib/labs/dimensionless";
|
||||
|
||||
/**
|
||||
* v1.44 — the marker's slope over all its readings, and where that straight
|
||||
@@ -35,12 +36,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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
import { BiomarkerManager } from "./biomarker-manager";
|
||||
import type { BiomarkerDto, BiomarkerListResponse } from "./types";
|
||||
import { useLabsLocalOcr, useUpdateLabsLocalOcr } from "./use-ocr-extract";
|
||||
import { labUnitLabel } from "@/lib/labs/dimensionless";
|
||||
|
||||
export function LabsSettings() {
|
||||
const { t } = useTranslations();
|
||||
@@ -61,7 +62,7 @@ export function LabsSettings() {
|
||||
const reorderItems: ReorderItem[] = ordered.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
secondary: m.panel ? `${m.unit} · ${m.panel}` : m.unit,
|
||||
secondary: [labUnitLabel(m.unit, t), m.panel].filter(Boolean).join(" · "),
|
||||
}));
|
||||
|
||||
const manual = prefs.sortDir === "manual";
|
||||
|
||||
@@ -21,6 +21,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { labUnitLabel } from "@/lib/labs/dimensionless";
|
||||
|
||||
export interface MarkerSuggestionDto {
|
||||
biomarkerId: string;
|
||||
@@ -110,7 +111,7 @@ export function MarkerMatchField({
|
||||
<option key={suggestion.biomarkerId} value={suggestion.biomarkerId}>
|
||||
{t("labs.match.suggestionOption", {
|
||||
name: suggestion.name,
|
||||
unit: suggestion.unit,
|
||||
unit: labUnitLabel(suggestion.unit, t),
|
||||
})}
|
||||
</option>
|
||||
))}
|
||||
|
||||
@@ -24,12 +24,14 @@ import { DateField } from "@/components/ui/date-field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { isDimensionlessUnit, labUnitLabel } from "@/lib/labs/dimensionless";
|
||||
import {
|
||||
OCR_ANALYTE_MAX,
|
||||
OCR_UNIT_MAX,
|
||||
OCR_VALUE_TEXT_MAX,
|
||||
} from "@/lib/validations/labs-ocr";
|
||||
|
||||
import { LabUnitInput } from "./lab-unit-input";
|
||||
import { MarkerMatchField } from "./marker-match-field";
|
||||
import { chooseMarker, type OcrReviewRow } from "./ocr-review-types";
|
||||
import {
|
||||
@@ -51,6 +53,28 @@ function RequiredMark() {
|
||||
);
|
||||
}
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string>) => string;
|
||||
|
||||
/** The badge naming a row whose unit differs from its marker's. */
|
||||
function unitDiffersText(row: OcrReviewRow, t: Translate): string {
|
||||
const unit = labUnitLabel(row.unit, t);
|
||||
return isDimensionlessUnit(row.markerUnit)
|
||||
? t("labs.ocr.unitDiffersBadgeNoUnit", { unit })
|
||||
: t("labs.ocr.unitDiffersBadge", {
|
||||
markerUnit: row.markerUnit ?? "",
|
||||
unit,
|
||||
});
|
||||
}
|
||||
|
||||
/** The message key and params of a unit the marker cannot take. */
|
||||
function unitMismatchMessage(
|
||||
row: OcrReviewRow,
|
||||
): [string, Record<string, string>?] {
|
||||
return isDimensionlessUnit(row.markerUnit)
|
||||
? ["labs.ocr.errorUnitMismatchNoUnit"]
|
||||
: ["labs.ocr.errorUnitMismatch", { markerUnit: row.markerUnit ?? "" }];
|
||||
}
|
||||
|
||||
export function OcrRowEditor({
|
||||
row,
|
||||
onChange,
|
||||
@@ -197,12 +221,7 @@ export function OcrRowEditor({
|
||||
{unitBlocked ? (
|
||||
<Badge variant="secondary" className="max-w-full">
|
||||
<Ruler aria-hidden />
|
||||
<span className="truncate">
|
||||
{t("labs.ocr.unitDiffersBadge", {
|
||||
markerUnit: row.markerUnit ?? "",
|
||||
unit: (row.unit ?? "").trim(),
|
||||
})}
|
||||
</span>
|
||||
<span className="truncate">{unitDiffersText(row, t)}</span>
|
||||
</Badge>
|
||||
) : null}
|
||||
{conversion ? (
|
||||
@@ -294,10 +313,10 @@ export function OcrRowEditor({
|
||||
{t("labs.ocr.unitLabel")}
|
||||
<RequiredMark />
|
||||
</Label>
|
||||
<Input
|
||||
<LabUnitInput
|
||||
id={`${fieldId}-unit`}
|
||||
value={row.unit ?? ""}
|
||||
onChange={(e) => onChange({ ...row, unit: e.target.value })}
|
||||
onChange={(unit) => onChange({ ...row, unit })}
|
||||
{...invalidProps(
|
||||
errors?.unitMismatch
|
||||
? "unitMismatch"
|
||||
@@ -310,9 +329,7 @@ export function OcrRowEditor({
|
||||
{fieldError("unitTooLong", "labs.ocr.errorTooLong", {
|
||||
max: String(OCR_UNIT_MAX),
|
||||
})}
|
||||
{fieldError("unitMismatch", "labs.ocr.errorUnitMismatch", {
|
||||
markerUnit: row.markerUnit ?? "",
|
||||
})}
|
||||
{fieldError("unitMismatch", ...unitMismatchMessage(row))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import type { LabResultDto } from "./types";
|
||||
import { useLabNumber } from "./use-lab-format";
|
||||
import { withLabUnit } from "@/lib/labs/dimensionless";
|
||||
|
||||
/**
|
||||
* The reference window a reading's own report printed, shown wherever that
|
||||
@@ -54,11 +55,14 @@ export function SourceRangeNote({
|
||||
// then stated from the converted bounds, in the marker's unit.
|
||||
const inForce =
|
||||
reading.sourceReferenceText ??
|
||||
`${formatReferenceRange(
|
||||
reading.referenceLow,
|
||||
reading.referenceHigh,
|
||||
labNumber,
|
||||
)} ${reading.unit}`.trim();
|
||||
withLabUnit(
|
||||
formatReferenceRange(
|
||||
reading.referenceLow,
|
||||
reading.referenceHigh,
|
||||
labNumber,
|
||||
),
|
||||
reading.unit,
|
||||
);
|
||||
|
||||
const catalog = formatReferenceRange(
|
||||
reading.catalogReferenceLow,
|
||||
@@ -77,7 +81,7 @@ export function SourceRangeNote({
|
||||
: null}
|
||||
{reading.referenceDivergesFromCatalog && catalog
|
||||
? ` · ${t("labs.sourceRange.catalogDiffers", {
|
||||
range: `${catalog} ${reading.unit}`.trim(),
|
||||
range: withLabUnit(catalog, reading.unit),
|
||||
})}`
|
||||
: null}
|
||||
</span>
|
||||
|
||||
@@ -100,6 +100,26 @@ export function RecordCsvImportCard() {
|
||||
);
|
||||
}
|
||||
|
||||
/** What the sheet says about reading the file, below the format choices. */
|
||||
function ImportNotes({ kind }: { kind: RecordImportKind }) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("recordImport.timezoneNote")}
|
||||
</p>
|
||||
{kind === "labs" ? (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="record-import-unitless-hint"
|
||||
>
|
||||
{t("recordImport.unitlessHint")}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordCsvImportSheet({
|
||||
kind,
|
||||
onOpenChange,
|
||||
@@ -421,9 +441,7 @@ function RecordCsvImportSheet({
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("recordImport.timezoneNote")}
|
||||
</p>
|
||||
<ImportNotes kind={kind} />
|
||||
|
||||
<div
|
||||
className="max-h-48 overflow-auto overscroll-contain rounded-lg border"
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
formatDayMonthYear,
|
||||
} from "./timeline-dates";
|
||||
import { seriesNameLines, type TimelineLayout } from "./timeline-geometry";
|
||||
import { withLabUnit } from "@/lib/labs/dimensionless";
|
||||
|
||||
/** "18 ng/mL", every digit the reading holds, in the reader's locale. */
|
||||
export type LabValueText = (
|
||||
@@ -50,7 +51,7 @@ export function useLabValueText(): LabValueText {
|
||||
const labNumber = useLabNumber();
|
||||
return useCallback(
|
||||
(value, sourceValue, unit) =>
|
||||
`${labNumber(value, sourceValue)} ${unit}`.trim(),
|
||||
withLabUnit(labNumber(value, sourceValue), unit),
|
||||
[labNumber],
|
||||
);
|
||||
}
|
||||
@@ -84,7 +85,7 @@ export function useLabReference(): (
|
||||
(v) => labNumber(v),
|
||||
);
|
||||
return range
|
||||
? t("timeline.labs.reference", { range: `${range} ${unit}`.trim() })
|
||||
? t("timeline.labs.reference", { range: withLabUnit(range, unit) })
|
||||
: null;
|
||||
},
|
||||
[t, labNumber],
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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.
|
||||
|
||||
@@ -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"]) {
|
||||
|
||||
@@ -21,6 +21,10 @@ import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
import { LOINC_SYSTEM, UCUM_SYSTEM } from "@/lib/fhir/loinc-map";
|
||||
import { resolveLabCoding } from "@/lib/fhir/lab-loinc";
|
||||
import { qualitativeValueConcept } from "@/lib/fhir/lab-qualitative";
|
||||
import {
|
||||
DIMENSIONLESS_UNIT,
|
||||
isDimensionlessUnit,
|
||||
} from "@/lib/labs/dimensionless";
|
||||
|
||||
import type {
|
||||
FhirObservation,
|
||||
@@ -84,10 +88,15 @@ export function labObservations(
|
||||
// the code system while withholding the code claims a coding the bundle
|
||||
// does not carry, and leaves a receiver unable to machine-compare the
|
||||
// value it is attached to.
|
||||
// A value without a unit is the UCUM unity by definition, so it carries
|
||||
// the code even for an analyte the map does not know.
|
||||
const ucum = isDimensionlessUnit(lab.unit)
|
||||
? DIMENSIONLESS_UNIT
|
||||
: coding?.ucum;
|
||||
const quantity = (value: number) => ({
|
||||
value,
|
||||
unit: lab.unit,
|
||||
...(coding?.ucum ? { system: UCUM_SYSTEM, code: coding.ucum } : {}),
|
||||
...(ucum ? { system: UCUM_SYSTEM, code: ucum } : {}),
|
||||
});
|
||||
// The bounds are stated in the SAME unit as the value, so they carry the
|
||||
// SAME coding — a bare unit beside a coded value leaves a receiver unable
|
||||
|
||||
@@ -65,6 +65,34 @@ describe("lab rows", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reads 1 in the unit column as a value without a unit", () => {
|
||||
expect(lab(["INR", "1.1", "1", "2025-03-01", "0.8-1.2"])).toMatchObject({
|
||||
ok: true,
|
||||
row: { value: 1.1, unit: "1", sourceReferenceHigh: 1.2 },
|
||||
});
|
||||
});
|
||||
|
||||
it("still refuses a number whose unit cell is empty", () => {
|
||||
for (const unit of ["", " "]) {
|
||||
expect(lab(["INR", "1.1", unit, "2025-03-01", ""])).toEqual({
|
||||
ok: false,
|
||||
reason: "missing_unit",
|
||||
field: "unit",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps any other spelling of no unit as the text it is", () => {
|
||||
// Only the UCUM unity means "no unit"; a word or a dash stays the literal
|
||||
// unit the file wrote, so a marker that already holds it keeps matching.
|
||||
for (const unit of ["ratio", "-", "none"]) {
|
||||
expect(lab(["A/G", "1.4", unit, "2025-03-01", ""])).toMatchObject({
|
||||
ok: true,
|
||||
row: { unit },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a non-numeric result as a qualitative reading", () => {
|
||||
expect(
|
||||
lab(["Urine protein", "negative", "", "2025-03-01", ""]),
|
||||
|
||||
@@ -332,6 +332,7 @@ export const RECORD_IMPORT_TEMPLATES: Record<RecordImportKind, string> = {
|
||||
"analyte,value,unit,date,time,reference range,panel,note",
|
||||
"HbA1c,5.4,%,2025-03-01,08:15,4.0-5.6,Diabetes,",
|
||||
"Ferritin,85,ng/mL,2025-03-01,08:15,30-400,Iron,",
|
||||
"INR,1.1,1,2025-03-01,08:15,0.8-1.2,Coagulation,",
|
||||
"Urine protein,negative,,2025-03-01,,,Urine,",
|
||||
"",
|
||||
].join("\n"),
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { userDayKey } from "@/lib/tz/resolver";
|
||||
import { sanitizeForPrompt } from "@/lib/insights/sanitize";
|
||||
import { labUnitText } from "@/lib/labs/dimensionless";
|
||||
|
||||
export type BiomarkerStatusResult = {
|
||||
hasProvider: boolean;
|
||||
@@ -321,7 +322,7 @@ function biomarkerSnapshot(args: {
|
||||
// position below, so both are scrubbed exactly like the coach
|
||||
// labs-snapshot scrubs the same fields.
|
||||
const safeMarkerName = sanitizeForPrompt(marker.name, 120);
|
||||
const safeMarkerUnit = sanitizeForPrompt(marker.unit, 40);
|
||||
const safeMarkerUnit = sanitizeForPrompt(labUnitText(marker.unit), 40);
|
||||
|
||||
const snapshot = {
|
||||
locale,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { fenceUserText } from "@/lib/ai/coach/data-fence";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
import { labUnitText } from "@/lib/labs/dimensionless";
|
||||
|
||||
/** Up to eight results of the panel, fenced; null when there is nothing to say. */
|
||||
export async function labsPanelEvidence(
|
||||
@@ -33,7 +34,8 @@ export async function labsPanelEvidence(
|
||||
lab.value === null
|
||||
? fenceUserText(lab.valueText ?? "not reported")
|
||||
: String(lab.value);
|
||||
const unit = lab.unit ? ` ${fenceUserText(lab.unit)}` : "";
|
||||
const shown = labUnitText(lab.unit);
|
||||
const unit = shown ? ` ${fenceUserText(shown)}` : "";
|
||||
return `- Newly arrived lab result: ${fenceUserText(lab.analyte)} ${value}${unit}.`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DIMENSIONLESS_UNIT,
|
||||
isDimensionlessUnit,
|
||||
labUnitText,
|
||||
withLabUnit,
|
||||
} from "../dimensionless";
|
||||
import { formatLabReading } from "../format-value";
|
||||
import { readingInMarkerUnit } from "../reading-conversion";
|
||||
import { sameLabUnit } from "../unit-normalise";
|
||||
|
||||
describe("the dimensionless lab unit", () => {
|
||||
it("is the UCUM unity", () => {
|
||||
expect(DIMENSIONLESS_UNIT).toBe("1");
|
||||
});
|
||||
|
||||
it("recognises only the explicit unity, never an empty unit", () => {
|
||||
expect(isDimensionlessUnit("1")).toBe(true);
|
||||
expect(isDimensionlessUnit(" 1 ")).toBe(true);
|
||||
expect(isDimensionlessUnit("")).toBe(false);
|
||||
expect(isDimensionlessUnit(null)).toBe(false);
|
||||
expect(isDimensionlessUnit(undefined)).toBe(false);
|
||||
expect(isDimensionlessUnit("10")).toBe(false);
|
||||
expect(isDimensionlessUnit("1/min")).toBe(false);
|
||||
});
|
||||
|
||||
it("renders as nothing beside a value", () => {
|
||||
expect(labUnitText("1")).toBe("");
|
||||
expect(labUnitText("mg/dL")).toBe("mg/dL");
|
||||
expect(labUnitText(null)).toBe("");
|
||||
expect(withLabUnit("3.6", "1")).toBe("3.6");
|
||||
expect(withLabUnit("3.6", " mg/dL ")).toBe("3.6 mg/dL");
|
||||
expect(withLabUnit("3.6", "")).toBe("3.6");
|
||||
});
|
||||
|
||||
it("never prints a literal 1 after a reading", () => {
|
||||
const text = formatLabReading(
|
||||
{ value: 3.6, valueText: null, unit: "1" },
|
||||
(v) => String(v),
|
||||
);
|
||||
expect(text).toBe("3.6");
|
||||
});
|
||||
|
||||
it("is a unit of its own when compared", () => {
|
||||
expect(sameLabUnit("1", "1")).toBe(true);
|
||||
expect(sameLabUnit("1", "")).toBe(false);
|
||||
expect(sameLabUnit("1", "mg/dL")).toBe(false);
|
||||
expect(sameLabUnit("1", "%")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a dimensionless reading against a marker", () => {
|
||||
const client = {
|
||||
biomarker: { update: async () => ({}) },
|
||||
} as unknown as Parameters<typeof readingInMarkerUnit>[0];
|
||||
|
||||
it("is stored as it is under a dimensionless marker", async () => {
|
||||
const reading = await readingInMarkerUnit(client, {
|
||||
marker: { id: "m1", name: "INR", unit: "1", analyteKey: null },
|
||||
value: 1.1,
|
||||
unit: "1",
|
||||
sourceReferenceLow: 0.8,
|
||||
sourceReferenceHigh: 1.2,
|
||||
});
|
||||
expect(reading).toMatchObject({ ok: true, converted: false, value: 1.1 });
|
||||
});
|
||||
|
||||
it("is refused under a marker with a real unit", async () => {
|
||||
const reading = await readingInMarkerUnit(client, {
|
||||
marker: { id: "m2", name: "LDL", unit: "mg/dL", analyteKey: "ldl" },
|
||||
value: 3.6,
|
||||
unit: "1",
|
||||
sourceReferenceLow: null,
|
||||
sourceReferenceHigh: null,
|
||||
});
|
||||
expect(reading.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a real unit under a dimensionless marker", async () => {
|
||||
const reading = await readingInMarkerUnit(client, {
|
||||
marker: { id: "m3", name: "INR", unit: "1", analyteKey: null },
|
||||
value: 1.1,
|
||||
unit: "s",
|
||||
sourceReferenceLow: null,
|
||||
sourceReferenceHigh: null,
|
||||
});
|
||||
expect(reading.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* The unit of a lab value that has none.
|
||||
*
|
||||
* A ratio (total cholesterol over HDL, albumin over globulin) or an index
|
||||
* (INR) is a number without a unit. It is stored and sent as the UCUM unity
|
||||
* `"1"`, never as an empty unit: an empty unit on a numeric reading is what an
|
||||
* extraction leaves behind when it dropped the unit, and every write path
|
||||
* refuses it for that reason. `"1"` is a choice somebody made on purpose.
|
||||
*
|
||||
* For every comparison `"1"` is an ordinary unit. It matches only itself, so
|
||||
* a reading without a unit is refused under a marker kept in mg/dL and the
|
||||
* other way round. Only the display differs: nobody wants "3.6 1", so the
|
||||
* helpers below render the unity as nothing beside a value.
|
||||
*
|
||||
* Pure and dependency-free.
|
||||
*/
|
||||
|
||||
/** The UCUM unit of a dimensionless quantity. */
|
||||
export const DIMENSIONLESS_UNIT = "1";
|
||||
|
||||
/** Whether a stored or stated unit is the explicit "no unit" choice. */
|
||||
export function isDimensionlessUnit(unit: string | null | undefined): boolean {
|
||||
return typeof unit === "string" && unit.trim() === DIMENSIONLESS_UNIT;
|
||||
}
|
||||
|
||||
/** The unit as it reads beside a value: trimmed, and empty for the unity. */
|
||||
export function labUnitText(unit: string | null | undefined): string {
|
||||
if (typeof unit !== "string") return "";
|
||||
const trimmed = unit.trim();
|
||||
return trimmed === DIMENSIONLESS_UNIT ? "" : trimmed;
|
||||
}
|
||||
|
||||
/** A formatted number followed by its unit, or the number alone. */
|
||||
export function withLabUnit(
|
||||
number: string,
|
||||
unit: string | null | undefined,
|
||||
): string {
|
||||
const shown = labUnitText(unit);
|
||||
return shown ? `${number} ${shown}` : number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The unit where it stands on its own (a column, a marker's subtitle, a
|
||||
* sentence naming it): the localised "no unit" label for the unity, the unit
|
||||
* itself otherwise.
|
||||
*/
|
||||
export function labUnitLabel(
|
||||
unit: string | null | undefined,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
return isDimensionlessUnit(unit) ? t("labs.unit.none") : labUnitText(unit);
|
||||
}
|
||||
|
||||
/** How the API contract describes a lab unit field. */
|
||||
export const LAB_UNIT_DESCRIPTION =
|
||||
'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is never read as `"1"` and stays refused.';
|
||||
@@ -13,6 +13,7 @@
|
||||
* German reader sees "1,8 mIU/L" where an English one sees "1.8 mIU/L". This
|
||||
* module only decides the precision.
|
||||
*/
|
||||
import { withLabUnit } from "@/lib/labs/dimensionless";
|
||||
import {
|
||||
formatRecordedNumber,
|
||||
type FractionDigitsFormat,
|
||||
@@ -38,7 +39,7 @@ export function formatLabValue(
|
||||
/**
|
||||
* Render a reading's display value: a qualitative row (numeric `value` is
|
||||
* null) shows its `valueText` verbatim; a numeric row shows the formatted
|
||||
* number plus its unit. One helper so the list card, the history rows, and
|
||||
* number plus its unit (none for a dimensionless reading). One helper so the list card, the history rows, and
|
||||
* the detail header stay consistent.
|
||||
*/
|
||||
export function formatLabReading(
|
||||
@@ -53,5 +54,8 @@ export function formatLabReading(
|
||||
if (reading.value === null) {
|
||||
return reading.valueText ?? "";
|
||||
}
|
||||
return `${formatNumber(reading.value, reading.sourceValue)} ${reading.unit}`.trim();
|
||||
return withLabUnit(
|
||||
formatNumber(reading.value, reading.sourceValue),
|
||||
reading.unit,
|
||||
);
|
||||
}
|
||||
@@ -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: [] })),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) ?? []);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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))),
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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) ?? []),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user