mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge branch 'feat/v144-report' into release/v1.44.0
This commit is contained in:
commit
4e71cf4dc2
58 files changed
+5703
-91
No files matched your search
+239
-9
@@ -1245,8 +1245,9 @@ paths:
|
||||
summary: FHIR R4 CapabilityStatement (v1.11.0)
|
||||
description: "Read-only FHIR R4 capability statement for the REST face. Declares the served resource types (Patient,
|
||||
Coverage, Observation, MedicationStatement, MedicationAdministration), their `read` and `search-type`
|
||||
interactions, the `Patient/$everything` operation, and the `application/fhir+json` format. Every advertised
|
||||
interaction has a route. Auth: `fhir:read` scope (cookie sessions also pass)."
|
||||
interactions, the search parameters (`_count`, `_offset`; `date` and `code` on Observation), the
|
||||
`Patient/$everything` operation, and the `application/fhir+json` format. Every advertised interaction has a
|
||||
route. Auth: `fhir:read` scope (cookie sessions also pass)."
|
||||
responses:
|
||||
"200":
|
||||
description: CapabilityStatement (application/fhir+json).
|
||||
@@ -1291,7 +1292,16 @@ paths:
|
||||
- FHIR
|
||||
summary: FHIR R4 Observation search (v1.11.0)
|
||||
description: "Read-only `searchset` Bundle of the caller's own Observations (vitals / activity / lab / survey). Auth:
|
||||
`fhir:read` scope. Offset paging via `_count` (clamped ≤200) / `_offset`."
|
||||
`fhir:read` scope. Offset paging via `_count` (clamped ≤200) / `_offset`. Without `date` the answer is the
|
||||
snapshot it has always been: one latest reading per type plus the derived panels, ids `obs-N`. With `date`
|
||||
(v1.44, repeatable, FHIR prefixes eq/gt/ge/lt/le, a value without an offset read as UTC) the answer is the time
|
||||
series instead: every reading in the range plus workouts, newest first, paged in the database, ids `m-<id>` /
|
||||
`w-<id>` that read back through `/api/fhir/Observation/{id}`. The series follows the same saved report selection
|
||||
as the snapshot, leaves sleep out (stored as stage segments), pairs a systolic and diastolic recorded at the
|
||||
same instant into the blood-pressure panel, and codes a workout as an `activity` Observation (LOINC 73985-4
|
||||
Exercise activity, duration 55411-3 and energy 41981-2 as components). `code` (repeatable, `system|code` or a
|
||||
bare code, comma-separated) narrows either answer; an unknown code matches nothing. A malformed `date` is a 400
|
||||
OperationOutcome."
|
||||
parameters:
|
||||
- in: query
|
||||
name: _count
|
||||
@@ -1301,6 +1311,14 @@ paths:
|
||||
name: _offset
|
||||
schema:
|
||||
type: number
|
||||
- in: query
|
||||
name: date
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: code
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: searchset Bundle (application/fhir+json).
|
||||
@@ -1463,9 +1481,10 @@ paths:
|
||||
tags:
|
||||
- FHIR
|
||||
summary: FHIR R4 Observation read
|
||||
description: "Read-only `Observation` by id, out of the caller's own record. An id belonging to someone else is
|
||||
indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: `fhir:read`
|
||||
scope; `userId` is narrowed from auth."
|
||||
description: "Read-only `Observation` by id, out of the caller's own record: a snapshot id (`obs-N`) or a series id
|
||||
(`m-<id>` / `w-<id>`, v1.44), the latter read inside the same saved selection the series search applies. An id
|
||||
belonging to someone else is indistinguishable from one that never existed — both answer a `not-found`
|
||||
OperationOutcome. Auth: `fhir:read` scope; `userId` is narrowed from auth."
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@@ -26155,7 +26174,7 @@ components:
|
||||
type: number
|
||||
const: 2
|
||||
leaves:
|
||||
maxItems: 104
|
||||
maxItems: 106
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
@@ -26215,7 +26234,7 @@ components:
|
||||
type: number
|
||||
const: 2
|
||||
leaves:
|
||||
maxItems: 104
|
||||
maxItems: 106
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
@@ -29248,7 +29267,7 @@ components:
|
||||
type: number
|
||||
const: 2
|
||||
leaves:
|
||||
maxItems: 104
|
||||
maxItems: 106
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
@@ -52711,6 +52730,216 @@ components:
|
||||
- present
|
||||
additionalProperties: false
|
||||
- type: "null"
|
||||
segmented:
|
||||
anyOf:
|
||||
- type: object
|
||||
properties:
|
||||
present:
|
||||
type: boolean
|
||||
reason:
|
||||
type: string
|
||||
enum:
|
||||
- no_start
|
||||
- insufficient_before
|
||||
- insufficient_after
|
||||
- fit_failed
|
||||
level:
|
||||
type: number
|
||||
window:
|
||||
anyOf:
|
||||
- type: object
|
||||
properties:
|
||||
from:
|
||||
type: string
|
||||
to:
|
||||
type: string
|
||||
endReason:
|
||||
type: string
|
||||
enum:
|
||||
- today
|
||||
- window
|
||||
- dose_change
|
||||
required:
|
||||
- from
|
||||
- to
|
||||
- endReason
|
||||
additionalProperties: false
|
||||
- type: "null"
|
||||
days:
|
||||
type: object
|
||||
properties:
|
||||
before:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
after:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
excludedLowAdherence:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
excludedNotTaking:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
required:
|
||||
- before
|
||||
- after
|
||||
- excludedLowAdherence
|
||||
- excludedNotTaking
|
||||
additionalProperties: false
|
||||
adherence:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- tracked
|
||||
- not_tracked
|
||||
floorPct:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
lookbackDays:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
required:
|
||||
- status
|
||||
- floorPct
|
||||
- lookbackDays
|
||||
additionalProperties: false
|
||||
levelChange:
|
||||
anyOf:
|
||||
- type: object
|
||||
properties:
|
||||
estimate:
|
||||
type: number
|
||||
ciLow:
|
||||
type: number
|
||||
ciHigh:
|
||||
type: number
|
||||
standardError:
|
||||
type: number
|
||||
includesZero:
|
||||
type: boolean
|
||||
required:
|
||||
- estimate
|
||||
- ciLow
|
||||
- ciHigh
|
||||
- standardError
|
||||
- includesZero
|
||||
additionalProperties: false
|
||||
- type: "null"
|
||||
slopeChangePerWeek:
|
||||
anyOf:
|
||||
- type: object
|
||||
properties:
|
||||
estimate:
|
||||
type: number
|
||||
ciLow:
|
||||
type: number
|
||||
ciHigh:
|
||||
type: number
|
||||
standardError:
|
||||
type: number
|
||||
includesZero:
|
||||
type: boolean
|
||||
required:
|
||||
- estimate
|
||||
- ciLow
|
||||
- ciHigh
|
||||
- standardError
|
||||
- includesZero
|
||||
additionalProperties: false
|
||||
- type: "null"
|
||||
preSlopePerWeek:
|
||||
anyOf:
|
||||
- type: object
|
||||
properties:
|
||||
estimate:
|
||||
type: number
|
||||
ciLow:
|
||||
type: number
|
||||
ciHigh:
|
||||
type: number
|
||||
standardError:
|
||||
type: number
|
||||
includesZero:
|
||||
type: boolean
|
||||
required:
|
||||
- estimate
|
||||
- ciLow
|
||||
- ciHigh
|
||||
- standardError
|
||||
- includesZero
|
||||
additionalProperties: false
|
||||
- type: "null"
|
||||
fit:
|
||||
anyOf:
|
||||
- type: object
|
||||
properties:
|
||||
n:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
df:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
rSquared:
|
||||
type: number
|
||||
residualAutocorrelation:
|
||||
type: number
|
||||
hacLag:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
required:
|
||||
- n
|
||||
- df
|
||||
- rSquared
|
||||
- residualAutocorrelation
|
||||
- hacLag
|
||||
additionalProperties: false
|
||||
- type: "null"
|
||||
confounders:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
medicationName:
|
||||
type: string
|
||||
event:
|
||||
type: string
|
||||
enum:
|
||||
- started
|
||||
- stopped
|
||||
- dose_change
|
||||
- paused
|
||||
- resumed
|
||||
date:
|
||||
type: string
|
||||
required:
|
||||
- medicationName
|
||||
- event
|
||||
- date
|
||||
additionalProperties: false
|
||||
required:
|
||||
- present
|
||||
- level
|
||||
- window
|
||||
- days
|
||||
- adherence
|
||||
- levelChange
|
||||
- slopeChangePerWeek
|
||||
- preSlopePerWeek
|
||||
- fit
|
||||
- confounders
|
||||
additionalProperties: false
|
||||
- type: "null"
|
||||
required:
|
||||
- kind
|
||||
- key
|
||||
@@ -52721,6 +52950,7 @@ components:
|
||||
- series
|
||||
- beforeAfter
|
||||
- levelShift
|
||||
- segmented
|
||||
additionalProperties: false
|
||||
adherence:
|
||||
type: array
|
||||
|
||||
+60
-3
@@ -401,7 +401,38 @@
|
||||
"lifeSittingValue": "{hours} Stunden, Selbstauskunft",
|
||||
"lifeWork": "Arbeit",
|
||||
"lifeNightWork": "Nachtarbeit",
|
||||
"lifeExposures": "Belastungen: {list}"
|
||||
"lifeExposures": "Belastungen: {list}",
|
||||
"workouts": {
|
||||
"title": "Trainings",
|
||||
"summary": "{sessions} Einheiten, insgesamt {hours} h; im Schnitt {perWeek} Einheiten und {minutesPerWeek} Minuten pro Woche.",
|
||||
"colSport": "Sportart",
|
||||
"colSessions": "Einheiten",
|
||||
"colTime": "Dauer",
|
||||
"colDistance": "Strecke",
|
||||
"colEnergy": "Energie",
|
||||
"colHeartRate": "Ø Herzfrequenz",
|
||||
"moreSports": "{count} weitere Sportarten sind in den Summen enthalten, aber nicht einzeln aufgeführt."
|
||||
},
|
||||
"medicationEffect": {
|
||||
"title": "Veränderung rund um den Start der Medikamente",
|
||||
"colMedication": "Medikament / Messwert",
|
||||
"colStart": "Start",
|
||||
"colLevel": "Niveauänderung (95-%-KI)",
|
||||
"colSlope": "Trendänderung (95-%-KI)",
|
||||
"colDays": "Tage vor / nach",
|
||||
"to": "bis",
|
||||
"week": "Woche",
|
||||
"reasonNoStart": "Kein Startdatum erfasst",
|
||||
"reasonBefore": "Zu wenige Werte vor dem Start",
|
||||
"reasonAfter": "Zu wenige Werte seit dem Start",
|
||||
"reasonFit": "Nicht berechenbar",
|
||||
"excludedAdherence": "{count} Tage nach dem Start ausgelassen (unter {floor} % der Dosen eingenommen)",
|
||||
"excludedNotTaking": "{count} Tage ausgelassen (pausiert oder zwischen Einnahmezeiträumen)",
|
||||
"notTracked": "Einnahme nicht erfasst, daher keine Tage wegen ausgelassener Dosen entfernt",
|
||||
"endDoseChange": "Zeitraum endet am {date}, vor der ersten Dosisänderung",
|
||||
"days": "{before} Tage vor, {after} Tage nach dem Start, {excluded} Tage ausgelassen",
|
||||
"method": "Segmentierte Regression über die Tageswerte von 90 Tagen vor bis höchstens 180 Tagen nach dem Start, Intervalle nach Newey-West. Tage nach dem Start, an denen in den 14 Tagen davor weniger als 80 % der Dosen eingenommen wurden, sind ausgelassen. Beschreibt einen zeitlichen Zusammenhang, keine nachgewiesene Wirkung des Medikaments."
|
||||
}
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Da ist etwas schiefgelaufen",
|
||||
@@ -2359,7 +2390,30 @@
|
||||
},
|
||||
"scaleAbsolute": "Absolut",
|
||||
"scalePercentSinceStart": "% seit Start",
|
||||
"scaleToggleLabel": "Diagrammskala"
|
||||
"scaleToggleLabel": "Diagrammskala",
|
||||
"segmented": {
|
||||
"title": "Veränderung zum Start",
|
||||
"level": "Direkt zum Start hat sich das Niveau um {value} {unit} verschoben (95-%-Bereich {low} bis {high}).",
|
||||
"slope": "Nach dem Start hat sich der Wochentrend um {value} {unit} pro Woche verändert (95-%-Bereich {low} bis {high}).",
|
||||
"preSlope": "Vor dem Start lag der Trend bei {value} {unit} pro Woche.",
|
||||
"includesZero": "Wo ein Bereich die Null einschließt, passen diese Werte auch zu gar keiner Veränderung.",
|
||||
"basis": "Grundlage: {before} Tage vor und {after} Tage nach dem Start ({from} bis {to}).",
|
||||
"excludedAdherence": "{count} Tage nach dem Start wurden ausgelassen, weil in den {lookback} Tagen davor weniger als {floor} % der Dosen eingenommen wurden.",
|
||||
"excludedNotTaking": "{count} Tage wurden ausgelassen, weil das Medikament pausiert war oder zwischen zwei Einnahmezeiträumen lag.",
|
||||
"notTracked": "Für dieses Medikament wird die Einnahme nicht erfasst, deshalb wurden keine Tage wegen ausgelassener Dosen entfernt.",
|
||||
"endDoseChange": "Der Zeitraum endet vor der ersten Dosisänderung und beschreibt deshalb die Anfangsdosis.",
|
||||
"confoundersTitle": "Weitere Medikamentenänderungen in diesem Zeitraum, die diese Ansicht nicht von diesem Medikament trennen kann:",
|
||||
"confounderStarted": "{name} begonnen am {date}",
|
||||
"confounderStopped": "{name} beendet am {date}",
|
||||
"confounderDoseChange": "{name}: Dosis geändert am {date}",
|
||||
"confounderPaused": "{name} pausiert am {date}",
|
||||
"confounderResumed": "{name} fortgesetzt am {date}",
|
||||
"method": "So wird gerechnet: Eine Gerade wird durch die Tageswerte vor dem Start gelegt, eine zweite durch die danach. Der Sprung zwischen beiden am Start ist die Niveauänderung, der Unterschied ihrer Steigungen die Trendänderung. Die Bereiche berücksichtigen, dass Werte von einem Tag auf den nächsten nachwirken.",
|
||||
"needStart": "Lege ein Startdatum fest, um die Veränderung zum Start zu sehen.",
|
||||
"needBefore": "Für die Veränderung zum Start braucht es Werte an mindestens {days} Tagen, verteilt über {span} Tage vor dem Start.",
|
||||
"needAfter": "Für die Veränderung zum Start braucht es Werte an mindestens {days} Tagen, verteilt über {span} Tage seit dem Start und an Tagen mit eingenommenen Dosen.",
|
||||
"fitFailed": "Diese Werte ließen sich nicht auswerten, deshalb wird keine Veränderung zum Start gezeigt."
|
||||
}
|
||||
},
|
||||
"import": {
|
||||
"resultSuccess": "{imported} Einnahmen importiert",
|
||||
@@ -12307,7 +12361,10 @@
|
||||
"leafVisits": "Arztbesuche",
|
||||
"leafSurgicalHistory": "Operationen und Eingriffe",
|
||||
"leafImmunizations": "Impfungen",
|
||||
"leafEmergency": "Notfalldaten (Blutgruppe, Kontakte, Verfügungen)"
|
||||
"leafEmergency": "Notfalldaten (Blutgruppe, Kontakte, Verfügungen)",
|
||||
"leafMedicationEffect": "Veränderung rund um den Start jedes Medikaments",
|
||||
"leafWorkouts": "Trainings (Übersicht je Sportart)",
|
||||
"leafHrvSdnn": "HRV (SDNN)"
|
||||
},
|
||||
"recordSharing": {
|
||||
"banner": {
|
||||
|
||||
+60
-3
@@ -401,7 +401,38 @@
|
||||
"lifeSittingValue": "{hours} hours, self-reported",
|
||||
"lifeWork": "Work",
|
||||
"lifeNightWork": "night work",
|
||||
"lifeExposures": "exposures: {list}"
|
||||
"lifeExposures": "exposures: {list}",
|
||||
"workouts": {
|
||||
"title": "Workouts",
|
||||
"summary": "{sessions} sessions, {hours} h in total; on average {perWeek} sessions and {minutesPerWeek} minutes per week.",
|
||||
"colSport": "Sport",
|
||||
"colSessions": "Sessions",
|
||||
"colTime": "Time",
|
||||
"colDistance": "Distance",
|
||||
"colEnergy": "Energy",
|
||||
"colHeartRate": "Avg heart rate",
|
||||
"moreSports": "{count} more sports are counted in the totals but not listed."
|
||||
},
|
||||
"medicationEffect": {
|
||||
"title": "Change around each medication start",
|
||||
"colMedication": "Medication / measure",
|
||||
"colStart": "Start",
|
||||
"colLevel": "Level change (95 % CI)",
|
||||
"colSlope": "Trend change (95 % CI)",
|
||||
"colDays": "Days before / after",
|
||||
"to": "to",
|
||||
"week": "week",
|
||||
"reasonNoStart": "No start date recorded",
|
||||
"reasonBefore": "Too few readings before the start",
|
||||
"reasonAfter": "Too few readings since the start",
|
||||
"reasonFit": "Could not be computed",
|
||||
"excludedAdherence": "{count} days after the start left out (under {floor} % of doses taken)",
|
||||
"excludedNotTaking": "{count} days left out (paused or between courses)",
|
||||
"notTracked": "intake not tracked, so no days left out for missed doses",
|
||||
"endDoseChange": "window ends {date}, before the first dose change",
|
||||
"days": "{before} days before, {after} days after the start, {excluded} days left out",
|
||||
"method": "Segmented regression on daily values from 90 days before to at most 180 days after the start, with Newey-West intervals. Days after the start on which fewer than 80 % of the doses of the preceding 14 days were taken are left out. Describes an association over time, not a proven effect of the medication."
|
||||
}
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Something went wrong",
|
||||
@@ -2359,7 +2390,30 @@
|
||||
},
|
||||
"scaleAbsolute": "Absolute",
|
||||
"scalePercentSinceStart": "% since start",
|
||||
"scaleToggleLabel": "Chart scale"
|
||||
"scaleToggleLabel": "Chart scale",
|
||||
"segmented": {
|
||||
"title": "Change at the start",
|
||||
"level": "Right at the start, the level moved by {value} {unit} (95 % range {low} to {high}).",
|
||||
"slope": "After the start, the weekly trend changed by {value} {unit} per week (95 % range {low} to {high}).",
|
||||
"preSlope": "Before the start, the trend was {value} {unit} per week.",
|
||||
"includesZero": "Where a range includes zero, these readings are also consistent with no change.",
|
||||
"basis": "Based on {before} days before and {after} days after the start ({from} to {to}).",
|
||||
"excludedAdherence": "{count} days after the start were left out because fewer than {floor} % of the doses in the {lookback} days before them were taken.",
|
||||
"excludedNotTaking": "{count} days were left out because the medication was paused or between courses.",
|
||||
"notTracked": "Intake is not tracked for this medication, so no days were left out for missed doses.",
|
||||
"endDoseChange": "The window ends before the first dose change, so this describes the starting dose.",
|
||||
"confoundersTitle": "Other medication changes in this window, which this view cannot separate from this one:",
|
||||
"confounderStarted": "{name} started on {date}",
|
||||
"confounderStopped": "{name} stopped on {date}",
|
||||
"confounderDoseChange": "{name}: dose changed on {date}",
|
||||
"confounderPaused": "{name} paused on {date}",
|
||||
"confounderResumed": "{name} resumed on {date}",
|
||||
"method": "How this is worked out: one line is fitted to the daily values before the start and one after it. The jump between them at the start is the level change, the difference in their slopes the trend change. The ranges allow for readings that carry over from one day to the next.",
|
||||
"needStart": "Set a start date to see the change at the start.",
|
||||
"needBefore": "The change at the start needs readings on at least {days} days spread over {span} days before the start.",
|
||||
"needAfter": "The change at the start needs readings on at least {days} days spread over {span} days since the start, on days the doses were taken.",
|
||||
"fitFailed": "These readings could not be fitted, so no change at the start is shown."
|
||||
}
|
||||
},
|
||||
"import": {
|
||||
"resultSuccess": "{imported} intakes imported",
|
||||
@@ -12307,7 +12361,10 @@
|
||||
"leafVisits": "Visits",
|
||||
"leafSurgicalHistory": "Surgical history",
|
||||
"leafImmunizations": "Immunizations",
|
||||
"leafEmergency": "Emergency information (blood type, contacts, directives)"
|
||||
"leafEmergency": "Emergency information (blood type, contacts, directives)",
|
||||
"leafMedicationEffect": "Change around each medication start",
|
||||
"leafWorkouts": "Workouts (summary per sport)",
|
||||
"leafHrvSdnn": "HRV (SDNN)"
|
||||
},
|
||||
"recordSharing": {
|
||||
"banner": {
|
||||
|
||||
+60
-3
@@ -401,7 +401,38 @@
|
||||
"lifeSittingValue": "{hours} horas, declarado",
|
||||
"lifeWork": "Trabajo",
|
||||
"lifeNightWork": "trabajo nocturno",
|
||||
"lifeExposures": "exposiciones: {list}"
|
||||
"lifeExposures": "exposiciones: {list}",
|
||||
"workouts": {
|
||||
"title": "Entrenamientos",
|
||||
"summary": "{sessions} sesiones, {hours} h en total; de media {perWeek} sesiones y {minutesPerWeek} minutos por semana.",
|
||||
"colSport": "Deporte",
|
||||
"colSessions": "Sesiones",
|
||||
"colTime": "Tiempo",
|
||||
"colDistance": "Distancia",
|
||||
"colEnergy": "Energía",
|
||||
"colHeartRate": "FC media",
|
||||
"moreSports": "{count} deportes más se cuentan en los totales, pero no se enumeran."
|
||||
},
|
||||
"medicationEffect": {
|
||||
"title": "Cambio en torno al inicio de cada medicamento",
|
||||
"colMedication": "Medicamento / medida",
|
||||
"colStart": "Inicio",
|
||||
"colLevel": "Cambio de nivel (IC 95 %)",
|
||||
"colSlope": "Cambio de tendencia (IC 95 %)",
|
||||
"colDays": "Días antes / después",
|
||||
"to": "a",
|
||||
"week": "semana",
|
||||
"reasonNoStart": "Sin fecha de inicio registrada",
|
||||
"reasonBefore": "Muy pocos valores antes del inicio",
|
||||
"reasonAfter": "Muy pocos valores desde el inicio",
|
||||
"reasonFit": "No se pudo calcular",
|
||||
"excludedAdherence": "{count} días después del inicio omitidos (menos del {floor} % de las dosis tomadas)",
|
||||
"excludedNotTaking": "{count} días omitidos (en pausa o entre tandas)",
|
||||
"notTracked": "toma no registrada, así que no se omitió ningún día por dosis olvidadas",
|
||||
"endDoseChange": "el periodo termina el {date}, antes del primer cambio de dosis",
|
||||
"days": "{before} días antes, {after} días después del inicio, {excluded} días omitidos",
|
||||
"method": "Regresión segmentada sobre los valores diarios desde 90 días antes hasta como máximo 180 días después del inicio, con intervalos de Newey-West. Se omiten los días posteriores al inicio en los que se tomó menos del 80 % de las dosis de los 14 días anteriores. Describe una asociación en el tiempo, no un efecto demostrado del medicamento."
|
||||
}
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Algo ha salido mal",
|
||||
@@ -2359,7 +2390,30 @@
|
||||
},
|
||||
"scaleAbsolute": "Absoluto",
|
||||
"scalePercentSinceStart": "% desde el inicio",
|
||||
"scaleToggleLabel": "Escala del gráfico"
|
||||
"scaleToggleLabel": "Escala del gráfico",
|
||||
"segmented": {
|
||||
"title": "Cambio al inicio",
|
||||
"level": "Justo al inicio, el nivel se desplazó {value} {unit} (intervalo del 95 %: {low} a {high}).",
|
||||
"slope": "Después del inicio, la tendencia semanal cambió {value} {unit} por semana (intervalo del 95 %: {low} a {high}).",
|
||||
"preSlope": "Antes del inicio, la tendencia era de {value} {unit} por semana.",
|
||||
"includesZero": "Cuando un intervalo incluye el cero, estos valores también son compatibles con ningún cambio.",
|
||||
"basis": "Basado en {before} días antes y {after} días después del inicio ({from} a {to}).",
|
||||
"excludedAdherence": "Se omitieron {count} días después del inicio porque en los {lookback} días anteriores se tomó menos del {floor} % de las dosis.",
|
||||
"excludedNotTaking": "Se omitieron {count} días porque el medicamento estaba en pausa o entre dos tandas.",
|
||||
"notTracked": "La toma de este medicamento no se registra, así que no se omitió ningún día por dosis olvidadas.",
|
||||
"endDoseChange": "El periodo termina antes del primer cambio de dosis, así que describe la dosis inicial.",
|
||||
"confoundersTitle": "Otros cambios de medicación en este periodo, que esta vista no puede separar de este:",
|
||||
"confounderStarted": "{name} iniciado el {date}",
|
||||
"confounderStopped": "{name} finalizado el {date}",
|
||||
"confounderDoseChange": "{name}: dosis cambiada el {date}",
|
||||
"confounderPaused": "{name} en pausa desde el {date}",
|
||||
"confounderResumed": "{name} reanudado el {date}",
|
||||
"method": "Cómo se calcula: se ajusta una recta a los valores diarios antes del inicio y otra a los posteriores. El salto entre ambas al inicio es el cambio de nivel y la diferencia de sus pendientes, el cambio de tendencia. Los intervalos tienen en cuenta que los valores de un día influyen en el siguiente.",
|
||||
"needStart": "Indica una fecha de inicio para ver el cambio al inicio.",
|
||||
"needBefore": "El cambio al inicio necesita valores de al menos {days} días repartidos en {span} días antes del inicio.",
|
||||
"needAfter": "El cambio al inicio necesita valores de al menos {days} días repartidos en {span} días desde el inicio, en días con las dosis tomadas.",
|
||||
"fitFailed": "No se pudieron ajustar estos valores, así que no se muestra el cambio al inicio."
|
||||
}
|
||||
},
|
||||
"import": {
|
||||
"resultSuccess": "{imported} tomas importadas",
|
||||
@@ -12307,7 +12361,10 @@
|
||||
"leafVisits": "Visitas médicas",
|
||||
"leafSurgicalHistory": "Antecedentes quirúrgicos",
|
||||
"leafImmunizations": "Vacunas",
|
||||
"leafEmergency": "Información de emergencia (grupo sanguíneo, contactos, voluntades)"
|
||||
"leafEmergency": "Información de emergencia (grupo sanguíneo, contactos, voluntades)",
|
||||
"leafMedicationEffect": "Cambio en torno al inicio de cada medicamento",
|
||||
"leafWorkouts": "Entrenamientos (resumen por deporte)",
|
||||
"leafHrvSdnn": "VFC (SDNN)"
|
||||
},
|
||||
"recordSharing": {
|
||||
"banner": {
|
||||
|
||||
+60
-3
@@ -401,7 +401,38 @@
|
||||
"lifeSittingValue": "{hours} heures, déclaré",
|
||||
"lifeWork": "Travail",
|
||||
"lifeNightWork": "travail de nuit",
|
||||
"lifeExposures": "expositions : {list}"
|
||||
"lifeExposures": "expositions : {list}",
|
||||
"workouts": {
|
||||
"title": "Entraînements",
|
||||
"summary": "{sessions} séances, {hours} h au total ; en moyenne {perWeek} séances et {minutesPerWeek} minutes par semaine.",
|
||||
"colSport": "Sport",
|
||||
"colSessions": "Séances",
|
||||
"colTime": "Durée",
|
||||
"colDistance": "Distance",
|
||||
"colEnergy": "Énergie",
|
||||
"colHeartRate": "FC moyenne",
|
||||
"moreSports": "{count} autres sports sont inclus dans les totaux, sans être listés."
|
||||
},
|
||||
"medicationEffect": {
|
||||
"title": "Changement autour du démarrage des médicaments",
|
||||
"colMedication": "Médicament / mesure",
|
||||
"colStart": "Début",
|
||||
"colLevel": "Changement de niveau (IC 95 %)",
|
||||
"colSlope": "Changement de tendance (IC 95 %)",
|
||||
"colDays": "Jours avant / après",
|
||||
"to": "à",
|
||||
"week": "semaine",
|
||||
"reasonNoStart": "Aucune date de début enregistrée",
|
||||
"reasonBefore": "Trop peu de valeurs avant le démarrage",
|
||||
"reasonAfter": "Trop peu de valeurs depuis le démarrage",
|
||||
"reasonFit": "Calcul impossible",
|
||||
"excludedAdherence": "{count} jours après le démarrage écartés (moins de {floor} % des doses prises)",
|
||||
"excludedNotTaking": "{count} jours écartés (en pause ou entre deux périodes de prise)",
|
||||
"notTracked": "prises non suivies, aucun jour écarté pour des doses manquées",
|
||||
"endDoseChange": "la période s'arrête le {date}, avant le premier changement de dose",
|
||||
"days": "{before} jours avant, {after} jours après le démarrage, {excluded} jours écartés",
|
||||
"method": "Régression segmentée sur les valeurs quotidiennes, de 90 jours avant à 180 jours au plus après le démarrage, intervalles de Newey-West. Les jours après le démarrage où moins de 80 % des doses des 14 jours précédents ont été prises sont écartés. Décrit une association dans le temps, pas un effet démontré du médicament."
|
||||
}
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Une erreur est survenue",
|
||||
@@ -2359,7 +2390,30 @@
|
||||
},
|
||||
"scaleAbsolute": "Absolu",
|
||||
"scalePercentSinceStart": "% depuis le début",
|
||||
"scaleToggleLabel": "Échelle du graphique"
|
||||
"scaleToggleLabel": "Échelle du graphique",
|
||||
"segmented": {
|
||||
"title": "Changement au démarrage",
|
||||
"level": "Dès le démarrage, le niveau s'est déplacé de {value} {unit} (intervalle à 95 % : {low} à {high}).",
|
||||
"slope": "Après le démarrage, la tendance hebdomadaire a changé de {value} {unit} par semaine (intervalle à 95 % : {low} à {high}).",
|
||||
"preSlope": "Avant le démarrage, la tendance était de {value} {unit} par semaine.",
|
||||
"includesZero": "Lorsqu'un intervalle inclut zéro, ces valeurs sont aussi compatibles avec une absence de changement.",
|
||||
"basis": "Sur la base de {before} jours avant et {after} jours après le démarrage ({from} au {to}).",
|
||||
"excludedAdherence": "{count} jours après le démarrage ont été écartés, car moins de {floor} % des doses ont été prises dans les {lookback} jours précédents.",
|
||||
"excludedNotTaking": "{count} jours ont été écartés, car le médicament était en pause ou entre deux périodes de prise.",
|
||||
"notTracked": "Les prises de ce médicament ne sont pas suivies, aucun jour n'a donc été écarté pour des doses manquées.",
|
||||
"endDoseChange": "La période s'arrête avant le premier changement de dose et décrit donc la dose de départ.",
|
||||
"confoundersTitle": "Autres changements de médicaments sur cette période, que cette vue ne peut pas distinguer de celui-ci :",
|
||||
"confounderStarted": "{name} commencé le {date}",
|
||||
"confounderStopped": "{name} arrêté le {date}",
|
||||
"confounderDoseChange": "{name} : dose modifiée le {date}",
|
||||
"confounderPaused": "{name} mis en pause le {date}",
|
||||
"confounderResumed": "{name} repris le {date}",
|
||||
"method": "Méthode : une droite est ajustée aux valeurs quotidiennes avant le démarrage, une autre à celles d'après. Le saut entre les deux au démarrage est le changement de niveau, l'écart de leurs pentes le changement de tendance. Les intervalles tiennent compte du fait que les valeurs d'un jour influencent le lendemain.",
|
||||
"needStart": "Indique une date de début pour voir le changement au démarrage.",
|
||||
"needBefore": "Le changement au démarrage demande des valeurs sur au moins {days} jours répartis sur {span} jours avant le démarrage.",
|
||||
"needAfter": "Le changement au démarrage demande des valeurs sur au moins {days} jours répartis sur {span} jours depuis le démarrage, les jours où les doses ont été prises.",
|
||||
"fitFailed": "Ces valeurs n'ont pas pu être ajustées, aucun changement au démarrage n'est donc affiché."
|
||||
}
|
||||
},
|
||||
"import": {
|
||||
"resultSuccess": "{imported} prises importées",
|
||||
@@ -12307,7 +12361,10 @@
|
||||
"leafVisits": "Consultations",
|
||||
"leafSurgicalHistory": "Antécédents chirurgicaux",
|
||||
"leafImmunizations": "Vaccinations",
|
||||
"leafEmergency": "Informations d'urgence (groupe sanguin, contacts, directives)"
|
||||
"leafEmergency": "Informations d'urgence (groupe sanguin, contacts, directives)",
|
||||
"leafMedicationEffect": "Changement autour du démarrage de chaque médicament",
|
||||
"leafWorkouts": "Entraînements (résumé par sport)",
|
||||
"leafHrvSdnn": "VFC (SDNN)"
|
||||
},
|
||||
"recordSharing": {
|
||||
"banner": {
|
||||
|
||||
+60
-3
@@ -401,7 +401,38 @@
|
||||
"lifeSittingValue": "{hours} ore, dichiarate",
|
||||
"lifeWork": "Lavoro",
|
||||
"lifeNightWork": "lavoro notturno",
|
||||
"lifeExposures": "esposizioni: {list}"
|
||||
"lifeExposures": "esposizioni: {list}",
|
||||
"workouts": {
|
||||
"title": "Allenamenti",
|
||||
"summary": "{sessions} sessioni, {hours} h in totale; in media {perWeek} sessioni e {minutesPerWeek} minuti a settimana.",
|
||||
"colSport": "Sport",
|
||||
"colSessions": "Sessioni",
|
||||
"colTime": "Durata",
|
||||
"colDistance": "Distanza",
|
||||
"colEnergy": "Energia",
|
||||
"colHeartRate": "FC media",
|
||||
"moreSports": "Altri {count} sport sono inclusi nei totali ma non elencati."
|
||||
},
|
||||
"medicationEffect": {
|
||||
"title": "Variazione intorno all'inizio dei farmaci",
|
||||
"colMedication": "Farmaco / misura",
|
||||
"colStart": "Inizio",
|
||||
"colLevel": "Variazione di livello (IC 95 %)",
|
||||
"colSlope": "Variazione di tendenza (IC 95 %)",
|
||||
"colDays": "Giorni prima / dopo",
|
||||
"to": "a",
|
||||
"week": "settimana",
|
||||
"reasonNoStart": "Nessuna data di inizio registrata",
|
||||
"reasonBefore": "Troppo pochi valori prima dell'inizio",
|
||||
"reasonAfter": "Troppo pochi valori dall'inizio",
|
||||
"reasonFit": "Non calcolabile",
|
||||
"excludedAdherence": "{count} giorni dopo l'inizio esclusi (meno del {floor} % delle dosi assunte)",
|
||||
"excludedNotTaking": "{count} giorni esclusi (in pausa o tra due cicli)",
|
||||
"notTracked": "assunzione non registrata, quindi nessun giorno escluso per dosi saltate",
|
||||
"endDoseChange": "il periodo termina il {date}, prima della prima variazione di dose",
|
||||
"days": "{before} giorni prima, {after} giorni dopo l'inizio, {excluded} giorni esclusi",
|
||||
"method": "Regressione segmentata sui valori giornalieri da 90 giorni prima a non oltre 180 giorni dopo l'inizio, con intervalli di Newey-West. Sono esclusi i giorni dopo l'inizio in cui è stato assunto meno dell'80 % delle dosi dei 14 giorni precedenti. Descrive un'associazione nel tempo, non un effetto dimostrato del farmaco."
|
||||
}
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Qualcosa è andato storto",
|
||||
@@ -2359,7 +2390,30 @@
|
||||
},
|
||||
"scaleAbsolute": "Assoluto",
|
||||
"scalePercentSinceStart": "% dall'inizio",
|
||||
"scaleToggleLabel": "Scala del grafico"
|
||||
"scaleToggleLabel": "Scala del grafico",
|
||||
"segmented": {
|
||||
"title": "Variazione all'inizio",
|
||||
"level": "Subito all'inizio il livello si è spostato di {value} {unit} (intervallo al 95 %: da {low} a {high}).",
|
||||
"slope": "Dopo l'inizio la tendenza settimanale è cambiata di {value} {unit} a settimana (intervallo al 95 %: da {low} a {high}).",
|
||||
"preSlope": "Prima dell'inizio la tendenza era di {value} {unit} a settimana.",
|
||||
"includesZero": "Quando un intervallo include lo zero, questi valori sono compatibili anche con nessuna variazione.",
|
||||
"basis": "Basato su {before} giorni prima e {after} giorni dopo l'inizio (dal {from} al {to}).",
|
||||
"excludedAdherence": "{count} giorni dopo l'inizio sono stati esclusi perché nei {lookback} giorni precedenti è stato assunto meno del {floor} % delle dosi.",
|
||||
"excludedNotTaking": "{count} giorni sono stati esclusi perché il farmaco era in pausa o tra due cicli.",
|
||||
"notTracked": "L'assunzione di questo farmaco non viene registrata, quindi nessun giorno è stato escluso per dosi saltate.",
|
||||
"endDoseChange": "Il periodo termina prima della prima variazione di dose, quindi descrive la dose iniziale.",
|
||||
"confoundersTitle": "Altre variazioni di farmaci in questo periodo, che questa vista non può separare da questo:",
|
||||
"confounderStarted": "{name} iniziato il {date}",
|
||||
"confounderStopped": "{name} terminato il {date}",
|
||||
"confounderDoseChange": "{name}: dose modificata il {date}",
|
||||
"confounderPaused": "{name} sospeso il {date}",
|
||||
"confounderResumed": "{name} ripreso il {date}",
|
||||
"method": "Come si calcola: una retta viene adattata ai valori giornalieri prima dell'inizio e un'altra a quelli successivi. Il salto tra le due all'inizio è la variazione di livello, la differenza delle pendenze la variazione di tendenza. Gli intervalli tengono conto del fatto che i valori di un giorno influenzano il successivo.",
|
||||
"needStart": "Imposta una data di inizio per vedere la variazione all'inizio.",
|
||||
"needBefore": "La variazione all'inizio richiede valori in almeno {days} giorni distribuiti su {span} giorni prima dell'inizio.",
|
||||
"needAfter": "La variazione all'inizio richiede valori in almeno {days} giorni distribuiti su {span} giorni dall'inizio, nei giorni con le dosi assunte.",
|
||||
"fitFailed": "Non è stato possibile elaborare questi valori, quindi non viene mostrata alcuna variazione all'inizio."
|
||||
}
|
||||
},
|
||||
"import": {
|
||||
"resultSuccess": "{imported} assunzioni importate",
|
||||
@@ -12307,7 +12361,10 @@
|
||||
"leafVisits": "Visite mediche",
|
||||
"leafSurgicalHistory": "Anamnesi chirurgica",
|
||||
"leafImmunizations": "Vaccinazioni",
|
||||
"leafEmergency": "Informazioni di emergenza (gruppo sanguigno, contatti, disposizioni)"
|
||||
"leafEmergency": "Informazioni di emergenza (gruppo sanguigno, contatti, disposizioni)",
|
||||
"leafMedicationEffect": "Variazione intorno all'inizio di ogni farmaco",
|
||||
"leafWorkouts": "Allenamenti (riepilogo per sport)",
|
||||
"leafHrvSdnn": "HRV (SDNN)"
|
||||
},
|
||||
"recordSharing": {
|
||||
"banner": {
|
||||
|
||||
+60
-3
@@ -401,7 +401,38 @@
|
||||
"lifeSittingValue": "{hours}시간, 본인 응답",
|
||||
"lifeWork": "일",
|
||||
"lifeNightWork": "야간 근무",
|
||||
"lifeExposures": "노출: {list}"
|
||||
"lifeExposures": "노출: {list}",
|
||||
"workouts": {
|
||||
"title": "운동",
|
||||
"summary": "총 {sessions}회, {hours}시간; 주당 평균 {perWeek}회, {minutesPerWeek}분.",
|
||||
"colSport": "종목",
|
||||
"colSessions": "횟수",
|
||||
"colTime": "시간",
|
||||
"colDistance": "거리",
|
||||
"colEnergy": "에너지",
|
||||
"colHeartRate": "평균 심박수",
|
||||
"moreSports": "{count}개 종목이 합계에는 포함되지만 목록에는 없습니다."
|
||||
},
|
||||
"medicationEffect": {
|
||||
"title": "약 시작 전후의 변화",
|
||||
"colMedication": "약 / 측정값",
|
||||
"colStart": "시작",
|
||||
"colLevel": "수준 변화(95% CI)",
|
||||
"colSlope": "추세 변화(95% CI)",
|
||||
"colDays": "전 / 후 일수",
|
||||
"to": "~",
|
||||
"week": "주",
|
||||
"reasonNoStart": "기록된 시작일 없음",
|
||||
"reasonBefore": "시작 전 측정값이 너무 적음",
|
||||
"reasonAfter": "시작 후 측정값이 너무 적음",
|
||||
"reasonFit": "계산할 수 없음",
|
||||
"excludedAdherence": "시작 후 {count}일 제외(복용률 {floor}% 미만)",
|
||||
"excludedNotTaking": "{count}일 제외(일시 중지 또는 복용 기간 사이)",
|
||||
"notTracked": "복용 미기록으로 놓친 용량에 따른 제외 없음",
|
||||
"endDoseChange": "기간은 첫 용량 변경 전인 {date}에 끝남",
|
||||
"days": "시작 전 {before}일, 시작 후 {after}일, 제외 {excluded}일",
|
||||
"method": "시작 90일 전부터 최대 180일 후까지의 일별 값에 대한 분절 회귀, Newey-West 구간. 직전 14일 복용률이 80% 미만인 시작 후 날짜는 제외했습니다. 시간에 따른 연관성을 보여 줄 뿐, 약의 효과를 입증하지 않습니다."
|
||||
}
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "문제가 생겼어요",
|
||||
@@ -2359,7 +2390,30 @@
|
||||
},
|
||||
"scaleAbsolute": "절대값",
|
||||
"scalePercentSinceStart": "시작 이후 %",
|
||||
"scaleToggleLabel": "차트 눈금"
|
||||
"scaleToggleLabel": "차트 눈금",
|
||||
"segmented": {
|
||||
"title": "시작 시점의 변화",
|
||||
"level": "시작 직후 수준이 {value} {unit} 이동했습니다(95% 범위 {low}~{high}).",
|
||||
"slope": "시작 후 주간 추세가 주당 {value} {unit} 변했습니다(95% 범위 {low}~{high}).",
|
||||
"preSlope": "시작 전 추세는 주당 {value} {unit}였습니다.",
|
||||
"includesZero": "범위에 0이 포함되면 이 측정값은 변화가 없다는 것과도 일치합니다.",
|
||||
"basis": "시작 전 {before}일과 시작 후 {after}일 기준({from}~{to}).",
|
||||
"excludedAdherence": "직전 {lookback}일 동안 복용한 용량이 {floor}% 미만이어서 시작 후 {count}일을 제외했습니다.",
|
||||
"excludedNotTaking": "약을 일시 중지했거나 복용 기간 사이였던 {count}일을 제외했습니다.",
|
||||
"notTracked": "이 약은 복용을 기록하지 않으므로 놓친 용량 때문에 제외한 날이 없습니다.",
|
||||
"endDoseChange": "기간이 첫 용량 변경 전에 끝나므로 시작 용량을 나타냅니다.",
|
||||
"confoundersTitle": "이 기간의 다른 약 변경 사항으로, 이 화면에서는 이 약과 구분할 수 없습니다.",
|
||||
"confounderStarted": "{name} {date} 시작",
|
||||
"confounderStopped": "{name} {date} 중단",
|
||||
"confounderDoseChange": "{name}: {date} 용량 변경",
|
||||
"confounderPaused": "{name} {date} 일시 중지",
|
||||
"confounderResumed": "{name} {date} 재개",
|
||||
"method": "계산 방법: 시작 전 일별 값에 직선 하나를, 시작 후 값에 또 하나를 맞춥니다. 시작 시점의 두 직선 사이 간격이 수준 변화이고, 기울기 차이가 추세 변화입니다. 범위는 하루의 값이 다음 날로 이어지는 점을 반영합니다.",
|
||||
"needStart": "시작 시점의 변화를 보려면 시작일을 설정하세요.",
|
||||
"needBefore": "시작 시점의 변화를 보려면 시작 전 {span}일에 걸쳐 최소 {days}일의 측정값이 필요합니다.",
|
||||
"needAfter": "시작 시점의 변화를 보려면 시작 후 {span}일에 걸쳐, 용량을 복용한 날 기준으로 최소 {days}일의 측정값이 필요합니다.",
|
||||
"fitFailed": "이 측정값으로는 계산할 수 없어 시작 시점의 변화를 표시하지 않습니다."
|
||||
}
|
||||
},
|
||||
"import": {
|
||||
"resultSuccess": "복용 {imported}건을 불러왔어요",
|
||||
@@ -12307,7 +12361,10 @@
|
||||
"leafVisits": "진료",
|
||||
"leafSurgicalHistory": "수술 이력",
|
||||
"leafImmunizations": "예방접종",
|
||||
"leafEmergency": "응급 정보 (혈액형, 연락처, 사전지시)"
|
||||
"leafEmergency": "응급 정보 (혈액형, 연락처, 사전지시)",
|
||||
"leafMedicationEffect": "각 약 시작 전후의 변화",
|
||||
"leafWorkouts": "운동(종목별 요약)",
|
||||
"leafHrvSdnn": "심박변이도 (SDNN)"
|
||||
},
|
||||
"recordSharing": {
|
||||
"banner": {
|
||||
|
||||
+60
-3
@@ -401,7 +401,38 @@
|
||||
"lifeSittingValue": "{hours} uur, zelf opgegeven",
|
||||
"lifeWork": "Werk",
|
||||
"lifeNightWork": "nachtwerk",
|
||||
"lifeExposures": "blootstellingen: {list}"
|
||||
"lifeExposures": "blootstellingen: {list}",
|
||||
"workouts": {
|
||||
"title": "Trainingen",
|
||||
"summary": "{sessions} trainingen, {hours} u in totaal; gemiddeld {perWeek} trainingen en {minutesPerWeek} minuten per week.",
|
||||
"colSport": "Sport",
|
||||
"colSessions": "Trainingen",
|
||||
"colTime": "Duur",
|
||||
"colDistance": "Afstand",
|
||||
"colEnergy": "Energie",
|
||||
"colHeartRate": "Gem. hartslag",
|
||||
"moreSports": "{count} andere sporten tellen mee in de totalen, maar staan niet in de lijst."
|
||||
},
|
||||
"medicationEffect": {
|
||||
"title": "Verandering rond de start van medicijnen",
|
||||
"colMedication": "Medicijn / meetwaarde",
|
||||
"colStart": "Start",
|
||||
"colLevel": "Niveauverandering (95%-BI)",
|
||||
"colSlope": "Trendverandering (95%-BI)",
|
||||
"colDays": "Dagen voor / na",
|
||||
"to": "tot",
|
||||
"week": "week",
|
||||
"reasonNoStart": "Geen startdatum vastgelegd",
|
||||
"reasonBefore": "Te weinig waarden vóór de start",
|
||||
"reasonAfter": "Te weinig waarden sinds de start",
|
||||
"reasonFit": "Niet te berekenen",
|
||||
"excludedAdherence": "{count} dagen na de start weggelaten (minder dan {floor}% van de doses ingenomen)",
|
||||
"excludedNotTaking": "{count} dagen weggelaten (gepauzeerd of tussen kuren)",
|
||||
"notTracked": "inname niet bijgehouden, dus geen dagen weggelaten om gemiste doses",
|
||||
"endDoseChange": "periode eindigt op {date}, vóór de eerste dosiswijziging",
|
||||
"days": "{before} dagen voor, {after} dagen na de start, {excluded} dagen weggelaten",
|
||||
"method": "Gesegmenteerde regressie op de dagwaarden van 90 dagen vóór tot hoogstens 180 dagen na de start, met Newey-West-intervallen. Dagen na de start waarop minder dan 80% van de doses van de 14 dagen ervoor is ingenomen, zijn weggelaten. Beschrijft een samenhang in de tijd, geen bewezen effect van het medicijn."
|
||||
}
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Er is iets misgegaan",
|
||||
@@ -2359,7 +2390,30 @@
|
||||
},
|
||||
"scaleAbsolute": "Absoluut",
|
||||
"scalePercentSinceStart": "% sinds start",
|
||||
"scaleToggleLabel": "Schaal van grafiek"
|
||||
"scaleToggleLabel": "Schaal van grafiek",
|
||||
"segmented": {
|
||||
"title": "Verandering bij de start",
|
||||
"level": "Direct bij de start verschoof het niveau met {value} {unit} (95%-bereik {low} tot {high}).",
|
||||
"slope": "Na de start veranderde de weektrend met {value} {unit} per week (95%-bereik {low} tot {high}).",
|
||||
"preSlope": "Voor de start was de trend {value} {unit} per week.",
|
||||
"includesZero": "Waar een bereik nul omvat, passen deze waarden ook bij geen verandering.",
|
||||
"basis": "Gebaseerd op {before} dagen voor en {after} dagen na de start ({from} tot {to}).",
|
||||
"excludedAdherence": "{count} dagen na de start zijn weggelaten, omdat in de {lookback} dagen ervoor minder dan {floor}% van de doses is ingenomen.",
|
||||
"excludedNotTaking": "{count} dagen zijn weggelaten, omdat het medicijn gepauzeerd was of tussen twee kuren lag.",
|
||||
"notTracked": "De inname van dit medicijn wordt niet bijgehouden, dus er zijn geen dagen weggelaten om gemiste doses.",
|
||||
"endDoseChange": "De periode eindigt vóór de eerste dosiswijziging en beschrijft dus de startdosis.",
|
||||
"confoundersTitle": "Andere medicijnwijzigingen in deze periode, die deze weergave niet van dit medicijn kan scheiden:",
|
||||
"confounderStarted": "{name} gestart op {date}",
|
||||
"confounderStopped": "{name} gestopt op {date}",
|
||||
"confounderDoseChange": "{name}: dosis gewijzigd op {date}",
|
||||
"confounderPaused": "{name} gepauzeerd op {date}",
|
||||
"confounderResumed": "{name} hervat op {date}",
|
||||
"method": "Zo wordt het berekend: door de dagwaarden vóór de start wordt een lijn gelegd en een tweede door die erna. De sprong tussen beide bij de start is de niveauverandering, het verschil in hun helling de trendverandering. De bereiken houden er rekening mee dat waarden van de ene dag doorwerken in de volgende.",
|
||||
"needStart": "Stel een startdatum in om de verandering bij de start te zien.",
|
||||
"needBefore": "Voor de verandering bij de start zijn waarden op minstens {days} dagen nodig, verspreid over {span} dagen vóór de start.",
|
||||
"needAfter": "Voor de verandering bij de start zijn waarden op minstens {days} dagen nodig, verspreid over {span} dagen sinds de start en op dagen waarop de doses zijn ingenomen.",
|
||||
"fitFailed": "Deze waarden konden niet worden verwerkt, dus er wordt geen verandering bij de start getoond."
|
||||
}
|
||||
},
|
||||
"import": {
|
||||
"resultSuccess": "{imported} innames geïmporteerd",
|
||||
@@ -12307,7 +12361,10 @@
|
||||
"leafVisits": "Bezoeken",
|
||||
"leafSurgicalHistory": "Operatiegeschiedenis",
|
||||
"leafImmunizations": "Vaccinaties",
|
||||
"leafEmergency": "Noodinformatie (bloedgroep, contactpersonen, wilsverklaringen)"
|
||||
"leafEmergency": "Noodinformatie (bloedgroep, contactpersonen, wilsverklaringen)",
|
||||
"leafMedicationEffect": "Verandering rond de start van elk medicijn",
|
||||
"leafWorkouts": "Trainingen (overzicht per sport)",
|
||||
"leafHrvSdnn": "HRV (SDNN)"
|
||||
},
|
||||
"recordSharing": {
|
||||
"banner": {
|
||||
|
||||
+60
-3
@@ -401,7 +401,38 @@
|
||||
"lifeSittingValue": "{hours} godz., według deklaracji",
|
||||
"lifeWork": "Praca",
|
||||
"lifeNightWork": "praca w nocy",
|
||||
"lifeExposures": "narażenia: {list}"
|
||||
"lifeExposures": "narażenia: {list}",
|
||||
"workouts": {
|
||||
"title": "Treningi",
|
||||
"summary": "{sessions} treningów, łącznie {hours} h; średnio {perWeek} treningu i {minutesPerWeek} minut tygodniowo.",
|
||||
"colSport": "Dyscyplina",
|
||||
"colSessions": "Treningi",
|
||||
"colTime": "Czas",
|
||||
"colDistance": "Dystans",
|
||||
"colEnergy": "Energia",
|
||||
"colHeartRate": "Średnie tętno",
|
||||
"moreSports": "Kolejne dyscypliny ({count}) są wliczone do sum, ale nie wymienione."
|
||||
},
|
||||
"medicationEffect": {
|
||||
"title": "Zmiana wokół rozpoczęcia leków",
|
||||
"colMedication": "Lek / pomiar",
|
||||
"colStart": "Początek",
|
||||
"colLevel": "Zmiana poziomu (95% CI)",
|
||||
"colSlope": "Zmiana trendu (95% CI)",
|
||||
"colDays": "Dni przed / po",
|
||||
"to": "do",
|
||||
"week": "tydzień",
|
||||
"reasonNoStart": "Brak zapisanej daty rozpoczęcia",
|
||||
"reasonBefore": "Za mało pomiarów przed rozpoczęciem",
|
||||
"reasonAfter": "Za mało pomiarów od rozpoczęcia",
|
||||
"reasonFit": "Nie można obliczyć",
|
||||
"excludedAdherence": "Pominięto {count} dni po rozpoczęciu (przyjęto mniej niż {floor}% dawek)",
|
||||
"excludedNotTaking": "Pominięto {count} dni (lek wstrzymany lub przerwa między kuracjami)",
|
||||
"notTracked": "przyjmowanie nie jest rejestrowane, więc nie pominięto dni z powodu opuszczonych dawek",
|
||||
"endDoseChange": "okres kończy się {date}, przed pierwszą zmianą dawki",
|
||||
"days": "{before} dni przed, {after} dni po rozpoczęciu, pominięto {excluded} dni",
|
||||
"method": "Regresja segmentowa na wartościach dziennych od 90 dni przed do najwyżej 180 dni po rozpoczęciu, przedziały Neweya-Westa. Pominięto dni po rozpoczęciu, w których w ciągu poprzednich 14 dni przyjęto mniej niż 80% dawek. Opisuje związek w czasie, a nie udowodnione działanie leku."
|
||||
}
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Coś poszło nie tak",
|
||||
@@ -2359,7 +2390,30 @@
|
||||
},
|
||||
"scaleAbsolute": "Bezwzględne",
|
||||
"scalePercentSinceStart": "% od początku",
|
||||
"scaleToggleLabel": "Skala wykresu"
|
||||
"scaleToggleLabel": "Skala wykresu",
|
||||
"segmented": {
|
||||
"title": "Zmiana na początku",
|
||||
"level": "Zaraz na początku poziom przesunął się o {value} {unit} (zakres 95%: od {low} do {high}).",
|
||||
"slope": "Po rozpoczęciu trend tygodniowy zmienił się o {value} {unit} na tydzień (zakres 95%: od {low} do {high}).",
|
||||
"preSlope": "Przed rozpoczęciem trend wynosił {value} {unit} na tydzień.",
|
||||
"includesZero": "Gdy zakres obejmuje zero, te pomiary są zgodne także z brakiem zmiany.",
|
||||
"basis": "Na podstawie {before} dni przed i {after} dni po rozpoczęciu (od {from} do {to}).",
|
||||
"excludedAdherence": "Pominięto {count} dni po rozpoczęciu, bo w ciągu {lookback} dni przed nimi przyjęto mniej niż {floor}% dawek.",
|
||||
"excludedNotTaking": "Pominięto {count} dni, bo lek był wstrzymany albo przypadały między kuracjami.",
|
||||
"notTracked": "Przyjmowanie tego leku nie jest rejestrowane, więc nie pominięto żadnych dni z powodu opuszczonych dawek.",
|
||||
"endDoseChange": "Okres kończy się przed pierwszą zmianą dawki, więc opisuje dawkę początkową.",
|
||||
"confoundersTitle": "Inne zmiany leków w tym okresie, których ten widok nie potrafi oddzielić od tego leku:",
|
||||
"confounderStarted": "{name} rozpoczęty {date}",
|
||||
"confounderStopped": "{name} zakończony {date}",
|
||||
"confounderDoseChange": "{name}: zmiana dawki {date}",
|
||||
"confounderPaused": "{name} wstrzymany {date}",
|
||||
"confounderResumed": "{name} wznowiony {date}",
|
||||
"method": "Jak to jest liczone: przez dzienne wartości sprzed rozpoczęcia prowadzi się jedną prostą, a przez te późniejsze drugą. Skok między nimi w dniu rozpoczęcia to zmiana poziomu, różnica ich nachyleń to zmiana trendu. Zakresy uwzględniają, że wartości z jednego dnia przenoszą się na następny.",
|
||||
"needStart": "Ustaw datę rozpoczęcia, aby zobaczyć zmianę na początku.",
|
||||
"needBefore": "Do zmiany na początku potrzeba pomiarów z co najmniej {days} dni rozłożonych na {span} dni przed rozpoczęciem.",
|
||||
"needAfter": "Do zmiany na początku potrzeba pomiarów z co najmniej {days} dni rozłożonych na {span} dni od rozpoczęcia, w dni z przyjętymi dawkami.",
|
||||
"fitFailed": "Nie udało się dopasować tych pomiarów, więc zmiana na początku nie jest pokazana."
|
||||
}
|
||||
},
|
||||
"import": {
|
||||
"resultSuccess": "Zaimportowano przyjęcia: {imported}",
|
||||
@@ -12307,7 +12361,10 @@
|
||||
"leafVisits": "Wizyty lekarskie",
|
||||
"leafSurgicalHistory": "Historia operacji",
|
||||
"leafImmunizations": "Szczepienia",
|
||||
"leafEmergency": "Informacje na wypadek nagły (grupa krwi, kontakty, oświadczenia)"
|
||||
"leafEmergency": "Informacje na wypadek nagły (grupa krwi, kontakty, oświadczenia)",
|
||||
"leafMedicationEffect": "Zmiana wokół rozpoczęcia każdego leku",
|
||||
"leafWorkouts": "Treningi (podsumowanie według dyscypliny)",
|
||||
"leafHrvSdnn": "HRV (SDNN)"
|
||||
},
|
||||
"recordSharing": {
|
||||
"banner": {
|
||||
|
||||
@@ -74,9 +74,9 @@ describe("report scope gating — structural guard", () => {
|
||||
it("reads a plausible catalogue", () => {
|
||||
// Sanity floor: a silently degraded catalogue would pass everything below
|
||||
// vacuously.
|
||||
expect(ALL_LEAF_IDS.length).toBe(104);
|
||||
expect(ALL_LEAF_IDS.length).toBe(106);
|
||||
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(86);
|
||||
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(18);
|
||||
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(20);
|
||||
});
|
||||
|
||||
it("places every leaf in exactly one group", () => {
|
||||
|
||||
@@ -98,6 +98,8 @@ const LEGIT_IDENTICAL = new Set<string>([
|
||||
"insights.derived.composite.READINESS.component.hrv",
|
||||
"labs.catalog.hs-crp",
|
||||
"measurements.typeHrvRmssd",
|
||||
// The report's SDNN row, named the way the RMSSD row above is.
|
||||
"reportSelection.leafHrvSdnn",
|
||||
"illness.vital.HRV_RMSSD",
|
||||
"settings.sections.sources.metrics.hrvRmssd",
|
||||
"measurements.typeVo2Max",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* is `STRUCTURED_LEAF_GROUP`, the other is HTML rendered by the real
|
||||
* `<ClinicianView>` over a real fixture. It proves four things per leaf.
|
||||
*
|
||||
* 1. `DISPOSITIONS` is `Record<StructuredLeafId, …>`, so a nineteenth
|
||||
* 1. `DISPOSITIONS` is `Record<StructuredLeafId, …>`, so a new
|
||||
* structured leaf cannot be added to the catalogue without deciding here
|
||||
* whether it renders — and saying so — or `pnpm typecheck` fails. That is
|
||||
* the lock, and it is the compiler's, not this suite's.
|
||||
@@ -291,6 +291,69 @@ const DISPOSITIONS: Record<StructuredLeafId, Disposition> = {
|
||||
},
|
||||
marker: "Semaglutide",
|
||||
},
|
||||
MEDICATION_EFFECT: {
|
||||
kind: "rendered",
|
||||
data: {
|
||||
medicationEffects: [
|
||||
{
|
||||
medicationName: "Effect-marker drug",
|
||||
startsOn: "2026-01-02",
|
||||
targets: [
|
||||
{
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
effect: {
|
||||
present: false,
|
||||
reason: "insufficient_after",
|
||||
level: 0.95,
|
||||
window: null,
|
||||
days: {
|
||||
before: 40,
|
||||
after: 3,
|
||||
excludedLowAdherence: 0,
|
||||
excludedNotTaking: 0,
|
||||
},
|
||||
adherence: {
|
||||
status: "tracked",
|
||||
floorPct: 80,
|
||||
lookbackDays: 14,
|
||||
},
|
||||
levelChange: null,
|
||||
slopeChangePerWeek: null,
|
||||
preSlopePerWeek: null,
|
||||
fit: null,
|
||||
confounders: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
marker: "Effect-marker drug",
|
||||
},
|
||||
WORKOUTS: {
|
||||
kind: "rendered",
|
||||
data: {
|
||||
workouts: {
|
||||
sessions: 3,
|
||||
totalDurationSec: 7200,
|
||||
sessionsPerWeek: 0.7,
|
||||
minutesPerWeek: 28,
|
||||
sportCount: 1,
|
||||
bySport: [
|
||||
{
|
||||
sportType: "rowing",
|
||||
sessions: 3,
|
||||
durationSec: 7200,
|
||||
distanceM: 15000,
|
||||
distanceSessions: 3,
|
||||
energyKcal: null,
|
||||
avgHeartRate: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
marker: "15.0 km",
|
||||
},
|
||||
ALLERGIES: {
|
||||
kind: "rendered",
|
||||
data: {
|
||||
@@ -444,7 +507,7 @@ describe("clinician view — every catalogue leaf has a renderer or a reason", (
|
||||
it("reads a plausible catalogue", () => {
|
||||
// Sanity floor: a degraded import would satisfy every loop below
|
||||
// vacuously, which is the failure mode a pair guard dies of.
|
||||
expect(STRUCTURED_LEAF_IDS).toHaveLength(18);
|
||||
expect(STRUCTURED_LEAF_IDS).toHaveLength(20);
|
||||
expect(Object.keys(DISPOSITIONS).sort()).toEqual(
|
||||
[...STRUCTURED_LEAF_IDS].sort(),
|
||||
);
|
||||
|
||||
@@ -30,6 +30,8 @@ const ALLOWED: Record<string, string> = {
|
||||
"The correlation engine's centred sums and the Welch t-test variances.",
|
||||
"lib/insights/mood-tag-influence.ts": "Welch t-test variances.",
|
||||
"lib/insights/mood-pattern-cards.ts": "A pooled variance for a t statistic.",
|
||||
"lib/stats/segmented-regression.ts":
|
||||
"The total sum of squares behind R² and the HAC standard errors of a segmented fit; neither is reported as a spread.",
|
||||
"lib/insights/derived/trajectory.ts":
|
||||
"The residual standard error of a regression fit (÷ n − 2).",
|
||||
"lib/insights/derived/sleep-score.ts":
|
||||
|
||||
@@ -361,6 +361,16 @@ const UNIT_LITERAL_ALLOWLIST: Record<
|
||||
reason:
|
||||
"SI by policy: the clinician artefacts keep mass in kg (glucose follows the owner)",
|
||||
},
|
||||
"lib/doctor-report-pdf/activity-effect-sections.ts": {
|
||||
lines: 2,
|
||||
reason:
|
||||
"SI by policy: the report's workout distance is in km, like every clinician artefact",
|
||||
},
|
||||
"components/clinician/activity-effect-sections.tsx": {
|
||||
lines: 1,
|
||||
reason:
|
||||
"SI by policy: the clinician page's workout distance is in km, like the PDF",
|
||||
},
|
||||
"components/insights/workout-detail/splits.tsx": {
|
||||
lines: 1,
|
||||
reason: "kilometre splits are cut server-side; their pace is per km",
|
||||
|
||||
@@ -43,6 +43,9 @@ const IDENTITY_OWNER: Record<string, string> = {
|
||||
"lib/jobs/workout-insight-generate.ts",
|
||||
"lib/workouts/list-read.ts": "lib/workouts/list-read.ts",
|
||||
"lib/workouts/sport-context.ts": "lib/workouts/sport-context.ts",
|
||||
// The doctor report's per-sport block (v1.44): sums sessions, so a session
|
||||
// two sources reported must count once, as it does on the list.
|
||||
"lib/doctor-report/workouts.ts": "lib/doctor-report/workouts.ts",
|
||||
// The only indirection in the set. The Coach block is handed rows it does not
|
||||
// read itself; the snapshot builder fetches them in parallel with every other
|
||||
// block's data. Recording the owner here rather than assuming the call site
|
||||
|
||||
@@ -38,6 +38,8 @@ vi.mock("@/lib/db", () => ({
|
||||
healthProfileFactRevision: { findMany: vi.fn() },
|
||||
// v1.43 — the anamnesis leaf also carries the newest AUDIT-C entry.
|
||||
mentalHealthAssessment: { findMany: vi.fn(async () => []) },
|
||||
// v1.44 — the per-sport workout block.
|
||||
workout: { findMany: vi.fn(async () => []) },
|
||||
user: { findUnique: vi.fn(), update: vi.fn() },
|
||||
// The whole-record gate reads the session's own stamps: a sign-in a
|
||||
// moment ago is the proof, on an account without a second factor.
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
* GET /api/fhir/Observation/{id} — FHIR R4 `read` of one Observation from the
|
||||
* caller's own record.
|
||||
*
|
||||
* A series id (`m-…` / `w-…`, v1.44) reads that one row inside the same scope
|
||||
* the series search applies; every other id is a snapshot id.
|
||||
*
|
||||
* The id space is the export's own (`obs-3`), stable for a given record and
|
||||
* reporting window. A `read` is a filter over the same emitter the search
|
||||
* route pages, so a resource can never differ between the two faces; an id the
|
||||
@@ -16,10 +19,13 @@ import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { observationsFromReportData } from "@/lib/fhir/resources";
|
||||
import {
|
||||
FHIR_READ_SCOPE,
|
||||
fhirJsonResponse,
|
||||
loadFhirContext,
|
||||
loadFhirSeriesScope,
|
||||
operationOutcome,
|
||||
readResponse,
|
||||
} from "@/lib/fhir/rest";
|
||||
import { readSeriesObservation } from "@/lib/fhir/series";
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, ctx: { params: Promise<{ id: string }> }) => {
|
||||
@@ -41,6 +47,19 @@ export const GET = apiHandler(
|
||||
return operationOutcome(429, "throttled", "Rate limit exceeded");
|
||||
}
|
||||
const { id } = await ctx.params;
|
||||
if (id.startsWith("m-") || id.startsWith("w-")) {
|
||||
const { scope, glucoseUnit } = await loadFhirSeriesScope(user.id, null);
|
||||
annotate({ meta: { resourceId: id, mode: "series" } });
|
||||
const observation = await readSeriesObservation({
|
||||
userId: user.id,
|
||||
id,
|
||||
scope,
|
||||
glucoseUnit,
|
||||
});
|
||||
return observation
|
||||
? fhirJsonResponse(observation)
|
||||
: operationOutcome(404, "not-found", "No such resource");
|
||||
}
|
||||
const context = await loadFhirContext(user.id);
|
||||
annotate({ meta: { resourceId: id } });
|
||||
return readResponse(observationsFromReportData(context.data), id);
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
/**
|
||||
* GET /api/fhir/Observation — FHIR R4 `searchset` of the caller's own Observations
|
||||
* (vitals / activity / lab / survey): one latest reading per type plus the BP
|
||||
* panel, BMI, glucose, adherence, mood and the wellness composites.
|
||||
* GET /api/fhir/Observation — FHIR R4 `searchset` of the caller's own Observations.
|
||||
*
|
||||
* Read-only. `userId` is narrowed from `requireAuth`; the shared emitters in
|
||||
* `@/lib/fhir/resources` are the single source of the coding, so this face and
|
||||
* the document export can never describe the same record differently. Offset
|
||||
* paging via `_count` (clamped ≤200) / `_offset`.
|
||||
* Two answers, chosen by the request:
|
||||
*
|
||||
* - no `date` parameter: the snapshot it has always been — one latest
|
||||
* reading per type plus the BP panel, BMI, glucose, adherence, mood and the
|
||||
* wellness composites (`obs-N` ids), offset-paged in memory.
|
||||
* - with `date` (v1.44): the time series — every reading in the range, plus
|
||||
* workouts, newest first, paged in the database (`m-…` / `w-…` ids). See
|
||||
* `@/lib/fhir/series`.
|
||||
*
|
||||
* `code` narrows either answer. Read-only. `userId` is narrowed from
|
||||
* `requireAuth`; both answers read the owner's saved report selection, so the
|
||||
* series cannot reach a type the snapshot withholds. Paging via `_count`
|
||||
* (clamped ≤200) / `_offset`.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
@@ -18,10 +25,18 @@ import { observationsFromReportData } from "@/lib/fhir/resources";
|
||||
import {
|
||||
FHIR_READ_SCOPE,
|
||||
loadFhirContext,
|
||||
loadFhirSeriesScope,
|
||||
operationOutcome,
|
||||
parsePaging,
|
||||
searchsetResponse,
|
||||
} from "@/lib/fhir/rest";
|
||||
import {
|
||||
emitSeriesPage,
|
||||
matchesCodeTokens,
|
||||
parseCodeParams,
|
||||
parseDateParams,
|
||||
querySeriesPage,
|
||||
} from "@/lib/fhir/series";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth(FHIR_READ_SCOPE);
|
||||
@@ -41,11 +56,45 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
if (!rl.allowed) {
|
||||
return operationOutcome(429, "throttled", "Rate limit exceeded");
|
||||
}
|
||||
const { count, offset } = parsePaging(request.nextUrl.searchParams);
|
||||
const context = await loadFhirContext(user.id);
|
||||
const params = request.nextUrl.searchParams;
|
||||
const { count, offset } = parsePaging(params);
|
||||
const codeValues = params.getAll("code");
|
||||
const range = parseDateParams(params.getAll("date"));
|
||||
if (range && "error" in range) {
|
||||
return operationOutcome(400, "invalid", range.error);
|
||||
}
|
||||
|
||||
const all = observationsFromReportData(context.data);
|
||||
if (range) {
|
||||
const { scope, glucoseUnit } = await loadFhirSeriesScope(
|
||||
user.id,
|
||||
parseCodeParams(codeValues),
|
||||
);
|
||||
const { total, refs } = await querySeriesPage({
|
||||
userId: user.id,
|
||||
scope,
|
||||
range,
|
||||
count,
|
||||
offset,
|
||||
});
|
||||
const page = await emitSeriesPage({
|
||||
userId: user.id,
|
||||
refs,
|
||||
scope,
|
||||
glucoseUnit,
|
||||
});
|
||||
annotate({
|
||||
meta: { mode: "series", total, count, offset, types: scope.types.length },
|
||||
});
|
||||
return searchsetResponse(request.nextUrl, page, total, count, offset);
|
||||
}
|
||||
|
||||
const context = await loadFhirContext(user.id);
|
||||
const snapshot = observationsFromReportData(context.data);
|
||||
const all =
|
||||
codeValues.length > 0
|
||||
? snapshot.filter((o) => matchesCodeTokens(o, codeValues))
|
||||
: snapshot;
|
||||
const page = all.slice(offset, offset + count);
|
||||
annotate({ meta: { total: all.length, count, offset } });
|
||||
annotate({ meta: { mode: "snapshot", total: all.length, count, offset } });
|
||||
return searchsetResponse(request.nextUrl, page, all.length, count, offset);
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* v1.44 — the Observation search's two answers. Without `date` the route
|
||||
* serves the snapshot exactly as before (the whole-record loader runs, the
|
||||
* series loader does not); with `date` it serves the paged time series and
|
||||
* never assembles the whole record. A malformed `date` is a 400, `code`
|
||||
* narrows the snapshot, and series ids read back through `/{id}`.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn(),
|
||||
isModuleEnabled: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/fhir/rest", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/fhir/rest")>();
|
||||
return {
|
||||
...actual,
|
||||
loadFhirContext: vi.fn(),
|
||||
loadFhirSeriesScope: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/fhir/resources", () => ({
|
||||
observationsFromReportData: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/fhir/series", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/fhir/series")>();
|
||||
return {
|
||||
...actual,
|
||||
querySeriesPage: vi.fn(),
|
||||
emitSeriesPage: vi.fn(),
|
||||
readSeriesObservation: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET as observationGet } from "../Observation/route";
|
||||
import { GET as observationRead } from "../Observation/[id]/route";
|
||||
import {
|
||||
emitSeriesPage,
|
||||
querySeriesPage,
|
||||
readSeriesObservation,
|
||||
} from "@/lib/fhir/series";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { loadFhirContext, loadFhirSeriesScope } from "@/lib/fhir/rest";
|
||||
import { requireModuleEnabled, isModuleEnabled } from "@/lib/modules/gate";
|
||||
import { observationsFromReportData } from "@/lib/fhir/resources";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
function obs(id: number) {
|
||||
return {
|
||||
resourceType: "Observation" as const,
|
||||
id: `obs-${id}`,
|
||||
status: "final" as const,
|
||||
code: {
|
||||
coding: [
|
||||
{ system: "http://loinc.org", code: id === 1 ? "29463-7" : "x" },
|
||||
],
|
||||
},
|
||||
subject: { reference: "Patient/patient-1" },
|
||||
};
|
||||
}
|
||||
|
||||
function req(query = ""): NextRequest {
|
||||
return new NextRequest(`http://localhost/api/fhir/Observation${query}`);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
count: 1,
|
||||
resetAt: Date.now(),
|
||||
} as never);
|
||||
// The doctorReport module is ON for the contract assertions below; the
|
||||
// OFF behaviour is pinned in `module-gate.test.ts`.
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
|
||||
vi.mocked(isModuleEnabled).mockResolvedValue(true);
|
||||
vi.mocked(loadFhirContext).mockResolvedValue({
|
||||
data: {} as never,
|
||||
identity: { insuranceNumber: null },
|
||||
germanAtc: false,
|
||||
records: {},
|
||||
});
|
||||
vi.mocked(loadFhirSeriesScope).mockResolvedValue({
|
||||
scope: {
|
||||
types: ["WEIGHT"],
|
||||
pairBloodPressure: false,
|
||||
glucoseContexts: null,
|
||||
workouts: true,
|
||||
},
|
||||
glucoseUnit: "mg/dL",
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/fhir/Observation — snapshot stays the default", () => {
|
||||
it("serves the snapshot without a date and never touches the series", async () => {
|
||||
vi.mocked(observationsFromReportData).mockReturnValue([
|
||||
obs(1),
|
||||
obs(2),
|
||||
] as never);
|
||||
const res = await observationGet(req());
|
||||
expect(res.status).toBe(200);
|
||||
const bundle = (await res.json()) as { total: number };
|
||||
expect(bundle.total).toBe(2);
|
||||
expect(loadFhirContext).toHaveBeenCalledTimes(1);
|
||||
expect(loadFhirSeriesScope).not.toHaveBeenCalled();
|
||||
expect(querySeriesPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("narrows the snapshot by code", async () => {
|
||||
vi.mocked(observationsFromReportData).mockReturnValue([
|
||||
obs(1),
|
||||
obs(2),
|
||||
] as never);
|
||||
const res = await observationGet(req("?code=http://loinc.org|29463-7"));
|
||||
const bundle = (await res.json()) as {
|
||||
total: number;
|
||||
entry: Array<{ resource: { id: string } }>;
|
||||
};
|
||||
expect(bundle.total).toBe(1);
|
||||
expect(bundle.entry[0].resource.id).toBe("obs-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/fhir/Observation?date= — time series", () => {
|
||||
it("pages in the database and never assembles the whole record", async () => {
|
||||
vi.mocked(querySeriesPage).mockResolvedValue({
|
||||
total: 340,
|
||||
refs: [{ kind: "m", id: "a" }],
|
||||
} as never);
|
||||
vi.mocked(emitSeriesPage).mockResolvedValue([
|
||||
{ ...obs(9), id: "m-a" },
|
||||
] as never);
|
||||
const res = await observationGet(
|
||||
req("?date=ge2026-01-01&date=lt2026-02-01&_count=50&_offset=100"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const bundle = (await res.json()) as {
|
||||
total: number;
|
||||
link: Array<{ relation: string; url: string }>;
|
||||
entry: Array<{ fullUrl: string }>;
|
||||
};
|
||||
expect(loadFhirContext).not.toHaveBeenCalled();
|
||||
expect(querySeriesPage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
count: 50,
|
||||
offset: 100,
|
||||
range: {
|
||||
from: new Date("2026-01-01T00:00:00.000Z"),
|
||||
to: new Date("2026-02-01T00:00:00.000Z"),
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(bundle.total).toBe(340);
|
||||
expect(bundle.entry[0].fullUrl).toContain("/api/fhir/Observation/m-a");
|
||||
const next = bundle.link.find((l) => l.relation === "next");
|
||||
expect(next?.url).toContain("_offset=150");
|
||||
expect(next?.url).toContain("date=ge2026-01-01");
|
||||
});
|
||||
|
||||
it("hands the code filter to the scope", async () => {
|
||||
vi.mocked(querySeriesPage).mockResolvedValue({ total: 0, refs: [] });
|
||||
vi.mocked(emitSeriesPage).mockResolvedValue([]);
|
||||
await observationGet(req("?date=2026&code=80404-7"));
|
||||
const codes = vi.mocked(loadFhirSeriesScope).mock.calls[0][1];
|
||||
expect([...(codes?.types ?? [])]).toEqual(["HEART_RATE_VARIABILITY"]);
|
||||
});
|
||||
|
||||
it("answers a malformed date with a 400 OperationOutcome", async () => {
|
||||
const res = await observationGet(req("?date=last-week"));
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as {
|
||||
resourceType: string;
|
||||
issue: Array<{ code: string }>;
|
||||
};
|
||||
expect(body.resourceType).toBe("OperationOutcome");
|
||||
expect(body.issue[0].code).toBe("invalid");
|
||||
expect(querySeriesPage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/fhir/Observation/{id} — series ids", () => {
|
||||
const ctx = (id: string) => ({ params: Promise.resolve({ id }) });
|
||||
|
||||
it("reads a series id through the series scope", async () => {
|
||||
vi.mocked(readSeriesObservation).mockResolvedValue({
|
||||
...obs(1),
|
||||
id: "w-x",
|
||||
} as never);
|
||||
const res = await observationRead(
|
||||
new NextRequest("http://localhost/api/fhir/Observation/w-x"),
|
||||
ctx("w-x"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(((await res.json()) as { id: string }).id).toBe("w-x");
|
||||
expect(loadFhirContext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("answers an unknown series id with not-found", async () => {
|
||||
vi.mocked(readSeriesObservation).mockResolvedValue(null);
|
||||
const res = await observationRead(
|
||||
new NextRequest("http://localhost/api/fhir/Observation/m-nope"),
|
||||
ctx("m-nope"),
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,8 @@
|
||||
*
|
||||
* Declares the read-only REST face: the resource types served, the `read` +
|
||||
* `search-type` interactions on each, the search parameters honoured
|
||||
* (`_count`, `_offset`), the `Patient-everything` operation, and the
|
||||
* (`_count`, `_offset`; `date` and `code` on Observation), the
|
||||
* `Patient-everything` operation, and the
|
||||
* `application/fhir+json` format. Static — no per-user data — but still gated
|
||||
* behind the `fhir:read` scope so the whole `/api/fhir` tree answers
|
||||
* uniformly. Read-only: no write interactions are advertised.
|
||||
@@ -22,6 +23,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
FHIR_READ_SCOPE,
|
||||
FHIR_REST_RESOURCE_TYPES,
|
||||
FHIR_OBSERVATION_SEARCH_PARAMS,
|
||||
FHIR_SEARCH_PARAMS,
|
||||
fhirJsonResponse,
|
||||
} from "@/lib/fhir/rest";
|
||||
@@ -65,10 +67,19 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
resource: FHIR_REST_RESOURCE_TYPES.map((type) => ({
|
||||
type,
|
||||
interaction: [{ code: "read" }, { code: "search-type" }],
|
||||
searchParam: FHIR_SEARCH_PARAMS.map((name) => ({
|
||||
name,
|
||||
type: "number",
|
||||
})),
|
||||
searchParam: [
|
||||
...FHIR_SEARCH_PARAMS.map((name) => ({
|
||||
name,
|
||||
type: "number",
|
||||
})),
|
||||
// v1.44: the Observation search's time-series range and code filter.
|
||||
...(type === "Observation"
|
||||
? FHIR_OBSERVATION_SEARCH_PARAMS.map((p) => ({
|
||||
name: p.name,
|
||||
type: p.type,
|
||||
}))
|
||||
: []),
|
||||
],
|
||||
...(type === "Patient"
|
||||
? {
|
||||
operation: [
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* The two leaves the v1.44 report added, on the clinician page: the per-sport
|
||||
* workout summary and the medication effect around each start. Content and
|
||||
* order follow the PDF blocks in `doctor-report-pdf/activity-effect-sections`,
|
||||
* and both read the same aggregated payload.
|
||||
*
|
||||
* A pure server component: no client hooks, no markdown — every value renders
|
||||
* as escaped React text.
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
import { reportStatUnit } from "@/lib/doctor-report/stat-display";
|
||||
import { resolveGlucoseUnit } from "@/lib/glucose";
|
||||
import { formatEstimate } from "@/lib/medications/efficacy/segmented-format";
|
||||
import { reportMeasurementLabelKey } from "@/lib/report-selection/catalogue";
|
||||
import { reportSportLabel } from "@/lib/doctor-report/sport-label";
|
||||
import {
|
||||
LeafSection,
|
||||
StatRow,
|
||||
type LeafScope,
|
||||
type Translate,
|
||||
} from "./report-sections";
|
||||
|
||||
const REASON_KEYS = {
|
||||
no_start: "doctorReport.medicationEffect.reasonNoStart",
|
||||
insufficient_before: "doctorReport.medicationEffect.reasonBefore",
|
||||
insufficient_after: "doctorReport.medicationEffect.reasonAfter",
|
||||
fit_failed: "doctorReport.medicationEffect.reasonFit",
|
||||
} as const;
|
||||
|
||||
const CONFOUNDER_KEYS = {
|
||||
started: "medications.efficacy.segmented.confounderStarted",
|
||||
stopped: "medications.efficacy.segmented.confounderStopped",
|
||||
dose_change: "medications.efficacy.segmented.confounderDoseChange",
|
||||
paused: "medications.efficacy.segmented.confounderPaused",
|
||||
resumed: "medications.efficacy.segmented.confounderResumed",
|
||||
} as const;
|
||||
|
||||
export function WorkoutsSection({
|
||||
t,
|
||||
report,
|
||||
scope,
|
||||
fmtDigits,
|
||||
}: {
|
||||
t: Translate;
|
||||
report: DoctorReportData;
|
||||
scope: LeafScope;
|
||||
fmtDigits: (n: number, digits: number) => string;
|
||||
}) {
|
||||
const w = report.workouts ?? null;
|
||||
return (
|
||||
<LeafSection
|
||||
t={t}
|
||||
scope={scope}
|
||||
leaves={["WORKOUTS"]}
|
||||
title={t("doctorReport.workouts.title")}
|
||||
empty={w === null || w.sessions === 0}
|
||||
>
|
||||
{w ? (
|
||||
<>
|
||||
<p className="text-muted-foreground mb-2 text-xs">
|
||||
{t("doctorReport.workouts.summary", {
|
||||
sessions: w.sessions,
|
||||
hours: fmtDigits(w.totalDurationSec / 3600, 1),
|
||||
perWeek: fmtDigits(w.sessionsPerWeek, 1),
|
||||
minutesPerWeek: fmtDigits(w.minutesPerWeek, 0),
|
||||
})}
|
||||
</p>
|
||||
{w.bySport.map((s) => (
|
||||
<StatRow
|
||||
key={s.sportType}
|
||||
label={`${reportSportLabel(t, s.sportType)} (${s.sessions})`}
|
||||
value={[
|
||||
`${fmtDigits(s.durationSec / 3600, 1)} h`,
|
||||
s.distanceM !== null
|
||||
? `${fmtDigits(s.distanceM / 1000, 1)} km`
|
||||
: null,
|
||||
s.energyKcal !== null
|
||||
? `${fmtDigits(s.energyKcal, 0)} kcal`
|
||||
: null,
|
||||
s.avgHeartRate !== null
|
||||
? `${fmtDigits(s.avgHeartRate, 0)} bpm`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
/>
|
||||
))}
|
||||
{w.sportCount > w.bySport.length ? (
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
{t("doctorReport.workouts.moreSports", {
|
||||
count: w.sportCount - w.bySport.length,
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</LeafSection>
|
||||
);
|
||||
}
|
||||
|
||||
export function MedicationEffectSection({
|
||||
t,
|
||||
report,
|
||||
scope,
|
||||
fmtDigits,
|
||||
fmtDay,
|
||||
}: {
|
||||
t: Translate;
|
||||
report: DoctorReportData;
|
||||
scope: LeafScope;
|
||||
fmtDigits: (n: number, digits: number) => string;
|
||||
/** A stated calendar day (YYYY-MM-DD) in the owner's date order. */
|
||||
fmtDay: (day: string) => string;
|
||||
}) {
|
||||
const effects = report.medicationEffects ?? [];
|
||||
const glucoseUnit = resolveGlucoseUnit(report.glucoseUnit ?? null);
|
||||
return (
|
||||
<LeafSection
|
||||
t={t}
|
||||
scope={scope}
|
||||
leaves={["MEDICATION_EFFECT"]}
|
||||
title={t("doctorReport.medicationEffect.title")}
|
||||
empty={effects.length === 0}
|
||||
>
|
||||
{effects.flatMap((med) =>
|
||||
med.targets.map((target) => {
|
||||
const e = target.effect;
|
||||
const label = `${med.medicationName}: ${t(
|
||||
reportMeasurementLabelKey(target.type as MeasurementType),
|
||||
)}`;
|
||||
const unit = reportStatUnit(target.type, glucoseUnit, t);
|
||||
if (!e.present || !e.levelChange || !e.slopeChangePerWeek) {
|
||||
return (
|
||||
<StatRow
|
||||
key={`${med.medicationName}:${target.type}`}
|
||||
label={label}
|
||||
value={t(REASON_KEYS[e.reason ?? "fit_failed"])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const level = formatEstimate(e.levelChange, fmtDigits);
|
||||
const slope = formatEstimate(e.slopeChangePerWeek, fmtDigits);
|
||||
return (
|
||||
<div
|
||||
key={`${med.medicationName}:${target.type}`}
|
||||
className="border-border/40 border-b py-2 last:border-0"
|
||||
>
|
||||
<p className="text-foreground text-sm font-medium">{label}</p>
|
||||
<p className="text-foreground text-sm">
|
||||
{t("medications.efficacy.segmented.level", {
|
||||
...level,
|
||||
unit,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-foreground text-sm">
|
||||
{t("medications.efficacy.segmented.slope", {
|
||||
...slope,
|
||||
unit,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("doctorReport.medicationEffect.days", {
|
||||
before: e.days.before,
|
||||
after: e.days.after,
|
||||
excluded:
|
||||
e.days.excludedLowAdherence + e.days.excludedNotTaking,
|
||||
})}
|
||||
</p>
|
||||
{e.confounders.map((c) => (
|
||||
<p
|
||||
key={`${c.medicationName}:${c.event}:${c.date}`}
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t(CONFOUNDER_KEYS[c.event], {
|
||||
name: c.medicationName,
|
||||
date: fmtDay(c.date),
|
||||
})}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
{effects.length > 0 ? (
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
{t("doctorReport.medicationEffect.method")}
|
||||
</p>
|
||||
) : null}
|
||||
</LeafSection>
|
||||
);
|
||||
}
|
||||
@@ -52,6 +52,10 @@ import {
|
||||
VisitsSection,
|
||||
} from "./history-sections";
|
||||
import { DoseLogSection, Glp1Section } from "./therapy-sections";
|
||||
import {
|
||||
MedicationEffectSection,
|
||||
WorkoutsSection,
|
||||
} from "./activity-effect-sections";
|
||||
import {
|
||||
CycleSection,
|
||||
FamilyHistorySection,
|
||||
@@ -219,6 +223,13 @@ export function ClinicianView({
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
<WorkoutsSection
|
||||
t={t}
|
||||
report={report}
|
||||
scope={scope}
|
||||
fmtDigits={fmt.number}
|
||||
/>
|
||||
|
||||
{/* ── glucose and labs ─────────────────────────────────── */}
|
||||
<GlucoseSection
|
||||
t={t}
|
||||
@@ -241,6 +252,13 @@ export function ClinicianView({
|
||||
scope={scope}
|
||||
fmtDate={fmtDate}
|
||||
/>
|
||||
<MedicationEffectSection
|
||||
t={t}
|
||||
report={report}
|
||||
scope={scope}
|
||||
fmtDigits={fmt.number}
|
||||
fmtDay={(day) => fmt.dateShortSmartCalendar(day)}
|
||||
/>
|
||||
<Glp1Section
|
||||
t={t}
|
||||
report={report}
|
||||
|
||||
@@ -26,11 +26,11 @@ import {
|
||||
} from "@/lib/doctor-report/stat-display";
|
||||
import type { FractionDigitsFormat } from "@/lib/format/recorded-number";
|
||||
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys";
|
||||
import {
|
||||
isReportLeafId,
|
||||
isStructuredLeafId,
|
||||
REPORT_GROUPS,
|
||||
reportMeasurementLabelKey,
|
||||
type ReportLeafId,
|
||||
} from "@/lib/report-selection/catalogue";
|
||||
import type { ReportSelection } from "@/lib/report-selection/selection";
|
||||
@@ -248,7 +248,7 @@ export function MeasurementGroups({
|
||||
return (
|
||||
<StatRow
|
||||
key={type}
|
||||
label={t(MEASUREMENT_TYPE_LABEL_KEYS[type])}
|
||||
label={t(reportMeasurementLabelKey(type))}
|
||||
value={statSummary(t, reportStatUnit(type, glucoseUnit, t), {
|
||||
latest: show(type, stat!.latest, digits),
|
||||
avg: show(type, stat!.avg, digits),
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import enMessages from "../../../../../../messages/en.json";
|
||||
import type { EfficacySegmented } from "@/lib/medications/efficacy/segmented-effect";
|
||||
import { ChangeAtStartSummary } from "../change-at-start-summary";
|
||||
|
||||
function est(estimate: number, se: number) {
|
||||
const ciLow = estimate - 2 * se;
|
||||
const ciHigh = estimate + 2 * se;
|
||||
return {
|
||||
estimate,
|
||||
ciLow,
|
||||
ciHigh,
|
||||
standardError: se,
|
||||
includesZero: ciLow <= 0 && ciHigh >= 0,
|
||||
};
|
||||
}
|
||||
|
||||
const present: EfficacySegmented = {
|
||||
present: true,
|
||||
level: 0.95,
|
||||
window: { from: "2026-01-01", to: "2026-05-30", endReason: "dose_change" },
|
||||
days: {
|
||||
before: 80,
|
||||
after: 70,
|
||||
excludedLowAdherence: 6,
|
||||
excludedNotTaking: 0,
|
||||
},
|
||||
adherence: { status: "tracked", floorPct: 80, lookbackDays: 14 },
|
||||
levelChange: est(-7.34, 0.6),
|
||||
slopeChangePerWeek: est(-0.26, 0.16),
|
||||
preSlopePerWeek: est(0.05, 0.1),
|
||||
fit: {
|
||||
n: 150,
|
||||
df: 146,
|
||||
rSquared: 0.6,
|
||||
residualAutocorrelation: 0.3,
|
||||
hacLag: 4,
|
||||
},
|
||||
confounders: [
|
||||
{ medicationName: "Example B", event: "started", date: "2026-02-10" },
|
||||
],
|
||||
};
|
||||
|
||||
function render(segmented: EfficacySegmented) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en" initialMessages={enMessages as never}>
|
||||
<ChangeAtStartSummary
|
||||
segmented={segmented}
|
||||
unit="mmHg"
|
||||
minDays={14}
|
||||
minSpanDays={21}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ChangeAtStartSummary", () => {
|
||||
it("states the level and trend change with their ranges in plain words", () => {
|
||||
const html = render(present);
|
||||
expect(html).toContain("the level moved by -7.3 mmHg");
|
||||
expect(html).toContain("95 % range -8.5 to -6.1");
|
||||
expect(html).toContain("changed by -0.3 mmHg per week");
|
||||
// The slope interval spans zero, so the no-change reading is said aloud.
|
||||
expect(html).toContain("also consistent with no change");
|
||||
expect(html).toContain("Based on 80 days before and 70 days after");
|
||||
expect(html).toContain("6 days after the start were left out");
|
||||
expect(html).toContain("describes the starting dose");
|
||||
expect(html).toContain("Example B started on");
|
||||
expect(html).not.toMatch(/work(s|ing)|effective|successful/i);
|
||||
});
|
||||
|
||||
it("names the missing floor instead of a number", () => {
|
||||
const html = render({
|
||||
...present,
|
||||
present: false,
|
||||
reason: "insufficient_before",
|
||||
levelChange: null,
|
||||
slopeChangePerWeek: null,
|
||||
preSlopePerWeek: null,
|
||||
fit: null,
|
||||
});
|
||||
expect(html).toContain("at least 14 days spread over 21 days before");
|
||||
expect(html).not.toContain("level moved");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The segmented-regression read-out on a Wirkung target card: the level
|
||||
* change and the change in weekly trend at the start, each with its 95 %
|
||||
* range, in plain sentences, followed by what the fit rests on (days used,
|
||||
* days left out and why) and the other medication changes it cannot separate
|
||||
* from this one. Renders the server DTO; recomputes nothing.
|
||||
*/
|
||||
import type { EfficacySegmented } from "@/lib/medications/efficacy/segmented-effect";
|
||||
import { formatEstimate } from "@/lib/medications/efficacy/segmented-format";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
const CONFOUNDER_KEYS = {
|
||||
started: "medications.efficacy.segmented.confounderStarted",
|
||||
stopped: "medications.efficacy.segmented.confounderStopped",
|
||||
dose_change: "medications.efficacy.segmented.confounderDoseChange",
|
||||
paused: "medications.efficacy.segmented.confounderPaused",
|
||||
resumed: "medications.efficacy.segmented.confounderResumed",
|
||||
} as const;
|
||||
|
||||
const NEED_KEYS = {
|
||||
no_start: "medications.efficacy.segmented.needStart",
|
||||
insufficient_before: "medications.efficacy.segmented.needBefore",
|
||||
insufficient_after: "medications.efficacy.segmented.needAfter",
|
||||
fit_failed: "medications.efficacy.segmented.fitFailed",
|
||||
} as const;
|
||||
|
||||
export function ChangeAtStartSummary({
|
||||
segmented,
|
||||
unit,
|
||||
minDays,
|
||||
minSpanDays,
|
||||
}: {
|
||||
segmented: EfficacySegmented;
|
||||
unit: string | null;
|
||||
minDays: number;
|
||||
minSpanDays: number;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const num = (v: number, d: number) => fmt.number(v, d);
|
||||
const u = unit ?? "";
|
||||
|
||||
if (
|
||||
!segmented.present ||
|
||||
!segmented.levelChange ||
|
||||
!segmented.slopeChangePerWeek ||
|
||||
!segmented.preSlopePerWeek ||
|
||||
!segmented.window
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
className="border-border/60 rounded-lg border border-dashed p-3"
|
||||
data-slot="medication-wirkung-segmented-empty"
|
||||
>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(NEED_KEYS[segmented.reason ?? "fit_failed"], {
|
||||
days: minDays,
|
||||
span: minSpanDays,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const level = formatEstimate(segmented.levelChange, num);
|
||||
const slope = formatEstimate(segmented.slopeChangePerWeek, num);
|
||||
const pre = formatEstimate(segmented.preSlopePerWeek, num);
|
||||
const anyZero =
|
||||
segmented.levelChange.includesZero ||
|
||||
segmented.slopeChangePerWeek.includesZero;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-muted/40 space-y-2 rounded-lg p-3"
|
||||
data-slot="medication-wirkung-segmented"
|
||||
>
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{t("medications.efficacy.segmented.title")}
|
||||
</p>
|
||||
<p className="text-foreground text-sm">
|
||||
{t("medications.efficacy.segmented.level", { ...level, unit: u })}
|
||||
</p>
|
||||
<p className="text-foreground text-sm">
|
||||
{t("medications.efficacy.segmented.slope", { ...slope, unit: u })}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.efficacy.segmented.preSlope", {
|
||||
value: pre.value,
|
||||
unit: u,
|
||||
})}
|
||||
</p>
|
||||
{anyZero ? (
|
||||
<p className="text-foreground text-sm">
|
||||
{t("medications.efficacy.segmented.includesZero")}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.efficacy.segmented.basis", {
|
||||
before: segmented.days.before,
|
||||
after: segmented.days.after,
|
||||
from: fmt.dateShortSmartCalendar(segmented.window.from),
|
||||
to: fmt.dateShortSmartCalendar(segmented.window.to),
|
||||
})}
|
||||
</p>
|
||||
{segmented.days.excludedLowAdherence > 0 ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.efficacy.segmented.excludedAdherence", {
|
||||
count: segmented.days.excludedLowAdherence,
|
||||
floor: segmented.adherence.floorPct,
|
||||
lookback: segmented.adherence.lookbackDays,
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{segmented.days.excludedNotTaking > 0 ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.efficacy.segmented.excludedNotTaking", {
|
||||
count: segmented.days.excludedNotTaking,
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{segmented.adherence.status === "not_tracked" ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.efficacy.segmented.notTracked")}
|
||||
</p>
|
||||
) : null}
|
||||
{segmented.window.endReason === "dose_change" ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.efficacy.segmented.endDoseChange")}
|
||||
</p>
|
||||
) : null}
|
||||
{segmented.confounders.length > 0 ? (
|
||||
<div data-slot="medication-wirkung-confounders">
|
||||
<p className="text-foreground text-sm">
|
||||
{t("medications.efficacy.segmented.confoundersTitle")}
|
||||
</p>
|
||||
<ul className="text-foreground list-disc pl-4 text-sm">
|
||||
{segmented.confounders.map((c) => (
|
||||
<li key={`${c.medicationName}:${c.event}:${c.date}`}>
|
||||
{t(CONFOUNDER_KEYS[c.event], {
|
||||
name: c.medicationName,
|
||||
date: fmt.dateShortSmartCalendar(c.date),
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.efficacy.segmented.method")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -43,6 +43,11 @@ import type {
|
||||
EfficacyTargetView,
|
||||
} from "@/lib/medications/efficacy/build-efficacy";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import {
|
||||
SEGMENTED_MIN_DAYS_PER_SIDE,
|
||||
SEGMENTED_MIN_SPAN_DAYS,
|
||||
} from "@/lib/medications/efficacy/segmented-effect";
|
||||
import { ChangeAtStartSummary } from "./change-at-start-summary";
|
||||
|
||||
// v1.30 UI audit (M8) — `EfficacyChart` mounts inside `TargetCard`'s own
|
||||
// `<Card>` as a bare 220px `ResponsiveContainer` with no chrome of its own.
|
||||
@@ -310,6 +315,15 @@ function TargetCard({
|
||||
|
||||
<BeforeAfterCard target={target} minWeeks={dto.minWeeksPerSide} />
|
||||
|
||||
{target.segmented ? (
|
||||
<ChangeAtStartSummary
|
||||
segmented={target.segmented}
|
||||
unit={target.unit}
|
||||
minDays={SEGMENTED_MIN_DAYS_PER_SIDE}
|
||||
minSpanDays={SEGMENTED_MIN_SPAN_DAYS}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{target.levelShift?.present &&
|
||||
target.levelShift.nearStart &&
|
||||
target.levelShift.at ? (
|
||||
|
||||
@@ -191,11 +191,11 @@ describe("<HealthRecordExportPanel> — the first run", () => {
|
||||
expect(groupChip(html, "vitals")).toBe("0/7");
|
||||
expect(groupChip(html, "body")).toBe("0/12");
|
||||
expect(groupChip(html, "labs")).toBe("0/1");
|
||||
expect(groupChip(html, "medications")).toBe("0/4");
|
||||
expect(groupChip(html, "medications")).toBe("0/5");
|
||||
// ALLERGIES, ILLNESS_EPISODES, VISITS, SURGICAL_HISTORY, IMMUNIZATIONS.
|
||||
expect(groupChip(html, "history")).toBe("0/5");
|
||||
expect(groupChip(html, "cardio")).toBe("0/15");
|
||||
expect(groupChip(html, "activity")).toBe("0/11");
|
||||
expect(groupChip(html, "activity")).toBe("0/12");
|
||||
expect(groupChip(html, "sleepRecovery")).toBe("0/16");
|
||||
expect(groupChip(html, "mobility")).toBe("0/11");
|
||||
expect(groupChip(html, "environment")).toBe("0/7");
|
||||
|
||||
@@ -36,6 +36,7 @@ import { buildEmergencyFirstPageSection } from "./doctor-report-pdf/emergency-fi
|
||||
import { buildHeaderProfileSection } from "./doctor-report-pdf/header-profile-section";
|
||||
import { buildMeasurementsChartsSection } from "./doctor-report-pdf/measurements-charts-section";
|
||||
import { buildMedicationMoodWellnessSection } from "./doctor-report-pdf/medication-mood-wellness-section";
|
||||
import { buildWorkoutsSection } from "./doctor-report-pdf/activity-effect-sections";
|
||||
import { buildClinicalRecordsNotesSection } from "./doctor-report-pdf/clinical-records-notes-section";
|
||||
import { buildReportFooter } from "./doctor-report-pdf/footer-section";
|
||||
import {
|
||||
@@ -294,6 +295,7 @@ export function buildDoctorReportPdfDocument(
|
||||
}
|
||||
cursor = buildHeaderProfileSection(context, cursor);
|
||||
cursor = buildMeasurementsChartsSection(context, cursor);
|
||||
cursor = buildWorkoutsSection(context, cursor);
|
||||
cursor = buildMedicationMoodWellnessSection(context, cursor);
|
||||
cursor = buildClinicalRecordsNotesSection(context, cursor);
|
||||
buildReportFooter(context, cursor);
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* The v1.44 report blocks in the rendered PDF: sleep, both heart-rate
|
||||
* variability statistics under their own labels, steps, the per-sport workout
|
||||
* block and the medication effect, plus the per-sport fold behind the workout
|
||||
* block. Text is read back out of the real PDF bytes.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PDFParse } from "pdf-parse";
|
||||
|
||||
import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core";
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { summariseReportWorkouts } from "@/lib/doctor-report/workouts";
|
||||
import type { EfficacySegmented } from "@/lib/medications/efficacy/segmented-effect";
|
||||
|
||||
const NOW = new Date("2026-05-03T12:00:00.000Z");
|
||||
|
||||
async function textOf(bytes: Uint8Array): Promise<string> {
|
||||
const parser = new PDFParse({ data: bytes });
|
||||
try {
|
||||
return (await parser.getText()).text;
|
||||
} finally {
|
||||
await parser.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
function series(values: number[]) {
|
||||
return values.map((value, i) => ({
|
||||
value,
|
||||
measuredAt: new Date(
|
||||
Date.parse("2026-03-01T06:00:00.000Z") + i * 86_400_000,
|
||||
).toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
function stat(values: number[]) {
|
||||
return {
|
||||
avg: values.reduce((a, b) => a + b, 0) / values.length,
|
||||
min: Math.min(...values),
|
||||
max: Math.max(...values),
|
||||
count: values.length,
|
||||
latest: values[values.length - 1],
|
||||
};
|
||||
}
|
||||
|
||||
const EFFECT: EfficacySegmented = {
|
||||
present: true,
|
||||
level: 0.95,
|
||||
window: { from: "2025-12-02", to: "2026-04-10", endReason: "dose_change" },
|
||||
days: {
|
||||
before: 84,
|
||||
after: 61,
|
||||
excludedLowAdherence: 5,
|
||||
excludedNotTaking: 0,
|
||||
},
|
||||
adherence: { status: "tracked", floorPct: 80, lookbackDays: 14 },
|
||||
levelChange: {
|
||||
estimate: -7.34,
|
||||
ciLow: -8.52,
|
||||
ciHigh: -6.15,
|
||||
standardError: 0.6,
|
||||
includesZero: false,
|
||||
},
|
||||
slopeChangePerWeek: {
|
||||
estimate: -0.26,
|
||||
ciLow: -0.58,
|
||||
ciHigh: 0.07,
|
||||
standardError: 0.16,
|
||||
includesZero: true,
|
||||
},
|
||||
preSlopePerWeek: {
|
||||
estimate: 0.1,
|
||||
ciLow: -0.1,
|
||||
ciHigh: 0.3,
|
||||
standardError: 0.1,
|
||||
includesZero: true,
|
||||
},
|
||||
fit: {
|
||||
n: 145,
|
||||
df: 141,
|
||||
rSquared: 0.55,
|
||||
residualAutocorrelation: 0.31,
|
||||
hacLag: 4,
|
||||
},
|
||||
confounders: [
|
||||
{ medicationName: "Other drug", event: "started", date: "2026-02-14" },
|
||||
],
|
||||
};
|
||||
|
||||
function data(overrides: Partial<DoctorReportData> = {}): DoctorReportData {
|
||||
const sleep = [400, 430, 455, 470, 410];
|
||||
const sdnn = [42.5, 45.25, 40.125, 47];
|
||||
const rmssd = [31, 35.5, 29, 33];
|
||||
const steps = [8000, 9500, 7200, 10100];
|
||||
return {
|
||||
period: {
|
||||
days: 90,
|
||||
since: "2026-02-02T00:00:00.000Z",
|
||||
start: "2026-02-02T00:00:00.000Z",
|
||||
end: "2026-05-03T12:00:00.000Z",
|
||||
},
|
||||
patient: {
|
||||
username: null,
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
},
|
||||
practiceName: null,
|
||||
measurements: {
|
||||
SLEEP_DURATION: series(sleep),
|
||||
HEART_RATE_VARIABILITY: series(sdnn),
|
||||
HRV_RMSSD: series(rmssd),
|
||||
ACTIVITY_STEPS: series(steps),
|
||||
},
|
||||
stats: {
|
||||
SLEEP_DURATION: stat(sleep),
|
||||
HEART_RATE_VARIABILITY: stat(sdnn),
|
||||
HRV_RMSSD: stat(rmssd),
|
||||
ACTIVITY_STEPS: stat(steps),
|
||||
},
|
||||
glucoseStats: {},
|
||||
glucoseRanges: {},
|
||||
glucoseClinical: computeGlucoseClinicalMetrics([], { now: NOW }),
|
||||
glucoseUnit: "mg/dL",
|
||||
bmi: null,
|
||||
compliance: {},
|
||||
medications: [],
|
||||
mood: null,
|
||||
workouts: {
|
||||
sessions: 9,
|
||||
totalDurationSec: 9 * 3600,
|
||||
sessionsPerWeek: 0.7,
|
||||
minutesPerWeek: 42,
|
||||
sportCount: 2,
|
||||
bySport: [
|
||||
{
|
||||
sportType: "running",
|
||||
sessions: 6,
|
||||
durationSec: 6 * 3600,
|
||||
distanceM: 54_300,
|
||||
distanceSessions: 6,
|
||||
energyKcal: 3900,
|
||||
avgHeartRate: 148.5,
|
||||
},
|
||||
{
|
||||
sportType: "yoga",
|
||||
sessions: 3,
|
||||
durationSec: 3 * 3600,
|
||||
distanceM: null,
|
||||
distanceSessions: 0,
|
||||
energyKcal: null,
|
||||
avgHeartRate: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
medicationEffects: [
|
||||
{
|
||||
medicationName: "Example ACE inhibitor",
|
||||
startsOn: "2026-03-02",
|
||||
targets: [{ type: "BLOOD_PRESSURE_SYS", effect: EFFECT }],
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
} as DoctorReportData;
|
||||
}
|
||||
|
||||
function render(d: DoctorReportData) {
|
||||
return renderDoctorReportPdfBytes(d, {
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
t: getServerTranslator("en").t,
|
||||
locale: "en",
|
||||
now: NOW,
|
||||
});
|
||||
}
|
||||
|
||||
describe("doctor report v1.44 sections", () => {
|
||||
it("labels SDNN and RMSSD as separate rows and keeps recorded precision", async () => {
|
||||
const text = await textOf(render(data()));
|
||||
expect(text).toContain("HRV (SDNN)");
|
||||
expect(text).toContain("HRV (RMSSD)");
|
||||
// #1228: the row keeps the digits the readings carry, up to three
|
||||
// significant ones (40.125 ms reads 40.1, never 40).
|
||||
expect(text).toContain("40.1");
|
||||
expect(text).not.toMatch(/\b40 ms/);
|
||||
expect(text).toContain("Sleep");
|
||||
expect(text).toContain("Steps");
|
||||
});
|
||||
|
||||
it("charts the wearable series at half width in the report's units", async () => {
|
||||
const text = await textOf(render(data()));
|
||||
// The sleep chart's max label is in hours (470 min = 7.8 h), never minutes.
|
||||
expect(text).toContain("7.8 h");
|
||||
expect(text).not.toContain("470.0 h");
|
||||
});
|
||||
|
||||
it("prints the workout block per sport with weekly figures", async () => {
|
||||
const text = await textOf(render(data()));
|
||||
expect(text).toContain("Workouts");
|
||||
expect(text).toContain("9 sessions, 9.0 h in total");
|
||||
expect(text).toContain("0.7 sessions and 42 minutes per week");
|
||||
expect(text).toContain("Running");
|
||||
expect(text).toContain("54.3 km");
|
||||
expect(text).toContain("149 bpm");
|
||||
expect(text).toContain("Yoga");
|
||||
});
|
||||
|
||||
it("prints the medication effect with intervals, exclusions and confounders", async () => {
|
||||
// Notes wrap at the page edge; read them as one run of words.
|
||||
const text = (await textOf(render(data()))).replace(/\s+/g, " ");
|
||||
expect(text).toContain("Change around each medication start");
|
||||
expect(text).toContain("Example ACE inhibitor");
|
||||
expect(text).toContain("-7.3 mmHg");
|
||||
expect(text).toContain("(-8.5 to -6.2)");
|
||||
expect(text).toContain("-0.3 mmHg/week");
|
||||
expect(text).toContain("84 / 61");
|
||||
expect(text).toContain("5 days after the start left out");
|
||||
expect(text).toContain("Other drug started on");
|
||||
expect(text).toContain("not a proven effect");
|
||||
});
|
||||
|
||||
it("names the missing floor instead of printing numbers", async () => {
|
||||
const text = await textOf(
|
||||
render(
|
||||
data({
|
||||
medicationEffects: [
|
||||
{
|
||||
medicationName: "Example ACE inhibitor",
|
||||
startsOn: null,
|
||||
targets: [
|
||||
{
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
effect: {
|
||||
...EFFECT,
|
||||
present: false,
|
||||
reason: "no_start",
|
||||
levelChange: null,
|
||||
slopeChangePerWeek: null,
|
||||
preSlopePerWeek: null,
|
||||
fit: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(text).toContain("No start date recorded");
|
||||
expect(text).not.toContain("-7.3 mmHg");
|
||||
});
|
||||
|
||||
it("omits both blocks when the aggregator handed none over", async () => {
|
||||
const text = await textOf(
|
||||
render(data({ workouts: null, medicationEffects: null })),
|
||||
);
|
||||
expect(text).not.toContain("Workouts");
|
||||
expect(text).not.toContain("Change around each medication start");
|
||||
});
|
||||
});
|
||||
|
||||
describe("summariseReportWorkouts", () => {
|
||||
it("sums per sport, longest total first, and averages over the window's weeks", () => {
|
||||
const w = summariseReportWorkouts(
|
||||
[
|
||||
{
|
||||
sportType: "running",
|
||||
durationSec: 1800,
|
||||
totalDistanceM: 5000,
|
||||
totalEnergyKcal: 300,
|
||||
avgHeartRate: 150,
|
||||
},
|
||||
{
|
||||
sportType: "running",
|
||||
durationSec: 2400,
|
||||
totalDistanceM: null,
|
||||
totalEnergyKcal: 400,
|
||||
avgHeartRate: 140,
|
||||
},
|
||||
{
|
||||
sportType: "cycling",
|
||||
durationSec: 7200,
|
||||
totalDistanceM: 40_000,
|
||||
totalEnergyKcal: null,
|
||||
avgHeartRate: null,
|
||||
},
|
||||
],
|
||||
14,
|
||||
)!;
|
||||
expect(w.sessions).toBe(3);
|
||||
expect(w.totalDurationSec).toBe(11_400);
|
||||
expect(w.sessionsPerWeek).toBe(1.5);
|
||||
expect(w.minutesPerWeek).toBe(95);
|
||||
expect(w.bySport.map((s) => s.sportType)).toEqual(["cycling", "running"]);
|
||||
const running = w.bySport[1];
|
||||
expect(running).toEqual({
|
||||
sportType: "running",
|
||||
sessions: 2,
|
||||
durationSec: 4200,
|
||||
distanceM: 5000,
|
||||
distanceSessions: 1,
|
||||
energyKcal: 700,
|
||||
avgHeartRate: 145,
|
||||
});
|
||||
expect(w.bySport[0].avgHeartRate).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null without sessions", () => {
|
||||
expect(summariseReportWorkouts([], 30)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Two compact PDF blocks added with the v1.44 report: the per-sport workout
|
||||
* summary and the medication effect around each start.
|
||||
*
|
||||
* Both render only what the aggregator handed over (the selection and the
|
||||
* modules are applied there), and both are tables of a few rows: a workout
|
||||
* block is one row per sport, never a session log, and the effect block is
|
||||
* one row per medication and target with its caveats as short notes under it.
|
||||
*/
|
||||
import type { jsPDF } from "jspdf";
|
||||
import autoTable from "jspdf-autotable";
|
||||
import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { formatEstimate } from "@/lib/medications/efficacy/segmented-format";
|
||||
import type { EfficacySegmented } from "@/lib/medications/efficacy/segmented-effect";
|
||||
import { reportMeasurementLabelKey } from "../report-selection/catalogue";
|
||||
import { reportSportLabel } from "../doctor-report/sport-label";
|
||||
import {
|
||||
pdfCursorState,
|
||||
type DoctorReportPdfCursorState,
|
||||
type DoctorReportPdfRenderContext,
|
||||
} from "./render-context";
|
||||
|
||||
const TABLE_STYLES = {
|
||||
theme: "grid" as const,
|
||||
styles: {
|
||||
fontSize: 8.5,
|
||||
cellPadding: 2.2,
|
||||
textColor: [30, 30, 30] as [number, number, number],
|
||||
lineColor: [200, 200, 200] as [number, number, number],
|
||||
lineWidth: 0.3,
|
||||
},
|
||||
headStyles: {
|
||||
fillColor: [245, 245, 245] as [number, number, number],
|
||||
textColor: [30, 30, 30] as [number, number, number],
|
||||
fontStyle: "bold" as const,
|
||||
},
|
||||
alternateRowStyles: {
|
||||
fillColor: [252, 252, 252] as [number, number, number],
|
||||
},
|
||||
};
|
||||
|
||||
function lastTableY(doc: jsPDF): number {
|
||||
return (doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||||
.finalY;
|
||||
}
|
||||
|
||||
function heading(
|
||||
context: DoctorReportPdfRenderContext,
|
||||
y: number,
|
||||
text: string,
|
||||
keepWith: number,
|
||||
): number {
|
||||
const { doc, margin, ensureSpace } = context;
|
||||
const top = ensureSpace(y, 6 + keepWith);
|
||||
doc.setFontSize(12);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(30, 30, 30);
|
||||
doc.text(text, margin, top);
|
||||
return top + 5;
|
||||
}
|
||||
|
||||
function note(
|
||||
context: DoctorReportPdfRenderContext,
|
||||
y: number,
|
||||
text: string,
|
||||
): number {
|
||||
const { doc, margin, pageWidth, ensureSpace } = context;
|
||||
doc.setFontSize(8);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setTextColor(90, 90, 90);
|
||||
let cursor = y;
|
||||
for (const line of doc.splitTextToSize(text, pageWidth - 2 * margin)) {
|
||||
cursor = ensureSpace(cursor, 4);
|
||||
doc.text(line, margin, cursor);
|
||||
cursor += 3.8;
|
||||
}
|
||||
doc.setTextColor(30, 30, 30);
|
||||
return cursor;
|
||||
}
|
||||
|
||||
/** The per-sport workout summary. */
|
||||
export function buildWorkoutsSection(
|
||||
context: DoctorReportPdfRenderContext,
|
||||
state: DoctorReportPdfCursorState,
|
||||
): DoctorReportPdfCursorState {
|
||||
const { doc, data, t, num, margin, tableBottomMargin } = context;
|
||||
const w = data.workouts;
|
||||
if (!w || w.sessions === 0) return state;
|
||||
|
||||
let y = heading(context, state.y, t("doctorReport.workouts.title"), 24);
|
||||
y = note(
|
||||
context,
|
||||
y,
|
||||
t("doctorReport.workouts.summary", {
|
||||
sessions: w.sessions,
|
||||
hours: num(w.totalDurationSec / 3600, 1),
|
||||
perWeek: num(w.sessionsPerWeek, 1),
|
||||
minutesPerWeek: num(w.minutesPerWeek, 0),
|
||||
}),
|
||||
);
|
||||
y += 1;
|
||||
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
head: [
|
||||
[
|
||||
t("doctorReport.workouts.colSport"),
|
||||
t("doctorReport.workouts.colSessions"),
|
||||
t("doctorReport.workouts.colTime"),
|
||||
t("doctorReport.workouts.colDistance"),
|
||||
t("doctorReport.workouts.colEnergy"),
|
||||
t("doctorReport.workouts.colHeartRate"),
|
||||
],
|
||||
],
|
||||
body: w.bySport.map((s) => [
|
||||
reportSportLabel(t, s.sportType),
|
||||
String(s.sessions),
|
||||
`${num(s.durationSec / 3600, 1)} h`,
|
||||
s.distanceM !== null
|
||||
? s.distanceSessions < s.sessions
|
||||
? `${num(s.distanceM / 1000, 1)} km (${s.distanceSessions}/${s.sessions})`
|
||||
: `${num(s.distanceM / 1000, 1)} km`
|
||||
: "",
|
||||
s.energyKcal !== null ? `${num(s.energyKcal, 0)} kcal` : "",
|
||||
s.avgHeartRate !== null ? `${num(s.avgHeartRate, 0)} bpm` : "",
|
||||
]),
|
||||
...TABLE_STYLES,
|
||||
margin: {
|
||||
left: margin,
|
||||
right: margin,
|
||||
top: margin,
|
||||
bottom: tableBottomMargin,
|
||||
},
|
||||
});
|
||||
y = lastTableY(doc) + 3;
|
||||
if (w.sportCount > w.bySport.length) {
|
||||
y = note(
|
||||
context,
|
||||
y,
|
||||
t("doctorReport.workouts.moreSports", {
|
||||
count: w.sportCount - w.bySport.length,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return pdfCursorState(doc, y + 5);
|
||||
}
|
||||
|
||||
const EFFECT_REASON_KEYS: Record<
|
||||
NonNullable<EfficacySegmented["reason"]>,
|
||||
string
|
||||
> = {
|
||||
no_start: "doctorReport.medicationEffect.reasonNoStart",
|
||||
insufficient_before: "doctorReport.medicationEffect.reasonBefore",
|
||||
insufficient_after: "doctorReport.medicationEffect.reasonAfter",
|
||||
fit_failed: "doctorReport.medicationEffect.reasonFit",
|
||||
};
|
||||
|
||||
const CONFOUNDER_KEYS = {
|
||||
started: "medications.efficacy.segmented.confounderStarted",
|
||||
stopped: "medications.efficacy.segmented.confounderStopped",
|
||||
dose_change: "medications.efficacy.segmented.confounderDoseChange",
|
||||
paused: "medications.efficacy.segmented.confounderPaused",
|
||||
resumed: "medications.efficacy.segmented.confounderResumed",
|
||||
} as const;
|
||||
|
||||
/** A stated calendar day printed in the report's date order. */
|
||||
function dayLabel(
|
||||
fmtDate: DoctorReportPdfRenderContext["fmtDate"],
|
||||
day: string,
|
||||
): string {
|
||||
return fmtDate(dateOnlyAtNoonUtc(day).toISOString());
|
||||
}
|
||||
|
||||
/** The segmented-regression effect, one row per medication and target. */
|
||||
export function buildMedicationEffectSection(
|
||||
context: DoctorReportPdfRenderContext,
|
||||
state: DoctorReportPdfCursorState,
|
||||
): DoctorReportPdfCursorState {
|
||||
const { doc, data, t, num, fmtDate, margin, tableBottomMargin, unitFor } =
|
||||
context;
|
||||
const effects = data.medicationEffects;
|
||||
if (!effects || effects.length === 0) return state;
|
||||
|
||||
const numAt = (v: number, d: number) => num(v, d);
|
||||
const rows: string[][] = [];
|
||||
const notes: string[] = [];
|
||||
for (const med of effects) {
|
||||
for (const target of med.targets) {
|
||||
const e = target.effect;
|
||||
const label = t(
|
||||
reportMeasurementLabelKey(target.type as MeasurementType),
|
||||
);
|
||||
const unit = unitFor(target.type);
|
||||
const name = `${med.medicationName}\n${label}`;
|
||||
const start = med.startsOn ? dayLabel(fmtDate, med.startsOn) : "";
|
||||
if (!e.present || !e.levelChange || !e.slopeChangePerWeek || !e.window) {
|
||||
rows.push([
|
||||
name,
|
||||
start,
|
||||
t(EFFECT_REASON_KEYS[e.reason ?? "fit_failed"]),
|
||||
"",
|
||||
"",
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
const level = formatEstimate(e.levelChange, numAt);
|
||||
const slope = formatEstimate(e.slopeChangePerWeek, numAt);
|
||||
rows.push([
|
||||
name,
|
||||
start,
|
||||
`${level.value} ${unit}\n(${level.low} ${t("doctorReport.medicationEffect.to")} ${level.high})`,
|
||||
`${slope.value} ${unit}/${t("doctorReport.medicationEffect.week")}\n(${slope.low} ${t("doctorReport.medicationEffect.to")} ${slope.high})`,
|
||||
`${e.days.before} / ${e.days.after}`,
|
||||
]);
|
||||
const extras: string[] = [];
|
||||
if (e.days.excludedLowAdherence > 0) {
|
||||
extras.push(
|
||||
t("doctorReport.medicationEffect.excludedAdherence", {
|
||||
count: e.days.excludedLowAdherence,
|
||||
floor: e.adherence.floorPct,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (e.days.excludedNotTaking > 0) {
|
||||
extras.push(
|
||||
t("doctorReport.medicationEffect.excludedNotTaking", {
|
||||
count: e.days.excludedNotTaking,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (e.adherence.status === "not_tracked") {
|
||||
extras.push(t("doctorReport.medicationEffect.notTracked"));
|
||||
}
|
||||
if (e.window.endReason === "dose_change") {
|
||||
extras.push(
|
||||
t("doctorReport.medicationEffect.endDoseChange", {
|
||||
date: dayLabel(fmtDate, e.window.to),
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const c of e.confounders) {
|
||||
extras.push(
|
||||
t(CONFOUNDER_KEYS[c.event], {
|
||||
name: c.medicationName,
|
||||
date: dayLabel(fmtDate, c.date),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (extras.length > 0) {
|
||||
notes.push(`${med.medicationName}, ${label}: ${extras.join("; ")}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (rows.length === 0) return state;
|
||||
|
||||
let y = heading(
|
||||
context,
|
||||
state.y,
|
||||
t("doctorReport.medicationEffect.title"),
|
||||
24,
|
||||
);
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
head: [
|
||||
[
|
||||
t("doctorReport.medicationEffect.colMedication"),
|
||||
t("doctorReport.medicationEffect.colStart"),
|
||||
t("doctorReport.medicationEffect.colLevel"),
|
||||
t("doctorReport.medicationEffect.colSlope"),
|
||||
t("doctorReport.medicationEffect.colDays"),
|
||||
],
|
||||
],
|
||||
body: rows,
|
||||
...TABLE_STYLES,
|
||||
margin: {
|
||||
left: margin,
|
||||
right: margin,
|
||||
top: margin,
|
||||
bottom: tableBottomMargin,
|
||||
},
|
||||
});
|
||||
y = lastTableY(doc) + 3;
|
||||
for (const line of notes) y = note(context, y, line);
|
||||
y = note(context, y + 1, t("doctorReport.medicationEffect.method"));
|
||||
return pdfCursorState(doc, y + 5);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
type DoctorReportData,
|
||||
} from "../doctor-report-data";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { MEASUREMENT_TYPE_LABEL_KEYS } from "../measurements/type-label-keys";
|
||||
import { reportMeasurementLabelKey } from "../report-selection/catalogue";
|
||||
import type {
|
||||
DoctorReportNumberFormatter,
|
||||
DoctorReportTranslator,
|
||||
@@ -35,6 +35,20 @@ export const SPARKLINE_TYPES = [
|
||||
"PULSE",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* The wearable series charted at half width, two to a row, below the
|
||||
* full-width vitals. Each still needs its leaf selected and two points in the
|
||||
* window. The points are the report's own series: one per night for sleep,
|
||||
* day buckets for heart-rate variability once the window or the row count
|
||||
* passes the raw limit, and the daily totals steps arrive as.
|
||||
*/
|
||||
export const COMPACT_SPARKLINE_TYPES = [
|
||||
"SLEEP_DURATION",
|
||||
"HEART_RATE_VARIABILITY",
|
||||
"HRV_RMSSD",
|
||||
"ACTIVITY_STEPS",
|
||||
] as const satisfies readonly MeasurementType[];
|
||||
|
||||
/** First-half vs second-half mean → "↑" / "↓" / "→". */
|
||||
export function trendArrow(values: number[]): "↑" | "↓" | "→" {
|
||||
if (values.length < 2) return "→";
|
||||
@@ -95,7 +109,7 @@ export function buildClinicalSummaryLines(
|
||||
);
|
||||
lines.push(
|
||||
t("doctorReport.summaryTrend", {
|
||||
label: t(MEASUREMENT_TYPE_LABEL_KEYS[type as MeasurementType] ?? ""),
|
||||
label: t(reportMeasurementLabelKey(type as MeasurementType)),
|
||||
latest: num(stat.latest, 1),
|
||||
arrow,
|
||||
}),
|
||||
|
||||
@@ -11,10 +11,11 @@ import {
|
||||
reportStatValue,
|
||||
} from "../doctor-report/stat-display";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { MEASUREMENT_TYPE_LABEL_KEYS } from "../measurements/type-label-keys";
|
||||
import { reportMeasurementLabelKey } from "../report-selection/catalogue";
|
||||
import {
|
||||
getBmiClassificationKey,
|
||||
getBpClassificationKey,
|
||||
COMPACT_SPARKLINE_TYPES,
|
||||
SPARKLINE_TYPES,
|
||||
} from "./clinical-summary";
|
||||
import {
|
||||
@@ -252,7 +253,7 @@ export function buildMeasurementsChartsSection(
|
||||
const show = (v: number) =>
|
||||
num(reportStatValue(type, v, glucoseUnit), digits);
|
||||
groupRows.push([
|
||||
t(MEASUREMENT_TYPE_LABEL_KEYS[type as MeasurementType] ?? ""),
|
||||
t(reportMeasurementLabelKey(type as MeasurementType)),
|
||||
`${show(s.latest)} ${unit}`.trim(),
|
||||
`${show(s.avg)} ${unit}`.trim(),
|
||||
show(s.min),
|
||||
@@ -433,7 +434,13 @@ export function buildMeasurementsChartsSection(
|
||||
const chartTypes = SPARKLINE_TYPES.filter(
|
||||
(type) => (data.measurements[type]?.length ?? 0) >= 2,
|
||||
);
|
||||
if (chartTypes.length > 0) {
|
||||
// v1.44 — the wearable series (sleep, both HRV statistics, steps) draw at
|
||||
// half width, two to a row: a clinician reads their direction, and four
|
||||
// full-width charts would push the medication blocks a page further.
|
||||
const compactTypes = COMPACT_SPARKLINE_TYPES.filter(
|
||||
(type) => (data.measurements[type]?.length ?? 0) >= 2,
|
||||
);
|
||||
if (chartTypes.length > 0 || compactTypes.length > 0) {
|
||||
// Keep the heading with at least the first chart.
|
||||
y = ensureSpace(y, 6 + SPARKLINE_HEIGHT + 4);
|
||||
doc.setFontSize(12);
|
||||
@@ -446,9 +453,7 @@ export function buildMeasurementsChartsSection(
|
||||
// A chart never tears across a page boundary — break before drawing
|
||||
// it whole.
|
||||
y = ensureSpace(y, SPARKLINE_HEIGHT + 4);
|
||||
const label = t(
|
||||
MEASUREMENT_TYPE_LABEL_KEYS[type as MeasurementType] ?? "",
|
||||
);
|
||||
const label = t(reportMeasurementLabelKey(type as MeasurementType));
|
||||
y = drawSparkline(doc, {
|
||||
x: margin,
|
||||
y,
|
||||
@@ -461,6 +466,32 @@ export function buildMeasurementsChartsSection(
|
||||
});
|
||||
y += 4;
|
||||
}
|
||||
const columnGap = 6;
|
||||
const halfWidth = (pageWidth - 2 * margin - columnGap) / 2;
|
||||
for (let i = 0; i < compactTypes.length; i += 2) {
|
||||
y = ensureSpace(y, SPARKLINE_HEIGHT + 4);
|
||||
let rowBottom = y;
|
||||
for (const [column, type] of compactTypes.slice(i, i + 2).entries()) {
|
||||
// Plotted in the unit the table prints (sleep in hours, not the
|
||||
// stored minutes), so the min/max labels match the row above.
|
||||
const points = (data.measurements[type] ?? []).map((p) => ({
|
||||
measuredAt: p.measuredAt,
|
||||
value: reportStatValue(type, p.value, glucoseUnit),
|
||||
}));
|
||||
const bottom = drawSparkline(doc, {
|
||||
x: margin + column * (halfWidth + columnGap),
|
||||
y,
|
||||
width: halfWidth,
|
||||
label: t(reportMeasurementLabelKey(type)),
|
||||
points,
|
||||
num,
|
||||
unit: unitFor(type),
|
||||
dateShort: (iso) => dateShort(iso),
|
||||
});
|
||||
rowBottom = Math.max(rowBottom, bottom);
|
||||
}
|
||||
y = rowBottom + 4;
|
||||
}
|
||||
y += 2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type DoctorReportPdfRenderContext,
|
||||
} from "./render-context";
|
||||
import { GLP1_SIDE_EFFECT_TAG_LABEL_KEYS } from "../medications/glp1-side-effect-tags";
|
||||
import { buildMedicationEffectSection } from "./activity-effect-sections";
|
||||
|
||||
const MOOD_LABEL_KEYS: Record<number, string> = {
|
||||
1: "doctorReport.moodAwful",
|
||||
@@ -140,6 +141,9 @@ export function buildMedicationMoodWellnessSection(
|
||||
.finalY + 8;
|
||||
}
|
||||
|
||||
// v1.44 — the effect around each medication's start, beside its adherence.
|
||||
y = buildMedicationEffectSection(context, pdfCursorState(doc, y)).y;
|
||||
|
||||
// v1.4.25 — GLP-1 therapy section. Only renders when the data
|
||||
// aggregator emitted a non-null `glp1` block (user has at least one
|
||||
// active GLP-1 medication and the compliance toggle is on). Lists
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { GlucoseUnit } from "@/lib/glucose";
|
||||
import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
|
||||
import type { MeasurementSource } from "@/generated/prisma/client";
|
||||
import type { ModuleKey } from "@/lib/modules/gate";
|
||||
import type { EfficacySegmented } from "@/lib/medications/efficacy/segmented-effect";
|
||||
import type { Glp1SideEffectTag } from "@/lib/medications/glp1-side-effect-tags";
|
||||
import type {
|
||||
ComplianceSchedule,
|
||||
@@ -497,6 +498,56 @@ export interface DoctorReportData {
|
||||
note: string | null;
|
||||
noteUnreadable: boolean;
|
||||
} | null;
|
||||
/**
|
||||
* Workouts over the window, summed per sport (v1.44). Null when the
|
||||
* WORKOUTS leaf was not selected, the `workouts` module is off, or no
|
||||
* session fell inside the window. Optional so older fixtures typecheck.
|
||||
*/
|
||||
workouts?: DoctorReportWorkouts | null;
|
||||
/**
|
||||
* The segmented-regression effect of each listed medication on its target
|
||||
* metric(s), around the medication's start (v1.44). Computed without AI from
|
||||
* the same day values and dose ledger the efficacy tab reads. A target rides
|
||||
* only when its own measurement leaf is admitted too, so the block never
|
||||
* carries a figure derived from a series the owner withheld. Null when the
|
||||
* MEDICATION_EFFECT leaf was not selected or nothing qualified.
|
||||
*/
|
||||
medicationEffects?: DoctorReportMedicationEffect[] | null;
|
||||
}
|
||||
|
||||
/** The report's workout block: totals plus one row per sport. */
|
||||
export interface DoctorReportWorkouts {
|
||||
sessions: number;
|
||||
totalDurationSec: number;
|
||||
/** Sessions and minutes divided by the window's weeks (days / 7). */
|
||||
sessionsPerWeek: number;
|
||||
minutesPerWeek: number;
|
||||
/** Distinct sports in the window; `bySport` lists at most twelve. */
|
||||
sportCount: number;
|
||||
bySport: Array<{
|
||||
/** The canonical sport key; the renderer names it in the reader's language. */
|
||||
sportType: string;
|
||||
sessions: number;
|
||||
durationSec: number;
|
||||
/** Summed over the sessions that reported a distance (`distanceSessions`). */
|
||||
distanceM: number | null;
|
||||
distanceSessions: number;
|
||||
energyKcal: number | null;
|
||||
/** Mean of the per-session average heart rates that were reported. */
|
||||
avgHeartRate: number | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** One medication's effect block in the report. */
|
||||
export interface DoctorReportMedicationEffect {
|
||||
medicationName: string;
|
||||
/** YYYY-MM-DD, or null when the medication carries no start date. */
|
||||
startsOn: string | null;
|
||||
targets: Array<{
|
||||
/** The target MeasurementType; the renderer labels it and picks the unit. */
|
||||
type: string;
|
||||
effect: EfficacySegmented;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** The three persisted score types surfaced in the wellness summary. */
|
||||
|
||||
@@ -257,6 +257,17 @@ function seed() {
|
||||
practitioner: { name: "Sample practice" },
|
||||
},
|
||||
];
|
||||
rows.workout = [
|
||||
{
|
||||
sportType: "running",
|
||||
startedAt: new Date("2026-02-03T07:00:00.000Z"),
|
||||
durationSec: 1800,
|
||||
source: "MANUAL",
|
||||
totalDistanceM: 5000,
|
||||
totalEnergyKcal: 300,
|
||||
avgHeartRate: 140,
|
||||
},
|
||||
];
|
||||
rows.familyHistoryEntry = [
|
||||
{ relationship: "MOTHER", condition: "Hypertension", ageAtOnset: 50 },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* The report's medication-effect block reads only what its gate admits: a
|
||||
* target whose own measurement leaf is withheld never comes back as an
|
||||
* effect, the reads stop at the report's end, and the block is bounded.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { findMany, resolveTargets, loadEffects } = vi.hoisted(() => ({
|
||||
findMany: vi.fn(),
|
||||
resolveTargets: vi.fn(),
|
||||
loadEffects: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({ prisma: { medication: { findMany } } }));
|
||||
vi.mock("@/lib/medications/efficacy/build-efficacy", () => ({
|
||||
resolveEffectiveTargets: resolveTargets,
|
||||
}));
|
||||
vi.mock("@/lib/medications/efficacy/load-segmented-effects", () => ({
|
||||
EFFECT_MEDICATION_INCLUDE: {},
|
||||
loadSegmentedEffects: loadEffects,
|
||||
}));
|
||||
|
||||
import {
|
||||
loadReportMedicationEffects,
|
||||
REPORT_EFFECT_MEDICATION_CAP,
|
||||
} from "../medication-effects";
|
||||
|
||||
const END = new Date("2026-05-01T00:00:00.000Z");
|
||||
|
||||
function med(id: string) {
|
||||
return {
|
||||
id,
|
||||
name: `Drug ${id}`,
|
||||
treatmentClass: "ANTIHYPERTENSIVE",
|
||||
atcCode: null,
|
||||
startsOn: new Date("2026-02-01T00:00:00.000Z"),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
findMany.mockReset();
|
||||
resolveTargets.mockReset();
|
||||
loadEffects.mockReset();
|
||||
resolveTargets.mockResolvedValue({
|
||||
tier: "atc",
|
||||
cls: "x",
|
||||
targets: [
|
||||
{ kind: "metric", measurementType: "BLOOD_PRESSURE_SYS" },
|
||||
{ kind: "metric", measurementType: "BLOOD_PRESSURE_DIA" },
|
||||
{ kind: "lab", analyte: "Potassium", label: "Potassium" },
|
||||
],
|
||||
});
|
||||
loadEffects.mockImplementation(
|
||||
async ({ targets }: { targets: string[] }) =>
|
||||
new Map(targets.map((t) => [t, { present: false, reason: "no_start" }])),
|
||||
);
|
||||
});
|
||||
|
||||
describe("loadReportMedicationEffects", () => {
|
||||
it("keeps only metric targets whose own leaf is admitted, and stops at the report end", async () => {
|
||||
findMany.mockResolvedValue([med("a")]);
|
||||
const out = await loadReportMedicationEffects({
|
||||
userId: "u1",
|
||||
medicationIds: ["a"],
|
||||
admitsMeasurement: (type) => type === "BLOOD_PRESSURE_SYS",
|
||||
timezone: "Europe/Berlin",
|
||||
end: END,
|
||||
});
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out![0].targets.map((t) => t.type)).toEqual(["BLOOD_PRESSURE_SYS"]);
|
||||
expect(out![0].startsOn).toBe("2026-02-01");
|
||||
expect(loadEffects).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
targets: ["BLOOD_PRESSURE_SYS"],
|
||||
now: END,
|
||||
timezone: "Europe/Berlin",
|
||||
}),
|
||||
);
|
||||
// Owner-scoped, one-shot medications excluded at the query.
|
||||
expect(findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId: "u1", id: { in: ["a"] }, oneShot: false },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("drops a medication none of whose targets is admitted, and answers null when nothing remains", async () => {
|
||||
findMany.mockResolvedValue([med("a")]);
|
||||
const out = await loadReportMedicationEffects({
|
||||
userId: "u1",
|
||||
medicationIds: ["a"],
|
||||
admitsMeasurement: () => false,
|
||||
timezone: "UTC",
|
||||
end: END,
|
||||
});
|
||||
expect(out).toBeNull();
|
||||
expect(loadEffects).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads nothing without medications", async () => {
|
||||
const out = await loadReportMedicationEffects({
|
||||
userId: "u1",
|
||||
medicationIds: [],
|
||||
admitsMeasurement: () => true,
|
||||
timezone: "UTC",
|
||||
end: END,
|
||||
});
|
||||
expect(out).toBeNull();
|
||||
expect(findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("caps the number of medications it computes", async () => {
|
||||
const ids = Array.from({ length: 12 }, (_, i) => `m${i}`);
|
||||
findMany.mockResolvedValue(ids.map(med));
|
||||
const out = await loadReportMedicationEffects({
|
||||
userId: "u1",
|
||||
medicationIds: ids,
|
||||
admitsMeasurement: () => true,
|
||||
timezone: "UTC",
|
||||
end: END,
|
||||
});
|
||||
expect(out).toHaveLength(REPORT_EFFECT_MEDICATION_CAP);
|
||||
expect(loadEffects).toHaveBeenCalledTimes(REPORT_EFFECT_MEDICATION_CAP);
|
||||
// In the report's own list order.
|
||||
expect(out![0].medicationName).toBe("Drug m0");
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,8 @@ import { loadDenseMeasurementBuckets } from "./dense-buckets";
|
||||
import { summariseDenseBuckets } from "./measurement-series";
|
||||
import { emptyGlucoseClinical } from "./glucose-panel";
|
||||
import { buildAdministrationLedger, buildGlp1Block } from "./medications";
|
||||
import { loadReportWorkouts } from "./workouts";
|
||||
import { loadReportMedicationEffects } from "./medication-effects";
|
||||
import { userDayKey, validTimezoneOr, DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import {
|
||||
loadAllergies,
|
||||
@@ -92,6 +94,9 @@ const DENSE_REPORT_RAW_WINDOW_DAYS = 90;
|
||||
const DENSE_STAT_TYPES = [
|
||||
"PULSE",
|
||||
"HEART_RATE_VARIABILITY",
|
||||
// RMSSD arrives per night from most rings and bands, but some write it per
|
||||
// five-minute window; the row cap below keeps the sparse case raw.
|
||||
"HRV_RMSSD",
|
||||
"OXYGEN_SATURATION",
|
||||
] as const satisfies readonly MeasurementType[];
|
||||
|
||||
@@ -114,6 +119,7 @@ const MEDICATION_LEAVES = [
|
||||
"MEDICATION_LIST",
|
||||
"MEDICATION_ADMINISTRATIONS",
|
||||
"MEDICATION_COMPLIANCE",
|
||||
"MEDICATION_EFFECT",
|
||||
"GLP1_THERAPY",
|
||||
] as const;
|
||||
|
||||
@@ -644,6 +650,8 @@ export async function collectDoctorReportData(
|
||||
|
||||
// Each remaining structured block: read only when its leaf was chosen.
|
||||
const [
|
||||
workouts,
|
||||
medicationEffects,
|
||||
cycle,
|
||||
labResults,
|
||||
illnessEpisodes,
|
||||
@@ -655,6 +663,26 @@ export async function collectDoctorReportData(
|
||||
anamnesis,
|
||||
emergency,
|
||||
] = await Promise.all([
|
||||
gate.admits("WORKOUTS")
|
||||
? loadReportWorkouts({
|
||||
userId,
|
||||
start,
|
||||
end,
|
||||
days,
|
||||
sourcePriorityJson: userProfile?.sourcePriorityJson ?? null,
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
// The effect around each listed medication's start. Its own leaf AND the
|
||||
// target's measurement leaf must both admit it; the loader asks the gate.
|
||||
gate.admits("MEDICATION_EFFECT")
|
||||
? loadReportMedicationEffects({
|
||||
userId,
|
||||
medicationIds: medications.map((m) => m.id),
|
||||
admitsMeasurement: (type) => gate.admits(type),
|
||||
timezone: reportTz,
|
||||
end,
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
gate.admits("CYCLE")
|
||||
? buildCycleExportSummary(userId, userDayKey(end, reportTz))
|
||||
: Promise.resolve(null),
|
||||
@@ -765,5 +793,7 @@ export async function collectDoctorReportData(
|
||||
familyHistory,
|
||||
anamnesis,
|
||||
emergency,
|
||||
workouts,
|
||||
medicationEffects,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* The medication-effect block of the doctor report: for each listed
|
||||
* medication, the segmented-regression effect on its target metric(s) around
|
||||
* its start, from the same loader the efficacy tab reads.
|
||||
*
|
||||
* Bounded: at most {@link REPORT_EFFECT_MEDICATION_CAP} medications, each a
|
||||
* handful of reads (targets, intake events, confounders, one per-day SQL fold
|
||||
* per target), whatever the window length. Every read stops at the report's
|
||||
* end, so a report pinned in the past never uses later data.
|
||||
*
|
||||
* A target rides only when the gate admits its own measurement leaf: a
|
||||
* deselected blood-pressure series must not come back as a blood-pressure
|
||||
* effect. Lab targets are left out (too sparse for a daily series).
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { dayKeyOfDate } from "@/lib/medications/course-window";
|
||||
import { resolveEffectiveTargets } from "@/lib/medications/efficacy/build-efficacy";
|
||||
import {
|
||||
EFFECT_MEDICATION_INCLUDE,
|
||||
loadSegmentedEffects,
|
||||
} from "@/lib/medications/efficacy/load-segmented-effects";
|
||||
import type { DoctorReportMedicationEffect } from "@/lib/doctor-report-types";
|
||||
|
||||
/** Medications the block computes at most, in the report's list order. */
|
||||
export const REPORT_EFFECT_MEDICATION_CAP = 8;
|
||||
|
||||
export async function loadReportMedicationEffects(params: {
|
||||
userId: string;
|
||||
medicationIds: readonly string[];
|
||||
admitsMeasurement: (type: MeasurementType) => boolean;
|
||||
timezone: string;
|
||||
end: Date;
|
||||
}): Promise<DoctorReportMedicationEffect[] | null> {
|
||||
const { userId, medicationIds, admitsMeasurement, timezone, end } = params;
|
||||
if (medicationIds.length === 0) return null;
|
||||
const meds = await prisma.medication.findMany({
|
||||
where: { userId, id: { in: [...medicationIds] }, oneShot: false },
|
||||
include: EFFECT_MEDICATION_INCLUDE,
|
||||
});
|
||||
const order = new Map(medicationIds.map((id, i) => [id, i]));
|
||||
meds.sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0));
|
||||
|
||||
const out: DoctorReportMedicationEffect[] = [];
|
||||
for (const med of meds) {
|
||||
if (out.length >= REPORT_EFFECT_MEDICATION_CAP) break;
|
||||
const resolved = await resolveEffectiveTargets({
|
||||
id: med.id,
|
||||
name: med.name,
|
||||
treatmentClass: med.treatmentClass,
|
||||
atcCode: med.atcCode ?? null,
|
||||
});
|
||||
const metricTargets = resolved.targets
|
||||
.flatMap((t) => (t.kind === "metric" ? [t.measurementType] : []))
|
||||
.filter((type) => admitsMeasurement(type));
|
||||
if (metricTargets.length === 0) continue;
|
||||
const effects = await loadSegmentedEffects({
|
||||
userId,
|
||||
med,
|
||||
targets: metricTargets,
|
||||
timezone,
|
||||
now: end,
|
||||
});
|
||||
out.push({
|
||||
medicationName: med.name,
|
||||
startsOn: med.startsOn ? dayKeyOfDate(med.startsOn) : null,
|
||||
targets: metricTargets.flatMap((type) => {
|
||||
const effect = effects.get(type);
|
||||
return effect ? [{ type, effect }] : [];
|
||||
}),
|
||||
});
|
||||
}
|
||||
return out.length > 0 ? out : null;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* A workout sport's name in the report's language, shared by the PDF and the
|
||||
* clinician view. A sport key the catalogue has no sentence for prints as its
|
||||
* own words ("stairClimber" → "stair climber"), never as a raw key.
|
||||
*/
|
||||
import { translateOrNull } from "@/lib/i18n/translate-code";
|
||||
|
||||
export function reportSportLabel(
|
||||
t: (key: string, params?: Record<string, string | number>) => string,
|
||||
sportType: string,
|
||||
): string {
|
||||
return (
|
||||
translateOrNull(t, `insights.workouts.sport.${sportType}`) ??
|
||||
sportType
|
||||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||
.replace(/_/g, " ")
|
||||
.toLowerCase()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* The workouts block of the doctor report: sessions over the window, summed
|
||||
* per sport.
|
||||
*
|
||||
* A clinician reads training volume, not a session log, so the block is a
|
||||
* per-sport summary (sessions, total time, distance, energy, mean of the
|
||||
* session heart-rate averages) plus the window's weekly figures. Workouts are
|
||||
* a sparse table — a handful a week even for a daily athlete — so the rows are
|
||||
* read narrow (no metadata, no samples, no route) and collapsed through the
|
||||
* canonical-workout picker first, the same rule the workout list applies, so a
|
||||
* session two sources reported counts once.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
|
||||
import type { DoctorReportWorkouts } from "@/lib/doctor-report-types";
|
||||
|
||||
/**
|
||||
* Sport rows the block lists at most, longest total time first. The totals
|
||||
* above the table still count every session, and `sportCount` says how many
|
||||
* sports there were.
|
||||
*/
|
||||
export const REPORT_WORKOUT_SPORT_CAP = 12;
|
||||
|
||||
export async function loadReportWorkouts(params: {
|
||||
userId: string;
|
||||
start: Date;
|
||||
end: Date;
|
||||
days: number;
|
||||
sourcePriorityJson: unknown;
|
||||
}): Promise<DoctorReportWorkouts | null> {
|
||||
const { userId, start, end, days, sourcePriorityJson } = params;
|
||||
const rows = await prisma.workout.findMany({
|
||||
where: { userId, startedAt: { gte: start, lte: end } },
|
||||
orderBy: { startedAt: "asc" },
|
||||
select: {
|
||||
sportType: true,
|
||||
startedAt: true,
|
||||
durationSec: true,
|
||||
source: true,
|
||||
totalDistanceM: true,
|
||||
totalEnergyKcal: true,
|
||||
avgHeartRate: true,
|
||||
},
|
||||
});
|
||||
const canonical = pickCanonicalWorkoutRows(rows, sourcePriorityJson);
|
||||
return summariseReportWorkouts(canonical, days);
|
||||
}
|
||||
|
||||
/** Pure fold, exported for tests. */
|
||||
export function summariseReportWorkouts(
|
||||
rows: ReadonlyArray<{
|
||||
sportType: string;
|
||||
durationSec: number;
|
||||
totalDistanceM: number | null;
|
||||
totalEnergyKcal: number | null;
|
||||
avgHeartRate: number | null;
|
||||
}>,
|
||||
days: number,
|
||||
): DoctorReportWorkouts | null {
|
||||
if (rows.length === 0) return null;
|
||||
const bySport = new Map<
|
||||
string,
|
||||
{
|
||||
sessions: number;
|
||||
durationSec: number;
|
||||
distanceM: number;
|
||||
distanceSessions: number;
|
||||
energyKcal: number;
|
||||
energySessions: number;
|
||||
hrSum: number;
|
||||
hrSessions: number;
|
||||
}
|
||||
>();
|
||||
let totalDurationSec = 0;
|
||||
for (const r of rows) {
|
||||
totalDurationSec += r.durationSec;
|
||||
const s = bySport.get(r.sportType) ?? {
|
||||
sessions: 0,
|
||||
durationSec: 0,
|
||||
distanceM: 0,
|
||||
distanceSessions: 0,
|
||||
energyKcal: 0,
|
||||
energySessions: 0,
|
||||
hrSum: 0,
|
||||
hrSessions: 0,
|
||||
};
|
||||
s.sessions += 1;
|
||||
s.durationSec += r.durationSec;
|
||||
if (r.totalDistanceM !== null && r.totalDistanceM > 0) {
|
||||
s.distanceM += r.totalDistanceM;
|
||||
s.distanceSessions += 1;
|
||||
}
|
||||
if (r.totalEnergyKcal !== null && r.totalEnergyKcal > 0) {
|
||||
s.energyKcal += r.totalEnergyKcal;
|
||||
s.energySessions += 1;
|
||||
}
|
||||
if (r.avgHeartRate !== null && r.avgHeartRate > 0) {
|
||||
s.hrSum += r.avgHeartRate;
|
||||
s.hrSessions += 1;
|
||||
}
|
||||
bySport.set(r.sportType, s);
|
||||
}
|
||||
const weeks = Math.max(days, 1) / 7;
|
||||
return {
|
||||
sessions: rows.length,
|
||||
totalDurationSec,
|
||||
sessionsPerWeek: rows.length / weeks,
|
||||
minutesPerWeek: totalDurationSec / 60 / weeks,
|
||||
sportCount: bySport.size,
|
||||
bySport: [...bySport.entries()]
|
||||
.sort((a, b) => b[1].durationSec - a[1].durationSec)
|
||||
.slice(0, REPORT_WORKOUT_SPORT_CAP)
|
||||
.map(([sportType, s]) => ({
|
||||
sportType,
|
||||
sessions: s.sessions,
|
||||
durationSec: s.durationSec,
|
||||
// A sum over the sessions that reported it, and how many did: a
|
||||
// partial distance never reads as the sport's whole distance.
|
||||
distanceM: s.distanceSessions > 0 ? s.distanceM : null,
|
||||
distanceSessions: s.distanceSessions,
|
||||
energyKcal: s.energySessions > 0 ? s.energyKcal : null,
|
||||
avgHeartRate: s.hrSessions > 0 ? s.hrSum / s.hrSessions : null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* The Observation time series: the `date` and `code` grammar, the scope the
|
||||
* saved selection allows, and the R4 shape of each emitted resource.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
CALORIES_BURNED_LOINC,
|
||||
EXERCISE_ACTIVITY_LOINC,
|
||||
EXERCISE_DURATION_LOINC,
|
||||
matchesCodeTokens,
|
||||
measurementSeriesObservation,
|
||||
parseCodeParams,
|
||||
parseDateParams,
|
||||
resolveSeriesScope,
|
||||
seriesCandidateTypes,
|
||||
workoutSeriesObservation,
|
||||
} from "@/lib/fhir/series";
|
||||
import { MEASUREMENT_LOINC } from "@/lib/fhir/loinc-map";
|
||||
import { isUcumCode } from "@/lib/fhir/ucum";
|
||||
import type { FhirObservation } from "@/lib/fhir/types";
|
||||
import type { ReportLeafId } from "@/lib/report-selection/catalogue";
|
||||
|
||||
const iso = (d: Date | null) => d?.toISOString() ?? null;
|
||||
|
||||
describe("parseDateParams", () => {
|
||||
it("is absent without a date parameter, so the snapshot stays the default", () => {
|
||||
expect(parseDateParams([])).toBeNull();
|
||||
});
|
||||
|
||||
it("reads prefixes and partial dates as half-open UTC ranges", () => {
|
||||
const r = parseDateParams(["ge2026-01-01", "lt2026-02-01"]);
|
||||
expect(r).not.toHaveProperty("error");
|
||||
const range = r as { from: Date | null; to: Date | null };
|
||||
expect(iso(range.from)).toBe("2026-01-01T00:00:00.000Z");
|
||||
expect(iso(range.to)).toBe("2026-02-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("covers a whole month for eq, and the day after for gt and le", () => {
|
||||
const month = parseDateParams(["2026-02"]) as { from: Date; to: Date };
|
||||
expect(iso(month.from)).toBe("2026-02-01T00:00:00.000Z");
|
||||
expect(iso(month.to)).toBe("2026-03-01T00:00:00.000Z");
|
||||
const gt = parseDateParams(["gt2026-02-10"]) as { from: Date; to: null };
|
||||
expect(iso(gt.from)).toBe("2026-02-11T00:00:00.000Z");
|
||||
expect(gt.to).toBeNull();
|
||||
const le = parseDateParams(["le2026"]) as { from: null; to: Date };
|
||||
expect(iso(le.to)).toBe("2027-01-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("honours an offset on a dateTime value", () => {
|
||||
const r = parseDateParams(["ge2026-03-01T10:00:00+02:00"]) as {
|
||||
from: Date;
|
||||
};
|
||||
expect(iso(r.from)).toBe("2026-03-01T08:00:00.000Z");
|
||||
});
|
||||
|
||||
it("rejects what it cannot read rather than ignoring it", () => {
|
||||
expect(parseDateParams(["sa2026-01-01"])).toHaveProperty("error");
|
||||
expect(parseDateParams(["2026-02-30"])).toHaveProperty("error");
|
||||
expect(parseDateParams(["yesterday"])).toHaveProperty("error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCodeParams", () => {
|
||||
it("maps LOINC codes to their types, with or without a system", () => {
|
||||
const f = parseCodeParams(["http://loinc.org|80404-7", "29463-7"])!;
|
||||
expect([...f.types].sort()).toEqual(["HEART_RATE_VARIABILITY", "WEIGHT"]);
|
||||
expect(f.workouts).toBe(false);
|
||||
});
|
||||
|
||||
it("maps any blood-pressure code to the paired panel stream", () => {
|
||||
const f = parseCodeParams(["8480-6"])!;
|
||||
expect([...f.types].sort()).toEqual([
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
]);
|
||||
});
|
||||
|
||||
it("narrows glucose to the contexts its code names", () => {
|
||||
const fasting = parseCodeParams(["1558-6"])!;
|
||||
expect([...fasting.types]).toEqual(["BLOOD_GLUCOSE"]);
|
||||
expect([...fasting.glucoseContexts!]).toEqual(["FASTING"]);
|
||||
const blood = parseCodeParams(["2339-0"])!;
|
||||
expect([...blood.glucoseContexts!].sort()).toEqual([
|
||||
"BEDTIME",
|
||||
"NONE",
|
||||
"RANDOM",
|
||||
]);
|
||||
});
|
||||
|
||||
it("selects workouts by the exercise-activity code", () => {
|
||||
expect(parseCodeParams([EXERCISE_ACTIVITY_LOINC])!.workouts).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match a LOINC code under a foreign system", () => {
|
||||
expect(
|
||||
parseCodeParams(["http://snomed.info/sct|80404-7"])!.types.size,
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSeriesScope", () => {
|
||||
const gateOf = (leaves: ReportLeafId[]) => ({
|
||||
admits: (leaf: ReportLeafId) => leaves.includes(leaf),
|
||||
});
|
||||
|
||||
it("carries only what the saved selection admits, never sleep", () => {
|
||||
const s = resolveSeriesScope(
|
||||
gateOf(["WEIGHT", "SLEEP_DURATION", "HRV_RMSSD"]),
|
||||
null,
|
||||
);
|
||||
expect(s.types.sort()).toEqual(["HRV_RMSSD", "WEIGHT"]);
|
||||
expect(s.workouts).toBe(false);
|
||||
expect(seriesCandidateTypes()).not.toContain("SLEEP_DURATION");
|
||||
});
|
||||
|
||||
it("pairs blood pressure only when both components are admitted", () => {
|
||||
expect(
|
||||
resolveSeriesScope(
|
||||
gateOf(["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]),
|
||||
null,
|
||||
).pairBloodPressure,
|
||||
).toBe(true);
|
||||
expect(
|
||||
resolveSeriesScope(gateOf(["BLOOD_PRESSURE_SYS"]), null)
|
||||
.pairBloodPressure,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("intersects the code filter with the gate", () => {
|
||||
const s = resolveSeriesScope(
|
||||
gateOf(["WEIGHT", "WORKOUTS"]),
|
||||
parseCodeParams(["80404-7", EXERCISE_ACTIVITY_LOINC]),
|
||||
);
|
||||
expect(s.types).toEqual([]);
|
||||
expect(s.workouts).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/** The structural R4 checks the conformance suite applies to every Observation. */
|
||||
function assertR4Observation(o: FhirObservation) {
|
||||
expect(o.resourceType).toBe("Observation");
|
||||
expect(o.status).toBe("final");
|
||||
expect(o.subject.reference).toBe("Patient/patient-1");
|
||||
// effective[x]: exactly one of dateTime / period.
|
||||
expect(Boolean(o.effectiveDateTime) !== Boolean(o.effectivePeriod)).toBe(
|
||||
true,
|
||||
);
|
||||
// value[x]: at most one.
|
||||
const values = [
|
||||
o.valueQuantity,
|
||||
o.valueCodeableConcept,
|
||||
o.valueString,
|
||||
o.valueInteger,
|
||||
].filter((v) => v !== undefined);
|
||||
expect(values.length).toBeLessThanOrEqual(1);
|
||||
// A coded UCUM unit is a parseable one, and system + code travel together.
|
||||
const quantities = [
|
||||
o.valueQuantity,
|
||||
...(o.component ?? []).map((c) => c.valueQuantity),
|
||||
].filter(Boolean);
|
||||
for (const q of quantities) {
|
||||
if (q!.code !== undefined) expect(isUcumCode(q!.code)).toBe(true);
|
||||
expect(q!.code === undefined).toBe(q!.system === undefined);
|
||||
}
|
||||
// vital-signs category promises a LOINC code.
|
||||
if (o.category?.some((c) => c.coding?.[0]?.code === "vital-signs")) {
|
||||
const loinc = o.code.coding?.some((c) => c.system === "http://loinc.org");
|
||||
const textOnly = !o.code.coding && Boolean(o.code.text);
|
||||
expect(loinc || textOnly).toBe(true);
|
||||
}
|
||||
expect(JSON.parse(JSON.stringify(o))).toEqual(o);
|
||||
}
|
||||
|
||||
const AT = new Date("2026-03-04T07:30:00.000Z");
|
||||
|
||||
describe("measurementSeriesObservation", () => {
|
||||
it("emits one reading per row with its own id and instant", () => {
|
||||
const o = measurementSeriesObservation(
|
||||
{
|
||||
id: "abc",
|
||||
type: "WEIGHT",
|
||||
value: 79.45,
|
||||
valueMin: null,
|
||||
valueMax: null,
|
||||
measuredAt: AT,
|
||||
glucoseContext: null,
|
||||
},
|
||||
"mg/dL",
|
||||
null,
|
||||
)!;
|
||||
assertR4Observation(o);
|
||||
expect(o.id).toBe("m-abc");
|
||||
expect(o.effectiveDateTime).toBe(AT.toISOString());
|
||||
expect(o.valueQuantity?.value).toBe(79.45);
|
||||
expect(o.code.coding?.[0]?.code).toBe(MEASUREMENT_LOINC.WEIGHT.loinc);
|
||||
});
|
||||
|
||||
it("labels RMSSD as its own text-only statistic, never under SDNN's code", () => {
|
||||
const o = measurementSeriesObservation(
|
||||
{
|
||||
id: "r1",
|
||||
type: "HRV_RMSSD",
|
||||
value: 38,
|
||||
valueMin: null,
|
||||
valueMax: null,
|
||||
measuredAt: AT,
|
||||
glucoseContext: null,
|
||||
},
|
||||
"mg/dL",
|
||||
null,
|
||||
)!;
|
||||
assertR4Observation(o);
|
||||
expect(o.code.text).toBe("Heart rate variability RMSSD");
|
||||
expect(o.code.coding).toBeUndefined();
|
||||
expect(o.valueQuantity).toMatchObject({ value: 38, unit: "ms" });
|
||||
});
|
||||
|
||||
it("reads an hourly heart-rate bucket as a mean over its hour", () => {
|
||||
const o = measurementSeriesObservation(
|
||||
{
|
||||
id: "h1",
|
||||
type: "PULSE",
|
||||
value: 72,
|
||||
valueMin: 60,
|
||||
valueMax: 95,
|
||||
measuredAt: AT,
|
||||
glucoseContext: null,
|
||||
},
|
||||
"mg/dL",
|
||||
null,
|
||||
)!;
|
||||
assertR4Observation(o);
|
||||
expect(o.effectivePeriod).toEqual({
|
||||
start: AT.toISOString(),
|
||||
end: "2026-03-04T08:30:00.000Z",
|
||||
});
|
||||
expect(o.note?.[0]?.text).toContain("60 to 95");
|
||||
});
|
||||
|
||||
it("pairs a systolic reading with its diastolic partner as the BP panel", () => {
|
||||
const o = measurementSeriesObservation(
|
||||
{
|
||||
id: "s1",
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
value: 131,
|
||||
valueMin: null,
|
||||
valueMax: null,
|
||||
measuredAt: AT,
|
||||
glucoseContext: null,
|
||||
},
|
||||
"mg/dL",
|
||||
84,
|
||||
)!;
|
||||
assertR4Observation(o);
|
||||
expect(o.code.coding?.[0]?.code).toBe("85354-9");
|
||||
expect(o.component?.map((c) => c.valueQuantity?.value)).toEqual([131, 84]);
|
||||
});
|
||||
|
||||
it("emits a lone component under its own code and outside the vital-signs profile", () => {
|
||||
const o = measurementSeriesObservation(
|
||||
{
|
||||
id: "d1",
|
||||
type: "BLOOD_PRESSURE_DIA",
|
||||
value: 84,
|
||||
valueMin: null,
|
||||
valueMax: null,
|
||||
measuredAt: AT,
|
||||
glucoseContext: null,
|
||||
},
|
||||
"mg/dL",
|
||||
null,
|
||||
)!;
|
||||
assertR4Observation(o);
|
||||
expect(o.code.coding?.[0]?.code).toBe("8462-4");
|
||||
expect(o.category).toBeUndefined();
|
||||
});
|
||||
|
||||
it("codes glucose by context and values it in the owner's unit", () => {
|
||||
const o = measurementSeriesObservation(
|
||||
{
|
||||
id: "g1",
|
||||
type: "BLOOD_GLUCOSE",
|
||||
value: 99,
|
||||
valueMin: null,
|
||||
valueMax: null,
|
||||
measuredAt: AT,
|
||||
glucoseContext: "FASTING",
|
||||
},
|
||||
"mmol/L",
|
||||
null,
|
||||
)!;
|
||||
assertR4Observation(o);
|
||||
expect(o.code.coding?.[0]?.code).toBe("1558-6");
|
||||
expect(o.valueQuantity?.unit).toBe("mmol/L");
|
||||
expect(o.valueQuantity?.value).toBeCloseTo(5.5, 1);
|
||||
});
|
||||
|
||||
it("emits nothing for sleep segments", () => {
|
||||
expect(
|
||||
measurementSeriesObservation(
|
||||
{
|
||||
id: "z",
|
||||
type: "SLEEP_DURATION",
|
||||
value: 90,
|
||||
valueMin: null,
|
||||
valueMax: null,
|
||||
measuredAt: AT,
|
||||
glucoseContext: null,
|
||||
},
|
||||
"mg/dL",
|
||||
null,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("workoutSeriesObservation", () => {
|
||||
it("emits an activity Observation per the Physical Activity IG pattern", () => {
|
||||
const o = workoutSeriesObservation({
|
||||
id: "w1",
|
||||
sportType: "running",
|
||||
startedAt: AT,
|
||||
endedAt: new Date("2026-03-04T08:15:30.000Z"),
|
||||
durationSec: 2730,
|
||||
totalDistanceM: 8012.5,
|
||||
totalEnergyKcal: 610,
|
||||
avgHeartRate: 151,
|
||||
});
|
||||
assertR4Observation(o);
|
||||
expect(o.id).toBe("w-w1");
|
||||
expect(o.category?.[0]?.coding?.[0]?.code).toBe("activity");
|
||||
expect(o.code.coding?.[0]?.code).toBe(EXERCISE_ACTIVITY_LOINC);
|
||||
expect(o.valueCodeableConcept?.coding?.[0]?.code).toBe("running");
|
||||
expect(o.effectivePeriod?.end).toBe("2026-03-04T08:15:30.000Z");
|
||||
const byCode = Object.fromEntries(
|
||||
(o.component ?? []).map((c) => [
|
||||
c.code.coding?.[0]?.code ?? c.code.text,
|
||||
c.valueQuantity,
|
||||
]),
|
||||
);
|
||||
expect(byCode[EXERCISE_DURATION_LOINC]).toMatchObject({
|
||||
value: 45.5,
|
||||
code: "min",
|
||||
});
|
||||
expect(byCode[CALORIES_BURNED_LOINC]).toMatchObject({ value: 610 });
|
||||
expect(byCode.Distance).toMatchObject({ value: 8012.5, unit: "m" });
|
||||
});
|
||||
|
||||
it("leaves out the components a source did not report", () => {
|
||||
const o = workoutSeriesObservation({
|
||||
id: "w2",
|
||||
sportType: "yoga",
|
||||
startedAt: AT,
|
||||
endedAt: AT,
|
||||
durationSec: 600,
|
||||
totalDistanceM: null,
|
||||
totalEnergyKcal: null,
|
||||
avgHeartRate: null,
|
||||
});
|
||||
expect(o.component).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchesCodeTokens", () => {
|
||||
it("matches a bare code on any system and a system|code pair exactly", () => {
|
||||
const o = {
|
||||
code: { coding: [{ system: "http://loinc.org", code: "29463-7" }] },
|
||||
} as FhirObservation;
|
||||
expect(matchesCodeTokens(o, ["29463-7"])).toBe(true);
|
||||
expect(matchesCodeTokens(o, ["http://loinc.org|29463-7"])).toBe(true);
|
||||
expect(matchesCodeTokens(o, ["urn:x|29463-7"])).toBe(false);
|
||||
expect(matchesCodeTokens(o, ["8480-6,29463-7"])).toBe(true);
|
||||
});
|
||||
});
|
||||
+46
-1
@@ -24,7 +24,15 @@ import {
|
||||
} from "@/lib/doctor-report-data";
|
||||
import type { FhirRecordInputs } from "@/lib/fhir/build-bundle";
|
||||
import { GERMAN_ATC_DEFAULT_LOCALES } from "@/lib/fhir/resources";
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
import { isModuleEnabled, resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { resolveReportGate } from "@/lib/doctor-report/selection-gate";
|
||||
import {
|
||||
resolveSeriesScope,
|
||||
seriesGlucoseUnit,
|
||||
type SeriesCodeFilter,
|
||||
type SeriesScope,
|
||||
} from "@/lib/fhir/series";
|
||||
import type { GlucoseUnit } from "@/lib/glucose";
|
||||
import { toAllergyDTO, toFamilyHistoryEntryDTO } from "@/lib/records/dto";
|
||||
import type {
|
||||
FhirBundleEntry,
|
||||
@@ -65,6 +73,16 @@ export const FHIR_EVERYTHING_OPERATION = "$everything";
|
||||
/** Search parameters honoured uniformly across the search routes. */
|
||||
export const FHIR_SEARCH_PARAMS = ["_count", "_offset"] as const;
|
||||
|
||||
/**
|
||||
* Further search parameters the Observation search honours (v1.44). `date`
|
||||
* switches the answer from the snapshot to the time series of every reading
|
||||
* in the range; `code` narrows either answer to the named codings.
|
||||
*/
|
||||
export const FHIR_OBSERVATION_SEARCH_PARAMS = [
|
||||
{ name: "date", type: "date" },
|
||||
{ name: "code", type: "token" },
|
||||
] as const;
|
||||
|
||||
/** Canonical FHIR media type for every response (success or outcome). */
|
||||
export const FHIR_CONTENT_TYPE = "application/fhir+json; charset=utf-8";
|
||||
|
||||
@@ -308,3 +326,30 @@ export async function loadFhirContext(userId: string): Promise<{
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The series face's scope: the same saved selection and module map the
|
||||
* snapshot replays, narrowed by an optional `code` filter, plus the owner's
|
||||
* glucose unit. Reads two small rows and the selection — never the
|
||||
* whole-record aggregate, so a series page costs what its page costs.
|
||||
*
|
||||
* Carries the same module backstop as {@link loadFhirContext}.
|
||||
*/
|
||||
export async function loadFhirSeriesScope(
|
||||
userId: string,
|
||||
codes: SeriesCodeFilter | null,
|
||||
): Promise<{ scope: SeriesScope; glucoseUnit: GlucoseUnit }> {
|
||||
if (!(await isModuleEnabled(userId, "doctorReport"))) {
|
||||
throw new Error(
|
||||
'loadFhirSeriesScope called with the "doctorReport" module disabled — ' +
|
||||
"the calling route is missing its requireModuleEnabled gate",
|
||||
);
|
||||
}
|
||||
const [selection, moduleMap, glucoseUnit] = await Promise.all([
|
||||
resolveSavedSelection(userId),
|
||||
resolveModuleMap(userId),
|
||||
seriesGlucoseUnit(userId),
|
||||
]);
|
||||
const gate = resolveReportGate(selection, moduleMap);
|
||||
return { scope: resolveSeriesScope(gate, codes), glucoseUnit };
|
||||
}
|
||||
@@ -0,0 +1,765 @@
|
||||
/**
|
||||
* The time-series face of `GET /api/fhir/Observation`: every recorded reading
|
||||
* as its own Observation, newest first, paged in the database.
|
||||
*
|
||||
* The search route's default answer stays what it always was, a snapshot of
|
||||
* the record (one latest reading per type plus the derived panels, ids
|
||||
* `obs-N`). A client opts into the series by naming a `date` range, which is
|
||||
* what a FHIR client does when it wants readings over time; the snapshot has
|
||||
* no date range to filter, so the parameter never meant anything there
|
||||
* before. Additive by construction: no existing request changes its answer.
|
||||
*
|
||||
* date=ge2026-01-01&date=lt2026-02-01 readings in January (UTC)
|
||||
* code=http://loinc.org|80404-7 narrowed to SDNN (also in snapshot mode)
|
||||
* _count / _offset paging, as on every search route
|
||||
*
|
||||
* Series ids are the row's own (`m-<id>` for a measurement, `w-<id>` for a
|
||||
* workout) so a reading keeps its id across pages and windows, and
|
||||
* `GET /Observation/{id}` reads it back.
|
||||
*
|
||||
* Scope is the snapshot's: the owner's saved report selection and module map
|
||||
* decide which types may appear (`resolveReportGate`), so the series can
|
||||
* never reach a type the snapshot would withhold. Memory is bounded by the
|
||||
* page: one id query (a UNION over measurements and workouts, ordered and
|
||||
* paged in SQL), one count, and a by-id read of at most `_count` rows.
|
||||
*
|
||||
* Every row as recorded. Two sources writing the same weighing are two
|
||||
* readings here; the snapshot is the de-duplicated view. Sleep is not in the
|
||||
* series: it is stored as per-stage segments, and a segment is not a night.
|
||||
*/
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import type {
|
||||
GlucoseContext,
|
||||
MeasurementType,
|
||||
} from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
convertGlucose,
|
||||
resolveGlucoseUnit,
|
||||
type GlucoseUnit,
|
||||
} from "@/lib/glucose";
|
||||
import {
|
||||
BP_DIA_LOINC,
|
||||
BP_PANEL_LOINC,
|
||||
BP_SYS_LOINC,
|
||||
BP_UNIT,
|
||||
GLUCOSE_LOINC,
|
||||
HEALTHKIT_CODESYSTEM,
|
||||
LOINC_SYSTEM,
|
||||
MEASUREMENT_LOINC,
|
||||
} from "@/lib/fhir/loinc-map";
|
||||
import { ucumQuantity } from "@/lib/fhir/ucum";
|
||||
import type {
|
||||
FhirObservation,
|
||||
FhirObservationComponent,
|
||||
} from "@/lib/fhir/types";
|
||||
import {
|
||||
categoryConcept,
|
||||
codeableFromMapping,
|
||||
patientRef,
|
||||
} from "@/lib/fhir/resources/common";
|
||||
import type { ReportGate } from "@/lib/doctor-report/selection-gate";
|
||||
|
||||
// ── date parameter ──────────────────────────────────────────────────────
|
||||
|
||||
/** A half-open instant range; either end may be open. */
|
||||
export interface SeriesRange {
|
||||
from: Date | null;
|
||||
to: Date | null;
|
||||
}
|
||||
|
||||
const DATE_RE =
|
||||
/^(eq|gt|ge|lt|le)?(\d{4})(?:-(\d{2})(?:-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(Z|[+-]\d{2}:\d{2})?)?)?)?$/;
|
||||
|
||||
/**
|
||||
* The instant span one FHIR date value covers: a year, a month, a day, a
|
||||
* minute or a second. A value without an offset is read as UTC.
|
||||
*/
|
||||
function dateSpan(match: RegExpMatchArray): { start: Date; end: Date } | null {
|
||||
const [, , y, mo, d, h, mi, s, tz] = match;
|
||||
const year = Number(y);
|
||||
if (mo === undefined) {
|
||||
return {
|
||||
start: new Date(Date.UTC(year, 0, 1)),
|
||||
end: new Date(Date.UTC(year + 1, 0, 1)),
|
||||
};
|
||||
}
|
||||
const month = Number(mo) - 1;
|
||||
if (month < 0 || month > 11) return null;
|
||||
if (d === undefined) {
|
||||
return {
|
||||
start: new Date(Date.UTC(year, month, 1)),
|
||||
end: new Date(Date.UTC(year, month + 1, 1)),
|
||||
};
|
||||
}
|
||||
const day = Number(d);
|
||||
const dayStart = new Date(Date.UTC(year, month, day));
|
||||
if (dayStart.getUTCMonth() !== month || dayStart.getUTCDate() !== day) {
|
||||
return null;
|
||||
}
|
||||
if (h === undefined) {
|
||||
return { start: dayStart, end: new Date(dayStart.getTime() + 86_400_000) };
|
||||
}
|
||||
const iso = `${y}-${mo}-${d}T${h}:${mi}:${s ?? "00"}${tz ?? "Z"}`;
|
||||
const start = new Date(iso);
|
||||
if (Number.isNaN(start.getTime())) return null;
|
||||
return { start, end: new Date(start.getTime() + (s ? 1000 : 60_000)) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse every `date` value into one range (their intersection). Returns null
|
||||
* when no `date` parameter was sent, and an error string for a value the
|
||||
* server cannot read — a 400, never a silently ignored filter.
|
||||
*/
|
||||
export function parseDateParams(
|
||||
values: readonly string[],
|
||||
): SeriesRange | { error: string } | null {
|
||||
if (values.length === 0) return null;
|
||||
let from: Date | null = null;
|
||||
let to: Date | null = null;
|
||||
const later = (a: Date | null, b: Date) => (a && a > b ? a : b);
|
||||
const earlier = (a: Date | null, b: Date) => (a && a < b ? a : b);
|
||||
for (const raw of values) {
|
||||
for (const value of raw.split(",")) {
|
||||
const match = value.trim().match(DATE_RE);
|
||||
const span = match ? dateSpan(match) : null;
|
||||
if (!match || !span) {
|
||||
return { error: `Unsupported date value: ${value.slice(0, 40)}` };
|
||||
}
|
||||
switch (match[1] ?? "eq") {
|
||||
case "eq":
|
||||
from = later(from, span.start);
|
||||
to = earlier(to, span.end);
|
||||
break;
|
||||
case "ge":
|
||||
from = later(from, span.start);
|
||||
break;
|
||||
case "gt":
|
||||
from = later(from, span.end);
|
||||
break;
|
||||
case "le":
|
||||
to = earlier(to, span.end);
|
||||
break;
|
||||
case "lt":
|
||||
to = earlier(to, span.start);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { from, to };
|
||||
}
|
||||
|
||||
// ── code parameter ──────────────────────────────────────────────────────
|
||||
|
||||
/** Exercise activity — the LOINC a workout Observation is coded with. */
|
||||
export const EXERCISE_ACTIVITY_LOINC = "73985-4";
|
||||
/** Exercise duration (min). */
|
||||
export const EXERCISE_DURATION_LOINC = "55411-3";
|
||||
/** Calories burned (kcal). */
|
||||
export const CALORIES_BURNED_LOINC = "41981-2";
|
||||
/** Local CodeSystem for the canonical workout sport keys. */
|
||||
export const WORKOUT_SPORT_CODESYSTEM =
|
||||
"https://healthlog.dev/fhir/CodeSystem/workout-sport";
|
||||
|
||||
/** What a set of codes selects from the series. */
|
||||
export interface SeriesCodeFilter {
|
||||
types: Set<MeasurementType>;
|
||||
/** Glucose contexts a glucose code narrows to; null keeps every context. */
|
||||
glucoseContexts: Set<GlucoseContext | "NONE"> | null;
|
||||
workouts: boolean;
|
||||
}
|
||||
|
||||
const GLUCOSE_CONTEXT_OF: Record<string, GlucoseContext | "NONE"> = {
|
||||
FASTING: "FASTING",
|
||||
POSTPRANDIAL: "POSTPRANDIAL",
|
||||
RANDOM: "RANDOM",
|
||||
BEDTIME: "BEDTIME",
|
||||
UNSPECIFIED: "NONE",
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve `code` tokens (`system|code` or a bare code, comma-separated, any
|
||||
* number of parameters) to the types they select. Returns null when no
|
||||
* `code` was sent. An unknown code selects nothing — an empty Bundle, which
|
||||
* is FHIR's answer to a search that matches nothing.
|
||||
*/
|
||||
export function parseCodeParams(
|
||||
values: readonly string[],
|
||||
): SeriesCodeFilter | null {
|
||||
if (values.length === 0) return null;
|
||||
const filter: SeriesCodeFilter = {
|
||||
types: new Set(),
|
||||
glucoseContexts: null,
|
||||
workouts: false,
|
||||
};
|
||||
let glucoseContexts: Set<GlucoseContext | "NONE"> | null = null;
|
||||
for (const raw of values) {
|
||||
for (const token of raw.split(",")) {
|
||||
const bar = token.indexOf("|");
|
||||
const system = bar >= 0 ? token.slice(0, bar) : null;
|
||||
const code = (bar >= 0 ? token.slice(bar + 1) : token).trim();
|
||||
if (!code) continue;
|
||||
const loinc = system === null || system === "" || system === LOINC_SYSTEM;
|
||||
const healthkit = system === null || system === HEALTHKIT_CODESYSTEM;
|
||||
if (loinc && code === EXERCISE_ACTIVITY_LOINC) filter.workouts = true;
|
||||
if (
|
||||
loinc &&
|
||||
(code === BP_PANEL_LOINC ||
|
||||
code === BP_SYS_LOINC ||
|
||||
code === BP_DIA_LOINC)
|
||||
) {
|
||||
filter.types.add("BLOOD_PRESSURE_SYS");
|
||||
filter.types.add("BLOOD_PRESSURE_DIA");
|
||||
}
|
||||
if (loinc) {
|
||||
for (const [ctx, map] of Object.entries(GLUCOSE_LOINC)) {
|
||||
if (map.loinc !== code) continue;
|
||||
filter.types.add("BLOOD_GLUCOSE");
|
||||
glucoseContexts ??= new Set();
|
||||
glucoseContexts.add(GLUCOSE_CONTEXT_OF[ctx]);
|
||||
}
|
||||
}
|
||||
for (const [type, mapping] of Object.entries(MEASUREMENT_LOINC)) {
|
||||
if (mapping.loinc !== code) continue;
|
||||
const isHk = mapping.loinc.startsWith("HKQuantityTypeIdentifier");
|
||||
if ((isHk && healthkit) || (!isHk && loinc)) {
|
||||
filter.types.add(type as MeasurementType);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
filter.glucoseContexts = glucoseContexts;
|
||||
return filter;
|
||||
}
|
||||
|
||||
// ── scope ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Types the series can carry: every single-value coding, BP and glucose. Never sleep. */
|
||||
export function seriesCandidateTypes(): MeasurementType[] {
|
||||
const types = Object.keys(MEASUREMENT_LOINC).filter(
|
||||
(t) => t !== "SLEEP_DURATION",
|
||||
) as MeasurementType[];
|
||||
return [
|
||||
...types,
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"BLOOD_GLUCOSE",
|
||||
];
|
||||
}
|
||||
|
||||
export interface SeriesScope {
|
||||
types: MeasurementType[];
|
||||
/** Pair diastolic onto systolic (both admitted): the BP panel. */
|
||||
pairBloodPressure: boolean;
|
||||
glucoseContexts: Set<GlucoseContext | "NONE"> | null;
|
||||
workouts: boolean;
|
||||
}
|
||||
|
||||
/** The gate's answer, narrowed by an optional code filter. */
|
||||
export function resolveSeriesScope(
|
||||
gate: Pick<ReportGate, "admits">,
|
||||
codes: SeriesCodeFilter | null,
|
||||
): SeriesScope {
|
||||
const types = seriesCandidateTypes().filter(
|
||||
(t) => gate.admits(t) && (codes === null || codes.types.has(t)),
|
||||
);
|
||||
return {
|
||||
types,
|
||||
pairBloodPressure:
|
||||
types.includes("BLOOD_PRESSURE_SYS") &&
|
||||
types.includes("BLOOD_PRESSURE_DIA"),
|
||||
glucoseContexts: codes?.glucoseContexts ?? null,
|
||||
workouts: gate.admits("WORKOUTS") && (codes === null || codes.workouts),
|
||||
};
|
||||
}
|
||||
|
||||
// ── query ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** One page of series row references, newest first. */
|
||||
interface SeriesRef {
|
||||
kind: "m" | "w";
|
||||
id: string;
|
||||
}
|
||||
|
||||
function rangeSql(column: string, range: SeriesRange): Prisma.Sql {
|
||||
const col = Prisma.raw(column);
|
||||
return Prisma.sql`${
|
||||
range.from ? Prisma.sql`AND ${col} >= ${range.from}` : Prisma.empty
|
||||
} ${range.to ? Prisma.sql`AND ${col} < ${range.to}` : Prisma.empty}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The UNION the page and the count share. Every value is a bound parameter;
|
||||
* the only spliced text is the closed column names above.
|
||||
*/
|
||||
function seriesUnion(
|
||||
userId: string,
|
||||
scope: SeriesScope,
|
||||
range: SeriesRange,
|
||||
): Prisma.Sql | null {
|
||||
const parts: Prisma.Sql[] = [];
|
||||
if (scope.types.length > 0) {
|
||||
const contexts = scope.glucoseContexts;
|
||||
const glucoseFilter =
|
||||
contexts === null
|
||||
? Prisma.empty
|
||||
: Prisma.sql`AND (m."type" <> 'BLOOD_GLUCOSE'::"measurement_type" OR m."glucose_context"::text = ANY(${[
|
||||
...contexts,
|
||||
].filter((c) => c !== "NONE")}::text[]) OR (${contexts.has(
|
||||
"NONE",
|
||||
)}::boolean AND m."glucose_context" IS NULL))`;
|
||||
// A diastolic reading that has its systolic partner rides inside the
|
||||
// systolic row's panel, never as a second entry.
|
||||
const pairFilter = scope.pairBloodPressure
|
||||
? Prisma.sql`AND NOT (m."type" = 'BLOOD_PRESSURE_DIA'::"measurement_type" AND EXISTS (
|
||||
SELECT 1 FROM measurements s
|
||||
WHERE s."user_id" = m."user_id"
|
||||
AND s."type" = 'BLOOD_PRESSURE_SYS'::"measurement_type"
|
||||
AND s."measured_at" = m."measured_at"
|
||||
AND s."deleted_at" IS NULL))`
|
||||
: Prisma.empty;
|
||||
parts.push(Prisma.sql`
|
||||
SELECT 'm' AS kind, m."id" AS id, m."measured_at" AS at
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."deleted_at" IS NULL
|
||||
AND m."type"::text = ANY(${scope.types}::text[])
|
||||
${rangeSql('m."measured_at"', range)}
|
||||
${glucoseFilter}
|
||||
${pairFilter}`);
|
||||
}
|
||||
if (scope.workouts) {
|
||||
parts.push(Prisma.sql`
|
||||
SELECT 'w' AS kind, w."id" AS id, w."started_at" AS at
|
||||
FROM workouts w
|
||||
WHERE w."user_id" = ${userId}
|
||||
${rangeSql('w."started_at"', range)}`);
|
||||
}
|
||||
if (parts.length === 0) return null;
|
||||
return Prisma.join(parts, " UNION ALL ");
|
||||
}
|
||||
|
||||
/** The total match count and one page of references, newest first. */
|
||||
export async function querySeriesPage(params: {
|
||||
userId: string;
|
||||
scope: SeriesScope;
|
||||
range: SeriesRange;
|
||||
count: number;
|
||||
offset: number;
|
||||
}): Promise<{ total: number; refs: SeriesRef[] }> {
|
||||
const { userId, scope, range, count, offset } = params;
|
||||
const union = seriesUnion(userId, scope, range);
|
||||
if (!union) return { total: 0, refs: [] };
|
||||
const [totals, refs] = await Promise.all([
|
||||
prisma.$queryRaw<Array<{ total: bigint | number }>>`
|
||||
SELECT COUNT(*) AS total FROM (${union}) AS series`,
|
||||
prisma.$queryRaw<SeriesRef[]>`
|
||||
SELECT kind, id FROM (${union}) AS series
|
||||
ORDER BY at DESC, id DESC
|
||||
LIMIT ${count} OFFSET ${offset}`,
|
||||
]);
|
||||
return { total: Number(totals[0]?.total ?? 0), refs };
|
||||
}
|
||||
|
||||
// ── emit ────────────────────────────────────────────────────────────────
|
||||
|
||||
const MEASUREMENT_SELECT = {
|
||||
id: true,
|
||||
type: true,
|
||||
value: true,
|
||||
valueMin: true,
|
||||
valueMax: true,
|
||||
measuredAt: true,
|
||||
glucoseContext: true,
|
||||
} as const;
|
||||
|
||||
type SeriesMeasurementRow = {
|
||||
id: string;
|
||||
type: MeasurementType;
|
||||
value: number;
|
||||
valueMin: number | null;
|
||||
valueMax: number | null;
|
||||
measuredAt: Date;
|
||||
glucoseContext: GlucoseContext | null;
|
||||
};
|
||||
|
||||
type SeriesWorkoutRow = {
|
||||
id: string;
|
||||
sportType: string;
|
||||
startedAt: Date;
|
||||
endedAt: Date;
|
||||
durationSec: number;
|
||||
totalDistanceM: number | null;
|
||||
totalEnergyKcal: number | null;
|
||||
avgHeartRate: number | null;
|
||||
};
|
||||
|
||||
const WORKOUT_SELECT = {
|
||||
id: true,
|
||||
sportType: true,
|
||||
startedAt: true,
|
||||
endedAt: true,
|
||||
durationSec: true,
|
||||
totalDistanceM: true,
|
||||
totalEnergyKcal: true,
|
||||
avgHeartRate: true,
|
||||
} as const;
|
||||
|
||||
function bpComponent(
|
||||
code: string,
|
||||
display: string,
|
||||
value: number,
|
||||
): FhirObservationComponent {
|
||||
return {
|
||||
code: { coding: [{ system: LOINC_SYSTEM, code, display }] },
|
||||
valueQuantity: ucumQuantity(value, BP_UNIT),
|
||||
};
|
||||
}
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
|
||||
/** One measurement row as an Observation; null for a type the series does not code. */
|
||||
export function measurementSeriesObservation(
|
||||
row: SeriesMeasurementRow,
|
||||
glucoseUnit: GlucoseUnit,
|
||||
diastolic: number | null,
|
||||
): FhirObservation | null {
|
||||
const id = `m-${row.id}`;
|
||||
const at = row.measuredAt.toISOString();
|
||||
if (row.type === "BLOOD_PRESSURE_SYS" || row.type === "BLOOD_PRESSURE_DIA") {
|
||||
const sys = row.type === "BLOOD_PRESSURE_SYS";
|
||||
if (sys && diastolic !== null) {
|
||||
return {
|
||||
resourceType: "Observation",
|
||||
id,
|
||||
status: "final",
|
||||
category: [categoryConcept("vital-signs")],
|
||||
code: {
|
||||
coding: [
|
||||
{
|
||||
system: LOINC_SYSTEM,
|
||||
code: BP_PANEL_LOINC,
|
||||
display: "Blood pressure panel with all children optional",
|
||||
},
|
||||
],
|
||||
text: "Blood pressure",
|
||||
},
|
||||
subject: patientRef,
|
||||
effectiveDateTime: at,
|
||||
component: [
|
||||
bpComponent(BP_SYS_LOINC, "Systolic blood pressure", row.value),
|
||||
bpComponent(BP_DIA_LOINC, "Diastolic blood pressure", diastolic),
|
||||
],
|
||||
};
|
||||
}
|
||||
// A lone component (its partner withheld or never recorded) is still a
|
||||
// reading, coded with its own LOINC. It carries no vital-signs category:
|
||||
// the R4 blood-pressure profile wants both components, and a category
|
||||
// that promises the profile would be a claim the resource cannot keep.
|
||||
return {
|
||||
resourceType: "Observation",
|
||||
id,
|
||||
status: "final",
|
||||
code: {
|
||||
coding: [
|
||||
sys
|
||||
? {
|
||||
system: LOINC_SYSTEM,
|
||||
code: BP_SYS_LOINC,
|
||||
display: "Systolic blood pressure",
|
||||
}
|
||||
: {
|
||||
system: LOINC_SYSTEM,
|
||||
code: BP_DIA_LOINC,
|
||||
display: "Diastolic blood pressure",
|
||||
},
|
||||
],
|
||||
text: sys ? "Systolic blood pressure" : "Diastolic blood pressure",
|
||||
},
|
||||
subject: patientRef,
|
||||
effectiveDateTime: at,
|
||||
valueQuantity: ucumQuantity(row.value, BP_UNIT),
|
||||
};
|
||||
}
|
||||
if (row.type === "BLOOD_GLUCOSE") {
|
||||
const map = GLUCOSE_LOINC[row.glucoseContext ?? "UNSPECIFIED"];
|
||||
if (!map) return null;
|
||||
return {
|
||||
resourceType: "Observation",
|
||||
id,
|
||||
status: "final",
|
||||
category: [categoryConcept("laboratory")],
|
||||
code: {
|
||||
coding: [
|
||||
{ system: LOINC_SYSTEM, code: map.loinc, display: map.display },
|
||||
],
|
||||
text: map.display,
|
||||
},
|
||||
subject: patientRef,
|
||||
effectiveDateTime: at,
|
||||
valueQuantity: ucumQuantity(
|
||||
convertGlucose(row.value, glucoseUnit),
|
||||
glucoseUnit,
|
||||
),
|
||||
};
|
||||
}
|
||||
const mapping = MEASUREMENT_LOINC[row.type];
|
||||
if (!mapping || row.type === "SLEEP_DURATION") return null;
|
||||
// An hourly heart-rate bucket (min and max stored beside the mean) is a
|
||||
// mean over its hour, not a reading at its first instant.
|
||||
const hourly = row.valueMin !== null && row.valueMax !== null;
|
||||
return {
|
||||
resourceType: "Observation",
|
||||
id,
|
||||
status: "final",
|
||||
category: [categoryConcept(mapping.category)],
|
||||
code: codeableFromMapping(mapping),
|
||||
subject: patientRef,
|
||||
...(hourly
|
||||
? {
|
||||
effectivePeriod: {
|
||||
start: at,
|
||||
end: new Date(row.measuredAt.getTime() + HOUR_MS).toISOString(),
|
||||
},
|
||||
note: [
|
||||
{
|
||||
text: `Mean over the hour; range ${row.valueMin} to ${row.valueMax} ${mapping.unit}.`,
|
||||
},
|
||||
],
|
||||
}
|
||||
: { effectiveDateTime: at }),
|
||||
valueQuantity: ucumQuantity(row.value, mapping.unit),
|
||||
};
|
||||
}
|
||||
|
||||
/** The sport key in words, for `valueCodeableConcept.text`. */
|
||||
function sportText(sportType: string): string {
|
||||
return sportType
|
||||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||
.replace(/_/g, " ")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* A workout as an Observation, after the HL7 Physical Activity IG: the
|
||||
* session is an `activity` Observation coded 73985-4 (Exercise activity) whose
|
||||
* value is the kind of exercise, over the session's period, with duration and
|
||||
* energy as LOINC-coded components. Not a Procedure: R4 reserves Procedure
|
||||
* for something done to or for the patient by a care process, and a run is a
|
||||
* measured behaviour.
|
||||
*/
|
||||
export function workoutSeriesObservation(
|
||||
row: SeriesWorkoutRow,
|
||||
): FhirObservation {
|
||||
const component: FhirObservationComponent[] = [
|
||||
{
|
||||
code: {
|
||||
coding: [
|
||||
{
|
||||
system: LOINC_SYSTEM,
|
||||
code: EXERCISE_DURATION_LOINC,
|
||||
display: "Exercise duration",
|
||||
},
|
||||
],
|
||||
},
|
||||
valueQuantity: ucumQuantity(
|
||||
Math.round((row.durationSec / 60) * 100) / 100,
|
||||
"min",
|
||||
),
|
||||
},
|
||||
];
|
||||
if (row.totalEnergyKcal !== null && row.totalEnergyKcal > 0) {
|
||||
component.push({
|
||||
code: {
|
||||
coding: [
|
||||
{
|
||||
system: LOINC_SYSTEM,
|
||||
code: CALORIES_BURNED_LOINC,
|
||||
display: "Calories burned",
|
||||
},
|
||||
],
|
||||
},
|
||||
valueQuantity: ucumQuantity(row.totalEnergyKcal, "kcal"),
|
||||
});
|
||||
}
|
||||
if (row.totalDistanceM !== null && row.totalDistanceM > 0) {
|
||||
component.push({
|
||||
code: { text: "Distance" },
|
||||
valueQuantity: ucumQuantity(row.totalDistanceM, "m"),
|
||||
});
|
||||
}
|
||||
if (row.avgHeartRate !== null && row.avgHeartRate > 0) {
|
||||
component.push({
|
||||
code: { text: "Average heart rate during exercise" },
|
||||
valueQuantity: ucumQuantity(row.avgHeartRate, "/min"),
|
||||
});
|
||||
}
|
||||
return {
|
||||
resourceType: "Observation",
|
||||
id: `w-${row.id}`,
|
||||
status: "final",
|
||||
category: [categoryConcept("activity")],
|
||||
code: {
|
||||
coding: [
|
||||
{
|
||||
system: LOINC_SYSTEM,
|
||||
code: EXERCISE_ACTIVITY_LOINC,
|
||||
display: "Exercise activity",
|
||||
},
|
||||
],
|
||||
text: "Exercise activity",
|
||||
},
|
||||
subject: patientRef,
|
||||
effectivePeriod: {
|
||||
start: row.startedAt.toISOString(),
|
||||
end: row.endedAt.toISOString(),
|
||||
},
|
||||
valueCodeableConcept: {
|
||||
coding: [{ system: WORKOUT_SPORT_CODESYSTEM, code: row.sportType }],
|
||||
text: sportText(row.sportType),
|
||||
},
|
||||
component,
|
||||
};
|
||||
}
|
||||
|
||||
/** The diastolic value recorded at the same instant as each systolic row. */
|
||||
async function diastolicPartners(
|
||||
userId: string,
|
||||
systolicAt: Date[],
|
||||
): Promise<Map<number, number>> {
|
||||
if (systolicAt.length === 0) return new Map();
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "BLOOD_PRESSURE_DIA",
|
||||
deletedAt: null,
|
||||
measuredAt: { in: systolicAt },
|
||||
},
|
||||
select: { value: true, measuredAt: true },
|
||||
});
|
||||
return new Map(rows.map((r) => [r.measuredAt.getTime(), r.value]));
|
||||
}
|
||||
|
||||
/** Load and emit one page of references, keeping their order. */
|
||||
export async function emitSeriesPage(params: {
|
||||
userId: string;
|
||||
refs: readonly SeriesRef[];
|
||||
scope: SeriesScope;
|
||||
glucoseUnit: GlucoseUnit;
|
||||
}): Promise<FhirObservation[]> {
|
||||
const { userId, refs, scope, glucoseUnit } = params;
|
||||
const mIds = refs.filter((r) => r.kind === "m").map((r) => r.id);
|
||||
const wIds = refs.filter((r) => r.kind === "w").map((r) => r.id);
|
||||
const [measurements, workouts] = await Promise.all([
|
||||
mIds.length > 0
|
||||
? prisma.measurement.findMany({
|
||||
where: { userId, id: { in: mIds }, deletedAt: null },
|
||||
select: MEASUREMENT_SELECT,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
wIds.length > 0
|
||||
? prisma.workout.findMany({
|
||||
where: { userId, id: { in: wIds } },
|
||||
select: WORKOUT_SELECT,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
const partners = scope.pairBloodPressure
|
||||
? await diastolicPartners(
|
||||
userId,
|
||||
measurements
|
||||
.filter((m) => m.type === "BLOOD_PRESSURE_SYS")
|
||||
.map((m) => m.measuredAt),
|
||||
)
|
||||
: new Map<number, number>();
|
||||
const byM = new Map(measurements.map((m) => [m.id, m]));
|
||||
const byW = new Map(workouts.map((w) => [w.id, w]));
|
||||
const out: FhirObservation[] = [];
|
||||
for (const ref of refs) {
|
||||
if (ref.kind === "w") {
|
||||
const w = byW.get(ref.id);
|
||||
if (w) out.push(workoutSeriesObservation(w));
|
||||
continue;
|
||||
}
|
||||
const m = byM.get(ref.id);
|
||||
if (!m) continue;
|
||||
const obs = measurementSeriesObservation(
|
||||
m,
|
||||
glucoseUnit,
|
||||
m.type === "BLOOD_PRESSURE_SYS"
|
||||
? (partners.get(m.measuredAt.getTime()) ?? null)
|
||||
: null,
|
||||
);
|
||||
if (obs) out.push(obs);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one series Observation by its id (`m-…` / `w-…`), inside the same
|
||||
* scope the search applies. Null for an id outside the caller's record or
|
||||
* scope — the route answers both with the same `not-found`.
|
||||
*/
|
||||
export async function readSeriesObservation(params: {
|
||||
userId: string;
|
||||
id: string;
|
||||
scope: SeriesScope;
|
||||
glucoseUnit: GlucoseUnit;
|
||||
}): Promise<FhirObservation | null> {
|
||||
const { userId, id, scope, glucoseUnit } = params;
|
||||
if (id.startsWith("w-")) {
|
||||
if (!scope.workouts) return null;
|
||||
const w = await prisma.workout.findFirst({
|
||||
where: { userId, id: id.slice(2) },
|
||||
select: WORKOUT_SELECT,
|
||||
});
|
||||
return w ? workoutSeriesObservation(w) : null;
|
||||
}
|
||||
if (!id.startsWith("m-")) return null;
|
||||
const m = await prisma.measurement.findFirst({
|
||||
where: { userId, id: id.slice(2), deletedAt: null },
|
||||
select: MEASUREMENT_SELECT,
|
||||
});
|
||||
if (!m || !scope.types.includes(m.type)) return null;
|
||||
const partners =
|
||||
scope.pairBloodPressure && m.type === "BLOOD_PRESSURE_SYS"
|
||||
? await diastolicPartners(userId, [m.measuredAt])
|
||||
: new Map<number, number>();
|
||||
return measurementSeriesObservation(
|
||||
m,
|
||||
glucoseUnit,
|
||||
partners.get(m.measuredAt.getTime()) ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
/** The owner's display glucose unit, for series values. */
|
||||
export async function seriesGlucoseUnit(userId: string): Promise<GlucoseUnit> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { glucoseUnit: true },
|
||||
});
|
||||
return resolveGlucoseUnit(user?.glucoseUnit ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot-mode `code` filter: keep the Observations whose `code` carries one
|
||||
* of the tokens (`system|code`, or a bare code matching any system).
|
||||
*/
|
||||
export function matchesCodeTokens(
|
||||
observation: FhirObservation,
|
||||
values: readonly string[],
|
||||
): boolean {
|
||||
const tokens = values.flatMap((v) => v.split(",")).map((t) => t.trim());
|
||||
const codings = observation.code.coding ?? [];
|
||||
return tokens.some((token) => {
|
||||
if (!token) return false;
|
||||
const bar = token.indexOf("|");
|
||||
const system = bar >= 0 ? token.slice(0, bar) : null;
|
||||
const code = bar >= 0 ? token.slice(bar + 1) : token;
|
||||
return codings.some(
|
||||
(c) =>
|
||||
c.code === code &&
|
||||
(system === null || system === "" || c.system === system),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* The medication-effect assembly around the segmented regression: the window,
|
||||
* the adherence and not-taking exclusions, the dose-change cut, the confounder
|
||||
* list and the minimum-data floor. The regression itself is pinned to
|
||||
* reference values in `src/lib/stats/__tests__/segmented-regression.test.ts`.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildSegmentedEffect,
|
||||
notTakingPredicate,
|
||||
type SegmentedEffectInput,
|
||||
} from "@/lib/medications/efficacy/segmented-effect";
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
const PIVOT = "2026-03-01";
|
||||
|
||||
function dayAt(offset: number): string {
|
||||
return new Date(Date.parse(`${PIVOT}T00:00:00Z`) + offset * DAY)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
/** 140 before, a clean −10 shift and −0.7/week (−0.1/day) slope after. */
|
||||
function series(fromOffset: number, toOffset: number) {
|
||||
const out: { day: string; value: number }[] = [];
|
||||
for (let t = fromOffset; t <= toOffset; t++) {
|
||||
out.push({
|
||||
day: dayAt(t),
|
||||
value: t < 0 ? 140 : 130 - 0.1 * t,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function input(over: Partial<SegmentedEffectInput> = {}): SegmentedEffectInput {
|
||||
return {
|
||||
dayValues: series(-60, 59),
|
||||
pivotDay: PIVOT,
|
||||
lastDay: dayAt(59),
|
||||
adherenceDays: null,
|
||||
notTakingOn: () => false,
|
||||
ownDoseChangeDays: [],
|
||||
otherMedicationEvents: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildSegmentedEffect", () => {
|
||||
it("recovers the known level and weekly slope change", () => {
|
||||
const r = buildSegmentedEffect(input());
|
||||
expect(r.present).toBe(true);
|
||||
expect(r.levelChange!.estimate).toBeCloseTo(-10, 9);
|
||||
expect(r.slopeChangePerWeek!.estimate).toBeCloseTo(-0.7, 9);
|
||||
expect(r.preSlopePerWeek!.estimate).toBeCloseTo(0, 9);
|
||||
expect(r.days).toEqual({
|
||||
before: 60,
|
||||
after: 60,
|
||||
excludedLowAdherence: 0,
|
||||
excludedNotTaking: 0,
|
||||
});
|
||||
expect(r.window).toEqual({
|
||||
from: dayAt(-90),
|
||||
to: dayAt(59),
|
||||
endReason: "today",
|
||||
});
|
||||
expect(r.adherence.status).toBe("not_tracked");
|
||||
expect(r.level).toBe(0.95);
|
||||
});
|
||||
|
||||
it("caps the post window at 180 days", () => {
|
||||
const r = buildSegmentedEffect(
|
||||
input({ dayValues: series(-30, 300), lastDay: dayAt(300) }),
|
||||
);
|
||||
expect(r.window).toEqual({
|
||||
from: dayAt(-90),
|
||||
to: dayAt(179),
|
||||
endReason: "window",
|
||||
});
|
||||
expect(r.days.after).toBe(180);
|
||||
});
|
||||
|
||||
it("drops values older than the 90-day pre window", () => {
|
||||
const r = buildSegmentedEffect(input({ dayValues: series(-200, 59) }));
|
||||
expect(r.days.before).toBe(90);
|
||||
});
|
||||
|
||||
it("ends the window the day before the first own dose change", () => {
|
||||
const r = buildSegmentedEffect(
|
||||
input({ ownDoseChangeDays: [PIVOT, dayAt(40)] }),
|
||||
);
|
||||
expect(r.window?.to).toBe(dayAt(39));
|
||||
expect(r.window?.endReason).toBe("dose_change");
|
||||
expect(r.days.after).toBe(40);
|
||||
});
|
||||
|
||||
it("leaves out post-start days whose trailing 14-day adherence is below 80 %", () => {
|
||||
// One dose a day; every dose from day 20 to day 29 missed.
|
||||
const adherenceDays = Array.from({ length: 60 }, (_, t) => ({
|
||||
day: dayAt(t),
|
||||
taken: t >= 20 && t < 30 ? 0 : 1,
|
||||
missed: t >= 20 && t < 30 ? 1 : 0,
|
||||
}));
|
||||
const r = buildSegmentedEffect(input({ adherenceDays }));
|
||||
expect(r.adherence.status).toBe("tracked");
|
||||
// Day d is low when misses in [d-13, d] exceed 2 (3/14 = 78.6 %): from
|
||||
// day 22 (3 misses) through day 29+13-2 = day 40 (3 misses left).
|
||||
expect(r.days.excludedLowAdherence).toBe(19);
|
||||
expect(r.days.after).toBe(60 - 19);
|
||||
// The fit on the remaining days still recovers the clean effect.
|
||||
expect(r.levelChange!.estimate).toBeCloseTo(-10, 9);
|
||||
});
|
||||
|
||||
it("treats a lookback without any expected dose as unknown, not low", () => {
|
||||
const r = buildSegmentedEffect(input({ adherenceDays: [] }));
|
||||
expect(r.days.excludedLowAdherence).toBe(0);
|
||||
expect(r.present).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves out days the medication was not being taken", () => {
|
||||
const r = buildSegmentedEffect(
|
||||
input({ notTakingOn: (d) => d >= dayAt(10) && d < dayAt(20) }),
|
||||
);
|
||||
expect(r.days.excludedNotTaking).toBe(10);
|
||||
});
|
||||
|
||||
it("needs 14 days on each side", () => {
|
||||
const before = buildSegmentedEffect(input({ dayValues: series(-13, 59) }));
|
||||
expect(before.present).toBe(false);
|
||||
expect(before.reason).toBe("insufficient_before");
|
||||
expect(before.levelChange).toBeNull();
|
||||
const after = buildSegmentedEffect(
|
||||
input({ dayValues: series(-60, 12), lastDay: dayAt(12) }),
|
||||
);
|
||||
expect(after.reason).toBe("insufficient_after");
|
||||
});
|
||||
|
||||
it("needs each side to span 21 days, not just hold 14 values", () => {
|
||||
// 14 consecutive days right before the start: enough values, too short.
|
||||
const r = buildSegmentedEffect(input({ dayValues: series(-14, 59) }));
|
||||
expect(r.days.before).toBe(14);
|
||||
expect(r.reason).toBe("insufficient_before");
|
||||
});
|
||||
|
||||
it("answers no_start without a start date", () => {
|
||||
const r = buildSegmentedEffect(input({ pivotDay: null }));
|
||||
expect(r).toMatchObject({ present: false, reason: "no_start" });
|
||||
expect(r.window).toBeNull();
|
||||
});
|
||||
|
||||
it("lists only the other medications' changes inside the window, oldest first", () => {
|
||||
const r = buildSegmentedEffect(
|
||||
input({
|
||||
otherMedicationEvents: [
|
||||
{ medicationName: "B", event: "dose_change", date: dayAt(30) },
|
||||
{ medicationName: "A", event: "started", date: dayAt(-20) },
|
||||
{ medicationName: "C", event: "stopped", date: dayAt(-120) },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(r.confounders).toEqual([
|
||||
{ medicationName: "A", event: "started", date: dayAt(-20) },
|
||||
{ medicationName: "B", event: "dose_change", date: dayAt(30) },
|
||||
]);
|
||||
});
|
||||
|
||||
it("flags an interval that spans zero", () => {
|
||||
const flat = series(-60, 59).map((p, i) => ({
|
||||
day: p.day,
|
||||
value: 120 + (i % 2 === 0 ? 3 : -3),
|
||||
}));
|
||||
const r = buildSegmentedEffect(input({ dayValues: flat }));
|
||||
expect(r.present).toBe(true);
|
||||
expect(r.levelChange!.includesZero).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("notTakingPredicate", () => {
|
||||
it("covers a pause up to the resume day and the gaps between courses", () => {
|
||||
const pred = notTakingPredicate(
|
||||
{
|
||||
pauseEras: [
|
||||
{
|
||||
pausedAt: new Date("2026-03-10T08:00:00Z"),
|
||||
resumedAt: new Date("2026-03-15T08:00:00Z"),
|
||||
},
|
||||
],
|
||||
courses: [
|
||||
{
|
||||
startsOn: new Date("2026-03-01T00:00:00Z"),
|
||||
endsOn: new Date("2026-03-31T00:00:00Z"),
|
||||
},
|
||||
{
|
||||
startsOn: new Date("2026-05-01T00:00:00Z"),
|
||||
endsOn: null,
|
||||
},
|
||||
],
|
||||
} as never,
|
||||
"UTC",
|
||||
);
|
||||
expect(pred("2026-03-09")).toBe(false);
|
||||
expect(pred("2026-03-10")).toBe(true);
|
||||
expect(pred("2026-03-14")).toBe(true);
|
||||
expect(pred("2026-03-15")).toBe(false);
|
||||
expect(pred("2026-04-10")).toBe(true);
|
||||
expect(pred("2026-05-02")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,6 @@ import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
|
||||
import { readDayMeanSeries } from "@/lib/insights/derived/baseline";
|
||||
import {
|
||||
buildComplianceMedicationContext,
|
||||
COURSES_COMPLIANCE_SELECT,
|
||||
buildMedicationComplianceBundle,
|
||||
dailyComplianceRatesFromLedger,
|
||||
lastNonSkippedTakenAt,
|
||||
@@ -40,6 +39,11 @@ import {
|
||||
type MedTargetTier,
|
||||
} from "@/lib/medications/med-target-map";
|
||||
import { dueSchedules } from "@/lib/medications/intake-tracking";
|
||||
import {
|
||||
EFFECT_MEDICATION_INCLUDE,
|
||||
loadSegmentedEffects,
|
||||
} from "@/lib/medications/efficacy/load-segmented-effects";
|
||||
import type { EfficacySegmented } from "@/lib/medications/efficacy/segmented-effect";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -93,6 +97,12 @@ export interface EfficacyTargetView {
|
||||
beforeAfter: EfficacyBeforeAfter;
|
||||
/** Metric targets only; null for lab targets (changepoint reads metrics). */
|
||||
levelShift: EfficacyLevelShift | null;
|
||||
/**
|
||||
* The segmented-regression effect (level + slope change with 95 %
|
||||
* intervals, adherence-adjusted). Metric targets only; null for a lab,
|
||||
* whose readings are too sparse for a daily time series.
|
||||
*/
|
||||
segmented: EfficacySegmented | null;
|
||||
}
|
||||
|
||||
export interface EfficacyMarkers {
|
||||
@@ -294,6 +304,7 @@ async function buildMetricTarget(
|
||||
series,
|
||||
beforeAfter: beforeAfterFromSeries(straddle, pivotMs),
|
||||
levelShift,
|
||||
segmented: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -343,6 +354,7 @@ async function buildLabTarget(
|
||||
series,
|
||||
beforeAfter: beforeAfterFromSeries(straddle, pivotMs),
|
||||
levelShift: null,
|
||||
segmented: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -351,7 +363,7 @@ async function buildLabTarget(
|
||||
* override wins (tier "override"); otherwise the derived ATC/name resolution.
|
||||
* Returns the tier + class provenance for the DTO alongside the target list.
|
||||
*/
|
||||
async function resolveEffectiveTargets(med: {
|
||||
export async function resolveEffectiveTargets(med: {
|
||||
id: string;
|
||||
name: string;
|
||||
treatmentClass: string;
|
||||
@@ -417,14 +429,8 @@ export async function buildMedicationEfficacy(
|
||||
): Promise<MedicationEfficacyDTO | null> {
|
||||
const med = await prisma.medication.findFirst({
|
||||
where: { id: medicationId, userId },
|
||||
include: {
|
||||
schedules: true,
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
pauseEras: { select: { pausedAt: true, resumedAt: true } },
|
||||
// v1.40 (#1024) — the courses, so a gap between two expects nothing.
|
||||
courses: COURSES_COMPLIANCE_SELECT,
|
||||
doseChanges: { orderBy: { effectiveFrom: "asc" } },
|
||||
},
|
||||
// The courses ride along, so a gap between two expects nothing (#1024).
|
||||
include: EFFECT_MEDICATION_INCLUDE,
|
||||
});
|
||||
if (!med) return null;
|
||||
|
||||
@@ -570,6 +576,23 @@ export async function buildMedicationEfficacy(
|
||||
}
|
||||
}
|
||||
|
||||
// The segmented effect, per metric target, around `startsOn` only: the
|
||||
// first-reading fallback has no "before" by definition.
|
||||
const segmented = await loadSegmentedEffects({
|
||||
userId,
|
||||
med,
|
||||
targets: targets
|
||||
.filter((t) => t.kind === "metric")
|
||||
.map((t) => t.key as MeasurementType),
|
||||
timezone,
|
||||
now,
|
||||
});
|
||||
for (const view of targets) {
|
||||
if (view.kind === "metric") {
|
||||
view.segmented = segmented.get(view.key as MeasurementType) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
// Adherence lane — the cadence-aware per-day rate from the SAME ledger the
|
||||
// compliance surfaces read; never recomputed here.
|
||||
const fetchFrom = new Date(
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* The database half of the segmented medication effect: one intake-event
|
||||
* read, one confounder read and one per-day SQL fold per target, handed to the
|
||||
* pure assembly in `./segmented-effect.ts`.
|
||||
*/
|
||||
import type { MeasurementType, Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
buildComplianceMedicationContext,
|
||||
buildMedicationComplianceBundle,
|
||||
COURSES_COMPLIANCE_SELECT,
|
||||
dailyComplianceRatesFromLedger,
|
||||
lastNonSkippedTakenAt,
|
||||
} from "@/lib/analytics/compliance";
|
||||
import { dueSchedules } from "@/lib/medications/intake-tracking";
|
||||
import { dayKeyOfDate } from "@/lib/medications/course-window";
|
||||
import { readDayAggregates } from "@/lib/measurements/day-aggregates";
|
||||
import { plausibleMetricRange } from "@/lib/measurements/value-domain";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
|
||||
import {
|
||||
buildSegmentedEffect,
|
||||
notTakingPredicate,
|
||||
SEGMENTED_POST_WINDOW_DAYS,
|
||||
SEGMENTED_PRE_WINDOW_DAYS,
|
||||
type EfficacyConfounder,
|
||||
type EfficacySegmented,
|
||||
type SegmentedEffectInput,
|
||||
} from "./segmented-effect";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** The medication shape the effect needs: the efficacy builder's include. */
|
||||
export const EFFECT_MEDICATION_INCLUDE = {
|
||||
schedules: true,
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
pauseEras: { select: { pausedAt: true, resumedAt: true } },
|
||||
courses: COURSES_COMPLIANCE_SELECT,
|
||||
doseChanges: { orderBy: { effectiveFrom: "asc" } },
|
||||
} satisfies Prisma.MedicationInclude;
|
||||
|
||||
export type EffectMedication = Prisma.MedicationGetPayload<{
|
||||
include: typeof EFFECT_MEDICATION_INCLUDE;
|
||||
}>;
|
||||
|
||||
/** Other medications' starts, stops, dose changes and pauses inside a window. */
|
||||
async function loadConfounders(
|
||||
userId: string,
|
||||
medicationId: string,
|
||||
windowFrom: Date,
|
||||
windowTo: Date,
|
||||
timezone: string,
|
||||
): Promise<EfficacyConfounder[]> {
|
||||
const inWindow = { gte: windowFrom, lte: windowTo };
|
||||
const others = await prisma.medication.findMany({
|
||||
where: {
|
||||
userId,
|
||||
id: { not: medicationId },
|
||||
OR: [
|
||||
{ startsOn: inWindow },
|
||||
{ endsOn: inWindow },
|
||||
{ doseChanges: { some: { effectiveFrom: inWindow } } },
|
||||
{ pauseEras: { some: { pausedAt: inWindow } } },
|
||||
{ pauseEras: { some: { resumedAt: inWindow } } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
name: true,
|
||||
startsOn: true,
|
||||
endsOn: true,
|
||||
doseChanges: {
|
||||
where: { effectiveFrom: inWindow },
|
||||
select: { effectiveFrom: true },
|
||||
},
|
||||
pauseEras: { select: { pausedAt: true, resumedAt: true } },
|
||||
},
|
||||
take: 50,
|
||||
});
|
||||
const out: EfficacyConfounder[] = [];
|
||||
const within = (d: Date | null): d is Date =>
|
||||
d !== null &&
|
||||
d.getTime() >= windowFrom.getTime() &&
|
||||
d.getTime() <= windowTo.getTime();
|
||||
for (const m of others) {
|
||||
if (within(m.startsOn)) {
|
||||
out.push({
|
||||
medicationName: m.name,
|
||||
event: "started",
|
||||
date: dayKeyOfDate(m.startsOn),
|
||||
});
|
||||
}
|
||||
if (within(m.endsOn)) {
|
||||
out.push({
|
||||
medicationName: m.name,
|
||||
event: "stopped",
|
||||
date: dayKeyOfDate(m.endsOn),
|
||||
});
|
||||
}
|
||||
for (const dc of m.doseChanges) {
|
||||
out.push({
|
||||
medicationName: m.name,
|
||||
event: "dose_change",
|
||||
date: userDayKey(dc.effectiveFrom, timezone),
|
||||
});
|
||||
}
|
||||
for (const p of m.pauseEras) {
|
||||
if (within(p.pausedAt)) {
|
||||
out.push({
|
||||
medicationName: m.name,
|
||||
event: "paused",
|
||||
date: userDayKey(p.pausedAt, timezone),
|
||||
});
|
||||
}
|
||||
if (within(p.resumedAt)) {
|
||||
out.push({
|
||||
medicationName: m.name,
|
||||
event: "resumed",
|
||||
date: userDayKey(p.resumedAt, timezone),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and fit the segmented effect for each metric target of one medication.
|
||||
* `now` bounds every read: a report pinned in the past passes its own end so
|
||||
* nothing after it is used. Reads: one intake-event query, one confounder
|
||||
* query, and one per-day SQL fold per target.
|
||||
*/
|
||||
export async function loadSegmentedEffects(params: {
|
||||
userId: string;
|
||||
med: EffectMedication;
|
||||
targets: readonly MeasurementType[];
|
||||
timezone: string;
|
||||
now: Date;
|
||||
}): Promise<Map<MeasurementType, EfficacySegmented>> {
|
||||
const { userId, med, targets, timezone, now } = params;
|
||||
const out = new Map<MeasurementType, EfficacySegmented>();
|
||||
if (targets.length === 0) return out;
|
||||
|
||||
const pivotDay = med.startsOn ? dayKeyOfDate(med.startsOn) : null;
|
||||
const lastDay = userDayKey(now, timezone);
|
||||
if (pivotDay === null || pivotDay > lastDay) {
|
||||
const empty = buildSegmentedEffect({
|
||||
dayValues: [],
|
||||
pivotDay: null,
|
||||
lastDay,
|
||||
adherenceDays: null,
|
||||
notTakingOn: () => false,
|
||||
ownDoseChangeDays: [],
|
||||
otherMedicationEvents: [],
|
||||
});
|
||||
for (const t of targets) out.set(t, empty);
|
||||
return out;
|
||||
}
|
||||
|
||||
// A day of margin either side: the SQL folds on local days, the window is
|
||||
// cut on day keys afterwards.
|
||||
const pivotMs = dayKeyAsUtcMidnight(pivotDay).getTime();
|
||||
const since = new Date(pivotMs - (SEGMENTED_PRE_WINDOW_DAYS + 1) * DAY_MS);
|
||||
const until = new Date(
|
||||
Math.min(
|
||||
now.getTime(),
|
||||
pivotMs + (SEGMENTED_POST_WINDOW_DAYS + 1) * DAY_MS,
|
||||
),
|
||||
);
|
||||
|
||||
let adherenceDays: SegmentedEffectInput["adherenceDays"] = null;
|
||||
if (med.trackIntake) {
|
||||
const events = await prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
medicationId: med.id,
|
||||
userId,
|
||||
deletedAt: null,
|
||||
scheduledFor: { gte: since, lte: until },
|
||||
},
|
||||
orderBy: { scheduledFor: "desc" },
|
||||
select: {
|
||||
takenAt: true,
|
||||
skipped: true,
|
||||
scheduledFor: true,
|
||||
autoMissed: true,
|
||||
attributionSource: true,
|
||||
},
|
||||
});
|
||||
const ctx = buildComplianceMedicationContext(
|
||||
med,
|
||||
lastNonSkippedTakenAt(events),
|
||||
timezone,
|
||||
);
|
||||
const bundle = buildMedicationComplianceBundle(
|
||||
events,
|
||||
dueSchedules(med),
|
||||
ctx,
|
||||
until,
|
||||
);
|
||||
adherenceDays = dailyComplianceRatesFromLedger(
|
||||
bundle.ledgerRows,
|
||||
timezone,
|
||||
).map((d) => ({
|
||||
day: userDayKey(d.date, timezone),
|
||||
taken: d.taken,
|
||||
missed: d.missed,
|
||||
}));
|
||||
}
|
||||
|
||||
const confounders = await loadConfounders(
|
||||
userId,
|
||||
med.id,
|
||||
since,
|
||||
until,
|
||||
timezone,
|
||||
);
|
||||
const notTakingOn = notTakingPredicate(med, timezone);
|
||||
const ownDoseChangeDays = med.doseChanges.map((dc) =>
|
||||
userDayKey(dc.effectiveFrom, timezone),
|
||||
);
|
||||
|
||||
for (const target of targets) {
|
||||
const rows = await readDayAggregates({
|
||||
userId,
|
||||
type: target,
|
||||
since,
|
||||
until,
|
||||
timeZone: timezone,
|
||||
valueRange: plausibleMetricRange(target),
|
||||
});
|
||||
out.set(
|
||||
target,
|
||||
buildSegmentedEffect({
|
||||
dayValues: rows.map((r) => ({
|
||||
day: r.day,
|
||||
value: r.dayMean ?? r.sum / r.n,
|
||||
})),
|
||||
pivotDay,
|
||||
lastDay,
|
||||
adherenceDays,
|
||||
notTakingOn,
|
||||
ownDoseChangeDays,
|
||||
otherMedicationEvents: confounders,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* The medication effect as an interrupted time series: the target metric's
|
||||
* daily values over a window around the start, fitted with a segmented
|
||||
* regression (level change + slope change, Newey–West intervals).
|
||||
*
|
||||
* Adherence-adjusted by exclusion. After the start, a day is left out when the
|
||||
* doses expected over the trailing fourteen days were taken less than 80 % of
|
||||
* the time, and when the medication was paused or between courses that day.
|
||||
* The adherence comes from the cadence-aware dose ledger the compliance
|
||||
* surfaces read (`dailyComplianceRatesFromLedger`), never a recount. With
|
||||
* intake tracking off nothing is excluded and the DTO says so.
|
||||
*
|
||||
* The window is ninety days before the start and up to 180 after it, cut short
|
||||
* at the medication's own first dose change (the estimate then describes the
|
||||
* starting dose, not a blend of two). Other medications that started, stopped,
|
||||
* changed dose or paused inside the window are listed as caveats: the model
|
||||
* cannot tell their effect from this one.
|
||||
*
|
||||
* Descriptive and association-only, like the rest of the efficacy DTO: there
|
||||
* is no verdict field. The renderer phrases the numbers and the caveats; it
|
||||
* never decides whether a medication "works".
|
||||
*
|
||||
* Pure: the database reads live in `./load-segmented-effects.ts`, so the
|
||||
* client renderer can import the constants and types from here. Computed on
|
||||
* request, without AI.
|
||||
*/
|
||||
import { dayKeyOfDate } from "@/lib/medications/course-window";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
|
||||
import {
|
||||
fitSegmentedRegression,
|
||||
type SegmentedEstimate,
|
||||
} from "@/lib/stats/segmented-regression";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Days of target data the window reaches back over before the start. */
|
||||
export const SEGMENTED_PRE_WINDOW_DAYS = 90;
|
||||
/** Days after the start the window reaches forward over, at most. */
|
||||
export const SEGMENTED_POST_WINDOW_DAYS = 180;
|
||||
/** Days with a value required on each side of the start. */
|
||||
export const SEGMENTED_MIN_DAYS_PER_SIDE = 14;
|
||||
/** Calendar span (first to last value) required on each side. */
|
||||
export const SEGMENTED_MIN_SPAN_DAYS = 21;
|
||||
/** Trailing window the per-day adherence is judged over. */
|
||||
export const ADHERENCE_LOOKBACK_DAYS = 14;
|
||||
/** Adherence below this share leaves a post-start day out. */
|
||||
export const ADHERENCE_FLOOR = 0.8;
|
||||
/** Confidence level of every interval. */
|
||||
export const SEGMENTED_LEVEL = 0.95;
|
||||
|
||||
/** An estimate in display terms: the unit per day for a level, per week for a slope. */
|
||||
export interface EfficacyEstimate {
|
||||
estimate: number;
|
||||
ciLow: number;
|
||||
ciHigh: number;
|
||||
standardError: number;
|
||||
/** True when the interval spans zero (the data is also consistent with no change). */
|
||||
includesZero: boolean;
|
||||
}
|
||||
|
||||
export type EfficacyConfounderEvent =
|
||||
"started" | "stopped" | "dose_change" | "paused" | "resumed";
|
||||
|
||||
export interface EfficacyConfounder {
|
||||
medicationName: string;
|
||||
event: EfficacyConfounderEvent;
|
||||
/** YYYY-MM-DD. */
|
||||
date: string;
|
||||
}
|
||||
|
||||
export interface EfficacySegmented {
|
||||
present: boolean;
|
||||
reason?:
|
||||
"no_start" | "insufficient_before" | "insufficient_after" | "fit_failed";
|
||||
/** Confidence level of the intervals (0.95). */
|
||||
level: number;
|
||||
/** The window actually fitted (YYYY-MM-DD, inclusive). */
|
||||
window: {
|
||||
from: string;
|
||||
to: string;
|
||||
endReason: "today" | "window" | "dose_change";
|
||||
} | null;
|
||||
/** Days with a value used on each side, and the days left out after the start. */
|
||||
days: {
|
||||
before: number;
|
||||
after: number;
|
||||
excludedLowAdherence: number;
|
||||
excludedNotTaking: number;
|
||||
};
|
||||
adherence: {
|
||||
/** "not_tracked" when the medication logs no intake, so nothing was excluded. */
|
||||
status: "tracked" | "not_tracked";
|
||||
floorPct: number;
|
||||
lookbackDays: number;
|
||||
};
|
||||
/** Immediate shift at the start, in the target's unit. */
|
||||
levelChange: EfficacyEstimate | null;
|
||||
/** Change in the weekly trend after the start, unit per week. */
|
||||
slopeChangePerWeek: EfficacyEstimate | null;
|
||||
/** The weekly trend before the start, unit per week. */
|
||||
preSlopePerWeek: EfficacyEstimate | null;
|
||||
fit: {
|
||||
n: number;
|
||||
df: number;
|
||||
rSquared: number;
|
||||
residualAutocorrelation: number;
|
||||
hacLag: number;
|
||||
} | null;
|
||||
/** Other medication changes inside the window, oldest first. */
|
||||
confounders: EfficacyConfounder[];
|
||||
}
|
||||
|
||||
function dayIndex(day: string): number {
|
||||
return Math.round(dayKeyAsUtcMidnight(day).getTime() / DAY_MS);
|
||||
}
|
||||
|
||||
function dayKeyOfIndex(index: number): string {
|
||||
return dayKeyOfDate(new Date(index * DAY_MS));
|
||||
}
|
||||
|
||||
function scaled(e: SegmentedEstimate, factor: number): EfficacyEstimate {
|
||||
const ciLow = e.ciLow * factor;
|
||||
const ciHigh = e.ciHigh * factor;
|
||||
return {
|
||||
estimate: e.estimate * factor,
|
||||
ciLow,
|
||||
ciHigh,
|
||||
standardError: e.standardError * factor,
|
||||
includesZero: ciLow <= 0 && ciHigh >= 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** The input to the pure assembly — every day already a local day key. */
|
||||
export interface SegmentedEffectInput {
|
||||
/** One value per local day (any order). */
|
||||
dayValues: ReadonlyArray<{ day: string; value: number }>;
|
||||
/** The start day, or null when the medication carries no start date. */
|
||||
pivotDay: string | null;
|
||||
/** The last day the window may reach (today, or a report's last day). */
|
||||
lastDay: string;
|
||||
/** Per-day dose tallies from the ledger; null when intake is not tracked. */
|
||||
adherenceDays: ReadonlyArray<{
|
||||
day: string;
|
||||
taken: number;
|
||||
missed: number;
|
||||
}> | null;
|
||||
/** Whether the medication was not being taken on a day (paused, between courses). */
|
||||
notTakingOn: (day: string) => boolean;
|
||||
/** The medication's own dose changes (YYYY-MM-DD). */
|
||||
ownDoseChangeDays: readonly string[];
|
||||
/** Other medications' changes (YYYY-MM-DD), any order. */
|
||||
otherMedicationEvents: readonly EfficacyConfounder[];
|
||||
}
|
||||
|
||||
const EMPTY_DAYS = {
|
||||
before: 0,
|
||||
after: 0,
|
||||
excludedLowAdherence: 0,
|
||||
excludedNotTaking: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Assemble the segmented effect from already-resolved day data. Pure, so the
|
||||
* window, exclusion and minimum-data rules are testable without a database.
|
||||
*/
|
||||
export function buildSegmentedEffect(
|
||||
input: SegmentedEffectInput,
|
||||
): EfficacySegmented {
|
||||
const adherence = {
|
||||
status: input.adherenceDays === null ? "not_tracked" : "tracked",
|
||||
floorPct: Math.round(ADHERENCE_FLOOR * 100),
|
||||
lookbackDays: ADHERENCE_LOOKBACK_DAYS,
|
||||
} as const;
|
||||
const absent = (
|
||||
reason: NonNullable<EfficacySegmented["reason"]>,
|
||||
window: EfficacySegmented["window"] = null,
|
||||
days = EMPTY_DAYS,
|
||||
confounders: EfficacyConfounder[] = [],
|
||||
): EfficacySegmented => ({
|
||||
present: false,
|
||||
reason,
|
||||
level: SEGMENTED_LEVEL,
|
||||
window,
|
||||
days,
|
||||
adherence,
|
||||
levelChange: null,
|
||||
slopeChangePerWeek: null,
|
||||
preSlopePerWeek: null,
|
||||
fit: null,
|
||||
confounders,
|
||||
});
|
||||
|
||||
if (input.pivotDay === null) return absent("no_start");
|
||||
|
||||
const pivot = dayIndex(input.pivotDay);
|
||||
const from = pivot - SEGMENTED_PRE_WINDOW_DAYS;
|
||||
let to = Math.min(
|
||||
dayIndex(input.lastDay),
|
||||
pivot + SEGMENTED_POST_WINDOW_DAYS - 1,
|
||||
);
|
||||
let endReason: "today" | "window" | "dose_change" =
|
||||
to === dayIndex(input.lastDay) ? "today" : "window";
|
||||
// The first own dose change after the start ends the window the day
|
||||
// before it: one estimate per dose. A dose recorded on the start day is
|
||||
// the starting dose, not a change.
|
||||
const firstChange = input.ownDoseChangeDays
|
||||
.map(dayIndex)
|
||||
.filter((d) => d > pivot)
|
||||
.sort((a, b) => a - b)[0];
|
||||
if (firstChange !== undefined && firstChange - 1 < to) {
|
||||
to = firstChange - 1;
|
||||
endReason = "dose_change";
|
||||
}
|
||||
const window = {
|
||||
from: dayKeyOfIndex(from),
|
||||
to: dayKeyOfIndex(to),
|
||||
endReason,
|
||||
};
|
||||
|
||||
const confounders = input.otherMedicationEvents
|
||||
.filter((c) => {
|
||||
const d = dayIndex(c.date);
|
||||
return d >= from && d <= to;
|
||||
})
|
||||
.slice()
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
|
||||
// Per-day adherence, judged over the trailing lookback.
|
||||
const adherenceByDay = new Map<number, { taken: number; missed: number }>();
|
||||
for (const a of input.adherenceDays ?? []) {
|
||||
adherenceByDay.set(dayIndex(a.day), { taken: a.taken, missed: a.missed });
|
||||
}
|
||||
const lowAdherenceOn = (d: number): boolean => {
|
||||
if (input.adherenceDays === null) return false;
|
||||
let taken = 0;
|
||||
let missed = 0;
|
||||
for (let k = d - ADHERENCE_LOOKBACK_DAYS + 1; k <= d; k++) {
|
||||
const a = adherenceByDay.get(k);
|
||||
if (!a) continue;
|
||||
taken += a.taken;
|
||||
missed += a.missed;
|
||||
}
|
||||
// No dose expected in the lookback: adherence is unknown, not low.
|
||||
if (taken + missed === 0) return false;
|
||||
return taken / (taken + missed) < ADHERENCE_FLOOR;
|
||||
};
|
||||
|
||||
const points: { t: number; y: number }[] = [];
|
||||
const days = { ...EMPTY_DAYS };
|
||||
const sorted = input.dayValues
|
||||
.map((v) => ({ d: dayIndex(v.day), day: v.day, value: v.value }))
|
||||
.filter((v) => v.d >= from && v.d <= to && Number.isFinite(v.value))
|
||||
.sort((a, b) => a.d - b.d);
|
||||
let beforeFirst: number | null = null;
|
||||
let beforeLast: number | null = null;
|
||||
let afterFirst: number | null = null;
|
||||
let afterLast: number | null = null;
|
||||
for (const v of sorted) {
|
||||
const t = v.d - pivot;
|
||||
if (t >= 0) {
|
||||
if (input.notTakingOn(v.day)) {
|
||||
days.excludedNotTaking += 1;
|
||||
continue;
|
||||
}
|
||||
if (lowAdherenceOn(v.d)) {
|
||||
days.excludedLowAdherence += 1;
|
||||
continue;
|
||||
}
|
||||
days.after += 1;
|
||||
afterFirst ??= t;
|
||||
afterLast = t;
|
||||
} else {
|
||||
days.before += 1;
|
||||
beforeFirst ??= t;
|
||||
beforeLast = t;
|
||||
}
|
||||
points.push({ t, y: v.value });
|
||||
}
|
||||
|
||||
const span = (a: number | null, b: number | null) =>
|
||||
a === null || b === null ? 0 : b - a + 1;
|
||||
if (
|
||||
days.before < SEGMENTED_MIN_DAYS_PER_SIDE ||
|
||||
span(beforeFirst, beforeLast) < SEGMENTED_MIN_SPAN_DAYS
|
||||
) {
|
||||
return absent("insufficient_before", window, days, confounders);
|
||||
}
|
||||
if (
|
||||
days.after < SEGMENTED_MIN_DAYS_PER_SIDE ||
|
||||
span(afterFirst, afterLast) < SEGMENTED_MIN_SPAN_DAYS
|
||||
) {
|
||||
return absent("insufficient_after", window, days, confounders);
|
||||
}
|
||||
|
||||
const fit = fitSegmentedRegression(points, { level: SEGMENTED_LEVEL });
|
||||
if (!fit) return absent("fit_failed", window, days, confounders);
|
||||
|
||||
return {
|
||||
present: true,
|
||||
level: SEGMENTED_LEVEL,
|
||||
window,
|
||||
days,
|
||||
adherence,
|
||||
levelChange: scaled(fit.levelChange, 1),
|
||||
slopeChangePerWeek: scaled(fit.slopeChange, 7),
|
||||
preSlopePerWeek: scaled(fit.preSlope, 7),
|
||||
fit: {
|
||||
n: fit.n,
|
||||
df: fit.df,
|
||||
rSquared: fit.rSquared,
|
||||
residualAutocorrelation: fit.residualAutocorrelation,
|
||||
hacLag: fit.hacLag,
|
||||
},
|
||||
confounders,
|
||||
};
|
||||
}
|
||||
|
||||
/** Day keys on which a medication was not being taken (paused, between courses). */
|
||||
export function notTakingPredicate(
|
||||
med: {
|
||||
pauseEras: ReadonlyArray<{ pausedAt: Date; resumedAt: Date | null }>;
|
||||
courses: ReadonlyArray<{ startsOn: Date; endsOn: Date | null }>;
|
||||
},
|
||||
timezone: string,
|
||||
): (day: string) => boolean {
|
||||
const pauses = med.pauseEras.map((p) => ({
|
||||
from: userDayKey(p.pausedAt, timezone),
|
||||
to: p.resumedAt ? userDayKey(p.resumedAt, timezone) : null,
|
||||
}));
|
||||
const courses = med.courses.map((c) => ({
|
||||
from: dayKeyOfDate(c.startsOn),
|
||||
to: c.endsOn ? dayKeyOfDate(c.endsOn) : null,
|
||||
}));
|
||||
return (day: string) => {
|
||||
// Paused from the pause day up to (not including) the resume day.
|
||||
if (pauses.some((p) => day >= p.from && (p.to === null || day < p.to))) {
|
||||
return true;
|
||||
}
|
||||
if (courses.length === 0) return false;
|
||||
return !courses.some(
|
||||
(c) => day >= c.from && (c.to === null || day <= c.to),
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* How a segmented-effect estimate is printed, shared by the efficacy tab, the
|
||||
* PDF report and the clinician view so all three show the same figure.
|
||||
*
|
||||
* The precision follows the uncertainty: one significant digit of the standard
|
||||
* error decides the decimals (an SE of 0.6 mmHg prints one decimal, 0.02 kg
|
||||
* prints two). More digits than that would claim a precision the data does not
|
||||
* have; fewer would hide a real interval. Capped at three decimals.
|
||||
*/
|
||||
import type { EfficacyEstimate } from "./segmented-effect";
|
||||
|
||||
export function estimateDecimals(e: Pick<EfficacyEstimate, "standardError">) {
|
||||
const se = Math.abs(e.standardError);
|
||||
if (!Number.isFinite(se) || se === 0) return 1;
|
||||
return Math.min(3, Math.max(0, -Math.floor(Math.log10(se))));
|
||||
}
|
||||
|
||||
/**
|
||||
* The three numbers of an estimate as display strings, the point estimate
|
||||
* signed. `num` is the caller's locale formatter.
|
||||
*/
|
||||
export function formatEstimate(
|
||||
e: EfficacyEstimate,
|
||||
num: (value: number, decimals: number) => string,
|
||||
): { value: string; low: string; high: string } {
|
||||
const decimals = estimateDecimals(e);
|
||||
// A value that rounds to zero prints unsigned, never "-0.0".
|
||||
const signed = (v: number) => {
|
||||
const text = num(v, decimals);
|
||||
const rounded = Number(v.toFixed(decimals));
|
||||
return rounded > 0 ? `+${text}` : rounded === 0 ? num(0, decimals) : text;
|
||||
};
|
||||
return {
|
||||
value: signed(e.estimate),
|
||||
low: num(e.ciLow, decimals),
|
||||
high: num(e.ciHigh, decimals),
|
||||
};
|
||||
}
|
||||
@@ -353,7 +353,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["FHIR"],
|
||||
summary: "FHIR R4 CapabilityStatement (v1.11.0)",
|
||||
description:
|
||||
"Read-only FHIR R4 capability statement for the REST face. Declares the served resource types (Patient, Coverage, Observation, MedicationStatement, MedicationAdministration), their `read` and `search-type` interactions, the `Patient/$everything` operation, and the `application/fhir+json` format. Every advertised interaction has a route. Auth: `fhir:read` scope (cookie sessions also pass).",
|
||||
"Read-only FHIR R4 capability statement for the REST face. Declares the served resource types (Patient, Coverage, Observation, MedicationStatement, MedicationAdministration), their `read` and `search-type` interactions, the search parameters (`_count`, `_offset`; `date` and `code` on Observation), the `Patient/$everything` operation, and the `application/fhir+json` format. Every advertised interaction has a route. Auth: `fhir:read` scope (cookie sessions also pass).",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "CapabilityStatement (application/fhir+json).",
|
||||
@@ -397,11 +397,13 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["FHIR"],
|
||||
summary: "FHIR R4 Observation search (v1.11.0)",
|
||||
description:
|
||||
"Read-only `searchset` Bundle of the caller's own Observations (vitals / activity / lab / survey). Auth: `fhir:read` scope. Offset paging via `_count` (clamped ≤200) / `_offset`.",
|
||||
"Read-only `searchset` Bundle of the caller's own Observations (vitals / activity / lab / survey). Auth: `fhir:read` scope. Offset paging via `_count` (clamped ≤200) / `_offset`. Without `date` the answer is the snapshot it has always been: one latest reading per type plus the derived panels, ids `obs-N`. With `date` (v1.44, repeatable, FHIR prefixes eq/gt/ge/lt/le, a value without an offset read as UTC) the answer is the time series instead: every reading in the range plus workouts, newest first, paged in the database, ids `m-<id>` / `w-<id>` that read back through `/api/fhir/Observation/{id}`. The series follows the same saved report selection as the snapshot, leaves sleep out (stored as stage segments), pairs a systolic and diastolic recorded at the same instant into the blood-pressure panel, and codes a workout as an `activity` Observation (LOINC 73985-4 Exercise activity, duration 55411-3 and energy 41981-2 as components). `code` (repeatable, `system|code` or a bare code, comma-separated) narrows either answer; an unknown code matches nothing. A malformed `date` is a 400 OperationOutcome.",
|
||||
requestParams: {
|
||||
query: z.object({
|
||||
_count: z.coerce.number().optional(),
|
||||
_offset: z.coerce.number().optional(),
|
||||
date: z.string().optional(),
|
||||
code: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
@@ -557,7 +559,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["FHIR"],
|
||||
summary: "FHIR R4 Observation read",
|
||||
description:
|
||||
"Read-only `Observation` by id, out of the caller's own record. An id belonging to someone else is indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: `fhir:read` scope; `userId` is narrowed from auth.",
|
||||
"Read-only `Observation` by id, out of the caller's own record: a snapshot id (`obs-N`) or a series id (`m-<id>` / `w-<id>`, v1.44), the latter read inside the same saved selection the series search applies. An id belonging to someone else is indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: `fhir:read` scope; `userId` is narrowed from auth.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string() }),
|
||||
},
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
leafLabelKey,
|
||||
LEAF_MODULE,
|
||||
MEASUREMENT_LEAF_GROUP,
|
||||
reportMeasurementLabelKey,
|
||||
} from "../catalogue";
|
||||
import { STANDARD_TEMPLATE_LEAVES, standardTemplateFor } from "../template";
|
||||
|
||||
@@ -42,6 +43,15 @@ const EN = JSON.parse(
|
||||
),
|
||||
) as Record<string, unknown>;
|
||||
|
||||
function labelOf(key: string): unknown {
|
||||
let node: unknown = EN;
|
||||
for (const part of key.split(".")) {
|
||||
if (typeof node !== "object" || node === null) return undefined;
|
||||
node = (node as Record<string, unknown>)[part];
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function resolves(key: string): boolean {
|
||||
let node: unknown = EN;
|
||||
for (const part of key.split(".")) {
|
||||
@@ -54,8 +64,8 @@ function resolves(key: string): boolean {
|
||||
describe("report selection catalogue", () => {
|
||||
it("carries every measurement type and every structured leaf", () => {
|
||||
expect(MEASUREMENT_LEAF_IDS).toHaveLength(86);
|
||||
expect(STRUCTURED_LEAF_IDS).toHaveLength(18);
|
||||
expect(ALL_LEAF_IDS).toHaveLength(104);
|
||||
expect(STRUCTURED_LEAF_IDS).toHaveLength(20);
|
||||
expect(ALL_LEAF_IDS).toHaveLength(106);
|
||||
});
|
||||
|
||||
it("resolves a label for every leaf", () => {
|
||||
@@ -205,6 +215,15 @@ describe("heart-rate variability in the report", () => {
|
||||
// own cardio leaf beside SDNN, never folded into it, and like SDNN no
|
||||
// module gates it: a report with Recovery off must not lose the account's
|
||||
// only HRV series for the estimator its device reports.
|
||||
it("names the statistic on both rows, so the two never read as one series", () => {
|
||||
const sdnn = reportMeasurementLabelKey("HEART_RATE_VARIABILITY");
|
||||
const rmssd = reportMeasurementLabelKey("HRV_RMSSD");
|
||||
expect(sdnn).not.toBe(rmssd);
|
||||
expect(leafLabelKey("HEART_RATE_VARIABILITY")).toBe(sdnn);
|
||||
expect(labelOf(sdnn)).toBe("HRV (SDNN)");
|
||||
expect(labelOf(rmssd)).toBe("HRV (RMSSD)");
|
||||
});
|
||||
|
||||
it("files SDNN and RMSSD as separate, ungated cardio leaves", () => {
|
||||
for (const type of ["HEART_RATE_VARIABILITY", "HRV_RMSSD"] as const) {
|
||||
expect(MEASUREMENT_LEAF_GROUP[type]).toBe("cardio");
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
* A LEAF is the smallest releasable unit. There are exactly two kinds: every
|
||||
* `MeasurementType` enum member, and the structured sections of the report
|
||||
* payload that are not measurement series (identity, insurance, the glucose
|
||||
* panel, labs, the three medication blocks, GLP-1, allergies, illness
|
||||
* episodes, family history, mood, cycle).
|
||||
* panel, labs, the medication blocks, GLP-1, the medication effect, workouts,
|
||||
* allergies, illness episodes, family history, mood, cycle).
|
||||
*
|
||||
* One catalogue drives the selection panel, the aggregator, the PDF, the
|
||||
* clinician share view and the guard tests, so a control can never again exist
|
||||
@@ -44,7 +44,9 @@ export type StructuredLeafId =
|
||||
| "MEDICATION_LIST"
|
||||
| "MEDICATION_ADMINISTRATIONS"
|
||||
| "MEDICATION_COMPLIANCE"
|
||||
| "MEDICATION_EFFECT"
|
||||
| "GLP1_THERAPY"
|
||||
| "WORKOUTS"
|
||||
| "ALLERGIES"
|
||||
| "ILLNESS_EPISODES"
|
||||
| "VISITS"
|
||||
@@ -213,7 +215,9 @@ export const STRUCTURED_LEAF_GROUP: Record<StructuredLeafId, ReportGroupId> = {
|
||||
MEDICATION_LIST: "medications",
|
||||
MEDICATION_ADMINISTRATIONS: "medications",
|
||||
MEDICATION_COMPLIANCE: "medications",
|
||||
MEDICATION_EFFECT: "medications",
|
||||
GLP1_THERAPY: "medications",
|
||||
WORKOUTS: "activity",
|
||||
ALLERGIES: "history",
|
||||
ILLNESS_EPISODES: "history",
|
||||
VISITS: "history",
|
||||
@@ -225,7 +229,7 @@ export const STRUCTURED_LEAF_GROUP: Record<StructuredLeafId, ReportGroupId> = {
|
||||
ANAMNESIS: "sensitive",
|
||||
};
|
||||
|
||||
/** i18n label keys for the 18 structured leaves. */
|
||||
/** i18n label keys for the structured leaves. */
|
||||
export const STRUCTURED_LEAF_LABEL_KEYS: Record<StructuredLeafId, string> = {
|
||||
PATIENT_IDENTITY: "reportSelection.leafPatientIdentity",
|
||||
EMERGENCY: "reportSelection.leafEmergency",
|
||||
@@ -235,7 +239,9 @@ export const STRUCTURED_LEAF_LABEL_KEYS: Record<StructuredLeafId, string> = {
|
||||
MEDICATION_LIST: "reportSelection.leafMedicationList",
|
||||
MEDICATION_ADMINISTRATIONS: "reportSelection.leafMedicationAdministrations",
|
||||
MEDICATION_COMPLIANCE: "reportSelection.leafMedicationCompliance",
|
||||
MEDICATION_EFFECT: "reportSelection.leafMedicationEffect",
|
||||
GLP1_THERAPY: "reportSelection.leafGlp1Therapy",
|
||||
WORKOUTS: "reportSelection.leafWorkouts",
|
||||
ALLERGIES: "reportSelection.leafAllergies",
|
||||
ILLNESS_EPISODES: "reportSelection.leafIllnessEpisodes",
|
||||
VISITS: "reportSelection.leafVisits",
|
||||
@@ -294,6 +300,8 @@ export const LEAF_MODULE: Partial<Record<ReportLeafId, ModuleKey>> = {
|
||||
STRAIN_SCORE: "workouts",
|
||||
DAY_STRAIN: "workouts",
|
||||
WORKOUT_STRAIN: "workouts",
|
||||
// The per-sport workout block.
|
||||
WORKOUTS: "workouts",
|
||||
// Readiness signals.
|
||||
RECOVERY_SCORE: "recovery",
|
||||
STRESS_SCORE: "recovery",
|
||||
@@ -339,6 +347,7 @@ export const LEAF_MODULE: Partial<Record<ReportLeafId, ModuleKey>> = {
|
||||
MEDICATION_LIST: "medications",
|
||||
MEDICATION_ADMINISTRATIONS: "medications",
|
||||
MEDICATION_COMPLIANCE: "medications",
|
||||
MEDICATION_EFFECT: "medications",
|
||||
GLP1_THERAPY: "medications",
|
||||
// The immunization log is its own opt-out module: the leaf hides when the
|
||||
// `vaccinations` module is off, and the deselected leaf hides regardless of
|
||||
@@ -433,11 +442,33 @@ export const SENSITIVE_LEAF_IDS: readonly ReportLeafId[] = ALL_LEAF_IDS.filter(
|
||||
: MEASUREMENT_LEAF_GROUP[leaf] === SENSITIVE_GROUP_ID,
|
||||
);
|
||||
|
||||
/**
|
||||
* Measurement labels the report artefacts print differently from the app.
|
||||
* Heart-rate variability comes as two statistics that are not
|
||||
* interchangeable (SDNN from Apple Health and Withings, RMSSD from Oura,
|
||||
* WHOOP, Polar and Fitbit); the app's generic "Heart-rate variability" label
|
||||
* on the SDNN series sits beside "HRV (RMSSD)" on a clinician's page, so the
|
||||
* report names the statistic on both.
|
||||
*/
|
||||
const REPORT_MEASUREMENT_LABEL_OVERRIDES: Partial<
|
||||
Record<MeasurementType, string>
|
||||
> = {
|
||||
HEART_RATE_VARIABILITY: "reportSelection.leafHrvSdnn",
|
||||
};
|
||||
|
||||
/** The i18n label key a report artefact prints a measurement type under. */
|
||||
export function reportMeasurementLabelKey(type: MeasurementType): string {
|
||||
return (
|
||||
REPORT_MEASUREMENT_LABEL_OVERRIDES[type] ??
|
||||
MEASUREMENT_TYPE_LABEL_KEYS[type]
|
||||
);
|
||||
}
|
||||
|
||||
/** The i18n label key for any leaf. */
|
||||
export function leafLabelKey(leaf: ReportLeafId): string {
|
||||
return isStructuredLeafId(leaf)
|
||||
? STRUCTURED_LEAF_LABEL_KEYS[leaf]
|
||||
: MEASUREMENT_TYPE_LABEL_KEYS[leaf];
|
||||
: reportMeasurementLabelKey(leaf);
|
||||
}
|
||||
|
||||
/** The group a leaf belongs to. */
|
||||
|
||||
@@ -16,13 +16,21 @@ import { type ReportLeafId } from "./catalogue";
|
||||
/**
|
||||
* Identity and insurer (the cover page a practice files the document under),
|
||||
* the three classic vitals, weight and BMI, the glucose series and its panel,
|
||||
* labs, the drug list and adherence, allergies, past illness episodes, the
|
||||
* surgical history and the immunization record.
|
||||
* labs, the drug list, adherence and the effect around each start, sleep,
|
||||
* both heart-rate-variability statistics, daily steps and the workout summary,
|
||||
* allergies, past illness episodes, the surgical history and the immunization
|
||||
* record.
|
||||
*
|
||||
* Deliberately not in it: everything a wearable produces, gait and mobility,
|
||||
* environment exposure, the per-dose administration ledger (a clinician asks
|
||||
* for adherence, not for every logged dose), the GLP-1 block, and the whole
|
||||
* fenced tier.
|
||||
* The wearable picks (v1.44) are the four a general visit reads: sleep
|
||||
* duration, SDNN and RMSSD as two labelled rows, steps and the per-sport
|
||||
* workout block. Each is one table row or one compact block, and each still
|
||||
* rides its module: an account with sleep or workouts switched off gets none
|
||||
* of it.
|
||||
*
|
||||
* Deliberately not in it: the rest of what a wearable produces, gait and
|
||||
* mobility, environment exposure, the per-dose administration ledger (a
|
||||
* clinician asks for adherence, not for every logged dose), the GLP-1 block,
|
||||
* and the whole fenced tier.
|
||||
*/
|
||||
export const STANDARD_TEMPLATE_LEAVES: readonly ReportLeafId[] = [
|
||||
"PATIENT_IDENTITY",
|
||||
@@ -38,6 +46,12 @@ export const STANDARD_TEMPLATE_LEAVES: readonly ReportLeafId[] = [
|
||||
"LAB_RESULTS",
|
||||
"MEDICATION_LIST",
|
||||
"MEDICATION_COMPLIANCE",
|
||||
"MEDICATION_EFFECT",
|
||||
"SLEEP_DURATION",
|
||||
"HEART_RATE_VARIABILITY",
|
||||
"HRV_RMSSD",
|
||||
"ACTIVITY_STEPS",
|
||||
"WORKOUTS",
|
||||
"ALLERGIES",
|
||||
"ILLNESS_EPISODES",
|
||||
"SURGICAL_HISTORY",
|
||||
|
||||
@@ -59,6 +59,12 @@
|
||||
"unit": "ms",
|
||||
"category": "vital-signs"
|
||||
},
|
||||
"HRV_RMSSD": {
|
||||
"loinc": null,
|
||||
"display": "Heart rate variability RMSSD",
|
||||
"unit": "ms",
|
||||
"category": "vital-signs"
|
||||
},
|
||||
"ACTIVITY_STEPS": {
|
||||
"loinc": "41950-7",
|
||||
"display": "Number of steps in 24 hour Measured",
|
||||
|
||||
@@ -211,6 +211,34 @@ export const SIGNALS: Record<string, SignalDefinition> = {
|
||||
category: "vital-signs",
|
||||
},
|
||||
},
|
||||
// v1.44 — RMSSD, the heart-rate-variability statistic rings and bands
|
||||
// report (Oura, WHOOP, Polar, Fitbit, Google Health). Registered for its FHIR
|
||||
// coding only: every other surface reads it through its own tables today, so
|
||||
// all surface flags stay off and nothing else derives from this entry.
|
||||
// LOINC publishes an SDNN term (80404-7, above) and none for RMSSD, so the
|
||||
// coding is text-only rather than borrowing SDNN's code for a different
|
||||
// statistic.
|
||||
HRV_RMSSD: {
|
||||
key: "HRV_RMSSD",
|
||||
kind: "measurement",
|
||||
source: { measurementType: "HRV_RMSSD" },
|
||||
displayName: "Heart-rate variability (RMSSD)",
|
||||
unit: "ms",
|
||||
direction: "higher-better",
|
||||
archetype: "physiological-vital",
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
correlationEligible: false,
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
fhir: {
|
||||
loinc: null,
|
||||
display: "Heart rate variability RMSSD",
|
||||
unit: "ms",
|
||||
category: "vital-signs",
|
||||
},
|
||||
},
|
||||
OXYGEN_SATURATION: {
|
||||
key: "OXYGEN_SATURATION",
|
||||
kind: "measurement",
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Exact statistics for the segmented (interrupted time series) regression.
|
||||
*
|
||||
* The reference values for the noisy fixtures were computed independently
|
||||
* with statsmodels 0.14 (`OLS(...).fit(cov_type="HAC", cov_kwds={"maxlags":
|
||||
* L, "use_correction": True}, use_t=True)`, L = floor(4·(n/100)^(2/9))) and
|
||||
* scipy's `stats.t`. The fixture generator below is the same deterministic
|
||||
* formula the reference script used, so a drift in the estimator, the HAC
|
||||
* meat, the small-sample factor or the t quantile fails here.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
fitSegmentedRegression,
|
||||
neweyWestLag,
|
||||
studentTCdf,
|
||||
studentTQuantile,
|
||||
type SegmentedPoint,
|
||||
} from "@/lib/stats/segmented-regression";
|
||||
|
||||
/** y = 140 − 0.05·t − 8·D − 0.1·t·D + deterministic wobble. */
|
||||
function fixture(
|
||||
nPre: number,
|
||||
nPost: number,
|
||||
opts: { gaps?: boolean; noise?: boolean } = {},
|
||||
): SegmentedPoint[] {
|
||||
const out: SegmentedPoint[] = [];
|
||||
for (let t = -nPre; t < nPost; t++) {
|
||||
if (opts.gaps && ((t % 5) + 5) % 5 === 2) continue;
|
||||
// Python's `%` is a floored modulo; JS `%` truncates. Match Python.
|
||||
const pyMod = (((0.45 * t * t) % 7) + 7) % 7;
|
||||
const noise =
|
||||
opts.noise === false ? 0 : 3 * Math.sin(1.7 * t) + 2 * Math.cos(pyMod);
|
||||
const d = t >= 0 ? 1 : 0;
|
||||
out.push({ t, y: 140 - 0.05 * t - 8 * d - 0.1 * t * d + noise });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("studentT", () => {
|
||||
it("matches scipy quantiles", () => {
|
||||
expect(studentTQuantile(0.975, 10)).toBeCloseTo(2.228138851986274, 9);
|
||||
expect(studentTQuantile(0.975, 86)).toBeCloseTo(1.98793420623902, 9);
|
||||
expect(studentTQuantile(0.95, 3)).toBeCloseTo(2.3533634348018233, 9);
|
||||
});
|
||||
|
||||
it("matches the scipy CDF", () => {
|
||||
expect(studentTCdf(-2.1, 7)).toBeCloseTo(0.036935598106461326, 10);
|
||||
expect(studentTCdf(0, 5)).toBeCloseTo(0.5, 12);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fitSegmentedRegression", () => {
|
||||
it("recovers a noise-free level and slope change exactly, with zero-width intervals", () => {
|
||||
const fit = fitSegmentedRegression(fixture(30, 30, { noise: false }));
|
||||
expect(fit).not.toBeNull();
|
||||
expect(fit!.intercept.estimate).toBeCloseTo(140, 9);
|
||||
expect(fit!.preSlope.estimate).toBeCloseTo(-0.05, 9);
|
||||
expect(fit!.levelChange.estimate).toBeCloseTo(-8, 9);
|
||||
expect(fit!.slopeChange.estimate).toBeCloseTo(-0.1, 9);
|
||||
expect(fit!.levelChange.standardError).toBeLessThan(1e-9);
|
||||
expect(fit!.rSquared).toBeCloseTo(1, 12);
|
||||
});
|
||||
|
||||
it("matches statsmodels HAC on a contiguous 90-day series", () => {
|
||||
const fit = fitSegmentedRegression(fixture(40, 50))!;
|
||||
expect(fit.n).toBe(90);
|
||||
expect(fit.df).toBe(86);
|
||||
expect(fit.hacLag).toBe(3);
|
||||
expect(fit.intercept.estimate).toBeCloseTo(139.46950523140714, 9);
|
||||
expect(fit.preSlope.estimate).toBeCloseTo(-0.09725785860840647, 10);
|
||||
expect(fit.levelChange.estimate).toBeCloseTo(-7.336815685253282, 9);
|
||||
expect(fit.slopeChange.estimate).toBeCloseTo(-0.03684505010294312, 10);
|
||||
expect(fit.intercept.standardError).toBeCloseTo(0.3824128525477469, 9);
|
||||
expect(fit.preSlope.standardError).toBeCloseTo(0.018189958785228614, 10);
|
||||
expect(fit.levelChange.standardError).toBeCloseTo(0.5960977099276101, 9);
|
||||
expect(fit.slopeChange.standardError).toBeCloseTo(0.02336733639963861, 10);
|
||||
expect(fit.levelChange.ciLow).toBeCloseTo(-8.521818713079124, 8);
|
||||
expect(fit.levelChange.ciHigh).toBeCloseTo(-6.151812657427441, 8);
|
||||
expect(fit.slopeChange.ciLow).toBeCloseTo(-0.08329777744047885, 9);
|
||||
expect(fit.slopeChange.ciHigh).toBeCloseTo(0.009607677234592618, 9);
|
||||
expect(fit.rSquared).toBeCloseTo(0.8683372493940129, 10);
|
||||
});
|
||||
|
||||
it("matches statsmodels HAC on a series with gaps", () => {
|
||||
const fit = fitSegmentedRegression(fixture(30, 30, { gaps: true }))!;
|
||||
expect(fit.n).toBe(48);
|
||||
expect(fit.hacLag).toBe(3);
|
||||
expect(fit.levelChange.estimate).toBeCloseTo(-6.123258149435133, 9);
|
||||
expect(fit.levelChange.standardError).toBeCloseTo(0.9608527406825199, 9);
|
||||
expect(fit.levelChange.ciLow).toBeCloseTo(-8.059729606822106, 8);
|
||||
expect(fit.levelChange.ciHigh).toBeCloseTo(-4.18678669204816, 8);
|
||||
expect(fit.slopeChange.estimate).toBeCloseTo(-0.11857109294626766, 10);
|
||||
expect(fit.slopeChange.ciLow).toBeCloseTo(-0.23733292721328714, 9);
|
||||
expect(fit.slopeChange.ciHigh).toBeCloseTo(0.0001907413207517944, 9);
|
||||
expect(fit.rSquared).toBeCloseTo(0.8247246446176286, 10);
|
||||
});
|
||||
|
||||
it("returns null when one side of the change has no points", () => {
|
||||
expect(
|
||||
fitSegmentedRegression(fixture(0, 30).filter((p) => p.t >= 0)),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null without residual degrees of freedom", () => {
|
||||
expect(fitSegmentedRegression(fixture(2, 2, { noise: false }))).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the automatic Newey–West lag", () => {
|
||||
expect(neweyWestLag(90)).toBe(3);
|
||||
expect(neweyWestLag(30)).toBe(3);
|
||||
expect(neweyWestLag(10)).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Segmented regression for an interrupted time series.
|
||||
*
|
||||
* One change point, known in advance (a medication start), four parameters:
|
||||
*
|
||||
* y(t) = b0 + b1·t + b2·D(t) + b3·t·D(t) + e(t)
|
||||
*
|
||||
* where `t` is the time in days relative to the change point and `D(t)` is 1
|
||||
* at and after it, 0 before. `b1` is the trend before the change, `b2` the
|
||||
* immediate shift in level, `b3` the change in slope. Fitted by ordinary
|
||||
* least squares.
|
||||
*
|
||||
* Daily health series are autocorrelated: today's blood pressure says a lot
|
||||
* about tomorrow's. Plain OLS standard errors ignore that and come out too
|
||||
* narrow, so the covariance here is the Newey–West estimator (Bartlett
|
||||
* kernel) with the usual automatic lag `floor(4·(n/100)^(2/9))` and the
|
||||
* small-sample factor n/(n−k). Intervals use Student's t with n−k degrees of
|
||||
* freedom. This matches `statsmodels` OLS `cov_type="HAC"` with
|
||||
* `use_correction=True` and `use_t=True`; the unit tests pin it to values
|
||||
* computed there.
|
||||
*
|
||||
* Observations are taken in the order given, which for a daily series with
|
||||
* gaps treats a gap as adjacent days. That is a known approximation of the
|
||||
* lag structure, not of the coefficients.
|
||||
*
|
||||
* Pure. No I/O, no rounding: callers round for display.
|
||||
*/
|
||||
|
||||
/** One observation: time in days relative to the change point, and a value. */
|
||||
export interface SegmentedPoint {
|
||||
t: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/** A coefficient with its HAC standard error and two-sided interval. */
|
||||
export interface SegmentedEstimate {
|
||||
estimate: number;
|
||||
standardError: number;
|
||||
ciLow: number;
|
||||
ciHigh: number;
|
||||
}
|
||||
|
||||
export interface SegmentedFit {
|
||||
/** Observations used. */
|
||||
n: number;
|
||||
/** Residual degrees of freedom, n − 4. */
|
||||
df: number;
|
||||
/** Confidence level of every interval, e.g. 0.95. */
|
||||
level: number;
|
||||
/** Newey–West truncation lag that was applied. */
|
||||
hacLag: number;
|
||||
intercept: SegmentedEstimate;
|
||||
preSlope: SegmentedEstimate;
|
||||
levelChange: SegmentedEstimate;
|
||||
slopeChange: SegmentedEstimate;
|
||||
/** Coefficient of determination of the fit. */
|
||||
rSquared: number;
|
||||
/** Lag-1 autocorrelation of the residuals (a reader's sanity check). */
|
||||
residualAutocorrelation: number;
|
||||
}
|
||||
|
||||
const K = 4;
|
||||
|
||||
/** Automatic Newey–West lag: floor(4·(n/100)^(2/9)). */
|
||||
export function neweyWestLag(n: number): number {
|
||||
return Math.floor(4 * Math.pow(n / 100, 2 / 9));
|
||||
}
|
||||
|
||||
/** Invert a small symmetric positive-definite matrix (Gauss–Jordan). */
|
||||
function invert(matrix: number[][]): number[][] | null {
|
||||
const size = matrix.length;
|
||||
const a = matrix.map((row, i) => [
|
||||
...row,
|
||||
...Array.from({ length: size }, (_, j) => (i === j ? 1 : 0)),
|
||||
]);
|
||||
for (let col = 0; col < size; col++) {
|
||||
let pivot = col;
|
||||
for (let r = col + 1; r < size; r++) {
|
||||
if (Math.abs(a[r][col]) > Math.abs(a[pivot][col])) pivot = r;
|
||||
}
|
||||
if (Math.abs(a[pivot][col]) < 1e-12) return null;
|
||||
[a[col], a[pivot]] = [a[pivot], a[col]];
|
||||
const p = a[col][col];
|
||||
for (let j = 0; j < 2 * size; j++) a[col][j] /= p;
|
||||
for (let r = 0; r < size; r++) {
|
||||
if (r === col) continue;
|
||||
const f = a[r][col];
|
||||
if (f === 0) continue;
|
||||
for (let j = 0; j < 2 * size; j++) a[r][j] -= f * a[col][j];
|
||||
}
|
||||
}
|
||||
return a.map((row) => row.slice(size));
|
||||
}
|
||||
|
||||
function designRow(p: SegmentedPoint): number[] {
|
||||
const d = p.t >= 0 ? 1 : 0;
|
||||
return [1, p.t, d, p.t * d];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fit the four-parameter segmented model. Returns null when the design is
|
||||
* singular (no points on one side, or every point at one instant) or when
|
||||
* there are no residual degrees of freedom.
|
||||
*/
|
||||
export function fitSegmentedRegression(
|
||||
points: readonly SegmentedPoint[],
|
||||
options: { level?: number; hacLag?: number } = {},
|
||||
): SegmentedFit | null {
|
||||
const level = options.level ?? 0.95;
|
||||
const n = points.length;
|
||||
const df = n - K;
|
||||
if (df < 1) return null;
|
||||
|
||||
const X = points.map(designRow);
|
||||
const y = points.map((p) => p.y);
|
||||
|
||||
const xtx = Array.from({ length: K }, () => new Array<number>(K).fill(0));
|
||||
const xty = new Array<number>(K).fill(0);
|
||||
for (let i = 0; i < n; i++) {
|
||||
for (let r = 0; r < K; r++) {
|
||||
xty[r] += X[i][r] * y[i];
|
||||
for (let c = 0; c < K; c++) xtx[r][c] += X[i][r] * X[i][c];
|
||||
}
|
||||
}
|
||||
const bread = invert(xtx);
|
||||
if (!bread) return null;
|
||||
const beta = bread.map((row) => row.reduce((s, v, c) => s + v * xty[c], 0));
|
||||
|
||||
const residuals = y.map(
|
||||
(yi, i) => yi - X[i].reduce((s, v, c) => s + v * beta[c], 0),
|
||||
);
|
||||
|
||||
// Meat: Σ e_t² x_t x_tᵀ + Σ_l w_l Σ_t e_t e_{t−l} (x_t x_{t−l}ᵀ + x_{t−l} x_tᵀ)
|
||||
const lag = Math.max(0, Math.min(options.hacLag ?? neweyWestLag(n), n - 1));
|
||||
const meat = Array.from({ length: K }, () => new Array<number>(K).fill(0));
|
||||
for (let l = 0; l <= lag; l++) {
|
||||
const w = l === 0 ? 1 : 1 - l / (lag + 1);
|
||||
for (let t = l; t < n; t++) {
|
||||
const ee = residuals[t] * residuals[t - l] * w;
|
||||
for (let r = 0; r < K; r++) {
|
||||
for (let c = 0; c < K; c++) {
|
||||
meat[r][c] +=
|
||||
l === 0
|
||||
? ee * X[t][r] * X[t][c]
|
||||
: ee * (X[t][r] * X[t - l][c] + X[t - l][r] * X[t][c]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const correction = n / df;
|
||||
const cov = Array.from({ length: K }, (_, r) =>
|
||||
Array.from({ length: K }, (_, c) => {
|
||||
let s = 0;
|
||||
for (let i = 0; i < K; i++) {
|
||||
for (let j = 0; j < K; j++) s += bread[r][i] * meat[i][j] * bread[j][c];
|
||||
}
|
||||
return s * correction;
|
||||
}),
|
||||
);
|
||||
|
||||
const tCrit = studentTQuantile(1 - (1 - level) / 2, df);
|
||||
const estimateAt = (i: number): SegmentedEstimate => {
|
||||
const se = Math.sqrt(Math.max(0, cov[i][i]));
|
||||
return {
|
||||
estimate: beta[i],
|
||||
standardError: se,
|
||||
ciLow: beta[i] - tCrit * se,
|
||||
ciHigh: beta[i] + tCrit * se,
|
||||
};
|
||||
};
|
||||
|
||||
const meanY = y.reduce((s, v) => s + v, 0) / n;
|
||||
let ssr = 0;
|
||||
let sst = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
ssr += residuals[i] ** 2;
|
||||
sst += (y[i] - meanY) ** 2;
|
||||
}
|
||||
let acNum = 0;
|
||||
for (let i = 1; i < n; i++) acNum += residuals[i] * residuals[i - 1];
|
||||
|
||||
return {
|
||||
n,
|
||||
df,
|
||||
level,
|
||||
hacLag: lag,
|
||||
intercept: estimateAt(0),
|
||||
preSlope: estimateAt(1),
|
||||
levelChange: estimateAt(2),
|
||||
slopeChange: estimateAt(3),
|
||||
rSquared: sst > 0 ? 1 - ssr / sst : 1,
|
||||
residualAutocorrelation: ssr > 0 ? acNum / ssr : 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Student's t ────────────────────────────────────────────────────────
|
||||
|
||||
/** ln Γ(x), Lanczos approximation (g = 7, n = 9). */
|
||||
function logGamma(x: number): number {
|
||||
const c = [
|
||||
0.99999999999980993, 676.5203681218851, -1259.1392167224028,
|
||||
771.32342877765313, -176.61502916214059, 12.507343278686905,
|
||||
-0.13857109526572012, 9.9843695780195716e-6, 1.5056327351493116e-7,
|
||||
];
|
||||
if (x < 0.5) {
|
||||
return Math.log(Math.PI / Math.sin(Math.PI * x)) - logGamma(1 - x);
|
||||
}
|
||||
const z = x - 1;
|
||||
let a = c[0];
|
||||
const t = z + 7.5;
|
||||
for (let i = 1; i < 9; i++) a += c[i] / (z + i);
|
||||
return (
|
||||
0.5 * Math.log(2 * Math.PI) + (z + 0.5) * Math.log(t) - t + Math.log(a)
|
||||
);
|
||||
}
|
||||
|
||||
/** Continued fraction for the incomplete beta (Numerical Recipes betacf). */
|
||||
function betaContinuedFraction(x: number, a: number, b: number): number {
|
||||
const tiny = 1e-300;
|
||||
let c = 1;
|
||||
let d = 1 - ((a + b) * x) / (a + 1);
|
||||
if (Math.abs(d) < tiny) d = tiny;
|
||||
d = 1 / d;
|
||||
let h = d;
|
||||
for (let m = 1; m <= 300; m++) {
|
||||
const m2 = 2 * m;
|
||||
let aa = (m * (b - m) * x) / ((a + m2 - 1) * (a + m2));
|
||||
d = 1 + aa * d;
|
||||
if (Math.abs(d) < tiny) d = tiny;
|
||||
c = 1 + aa / c;
|
||||
if (Math.abs(c) < tiny) c = tiny;
|
||||
d = 1 / d;
|
||||
h *= d * c;
|
||||
aa = (-(a + m) * (a + b + m) * x) / ((a + m2) * (a + m2 + 1));
|
||||
d = 1 + aa * d;
|
||||
if (Math.abs(d) < tiny) d = tiny;
|
||||
c = 1 + aa / c;
|
||||
if (Math.abs(c) < tiny) c = tiny;
|
||||
d = 1 / d;
|
||||
const delta = d * c;
|
||||
h *= delta;
|
||||
if (Math.abs(delta - 1) < 1e-15) break;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
/** Regularised incomplete beta I_x(a, b). */
|
||||
function incompleteBeta(x: number, a: number, b: number): number {
|
||||
if (x <= 0) return 0;
|
||||
if (x >= 1) return 1;
|
||||
const front = Math.exp(
|
||||
logGamma(a + b) -
|
||||
logGamma(a) -
|
||||
logGamma(b) +
|
||||
a * Math.log(x) +
|
||||
b * Math.log(1 - x),
|
||||
);
|
||||
if (x < (a + 1) / (a + b + 2)) {
|
||||
return (front * betaContinuedFraction(x, a, b)) / a;
|
||||
}
|
||||
return 1 - (front * betaContinuedFraction(1 - x, b, a)) / b;
|
||||
}
|
||||
|
||||
/** Student's t cumulative distribution function. */
|
||||
export function studentTCdf(t: number, df: number): number {
|
||||
const x = df / (df + t * t);
|
||||
const tail = 0.5 * incompleteBeta(x, df / 2, 0.5);
|
||||
return t >= 0 ? 1 - tail : tail;
|
||||
}
|
||||
|
||||
/** Student's t quantile, by bisection on the CDF (monotone, so it converges). */
|
||||
export function studentTQuantile(p: number, df: number): number {
|
||||
if (p <= 0) return Number.NEGATIVE_INFINITY;
|
||||
if (p >= 1) return Number.POSITIVE_INFINITY;
|
||||
if (p === 0.5) return 0;
|
||||
let lo = -1e3;
|
||||
let hi = 1e3;
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const mid = (lo + hi) / 2;
|
||||
if (studentTCdf(mid, df) < p) lo = mid;
|
||||
else hi = mid;
|
||||
if (hi - lo < 1e-12) break;
|
||||
}
|
||||
return (lo + hi) / 2;
|
||||
}
|
||||
@@ -81,6 +81,81 @@ const levelShiftSchema = z
|
||||
})
|
||||
.nullable();
|
||||
|
||||
const estimateSchema = z
|
||||
.object({
|
||||
estimate: z.number(),
|
||||
ciLow: z.number(),
|
||||
ciHigh: z.number(),
|
||||
standardError: z.number(),
|
||||
includesZero: z.boolean(),
|
||||
})
|
||||
.nullable();
|
||||
|
||||
/**
|
||||
* The segmented-regression (interrupted time series) effect: the immediate
|
||||
* level change and the change in weekly trend at the start, each with a 95 %
|
||||
* interval (Newey–West), fitted over up to 90 days before and 180 after with
|
||||
* low-adherence and not-taking days after the start left out. Descriptive
|
||||
* only; `present: false` carries the reason the data floor was not met.
|
||||
*/
|
||||
const segmentedSchema = z
|
||||
.object({
|
||||
present: z.boolean(),
|
||||
reason: z
|
||||
.enum([
|
||||
"no_start",
|
||||
"insufficient_before",
|
||||
"insufficient_after",
|
||||
"fit_failed",
|
||||
])
|
||||
.optional(),
|
||||
level: z.number(),
|
||||
window: z
|
||||
.object({
|
||||
from: z.string(),
|
||||
to: z.string(),
|
||||
endReason: z.enum(["today", "window", "dose_change"]),
|
||||
})
|
||||
.nullable(),
|
||||
days: z.object({
|
||||
before: z.number().int(),
|
||||
after: z.number().int(),
|
||||
excludedLowAdherence: z.number().int(),
|
||||
excludedNotTaking: z.number().int(),
|
||||
}),
|
||||
adherence: z.object({
|
||||
status: z.enum(["tracked", "not_tracked"]),
|
||||
floorPct: z.number().int(),
|
||||
lookbackDays: z.number().int(),
|
||||
}),
|
||||
levelChange: estimateSchema,
|
||||
slopeChangePerWeek: estimateSchema,
|
||||
preSlopePerWeek: estimateSchema,
|
||||
fit: z
|
||||
.object({
|
||||
n: z.number().int(),
|
||||
df: z.number().int(),
|
||||
rSquared: z.number(),
|
||||
residualAutocorrelation: z.number(),
|
||||
hacLag: z.number().int(),
|
||||
})
|
||||
.nullable(),
|
||||
confounders: z.array(
|
||||
z.object({
|
||||
medicationName: z.string(),
|
||||
event: z.enum([
|
||||
"started",
|
||||
"stopped",
|
||||
"dose_change",
|
||||
"paused",
|
||||
"resumed",
|
||||
]),
|
||||
date: z.string(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.nullable();
|
||||
|
||||
const targetViewSchema = z.object({
|
||||
kind: z.enum(["metric", "lab"]),
|
||||
key: z.string(),
|
||||
@@ -91,6 +166,7 @@ const targetViewSchema = z.object({
|
||||
series: z.array(seriesPointSchema),
|
||||
beforeAfter: beforeAfterSchema,
|
||||
levelShift: levelShiftSchema,
|
||||
segmented: segmentedSchema,
|
||||
});
|
||||
|
||||
export const medicationEfficacyResponseSchema = z.object({
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* v1.44 against a real Postgres: the FHIR Observation time series (the UNION
|
||||
* page query, its count, the BP pairing, the glucose-context filter, owner and
|
||||
* soft-delete scoping), the segmented medication effect read end to end, and
|
||||
* the doctor report's workout and effect blocks.
|
||||
*
|
||||
* The effect fixture: an ACE inhibitor started on 2026-03-01 with systolic
|
||||
* readings of exactly 140 for 60 days before and 130 − 0.1·t after, so the
|
||||
* fit must recover a level change of −10 mmHg and a slope change of
|
||||
* −0.7 mmHg/week, and a second medication starting inside the window must be
|
||||
* listed as a confounder.
|
||||
*/
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import {
|
||||
emitSeriesPage,
|
||||
querySeriesPage,
|
||||
resolveSeriesScope,
|
||||
} from "@/lib/fhir/series";
|
||||
import {
|
||||
EFFECT_MEDICATION_INCLUDE,
|
||||
loadSegmentedEffects,
|
||||
} from "@/lib/medications/efficacy/load-segmented-effects";
|
||||
import { collectDoctorReportData } from "@/lib/doctor-report-data";
|
||||
import { selectionFromLeaves } from "@/lib/report-selection/selection";
|
||||
import type { ReportLeafId } from "@/lib/report-selection/catalogue";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const DAY = 86_400_000;
|
||||
const PIVOT = Date.parse("2026-03-01T00:00:00.000Z");
|
||||
|
||||
let userId = "";
|
||||
let otherId = "";
|
||||
let medId = "";
|
||||
|
||||
const MODULES = {
|
||||
cycle: false,
|
||||
mood: false,
|
||||
sleep: true,
|
||||
glucose: true,
|
||||
workouts: true,
|
||||
recovery: true,
|
||||
labs: true,
|
||||
illness: true,
|
||||
achievements: true,
|
||||
coach: true,
|
||||
insights: true,
|
||||
medications: true,
|
||||
doctorReport: true,
|
||||
environment: true,
|
||||
mcp: true,
|
||||
inboundDocuments: true,
|
||||
mentalHealth: true,
|
||||
nutrients: true,
|
||||
vaccinations: true,
|
||||
timeline: true,
|
||||
} as const;
|
||||
|
||||
const gateOf = (leaves: ReportLeafId[]) => ({
|
||||
admits: (leaf: ReportLeafId) => leaves.includes(leaf),
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await truncateAllTables(prisma);
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: "fhir-series-effect",
|
||||
email: "fhir-series-effect@example.test",
|
||||
timezone: "UTC",
|
||||
},
|
||||
});
|
||||
userId = user.id;
|
||||
const other = await prisma.user.create({
|
||||
data: {
|
||||
username: "fhir-series-other",
|
||||
email: "fhir-series-other@example.test",
|
||||
timezone: "UTC",
|
||||
},
|
||||
});
|
||||
otherId = other.id;
|
||||
|
||||
const rows: Prisma.MeasurementCreateManyInput[] = [];
|
||||
// Systolic: 140 before, 130 − 0.1·t after (one reading a day at 08:00).
|
||||
for (let t = -60; t < 60; t++) {
|
||||
rows.push({
|
||||
userId,
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
value: t < 0 ? 140 : 130 - 0.1 * t,
|
||||
unit: "mmHg",
|
||||
source: "MANUAL",
|
||||
measuredAt: new Date(PIVOT + t * DAY + 8 * 3_600_000),
|
||||
});
|
||||
}
|
||||
// Two paired diastolic readings and one lone diastolic.
|
||||
rows.push(
|
||||
{
|
||||
userId,
|
||||
type: "BLOOD_PRESSURE_DIA",
|
||||
value: 85,
|
||||
unit: "mmHg",
|
||||
source: "MANUAL",
|
||||
measuredAt: new Date(PIVOT + 10 * DAY + 8 * 3_600_000),
|
||||
},
|
||||
{
|
||||
userId,
|
||||
type: "BLOOD_PRESSURE_DIA",
|
||||
value: 84,
|
||||
unit: "mmHg",
|
||||
source: "MANUAL",
|
||||
measuredAt: new Date(PIVOT + 11 * DAY + 8 * 3_600_000),
|
||||
},
|
||||
{
|
||||
userId,
|
||||
type: "BLOOD_PRESSURE_DIA",
|
||||
value: 80,
|
||||
unit: "mmHg",
|
||||
source: "MANUAL",
|
||||
measuredAt: new Date(PIVOT + 11 * DAY + 20 * 3_600_000),
|
||||
},
|
||||
);
|
||||
rows.push(
|
||||
{
|
||||
userId,
|
||||
type: "BLOOD_GLUCOSE",
|
||||
value: 95,
|
||||
unit: "mg/dL",
|
||||
source: "MANUAL",
|
||||
glucoseContext: "FASTING",
|
||||
measuredAt: new Date(PIVOT + 12 * DAY),
|
||||
},
|
||||
{
|
||||
userId,
|
||||
type: "BLOOD_GLUCOSE",
|
||||
value: 130,
|
||||
unit: "mg/dL",
|
||||
source: "MANUAL",
|
||||
glucoseContext: null,
|
||||
measuredAt: new Date(PIVOT + 12 * DAY + 3_600_000),
|
||||
},
|
||||
{
|
||||
userId,
|
||||
type: "WEIGHT",
|
||||
value: 80,
|
||||
unit: "kg",
|
||||
source: "MANUAL",
|
||||
measuredAt: new Date(PIVOT + 13 * DAY),
|
||||
deletedAt: new Date(PIVOT + 14 * DAY),
|
||||
},
|
||||
{
|
||||
userId: otherId,
|
||||
type: "WEIGHT",
|
||||
value: 70,
|
||||
unit: "kg",
|
||||
source: "MANUAL",
|
||||
measuredAt: new Date(PIVOT + 13 * DAY),
|
||||
},
|
||||
);
|
||||
await prisma.measurement.createMany({ data: rows });
|
||||
|
||||
await prisma.workout.createMany({
|
||||
data: [
|
||||
{
|
||||
userId,
|
||||
source: "MANUAL",
|
||||
sportType: "running",
|
||||
externalId: "run-1",
|
||||
startedAt: new Date(PIVOT + 11 * DAY + 12 * 3_600_000),
|
||||
endedAt: new Date(PIVOT + 11 * DAY + 13 * 3_600_000),
|
||||
durationSec: 3600,
|
||||
totalDistanceM: 10_000,
|
||||
totalEnergyKcal: 700,
|
||||
avgHeartRate: 150,
|
||||
},
|
||||
{
|
||||
userId,
|
||||
source: "MANUAL",
|
||||
sportType: "running",
|
||||
externalId: "run-2",
|
||||
startedAt: new Date(PIVOT + 20 * DAY),
|
||||
endedAt: new Date(PIVOT + 20 * DAY + 1_800_000),
|
||||
durationSec: 1800,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const med = await prisma.medication.create({
|
||||
data: {
|
||||
userId,
|
||||
name: "Ramipril",
|
||||
dose: "5 mg",
|
||||
treatmentClass: "GENERIC",
|
||||
atcCode: "C09AA05",
|
||||
trackIntake: false,
|
||||
startsOn: new Date(PIVOT),
|
||||
},
|
||||
});
|
||||
medId = med.id;
|
||||
await prisma.medication.create({
|
||||
data: {
|
||||
userId,
|
||||
name: "Second drug",
|
||||
dose: "1 mg",
|
||||
treatmentClass: "GENERIC",
|
||||
startsOn: new Date(PIVOT + 20 * DAY),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("FHIR Observation series in Postgres", () => {
|
||||
const range = {
|
||||
from: new Date(PIVOT + 10 * DAY),
|
||||
to: new Date(PIVOT + 21 * DAY),
|
||||
};
|
||||
|
||||
it("pages newest first, pairs BP, scopes to the owner and drops soft-deleted rows", async () => {
|
||||
const scope = resolveSeriesScope(
|
||||
gateOf([
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"BLOOD_GLUCOSE",
|
||||
"WEIGHT",
|
||||
"WORKOUTS",
|
||||
]),
|
||||
null,
|
||||
);
|
||||
const { total, refs } = await querySeriesPage({
|
||||
userId,
|
||||
scope,
|
||||
range,
|
||||
count: 200,
|
||||
offset: 0,
|
||||
});
|
||||
// 11 systolic (days 10..20), 1 lone diastolic, 2 glucose, 2 workouts.
|
||||
// The two paired diastolic rows ride inside their systolic panels; the
|
||||
// deleted weight and the other account's weight never appear.
|
||||
expect(total).toBe(16);
|
||||
expect(refs).toHaveLength(16);
|
||||
const page = await emitSeriesPage({
|
||||
userId,
|
||||
refs,
|
||||
scope,
|
||||
glucoseUnit: "mg/dL",
|
||||
});
|
||||
expect(page).toHaveLength(16);
|
||||
// Newest first: the day-20 systolic (08:00, no diastolic partner, so a
|
||||
// lone 8480-6 reading), then the day-20 run (00:00).
|
||||
expect(page[0].code.coding?.[0]?.code).toBe("8480-6");
|
||||
expect(page[1].id.startsWith("w-")).toBe(true);
|
||||
const panels = page.filter(
|
||||
(o) =>
|
||||
o.code.coding?.[0]?.code === "85354-9" && o.component?.length === 2,
|
||||
);
|
||||
expect(
|
||||
panels.map((p) => p.component?.[1]?.valueQuantity?.value).sort(),
|
||||
).toEqual([84, 85]);
|
||||
expect(page.some((o) => o.code.coding?.[0]?.code === "8462-4")).toBe(true);
|
||||
expect(page.some((o) => o.code.coding?.[0]?.code === "29463-7")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("pages with offset and agrees with the count", async () => {
|
||||
const scope = resolveSeriesScope(gateOf(["BLOOD_PRESSURE_SYS"]), null);
|
||||
const first = await querySeriesPage({
|
||||
userId,
|
||||
scope,
|
||||
range,
|
||||
count: 5,
|
||||
offset: 0,
|
||||
});
|
||||
const second = await querySeriesPage({
|
||||
userId,
|
||||
scope,
|
||||
range,
|
||||
count: 5,
|
||||
offset: 5,
|
||||
});
|
||||
expect(first.total).toBe(11);
|
||||
expect(first.refs).toHaveLength(5);
|
||||
expect(second.refs).toHaveLength(5);
|
||||
const ids = new Set([...first.refs, ...second.refs].map((r) => r.id));
|
||||
expect(ids.size).toBe(10);
|
||||
});
|
||||
|
||||
it("narrows glucose to a code's contexts", async () => {
|
||||
const scope = resolveSeriesScope(gateOf(["BLOOD_GLUCOSE"]), {
|
||||
types: new Set(["BLOOD_GLUCOSE"]),
|
||||
glucoseContexts: new Set(["FASTING"]),
|
||||
workouts: false,
|
||||
});
|
||||
const { total } = await querySeriesPage({
|
||||
userId,
|
||||
scope,
|
||||
range,
|
||||
count: 50,
|
||||
offset: 0,
|
||||
});
|
||||
expect(total).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("segmented medication effect in Postgres", () => {
|
||||
it("recovers the known level and slope change and lists the confounder", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const med = await prisma.medication.findFirstOrThrow({
|
||||
where: { id: medId },
|
||||
include: EFFECT_MEDICATION_INCLUDE,
|
||||
});
|
||||
const effects = await loadSegmentedEffects({
|
||||
userId,
|
||||
med,
|
||||
targets: ["BLOOD_PRESSURE_SYS"],
|
||||
timezone: "UTC",
|
||||
now: new Date(PIVOT + 59 * DAY + 12 * 3_600_000),
|
||||
});
|
||||
const e = effects.get("BLOOD_PRESSURE_SYS")!;
|
||||
expect(e.present).toBe(true);
|
||||
expect(e.levelChange!.estimate).toBeCloseTo(-10, 6);
|
||||
expect(e.slopeChangePerWeek!.estimate).toBeCloseTo(-0.7, 6);
|
||||
expect(e.days.before).toBe(60);
|
||||
expect(e.days.after).toBe(60);
|
||||
expect(e.adherence.status).toBe("not_tracked");
|
||||
expect(e.confounders).toEqual([
|
||||
{ medicationName: "Second drug", event: "started", date: "2026-03-21" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reaches the doctor report with its workout block", async () => {
|
||||
const data = await collectDoctorReportData(
|
||||
userId,
|
||||
{
|
||||
start: new Date(PIVOT),
|
||||
end: new Date(PIVOT + 59 * DAY + 12 * 3_600_000),
|
||||
days: 60,
|
||||
},
|
||||
selectionFromLeaves([
|
||||
"MEDICATION_LIST",
|
||||
"MEDICATION_EFFECT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"WORKOUTS",
|
||||
]),
|
||||
{ moduleMap: MODULES as never },
|
||||
);
|
||||
const effect = data.medicationEffects?.find(
|
||||
(m) => m.medicationName === "Ramipril",
|
||||
);
|
||||
// Diastolic was not selected, so only the systolic target rides.
|
||||
expect(effect?.targets.map((t) => t.type)).toEqual(["BLOOD_PRESSURE_SYS"]);
|
||||
expect(effect?.targets[0].effect.present).toBe(true);
|
||||
expect(data.workouts?.sessions).toBe(2);
|
||||
expect(data.workouts?.bySport[0]).toMatchObject({
|
||||
sportType: "running",
|
||||
sessions: 2,
|
||||
durationSec: 5400,
|
||||
distanceM: 10_000,
|
||||
distanceSessions: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user