From 9e2a0681b2bf6e036b647e123d6c442f31207a73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 21:55:57 +0200 Subject: [PATCH] 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. --- docs/api/openapi.yaml | 6 +- messages/de.json | 38 ++- messages/en.json | 38 ++- messages/es.json | 38 ++- messages/fr.json | 38 ++- messages/it.json | 38 ++- messages/ko.json | 38 ++- messages/nl.json | 38 ++- messages/pl.json | 38 ++- ...doctor-report-control-gating-guard.test.ts | 4 +- src/__tests__/i18n-english-leak-guard.test.ts | 2 + .../share-view-leaf-render-guard.test.ts | 67 +++- .../unit-preference-display-guard.test.ts | 10 + .../workout-canonical-identity-guard.test.ts | 3 + .../health-record/__tests__/route.test.ts | 2 + .../clinician/activity-effect-sections.tsx | 192 +++++++++++ src/components/clinician/clinician-view.tsx | 18 + src/components/clinician/report-sections.tsx | 4 +- .../__tests__/report-selection-panel.test.tsx | 4 +- src/lib/doctor-report-pdf-core.ts | 2 + .../activity-effect-sections.test.ts | 312 ++++++++++++++++++ .../activity-effect-sections.ts | 287 ++++++++++++++++ src/lib/doctor-report-pdf/clinical-summary.ts | 18 +- .../measurements-charts-section.ts | 43 ++- .../medication-mood-wellness-section.ts | 4 + src/lib/doctor-report-types.ts | 51 +++ .../__tests__/leaf-gating-sweep.test.ts | 11 + .../__tests__/medication-effects.test.ts | 127 +++++++ src/lib/doctor-report/collect.ts | 30 ++ src/lib/doctor-report/medication-effects.ts | 74 +++++ src/lib/doctor-report/sport-label.ts | 19 ++ src/lib/doctor-report/workouts.ts | 125 +++++++ .../__tests__/catalogue-guard.test.ts | 23 +- src/lib/report-selection/catalogue.ts | 39 ++- src/lib/report-selection/template.ts | 26 +- 35 files changed, 1760 insertions(+), 47 deletions(-) create mode 100644 src/components/clinician/activity-effect-sections.tsx create mode 100644 src/lib/doctor-report-pdf/__tests__/activity-effect-sections.test.ts create mode 100644 src/lib/doctor-report-pdf/activity-effect-sections.ts create mode 100644 src/lib/doctor-report/__tests__/medication-effects.test.ts create mode 100644 src/lib/doctor-report/medication-effects.ts create mode 100644 src/lib/doctor-report/sport-label.ts create mode 100644 src/lib/doctor-report/workouts.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a5bea3f73..356e42699 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/messages/de.json b/messages/de.json index 365d2274c..eac8cfe2e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index 4881ff789..f20f1394d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index 449ea8372..bd5c8f2e9 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index d9c64674a..5ec7d0e56 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index 15be9a0fa..d9d5d124d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/ko.json b/messages/ko.json index d943eb2f3..c4460503e 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -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": { diff --git a/messages/nl.json b/messages/nl.json index 2bd95f72c..804cc5662 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index 2467c66a8..e585a2ff6 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/src/__tests__/doctor-report-control-gating-guard.test.ts b/src/__tests__/doctor-report-control-gating-guard.test.ts index b0763a13f..1c2d51c0c 100644 --- a/src/__tests__/doctor-report-control-gating-guard.test.ts +++ b/src/__tests__/doctor-report-control-gating-guard.test.ts @@ -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", () => { diff --git a/src/__tests__/i18n-english-leak-guard.test.ts b/src/__tests__/i18n-english-leak-guard.test.ts index b091256da..9bda44703 100644 --- a/src/__tests__/i18n-english-leak-guard.test.ts +++ b/src/__tests__/i18n-english-leak-guard.test.ts @@ -98,6 +98,8 @@ const LEGIT_IDENTICAL = new Set([ "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", diff --git a/src/__tests__/share-view-leaf-render-guard.test.ts b/src/__tests__/share-view-leaf-render-guard.test.ts index 841f63ffa..bd50f93f0 100644 --- a/src/__tests__/share-view-leaf-render-guard.test.ts +++ b/src/__tests__/share-view-leaf-render-guard.test.ts @@ -13,7 +13,7 @@ * is `STRUCTURED_LEAF_GROUP`, the other is HTML rendered by the real * `` over a real fixture. It proves four things per leaf. * - * 1. `DISPOSITIONS` is `Record`, so a nineteenth + * 1. `DISPOSITIONS` is `Record`, 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 = { }, 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(), ); diff --git a/src/__tests__/unit-preference-display-guard.test.ts b/src/__tests__/unit-preference-display-guard.test.ts index b5febe503..df2d41130 100644 --- a/src/__tests__/unit-preference-display-guard.test.ts +++ b/src/__tests__/unit-preference-display-guard.test.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", diff --git a/src/__tests__/workout-canonical-identity-guard.test.ts b/src/__tests__/workout-canonical-identity-guard.test.ts index 416faa7e5..84d221c5b 100644 --- a/src/__tests__/workout-canonical-identity-guard.test.ts +++ b/src/__tests__/workout-canonical-identity-guard.test.ts @@ -43,6 +43,9 @@ const IDENTITY_OWNER: Record = { "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 diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts index 0a768cb86..861553985 100644 --- a/src/app/api/export/health-record/__tests__/route.test.ts +++ b/src/app/api/export/health-record/__tests__/route.test.ts @@ -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. diff --git a/src/components/clinician/activity-effect-sections.tsx b/src/components/clinician/activity-effect-sections.tsx new file mode 100644 index 000000000..3f62c9878 --- /dev/null +++ b/src/components/clinician/activity-effect-sections.tsx @@ -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 ( + + {w ? ( + <> +

+ {t("doctorReport.workouts.summary", { + sessions: w.sessions, + hours: fmtDigits(w.totalDurationSec / 3600, 1), + perWeek: fmtDigits(w.sessionsPerWeek, 1), + minutesPerWeek: fmtDigits(w.minutesPerWeek, 0), + })} +

+ {w.bySport.map((s) => ( + + ))} + {w.sportCount > w.bySport.length ? ( +

+ {t("doctorReport.workouts.moreSports", { + count: w.sportCount - w.bySport.length, + })} +

+ ) : null} + + ) : null} +
+ ); +} + +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 ( + + {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 ( + + ); + } + const level = formatEstimate(e.levelChange, fmtDigits); + const slope = formatEstimate(e.slopeChangePerWeek, fmtDigits); + return ( +
+

{label}

+

+ {t("medications.efficacy.segmented.level", { + ...level, + unit, + })} +

+

+ {t("medications.efficacy.segmented.slope", { + ...slope, + unit, + })} +

+

+ {t("doctorReport.medicationEffect.days", { + before: e.days.before, + after: e.days.after, + excluded: + e.days.excludedLowAdherence + e.days.excludedNotTaking, + })} +

+ {e.confounders.map((c) => ( +

+ {t(CONFOUNDER_KEYS[c.event], { + name: c.medicationName, + date: fmtDay(c.date), + })} +

+ ))} +
+ ); + }), + )} + {effects.length > 0 ? ( +

+ {t("doctorReport.medicationEffect.method")} +

+ ) : null} +
+ ); +} diff --git a/src/components/clinician/clinician-view.tsx b/src/components/clinician/clinician-view.tsx index d7ab42b1f..2be46e88b 100644 --- a/src/components/clinician/clinician-view.tsx +++ b/src/components/clinician/clinician-view.tsx @@ -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({ ) : null} + + {/* ── glucose and labs ─────────────────────────────────── */} + fmt.dateShortSmartCalendar(day)} + /> — 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"); diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index 447ec726a..bdf82ce9d 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -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); diff --git a/src/lib/doctor-report-pdf/__tests__/activity-effect-sections.test.ts b/src/lib/doctor-report-pdf/__tests__/activity-effect-sections.test.ts new file mode 100644 index 000000000..a2c0ac544 --- /dev/null +++ b/src/lib/doctor-report-pdf/__tests__/activity-effect-sections.test.ts @@ -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 { + 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 { + 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(); + }); +}); diff --git a/src/lib/doctor-report-pdf/activity-effect-sections.ts b/src/lib/doctor-report-pdf/activity-effect-sections.ts new file mode 100644 index 000000000..cbf7c0d36 --- /dev/null +++ b/src/lib/doctor-report-pdf/activity-effect-sections.ts @@ -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, + 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); +} diff --git a/src/lib/doctor-report-pdf/clinical-summary.ts b/src/lib/doctor-report-pdf/clinical-summary.ts index 037a765d5..535f9060b 100644 --- a/src/lib/doctor-report-pdf/clinical-summary.ts +++ b/src/lib/doctor-report-pdf/clinical-summary.ts @@ -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, }), diff --git a/src/lib/doctor-report-pdf/measurements-charts-section.ts b/src/lib/doctor-report-pdf/measurements-charts-section.ts index 672857029..91e19f5a6 100644 --- a/src/lib/doctor-report-pdf/measurements-charts-section.ts +++ b/src/lib/doctor-report-pdf/measurements-charts-section.ts @@ -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; } } diff --git a/src/lib/doctor-report-pdf/medication-mood-wellness-section.ts b/src/lib/doctor-report-pdf/medication-mood-wellness-section.ts index e29c1b040..55a42ae4a 100644 --- a/src/lib/doctor-report-pdf/medication-mood-wellness-section.ts +++ b/src/lib/doctor-report-pdf/medication-mood-wellness-section.ts @@ -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 = { 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 diff --git a/src/lib/doctor-report-types.ts b/src/lib/doctor-report-types.ts index 6c7ae66b6..3a18f18fb 100644 --- a/src/lib/doctor-report-types.ts +++ b/src/lib/doctor-report-types.ts @@ -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. */ diff --git a/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts b/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts index 9ce9fc22e..fce815725 100644 --- a/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts +++ b/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts @@ -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 }, ]; diff --git a/src/lib/doctor-report/__tests__/medication-effects.test.ts b/src/lib/doctor-report/__tests__/medication-effects.test.ts new file mode 100644 index 000000000..23f8c5250 --- /dev/null +++ b/src/lib/doctor-report/__tests__/medication-effects.test.ts @@ -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"); + }); +}); diff --git a/src/lib/doctor-report/collect.ts b/src/lib/doctor-report/collect.ts index 0f850b658..a38c3313b 100644 --- a/src/lib/doctor-report/collect.ts +++ b/src/lib/doctor-report/collect.ts @@ -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, }; } diff --git a/src/lib/doctor-report/medication-effects.ts b/src/lib/doctor-report/medication-effects.ts new file mode 100644 index 000000000..a90920e2a --- /dev/null +++ b/src/lib/doctor-report/medication-effects.ts @@ -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 { + 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; +} diff --git a/src/lib/doctor-report/sport-label.ts b/src/lib/doctor-report/sport-label.ts new file mode 100644 index 000000000..95c0e57df --- /dev/null +++ b/src/lib/doctor-report/sport-label.ts @@ -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, + sportType: string, +): string { + return ( + translateOrNull(t, `insights.workouts.sport.${sportType}`) ?? + sportType + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/_/g, " ") + .toLowerCase() + ); +} diff --git a/src/lib/doctor-report/workouts.ts b/src/lib/doctor-report/workouts.ts new file mode 100644 index 000000000..dc20b1817 --- /dev/null +++ b/src/lib/doctor-report/workouts.ts @@ -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 { + 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, + })), + }; +} diff --git a/src/lib/report-selection/__tests__/catalogue-guard.test.ts b/src/lib/report-selection/__tests__/catalogue-guard.test.ts index 593bec93c..5355e3b79 100644 --- a/src/lib/report-selection/__tests__/catalogue-guard.test.ts +++ b/src/lib/report-selection/__tests__/catalogue-guard.test.ts @@ -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; +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)[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"); diff --git a/src/lib/report-selection/catalogue.ts b/src/lib/report-selection/catalogue.ts index de1683fef..dfd7b9bb7 100644 --- a/src/lib/report-selection/catalogue.ts +++ b/src/lib/report-selection/catalogue.ts @@ -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 = { 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 = { ANAMNESIS: "sensitive", }; -/** i18n label keys for the 18 structured leaves. */ +/** i18n label keys for the structured leaves. */ export const STRUCTURED_LEAF_LABEL_KEYS: Record = { PATIENT_IDENTITY: "reportSelection.leafPatientIdentity", EMERGENCY: "reportSelection.leafEmergency", @@ -235,7 +239,9 @@ export const STRUCTURED_LEAF_LABEL_KEYS: Record = { 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> = { 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> = { 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 +> = { + 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. */ diff --git a/src/lib/report-selection/template.ts b/src/lib/report-selection/template.ts index 01e2a04a3..a93f92bcb 100644 --- a/src/lib/report-selection/template.ts +++ b/src/lib/report-selection/template.ts @@ -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",