feat(doctor-report): add sleep, HRV, steps, workouts and the medication effect

The standard report template now carries sleep duration, heart-rate
variability as two labelled rows (SDNN and RMSSD), daily steps, a
per-sport workout summary and the change around each medication start.
Each still rides its module, and the template is still only applied by
the button that names it; saved selections and share links are
untouched.

Two structured leaves join the catalogue: WORKOUTS (activity group,
workouts module) and MEDICATION_EFFECT (medications group). The workout
block reads narrow rows through the canonical-workout picker and sums
them per sport. The effect block reuses the efficacy loader with the
report's end as its horizon, carries a target only when that target's
own measurement leaf is admitted, and is capped at eight medications.

In the PDF the wearable series chart at half width, two to a row, in
the units the table prints; the workout and effect blocks are compact
tables with short notes. The clinician view renders both blocks from
the same payload. RMSSD joins the dense day-bucket path so long windows
stay aggregated.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 21:55:57 +02:00
1 parent 1cf0582603
commit 9e2a0681b2
35 files changed
+1760 -47

No files matched your search

+3 -3
View File
@@ -26155,7 +26155,7 @@ components:
type: number
const: 2
leaves:
maxItems: 104
maxItems: 106
type: array
items:
type: string
@@ -26215,7 +26215,7 @@ components:
type: number
const: 2
leaves:
maxItems: 104
maxItems: 106
type: array
items:
type: string
@@ -29248,7 +29248,7 @@ components:
type: number
const: 2
leaves:
maxItems: 104
maxItems: 106
type: array
items:
type: string
+36 -2
View File
@@ -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",
@@ -12314,7 +12345,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": {
+36 -2
View File
@@ -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",
@@ -12314,7 +12345,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": {
+36 -2
View File
@@ -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",
@@ -12314,7 +12345,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": {
+36 -2
View File
@@ -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",
@@ -12314,7 +12345,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": {
+36 -2
View File
@@ -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",
@@ -12314,7 +12345,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": {
+36 -2
View File
@@ -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": "문제가 생겼어요",
@@ -12314,7 +12345,10 @@
"leafVisits": "진료",
"leafSurgicalHistory": "수술 이력",
"leafImmunizations": "예방접종",
"leafEmergency": "응급 정보 (혈액형, 연락처, 사전지시)"
"leafEmergency": "응급 정보 (혈액형, 연락처, 사전지시)",
"leafMedicationEffect": "각 약 시작 전후의 변화",
"leafWorkouts": "운동(종목별 요약)",
"leafHrvSdnn": "심박변이도 (SDNN)"
},
"recordSharing": {
"banner": {
+36 -2
View File
@@ -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",
@@ -12314,7 +12345,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": {
+36 -2
View File
@@ -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",
@@ -12314,7 +12345,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(),
);
@@ -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.
@@ -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}
+2 -2
View File
@@ -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),
@@ -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");
+2
View File
@@ -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);
}
+16 -2
View File
@@ -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
+51
View File
@@ -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");
});
});
+30
View File
@@ -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;
}
+19
View File
@@ -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()
);
}
+125
View File
@@ -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,
})),
};
}
@@ -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");
+35 -4
View File
@@ -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. */
+20 -6
View File
@@ -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",