mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
1cf0582603
commit
9e2a0681b2
35 files changed
+1760
-47
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -36,6 +36,7 @@ import { buildEmergencyFirstPageSection } from "./doctor-report-pdf/emergency-fi
|
||||
import { buildHeaderProfileSection } from "./doctor-report-pdf/header-profile-section";
|
||||
import { buildMeasurementsChartsSection } from "./doctor-report-pdf/measurements-charts-section";
|
||||
import { buildMedicationMoodWellnessSection } from "./doctor-report-pdf/medication-mood-wellness-section";
|
||||
import { buildWorkoutsSection } from "./doctor-report-pdf/activity-effect-sections";
|
||||
import { buildClinicalRecordsNotesSection } from "./doctor-report-pdf/clinical-records-notes-section";
|
||||
import { buildReportFooter } from "./doctor-report-pdf/footer-section";
|
||||
import {
|
||||
@@ -294,6 +295,7 @@ export function buildDoctorReportPdfDocument(
|
||||
}
|
||||
cursor = buildHeaderProfileSection(context, cursor);
|
||||
cursor = buildMeasurementsChartsSection(context, cursor);
|
||||
cursor = buildWorkoutsSection(context, cursor);
|
||||
cursor = buildMedicationMoodWellnessSection(context, cursor);
|
||||
cursor = buildClinicalRecordsNotesSection(context, cursor);
|
||||
buildReportFooter(context, cursor);
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* The v1.44 report blocks in the rendered PDF: sleep, both heart-rate
|
||||
* variability statistics under their own labels, steps, the per-sport workout
|
||||
* block and the medication effect, plus the per-sport fold behind the workout
|
||||
* block. Text is read back out of the real PDF bytes.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PDFParse } from "pdf-parse";
|
||||
|
||||
import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core";
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { summariseReportWorkouts } from "@/lib/doctor-report/workouts";
|
||||
import type { EfficacySegmented } from "@/lib/medications/efficacy/segmented-effect";
|
||||
|
||||
const NOW = new Date("2026-05-03T12:00:00.000Z");
|
||||
|
||||
async function textOf(bytes: Uint8Array): Promise<string> {
|
||||
const parser = new PDFParse({ data: bytes });
|
||||
try {
|
||||
return (await parser.getText()).text;
|
||||
} finally {
|
||||
await parser.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
function series(values: number[]) {
|
||||
return values.map((value, i) => ({
|
||||
value,
|
||||
measuredAt: new Date(
|
||||
Date.parse("2026-03-01T06:00:00.000Z") + i * 86_400_000,
|
||||
).toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
function stat(values: number[]) {
|
||||
return {
|
||||
avg: values.reduce((a, b) => a + b, 0) / values.length,
|
||||
min: Math.min(...values),
|
||||
max: Math.max(...values),
|
||||
count: values.length,
|
||||
latest: values[values.length - 1],
|
||||
};
|
||||
}
|
||||
|
||||
const EFFECT: EfficacySegmented = {
|
||||
present: true,
|
||||
level: 0.95,
|
||||
window: { from: "2025-12-02", to: "2026-04-10", endReason: "dose_change" },
|
||||
days: {
|
||||
before: 84,
|
||||
after: 61,
|
||||
excludedLowAdherence: 5,
|
||||
excludedNotTaking: 0,
|
||||
},
|
||||
adherence: { status: "tracked", floorPct: 80, lookbackDays: 14 },
|
||||
levelChange: {
|
||||
estimate: -7.34,
|
||||
ciLow: -8.52,
|
||||
ciHigh: -6.15,
|
||||
standardError: 0.6,
|
||||
includesZero: false,
|
||||
},
|
||||
slopeChangePerWeek: {
|
||||
estimate: -0.26,
|
||||
ciLow: -0.58,
|
||||
ciHigh: 0.07,
|
||||
standardError: 0.16,
|
||||
includesZero: true,
|
||||
},
|
||||
preSlopePerWeek: {
|
||||
estimate: 0.1,
|
||||
ciLow: -0.1,
|
||||
ciHigh: 0.3,
|
||||
standardError: 0.1,
|
||||
includesZero: true,
|
||||
},
|
||||
fit: {
|
||||
n: 145,
|
||||
df: 141,
|
||||
rSquared: 0.55,
|
||||
residualAutocorrelation: 0.31,
|
||||
hacLag: 4,
|
||||
},
|
||||
confounders: [
|
||||
{ medicationName: "Other drug", event: "started", date: "2026-02-14" },
|
||||
],
|
||||
};
|
||||
|
||||
function data(overrides: Partial<DoctorReportData> = {}): DoctorReportData {
|
||||
const sleep = [400, 430, 455, 470, 410];
|
||||
const sdnn = [42.5, 45.25, 40.125, 47];
|
||||
const rmssd = [31, 35.5, 29, 33];
|
||||
const steps = [8000, 9500, 7200, 10100];
|
||||
return {
|
||||
period: {
|
||||
days: 90,
|
||||
since: "2026-02-02T00:00:00.000Z",
|
||||
start: "2026-02-02T00:00:00.000Z",
|
||||
end: "2026-05-03T12:00:00.000Z",
|
||||
},
|
||||
patient: {
|
||||
username: null,
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
},
|
||||
practiceName: null,
|
||||
measurements: {
|
||||
SLEEP_DURATION: series(sleep),
|
||||
HEART_RATE_VARIABILITY: series(sdnn),
|
||||
HRV_RMSSD: series(rmssd),
|
||||
ACTIVITY_STEPS: series(steps),
|
||||
},
|
||||
stats: {
|
||||
SLEEP_DURATION: stat(sleep),
|
||||
HEART_RATE_VARIABILITY: stat(sdnn),
|
||||
HRV_RMSSD: stat(rmssd),
|
||||
ACTIVITY_STEPS: stat(steps),
|
||||
},
|
||||
glucoseStats: {},
|
||||
glucoseRanges: {},
|
||||
glucoseClinical: computeGlucoseClinicalMetrics([], { now: NOW }),
|
||||
glucoseUnit: "mg/dL",
|
||||
bmi: null,
|
||||
compliance: {},
|
||||
medications: [],
|
||||
mood: null,
|
||||
workouts: {
|
||||
sessions: 9,
|
||||
totalDurationSec: 9 * 3600,
|
||||
sessionsPerWeek: 0.7,
|
||||
minutesPerWeek: 42,
|
||||
sportCount: 2,
|
||||
bySport: [
|
||||
{
|
||||
sportType: "running",
|
||||
sessions: 6,
|
||||
durationSec: 6 * 3600,
|
||||
distanceM: 54_300,
|
||||
distanceSessions: 6,
|
||||
energyKcal: 3900,
|
||||
avgHeartRate: 148.5,
|
||||
},
|
||||
{
|
||||
sportType: "yoga",
|
||||
sessions: 3,
|
||||
durationSec: 3 * 3600,
|
||||
distanceM: null,
|
||||
distanceSessions: 0,
|
||||
energyKcal: null,
|
||||
avgHeartRate: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
medicationEffects: [
|
||||
{
|
||||
medicationName: "Example ACE inhibitor",
|
||||
startsOn: "2026-03-02",
|
||||
targets: [{ type: "BLOOD_PRESSURE_SYS", effect: EFFECT }],
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
} as DoctorReportData;
|
||||
}
|
||||
|
||||
function render(d: DoctorReportData) {
|
||||
return renderDoctorReportPdfBytes(d, {
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
t: getServerTranslator("en").t,
|
||||
locale: "en",
|
||||
now: NOW,
|
||||
});
|
||||
}
|
||||
|
||||
describe("doctor report v1.44 sections", () => {
|
||||
it("labels SDNN and RMSSD as separate rows and keeps recorded precision", async () => {
|
||||
const text = await textOf(render(data()));
|
||||
expect(text).toContain("HRV (SDNN)");
|
||||
expect(text).toContain("HRV (RMSSD)");
|
||||
// #1228: the row keeps the digits the readings carry, up to three
|
||||
// significant ones (40.125 ms reads 40.1, never 40).
|
||||
expect(text).toContain("40.1");
|
||||
expect(text).not.toMatch(/\b40 ms/);
|
||||
expect(text).toContain("Sleep");
|
||||
expect(text).toContain("Steps");
|
||||
});
|
||||
|
||||
it("charts the wearable series at half width in the report's units", async () => {
|
||||
const text = await textOf(render(data()));
|
||||
// The sleep chart's max label is in hours (470 min = 7.8 h), never minutes.
|
||||
expect(text).toContain("7.8 h");
|
||||
expect(text).not.toContain("470.0 h");
|
||||
});
|
||||
|
||||
it("prints the workout block per sport with weekly figures", async () => {
|
||||
const text = await textOf(render(data()));
|
||||
expect(text).toContain("Workouts");
|
||||
expect(text).toContain("9 sessions, 9.0 h in total");
|
||||
expect(text).toContain("0.7 sessions and 42 minutes per week");
|
||||
expect(text).toContain("Running");
|
||||
expect(text).toContain("54.3 km");
|
||||
expect(text).toContain("149 bpm");
|
||||
expect(text).toContain("Yoga");
|
||||
});
|
||||
|
||||
it("prints the medication effect with intervals, exclusions and confounders", async () => {
|
||||
// Notes wrap at the page edge; read them as one run of words.
|
||||
const text = (await textOf(render(data()))).replace(/\s+/g, " ");
|
||||
expect(text).toContain("Change around each medication start");
|
||||
expect(text).toContain("Example ACE inhibitor");
|
||||
expect(text).toContain("-7.3 mmHg");
|
||||
expect(text).toContain("(-8.5 to -6.2)");
|
||||
expect(text).toContain("-0.3 mmHg/week");
|
||||
expect(text).toContain("84 / 61");
|
||||
expect(text).toContain("5 days after the start left out");
|
||||
expect(text).toContain("Other drug started on");
|
||||
expect(text).toContain("not a proven effect");
|
||||
});
|
||||
|
||||
it("names the missing floor instead of printing numbers", async () => {
|
||||
const text = await textOf(
|
||||
render(
|
||||
data({
|
||||
medicationEffects: [
|
||||
{
|
||||
medicationName: "Example ACE inhibitor",
|
||||
startsOn: null,
|
||||
targets: [
|
||||
{
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
effect: {
|
||||
...EFFECT,
|
||||
present: false,
|
||||
reason: "no_start",
|
||||
levelChange: null,
|
||||
slopeChangePerWeek: null,
|
||||
preSlopePerWeek: null,
|
||||
fit: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(text).toContain("No start date recorded");
|
||||
expect(text).not.toContain("-7.3 mmHg");
|
||||
});
|
||||
|
||||
it("omits both blocks when the aggregator handed none over", async () => {
|
||||
const text = await textOf(
|
||||
render(data({ workouts: null, medicationEffects: null })),
|
||||
);
|
||||
expect(text).not.toContain("Workouts");
|
||||
expect(text).not.toContain("Change around each medication start");
|
||||
});
|
||||
});
|
||||
|
||||
describe("summariseReportWorkouts", () => {
|
||||
it("sums per sport, longest total first, and averages over the window's weeks", () => {
|
||||
const w = summariseReportWorkouts(
|
||||
[
|
||||
{
|
||||
sportType: "running",
|
||||
durationSec: 1800,
|
||||
totalDistanceM: 5000,
|
||||
totalEnergyKcal: 300,
|
||||
avgHeartRate: 150,
|
||||
},
|
||||
{
|
||||
sportType: "running",
|
||||
durationSec: 2400,
|
||||
totalDistanceM: null,
|
||||
totalEnergyKcal: 400,
|
||||
avgHeartRate: 140,
|
||||
},
|
||||
{
|
||||
sportType: "cycling",
|
||||
durationSec: 7200,
|
||||
totalDistanceM: 40_000,
|
||||
totalEnergyKcal: null,
|
||||
avgHeartRate: null,
|
||||
},
|
||||
],
|
||||
14,
|
||||
)!;
|
||||
expect(w.sessions).toBe(3);
|
||||
expect(w.totalDurationSec).toBe(11_400);
|
||||
expect(w.sessionsPerWeek).toBe(1.5);
|
||||
expect(w.minutesPerWeek).toBe(95);
|
||||
expect(w.bySport.map((s) => s.sportType)).toEqual(["cycling", "running"]);
|
||||
const running = w.bySport[1];
|
||||
expect(running).toEqual({
|
||||
sportType: "running",
|
||||
sessions: 2,
|
||||
durationSec: 4200,
|
||||
distanceM: 5000,
|
||||
distanceSessions: 1,
|
||||
energyKcal: 700,
|
||||
avgHeartRate: 145,
|
||||
});
|
||||
expect(w.bySport[0].avgHeartRate).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null without sessions", () => {
|
||||
expect(summariseReportWorkouts([], 30)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Two compact PDF blocks added with the v1.44 report: the per-sport workout
|
||||
* summary and the medication effect around each start.
|
||||
*
|
||||
* Both render only what the aggregator handed over (the selection and the
|
||||
* modules are applied there), and both are tables of a few rows: a workout
|
||||
* block is one row per sport, never a session log, and the effect block is
|
||||
* one row per medication and target with its caveats as short notes under it.
|
||||
*/
|
||||
import type { jsPDF } from "jspdf";
|
||||
import autoTable from "jspdf-autotable";
|
||||
import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { formatEstimate } from "@/lib/medications/efficacy/segmented-format";
|
||||
import type { EfficacySegmented } from "@/lib/medications/efficacy/segmented-effect";
|
||||
import { reportMeasurementLabelKey } from "../report-selection/catalogue";
|
||||
import { reportSportLabel } from "../doctor-report/sport-label";
|
||||
import {
|
||||
pdfCursorState,
|
||||
type DoctorReportPdfCursorState,
|
||||
type DoctorReportPdfRenderContext,
|
||||
} from "./render-context";
|
||||
|
||||
const TABLE_STYLES = {
|
||||
theme: "grid" as const,
|
||||
styles: {
|
||||
fontSize: 8.5,
|
||||
cellPadding: 2.2,
|
||||
textColor: [30, 30, 30] as [number, number, number],
|
||||
lineColor: [200, 200, 200] as [number, number, number],
|
||||
lineWidth: 0.3,
|
||||
},
|
||||
headStyles: {
|
||||
fillColor: [245, 245, 245] as [number, number, number],
|
||||
textColor: [30, 30, 30] as [number, number, number],
|
||||
fontStyle: "bold" as const,
|
||||
},
|
||||
alternateRowStyles: {
|
||||
fillColor: [252, 252, 252] as [number, number, number],
|
||||
},
|
||||
};
|
||||
|
||||
function lastTableY(doc: jsPDF): number {
|
||||
return (doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||||
.finalY;
|
||||
}
|
||||
|
||||
function heading(
|
||||
context: DoctorReportPdfRenderContext,
|
||||
y: number,
|
||||
text: string,
|
||||
keepWith: number,
|
||||
): number {
|
||||
const { doc, margin, ensureSpace } = context;
|
||||
const top = ensureSpace(y, 6 + keepWith);
|
||||
doc.setFontSize(12);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(30, 30, 30);
|
||||
doc.text(text, margin, top);
|
||||
return top + 5;
|
||||
}
|
||||
|
||||
function note(
|
||||
context: DoctorReportPdfRenderContext,
|
||||
y: number,
|
||||
text: string,
|
||||
): number {
|
||||
const { doc, margin, pageWidth, ensureSpace } = context;
|
||||
doc.setFontSize(8);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setTextColor(90, 90, 90);
|
||||
let cursor = y;
|
||||
for (const line of doc.splitTextToSize(text, pageWidth - 2 * margin)) {
|
||||
cursor = ensureSpace(cursor, 4);
|
||||
doc.text(line, margin, cursor);
|
||||
cursor += 3.8;
|
||||
}
|
||||
doc.setTextColor(30, 30, 30);
|
||||
return cursor;
|
||||
}
|
||||
|
||||
/** The per-sport workout summary. */
|
||||
export function buildWorkoutsSection(
|
||||
context: DoctorReportPdfRenderContext,
|
||||
state: DoctorReportPdfCursorState,
|
||||
): DoctorReportPdfCursorState {
|
||||
const { doc, data, t, num, margin, tableBottomMargin } = context;
|
||||
const w = data.workouts;
|
||||
if (!w || w.sessions === 0) return state;
|
||||
|
||||
let y = heading(context, state.y, t("doctorReport.workouts.title"), 24);
|
||||
y = note(
|
||||
context,
|
||||
y,
|
||||
t("doctorReport.workouts.summary", {
|
||||
sessions: w.sessions,
|
||||
hours: num(w.totalDurationSec / 3600, 1),
|
||||
perWeek: num(w.sessionsPerWeek, 1),
|
||||
minutesPerWeek: num(w.minutesPerWeek, 0),
|
||||
}),
|
||||
);
|
||||
y += 1;
|
||||
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
head: [
|
||||
[
|
||||
t("doctorReport.workouts.colSport"),
|
||||
t("doctorReport.workouts.colSessions"),
|
||||
t("doctorReport.workouts.colTime"),
|
||||
t("doctorReport.workouts.colDistance"),
|
||||
t("doctorReport.workouts.colEnergy"),
|
||||
t("doctorReport.workouts.colHeartRate"),
|
||||
],
|
||||
],
|
||||
body: w.bySport.map((s) => [
|
||||
reportSportLabel(t, s.sportType),
|
||||
String(s.sessions),
|
||||
`${num(s.durationSec / 3600, 1)} h`,
|
||||
s.distanceM !== null
|
||||
? s.distanceSessions < s.sessions
|
||||
? `${num(s.distanceM / 1000, 1)} km (${s.distanceSessions}/${s.sessions})`
|
||||
: `${num(s.distanceM / 1000, 1)} km`
|
||||
: "",
|
||||
s.energyKcal !== null ? `${num(s.energyKcal, 0)} kcal` : "",
|
||||
s.avgHeartRate !== null ? `${num(s.avgHeartRate, 0)} bpm` : "",
|
||||
]),
|
||||
...TABLE_STYLES,
|
||||
margin: {
|
||||
left: margin,
|
||||
right: margin,
|
||||
top: margin,
|
||||
bottom: tableBottomMargin,
|
||||
},
|
||||
});
|
||||
y = lastTableY(doc) + 3;
|
||||
if (w.sportCount > w.bySport.length) {
|
||||
y = note(
|
||||
context,
|
||||
y,
|
||||
t("doctorReport.workouts.moreSports", {
|
||||
count: w.sportCount - w.bySport.length,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return pdfCursorState(doc, y + 5);
|
||||
}
|
||||
|
||||
const EFFECT_REASON_KEYS: Record<
|
||||
NonNullable<EfficacySegmented["reason"]>,
|
||||
string
|
||||
> = {
|
||||
no_start: "doctorReport.medicationEffect.reasonNoStart",
|
||||
insufficient_before: "doctorReport.medicationEffect.reasonBefore",
|
||||
insufficient_after: "doctorReport.medicationEffect.reasonAfter",
|
||||
fit_failed: "doctorReport.medicationEffect.reasonFit",
|
||||
};
|
||||
|
||||
const CONFOUNDER_KEYS = {
|
||||
started: "medications.efficacy.segmented.confounderStarted",
|
||||
stopped: "medications.efficacy.segmented.confounderStopped",
|
||||
dose_change: "medications.efficacy.segmented.confounderDoseChange",
|
||||
paused: "medications.efficacy.segmented.confounderPaused",
|
||||
resumed: "medications.efficacy.segmented.confounderResumed",
|
||||
} as const;
|
||||
|
||||
/** A stated calendar day printed in the report's date order. */
|
||||
function dayLabel(
|
||||
fmtDate: DoctorReportPdfRenderContext["fmtDate"],
|
||||
day: string,
|
||||
): string {
|
||||
return fmtDate(dateOnlyAtNoonUtc(day).toISOString());
|
||||
}
|
||||
|
||||
/** The segmented-regression effect, one row per medication and target. */
|
||||
export function buildMedicationEffectSection(
|
||||
context: DoctorReportPdfRenderContext,
|
||||
state: DoctorReportPdfCursorState,
|
||||
): DoctorReportPdfCursorState {
|
||||
const { doc, data, t, num, fmtDate, margin, tableBottomMargin, unitFor } =
|
||||
context;
|
||||
const effects = data.medicationEffects;
|
||||
if (!effects || effects.length === 0) return state;
|
||||
|
||||
const numAt = (v: number, d: number) => num(v, d);
|
||||
const rows: string[][] = [];
|
||||
const notes: string[] = [];
|
||||
for (const med of effects) {
|
||||
for (const target of med.targets) {
|
||||
const e = target.effect;
|
||||
const label = t(
|
||||
reportMeasurementLabelKey(target.type as MeasurementType),
|
||||
);
|
||||
const unit = unitFor(target.type);
|
||||
const name = `${med.medicationName}\n${label}`;
|
||||
const start = med.startsOn ? dayLabel(fmtDate, med.startsOn) : "";
|
||||
if (!e.present || !e.levelChange || !e.slopeChangePerWeek || !e.window) {
|
||||
rows.push([
|
||||
name,
|
||||
start,
|
||||
t(EFFECT_REASON_KEYS[e.reason ?? "fit_failed"]),
|
||||
"",
|
||||
"",
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
const level = formatEstimate(e.levelChange, numAt);
|
||||
const slope = formatEstimate(e.slopeChangePerWeek, numAt);
|
||||
rows.push([
|
||||
name,
|
||||
start,
|
||||
`${level.value} ${unit}\n(${level.low} ${t("doctorReport.medicationEffect.to")} ${level.high})`,
|
||||
`${slope.value} ${unit}/${t("doctorReport.medicationEffect.week")}\n(${slope.low} ${t("doctorReport.medicationEffect.to")} ${slope.high})`,
|
||||
`${e.days.before} / ${e.days.after}`,
|
||||
]);
|
||||
const extras: string[] = [];
|
||||
if (e.days.excludedLowAdherence > 0) {
|
||||
extras.push(
|
||||
t("doctorReport.medicationEffect.excludedAdherence", {
|
||||
count: e.days.excludedLowAdherence,
|
||||
floor: e.adherence.floorPct,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (e.days.excludedNotTaking > 0) {
|
||||
extras.push(
|
||||
t("doctorReport.medicationEffect.excludedNotTaking", {
|
||||
count: e.days.excludedNotTaking,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (e.adherence.status === "not_tracked") {
|
||||
extras.push(t("doctorReport.medicationEffect.notTracked"));
|
||||
}
|
||||
if (e.window.endReason === "dose_change") {
|
||||
extras.push(
|
||||
t("doctorReport.medicationEffect.endDoseChange", {
|
||||
date: dayLabel(fmtDate, e.window.to),
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const c of e.confounders) {
|
||||
extras.push(
|
||||
t(CONFOUNDER_KEYS[c.event], {
|
||||
name: c.medicationName,
|
||||
date: dayLabel(fmtDate, c.date),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (extras.length > 0) {
|
||||
notes.push(`${med.medicationName}, ${label}: ${extras.join("; ")}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (rows.length === 0) return state;
|
||||
|
||||
let y = heading(
|
||||
context,
|
||||
state.y,
|
||||
t("doctorReport.medicationEffect.title"),
|
||||
24,
|
||||
);
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
head: [
|
||||
[
|
||||
t("doctorReport.medicationEffect.colMedication"),
|
||||
t("doctorReport.medicationEffect.colStart"),
|
||||
t("doctorReport.medicationEffect.colLevel"),
|
||||
t("doctorReport.medicationEffect.colSlope"),
|
||||
t("doctorReport.medicationEffect.colDays"),
|
||||
],
|
||||
],
|
||||
body: rows,
|
||||
...TABLE_STYLES,
|
||||
margin: {
|
||||
left: margin,
|
||||
right: margin,
|
||||
top: margin,
|
||||
bottom: tableBottomMargin,
|
||||
},
|
||||
});
|
||||
y = lastTableY(doc) + 3;
|
||||
for (const line of notes) y = note(context, y, line);
|
||||
y = note(context, y + 1, t("doctorReport.medicationEffect.method"));
|
||||
return pdfCursorState(doc, y + 5);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
type DoctorReportData,
|
||||
} from "../doctor-report-data";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { MEASUREMENT_TYPE_LABEL_KEYS } from "../measurements/type-label-keys";
|
||||
import { reportMeasurementLabelKey } from "../report-selection/catalogue";
|
||||
import type {
|
||||
DoctorReportNumberFormatter,
|
||||
DoctorReportTranslator,
|
||||
@@ -35,6 +35,20 @@ export const SPARKLINE_TYPES = [
|
||||
"PULSE",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* The wearable series charted at half width, two to a row, below the
|
||||
* full-width vitals. Each still needs its leaf selected and two points in the
|
||||
* window. The points are the report's own series: one per night for sleep,
|
||||
* day buckets for heart-rate variability once the window or the row count
|
||||
* passes the raw limit, and the daily totals steps arrive as.
|
||||
*/
|
||||
export const COMPACT_SPARKLINE_TYPES = [
|
||||
"SLEEP_DURATION",
|
||||
"HEART_RATE_VARIABILITY",
|
||||
"HRV_RMSSD",
|
||||
"ACTIVITY_STEPS",
|
||||
] as const satisfies readonly MeasurementType[];
|
||||
|
||||
/** First-half vs second-half mean → "↑" / "↓" / "→". */
|
||||
export function trendArrow(values: number[]): "↑" | "↓" | "→" {
|
||||
if (values.length < 2) return "→";
|
||||
@@ -95,7 +109,7 @@ export function buildClinicalSummaryLines(
|
||||
);
|
||||
lines.push(
|
||||
t("doctorReport.summaryTrend", {
|
||||
label: t(MEASUREMENT_TYPE_LABEL_KEYS[type as MeasurementType] ?? ""),
|
||||
label: t(reportMeasurementLabelKey(type as MeasurementType)),
|
||||
latest: num(stat.latest, 1),
|
||||
arrow,
|
||||
}),
|
||||
|
||||
@@ -11,10 +11,11 @@ import {
|
||||
reportStatValue,
|
||||
} from "../doctor-report/stat-display";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { MEASUREMENT_TYPE_LABEL_KEYS } from "../measurements/type-label-keys";
|
||||
import { reportMeasurementLabelKey } from "../report-selection/catalogue";
|
||||
import {
|
||||
getBmiClassificationKey,
|
||||
getBpClassificationKey,
|
||||
COMPACT_SPARKLINE_TYPES,
|
||||
SPARKLINE_TYPES,
|
||||
} from "./clinical-summary";
|
||||
import {
|
||||
@@ -252,7 +253,7 @@ export function buildMeasurementsChartsSection(
|
||||
const show = (v: number) =>
|
||||
num(reportStatValue(type, v, glucoseUnit), digits);
|
||||
groupRows.push([
|
||||
t(MEASUREMENT_TYPE_LABEL_KEYS[type as MeasurementType] ?? ""),
|
||||
t(reportMeasurementLabelKey(type as MeasurementType)),
|
||||
`${show(s.latest)} ${unit}`.trim(),
|
||||
`${show(s.avg)} ${unit}`.trim(),
|
||||
show(s.min),
|
||||
@@ -433,7 +434,13 @@ export function buildMeasurementsChartsSection(
|
||||
const chartTypes = SPARKLINE_TYPES.filter(
|
||||
(type) => (data.measurements[type]?.length ?? 0) >= 2,
|
||||
);
|
||||
if (chartTypes.length > 0) {
|
||||
// v1.44 — the wearable series (sleep, both HRV statistics, steps) draw at
|
||||
// half width, two to a row: a clinician reads their direction, and four
|
||||
// full-width charts would push the medication blocks a page further.
|
||||
const compactTypes = COMPACT_SPARKLINE_TYPES.filter(
|
||||
(type) => (data.measurements[type]?.length ?? 0) >= 2,
|
||||
);
|
||||
if (chartTypes.length > 0 || compactTypes.length > 0) {
|
||||
// Keep the heading with at least the first chart.
|
||||
y = ensureSpace(y, 6 + SPARKLINE_HEIGHT + 4);
|
||||
doc.setFontSize(12);
|
||||
@@ -446,9 +453,7 @@ export function buildMeasurementsChartsSection(
|
||||
// A chart never tears across a page boundary — break before drawing
|
||||
// it whole.
|
||||
y = ensureSpace(y, SPARKLINE_HEIGHT + 4);
|
||||
const label = t(
|
||||
MEASUREMENT_TYPE_LABEL_KEYS[type as MeasurementType] ?? "",
|
||||
);
|
||||
const label = t(reportMeasurementLabelKey(type as MeasurementType));
|
||||
y = drawSparkline(doc, {
|
||||
x: margin,
|
||||
y,
|
||||
@@ -461,6 +466,32 @@ export function buildMeasurementsChartsSection(
|
||||
});
|
||||
y += 4;
|
||||
}
|
||||
const columnGap = 6;
|
||||
const halfWidth = (pageWidth - 2 * margin - columnGap) / 2;
|
||||
for (let i = 0; i < compactTypes.length; i += 2) {
|
||||
y = ensureSpace(y, SPARKLINE_HEIGHT + 4);
|
||||
let rowBottom = y;
|
||||
for (const [column, type] of compactTypes.slice(i, i + 2).entries()) {
|
||||
// Plotted in the unit the table prints (sleep in hours, not the
|
||||
// stored minutes), so the min/max labels match the row above.
|
||||
const points = (data.measurements[type] ?? []).map((p) => ({
|
||||
measuredAt: p.measuredAt,
|
||||
value: reportStatValue(type, p.value, glucoseUnit),
|
||||
}));
|
||||
const bottom = drawSparkline(doc, {
|
||||
x: margin + column * (halfWidth + columnGap),
|
||||
y,
|
||||
width: halfWidth,
|
||||
label: t(reportMeasurementLabelKey(type)),
|
||||
points,
|
||||
num,
|
||||
unit: unitFor(type),
|
||||
dateShort: (iso) => dateShort(iso),
|
||||
});
|
||||
rowBottom = Math.max(rowBottom, bottom);
|
||||
}
|
||||
y = rowBottom + 4;
|
||||
}
|
||||
y += 2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type DoctorReportPdfRenderContext,
|
||||
} from "./render-context";
|
||||
import { GLP1_SIDE_EFFECT_TAG_LABEL_KEYS } from "../medications/glp1-side-effect-tags";
|
||||
import { buildMedicationEffectSection } from "./activity-effect-sections";
|
||||
|
||||
const MOOD_LABEL_KEYS: Record<number, string> = {
|
||||
1: "doctorReport.moodAwful",
|
||||
@@ -140,6 +141,9 @@ export function buildMedicationMoodWellnessSection(
|
||||
.finalY + 8;
|
||||
}
|
||||
|
||||
// v1.44 — the effect around each medication's start, beside its adherence.
|
||||
y = buildMedicationEffectSection(context, pdfCursorState(doc, y)).y;
|
||||
|
||||
// v1.4.25 — GLP-1 therapy section. Only renders when the data
|
||||
// aggregator emitted a non-null `glp1` block (user has at least one
|
||||
// active GLP-1 medication and the compliance toggle is on). Lists
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { GlucoseUnit } from "@/lib/glucose";
|
||||
import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
|
||||
import type { MeasurementSource } from "@/generated/prisma/client";
|
||||
import type { ModuleKey } from "@/lib/modules/gate";
|
||||
import type { EfficacySegmented } from "@/lib/medications/efficacy/segmented-effect";
|
||||
import type { Glp1SideEffectTag } from "@/lib/medications/glp1-side-effect-tags";
|
||||
import type {
|
||||
ComplianceSchedule,
|
||||
@@ -497,6 +498,56 @@ export interface DoctorReportData {
|
||||
note: string | null;
|
||||
noteUnreadable: boolean;
|
||||
} | null;
|
||||
/**
|
||||
* Workouts over the window, summed per sport (v1.44). Null when the
|
||||
* WORKOUTS leaf was not selected, the `workouts` module is off, or no
|
||||
* session fell inside the window. Optional so older fixtures typecheck.
|
||||
*/
|
||||
workouts?: DoctorReportWorkouts | null;
|
||||
/**
|
||||
* The segmented-regression effect of each listed medication on its target
|
||||
* metric(s), around the medication's start (v1.44). Computed without AI from
|
||||
* the same day values and dose ledger the efficacy tab reads. A target rides
|
||||
* only when its own measurement leaf is admitted too, so the block never
|
||||
* carries a figure derived from a series the owner withheld. Null when the
|
||||
* MEDICATION_EFFECT leaf was not selected or nothing qualified.
|
||||
*/
|
||||
medicationEffects?: DoctorReportMedicationEffect[] | null;
|
||||
}
|
||||
|
||||
/** The report's workout block: totals plus one row per sport. */
|
||||
export interface DoctorReportWorkouts {
|
||||
sessions: number;
|
||||
totalDurationSec: number;
|
||||
/** Sessions and minutes divided by the window's weeks (days / 7). */
|
||||
sessionsPerWeek: number;
|
||||
minutesPerWeek: number;
|
||||
/** Distinct sports in the window; `bySport` lists at most twelve. */
|
||||
sportCount: number;
|
||||
bySport: Array<{
|
||||
/** The canonical sport key; the renderer names it in the reader's language. */
|
||||
sportType: string;
|
||||
sessions: number;
|
||||
durationSec: number;
|
||||
/** Summed over the sessions that reported a distance (`distanceSessions`). */
|
||||
distanceM: number | null;
|
||||
distanceSessions: number;
|
||||
energyKcal: number | null;
|
||||
/** Mean of the per-session average heart rates that were reported. */
|
||||
avgHeartRate: number | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** One medication's effect block in the report. */
|
||||
export interface DoctorReportMedicationEffect {
|
||||
medicationName: string;
|
||||
/** YYYY-MM-DD, or null when the medication carries no start date. */
|
||||
startsOn: string | null;
|
||||
targets: Array<{
|
||||
/** The target MeasurementType; the renderer labels it and picks the unit. */
|
||||
type: string;
|
||||
effect: EfficacySegmented;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** The three persisted score types surfaced in the wellness summary. */
|
||||
|
||||
@@ -257,6 +257,17 @@ function seed() {
|
||||
practitioner: { name: "Sample practice" },
|
||||
},
|
||||
];
|
||||
rows.workout = [
|
||||
{
|
||||
sportType: "running",
|
||||
startedAt: new Date("2026-02-03T07:00:00.000Z"),
|
||||
durationSec: 1800,
|
||||
source: "MANUAL",
|
||||
totalDistanceM: 5000,
|
||||
totalEnergyKcal: 300,
|
||||
avgHeartRate: 140,
|
||||
},
|
||||
];
|
||||
rows.familyHistoryEntry = [
|
||||
{ relationship: "MOTHER", condition: "Hypertension", ageAtOnset: 50 },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* The report's medication-effect block reads only what its gate admits: a
|
||||
* target whose own measurement leaf is withheld never comes back as an
|
||||
* effect, the reads stop at the report's end, and the block is bounded.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { findMany, resolveTargets, loadEffects } = vi.hoisted(() => ({
|
||||
findMany: vi.fn(),
|
||||
resolveTargets: vi.fn(),
|
||||
loadEffects: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({ prisma: { medication: { findMany } } }));
|
||||
vi.mock("@/lib/medications/efficacy/build-efficacy", () => ({
|
||||
resolveEffectiveTargets: resolveTargets,
|
||||
}));
|
||||
vi.mock("@/lib/medications/efficacy/load-segmented-effects", () => ({
|
||||
EFFECT_MEDICATION_INCLUDE: {},
|
||||
loadSegmentedEffects: loadEffects,
|
||||
}));
|
||||
|
||||
import {
|
||||
loadReportMedicationEffects,
|
||||
REPORT_EFFECT_MEDICATION_CAP,
|
||||
} from "../medication-effects";
|
||||
|
||||
const END = new Date("2026-05-01T00:00:00.000Z");
|
||||
|
||||
function med(id: string) {
|
||||
return {
|
||||
id,
|
||||
name: `Drug ${id}`,
|
||||
treatmentClass: "ANTIHYPERTENSIVE",
|
||||
atcCode: null,
|
||||
startsOn: new Date("2026-02-01T00:00:00.000Z"),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
findMany.mockReset();
|
||||
resolveTargets.mockReset();
|
||||
loadEffects.mockReset();
|
||||
resolveTargets.mockResolvedValue({
|
||||
tier: "atc",
|
||||
cls: "x",
|
||||
targets: [
|
||||
{ kind: "metric", measurementType: "BLOOD_PRESSURE_SYS" },
|
||||
{ kind: "metric", measurementType: "BLOOD_PRESSURE_DIA" },
|
||||
{ kind: "lab", analyte: "Potassium", label: "Potassium" },
|
||||
],
|
||||
});
|
||||
loadEffects.mockImplementation(
|
||||
async ({ targets }: { targets: string[] }) =>
|
||||
new Map(targets.map((t) => [t, { present: false, reason: "no_start" }])),
|
||||
);
|
||||
});
|
||||
|
||||
describe("loadReportMedicationEffects", () => {
|
||||
it("keeps only metric targets whose own leaf is admitted, and stops at the report end", async () => {
|
||||
findMany.mockResolvedValue([med("a")]);
|
||||
const out = await loadReportMedicationEffects({
|
||||
userId: "u1",
|
||||
medicationIds: ["a"],
|
||||
admitsMeasurement: (type) => type === "BLOOD_PRESSURE_SYS",
|
||||
timezone: "Europe/Berlin",
|
||||
end: END,
|
||||
});
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out![0].targets.map((t) => t.type)).toEqual(["BLOOD_PRESSURE_SYS"]);
|
||||
expect(out![0].startsOn).toBe("2026-02-01");
|
||||
expect(loadEffects).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
targets: ["BLOOD_PRESSURE_SYS"],
|
||||
now: END,
|
||||
timezone: "Europe/Berlin",
|
||||
}),
|
||||
);
|
||||
// Owner-scoped, one-shot medications excluded at the query.
|
||||
expect(findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId: "u1", id: { in: ["a"] }, oneShot: false },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("drops a medication none of whose targets is admitted, and answers null when nothing remains", async () => {
|
||||
findMany.mockResolvedValue([med("a")]);
|
||||
const out = await loadReportMedicationEffects({
|
||||
userId: "u1",
|
||||
medicationIds: ["a"],
|
||||
admitsMeasurement: () => false,
|
||||
timezone: "UTC",
|
||||
end: END,
|
||||
});
|
||||
expect(out).toBeNull();
|
||||
expect(loadEffects).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads nothing without medications", async () => {
|
||||
const out = await loadReportMedicationEffects({
|
||||
userId: "u1",
|
||||
medicationIds: [],
|
||||
admitsMeasurement: () => true,
|
||||
timezone: "UTC",
|
||||
end: END,
|
||||
});
|
||||
expect(out).toBeNull();
|
||||
expect(findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("caps the number of medications it computes", async () => {
|
||||
const ids = Array.from({ length: 12 }, (_, i) => `m${i}`);
|
||||
findMany.mockResolvedValue(ids.map(med));
|
||||
const out = await loadReportMedicationEffects({
|
||||
userId: "u1",
|
||||
medicationIds: ids,
|
||||
admitsMeasurement: () => true,
|
||||
timezone: "UTC",
|
||||
end: END,
|
||||
});
|
||||
expect(out).toHaveLength(REPORT_EFFECT_MEDICATION_CAP);
|
||||
expect(loadEffects).toHaveBeenCalledTimes(REPORT_EFFECT_MEDICATION_CAP);
|
||||
// In the report's own list order.
|
||||
expect(out![0].medicationName).toBe("Drug m0");
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,8 @@ import { loadDenseMeasurementBuckets } from "./dense-buckets";
|
||||
import { summariseDenseBuckets } from "./measurement-series";
|
||||
import { emptyGlucoseClinical } from "./glucose-panel";
|
||||
import { buildAdministrationLedger, buildGlp1Block } from "./medications";
|
||||
import { loadReportWorkouts } from "./workouts";
|
||||
import { loadReportMedicationEffects } from "./medication-effects";
|
||||
import { userDayKey, validTimezoneOr, DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import {
|
||||
loadAllergies,
|
||||
@@ -92,6 +94,9 @@ const DENSE_REPORT_RAW_WINDOW_DAYS = 90;
|
||||
const DENSE_STAT_TYPES = [
|
||||
"PULSE",
|
||||
"HEART_RATE_VARIABILITY",
|
||||
// RMSSD arrives per night from most rings and bands, but some write it per
|
||||
// five-minute window; the row cap below keeps the sparse case raw.
|
||||
"HRV_RMSSD",
|
||||
"OXYGEN_SATURATION",
|
||||
] as const satisfies readonly MeasurementType[];
|
||||
|
||||
@@ -114,6 +119,7 @@ const MEDICATION_LEAVES = [
|
||||
"MEDICATION_LIST",
|
||||
"MEDICATION_ADMINISTRATIONS",
|
||||
"MEDICATION_COMPLIANCE",
|
||||
"MEDICATION_EFFECT",
|
||||
"GLP1_THERAPY",
|
||||
] as const;
|
||||
|
||||
@@ -644,6 +650,8 @@ export async function collectDoctorReportData(
|
||||
|
||||
// Each remaining structured block: read only when its leaf was chosen.
|
||||
const [
|
||||
workouts,
|
||||
medicationEffects,
|
||||
cycle,
|
||||
labResults,
|
||||
illnessEpisodes,
|
||||
@@ -655,6 +663,26 @@ export async function collectDoctorReportData(
|
||||
anamnesis,
|
||||
emergency,
|
||||
] = await Promise.all([
|
||||
gate.admits("WORKOUTS")
|
||||
? loadReportWorkouts({
|
||||
userId,
|
||||
start,
|
||||
end,
|
||||
days,
|
||||
sourcePriorityJson: userProfile?.sourcePriorityJson ?? null,
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
// The effect around each listed medication's start. Its own leaf AND the
|
||||
// target's measurement leaf must both admit it; the loader asks the gate.
|
||||
gate.admits("MEDICATION_EFFECT")
|
||||
? loadReportMedicationEffects({
|
||||
userId,
|
||||
medicationIds: medications.map((m) => m.id),
|
||||
admitsMeasurement: (type) => gate.admits(type),
|
||||
timezone: reportTz,
|
||||
end,
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
gate.admits("CYCLE")
|
||||
? buildCycleExportSummary(userId, userDayKey(end, reportTz))
|
||||
: Promise.resolve(null),
|
||||
@@ -765,5 +793,7 @@ export async function collectDoctorReportData(
|
||||
familyHistory,
|
||||
anamnesis,
|
||||
emergency,
|
||||
workouts,
|
||||
medicationEffects,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* The medication-effect block of the doctor report: for each listed
|
||||
* medication, the segmented-regression effect on its target metric(s) around
|
||||
* its start, from the same loader the efficacy tab reads.
|
||||
*
|
||||
* Bounded: at most {@link REPORT_EFFECT_MEDICATION_CAP} medications, each a
|
||||
* handful of reads (targets, intake events, confounders, one per-day SQL fold
|
||||
* per target), whatever the window length. Every read stops at the report's
|
||||
* end, so a report pinned in the past never uses later data.
|
||||
*
|
||||
* A target rides only when the gate admits its own measurement leaf: a
|
||||
* deselected blood-pressure series must not come back as a blood-pressure
|
||||
* effect. Lab targets are left out (too sparse for a daily series).
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { dayKeyOfDate } from "@/lib/medications/course-window";
|
||||
import { resolveEffectiveTargets } from "@/lib/medications/efficacy/build-efficacy";
|
||||
import {
|
||||
EFFECT_MEDICATION_INCLUDE,
|
||||
loadSegmentedEffects,
|
||||
} from "@/lib/medications/efficacy/load-segmented-effects";
|
||||
import type { DoctorReportMedicationEffect } from "@/lib/doctor-report-types";
|
||||
|
||||
/** Medications the block computes at most, in the report's list order. */
|
||||
export const REPORT_EFFECT_MEDICATION_CAP = 8;
|
||||
|
||||
export async function loadReportMedicationEffects(params: {
|
||||
userId: string;
|
||||
medicationIds: readonly string[];
|
||||
admitsMeasurement: (type: MeasurementType) => boolean;
|
||||
timezone: string;
|
||||
end: Date;
|
||||
}): Promise<DoctorReportMedicationEffect[] | null> {
|
||||
const { userId, medicationIds, admitsMeasurement, timezone, end } = params;
|
||||
if (medicationIds.length === 0) return null;
|
||||
const meds = await prisma.medication.findMany({
|
||||
where: { userId, id: { in: [...medicationIds] }, oneShot: false },
|
||||
include: EFFECT_MEDICATION_INCLUDE,
|
||||
});
|
||||
const order = new Map(medicationIds.map((id, i) => [id, i]));
|
||||
meds.sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0));
|
||||
|
||||
const out: DoctorReportMedicationEffect[] = [];
|
||||
for (const med of meds) {
|
||||
if (out.length >= REPORT_EFFECT_MEDICATION_CAP) break;
|
||||
const resolved = await resolveEffectiveTargets({
|
||||
id: med.id,
|
||||
name: med.name,
|
||||
treatmentClass: med.treatmentClass,
|
||||
atcCode: med.atcCode ?? null,
|
||||
});
|
||||
const metricTargets = resolved.targets
|
||||
.flatMap((t) => (t.kind === "metric" ? [t.measurementType] : []))
|
||||
.filter((type) => admitsMeasurement(type));
|
||||
if (metricTargets.length === 0) continue;
|
||||
const effects = await loadSegmentedEffects({
|
||||
userId,
|
||||
med,
|
||||
targets: metricTargets,
|
||||
timezone,
|
||||
now: end,
|
||||
});
|
||||
out.push({
|
||||
medicationName: med.name,
|
||||
startsOn: med.startsOn ? dayKeyOfDate(med.startsOn) : null,
|
||||
targets: metricTargets.flatMap((type) => {
|
||||
const effect = effects.get(type);
|
||||
return effect ? [{ type, effect }] : [];
|
||||
}),
|
||||
});
|
||||
}
|
||||
return out.length > 0 ? out : null;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* A workout sport's name in the report's language, shared by the PDF and the
|
||||
* clinician view. A sport key the catalogue has no sentence for prints as its
|
||||
* own words ("stairClimber" → "stair climber"), never as a raw key.
|
||||
*/
|
||||
import { translateOrNull } from "@/lib/i18n/translate-code";
|
||||
|
||||
export function reportSportLabel(
|
||||
t: (key: string, params?: Record<string, string | number>) => string,
|
||||
sportType: string,
|
||||
): string {
|
||||
return (
|
||||
translateOrNull(t, `insights.workouts.sport.${sportType}`) ??
|
||||
sportType
|
||||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||
.replace(/_/g, " ")
|
||||
.toLowerCase()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* The workouts block of the doctor report: sessions over the window, summed
|
||||
* per sport.
|
||||
*
|
||||
* A clinician reads training volume, not a session log, so the block is a
|
||||
* per-sport summary (sessions, total time, distance, energy, mean of the
|
||||
* session heart-rate averages) plus the window's weekly figures. Workouts are
|
||||
* a sparse table — a handful a week even for a daily athlete — so the rows are
|
||||
* read narrow (no metadata, no samples, no route) and collapsed through the
|
||||
* canonical-workout picker first, the same rule the workout list applies, so a
|
||||
* session two sources reported counts once.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
|
||||
import type { DoctorReportWorkouts } from "@/lib/doctor-report-types";
|
||||
|
||||
/**
|
||||
* Sport rows the block lists at most, longest total time first. The totals
|
||||
* above the table still count every session, and `sportCount` says how many
|
||||
* sports there were.
|
||||
*/
|
||||
export const REPORT_WORKOUT_SPORT_CAP = 12;
|
||||
|
||||
export async function loadReportWorkouts(params: {
|
||||
userId: string;
|
||||
start: Date;
|
||||
end: Date;
|
||||
days: number;
|
||||
sourcePriorityJson: unknown;
|
||||
}): Promise<DoctorReportWorkouts | null> {
|
||||
const { userId, start, end, days, sourcePriorityJson } = params;
|
||||
const rows = await prisma.workout.findMany({
|
||||
where: { userId, startedAt: { gte: start, lte: end } },
|
||||
orderBy: { startedAt: "asc" },
|
||||
select: {
|
||||
sportType: true,
|
||||
startedAt: true,
|
||||
durationSec: true,
|
||||
source: true,
|
||||
totalDistanceM: true,
|
||||
totalEnergyKcal: true,
|
||||
avgHeartRate: true,
|
||||
},
|
||||
});
|
||||
const canonical = pickCanonicalWorkoutRows(rows, sourcePriorityJson);
|
||||
return summariseReportWorkouts(canonical, days);
|
||||
}
|
||||
|
||||
/** Pure fold, exported for tests. */
|
||||
export function summariseReportWorkouts(
|
||||
rows: ReadonlyArray<{
|
||||
sportType: string;
|
||||
durationSec: number;
|
||||
totalDistanceM: number | null;
|
||||
totalEnergyKcal: number | null;
|
||||
avgHeartRate: number | null;
|
||||
}>,
|
||||
days: number,
|
||||
): DoctorReportWorkouts | null {
|
||||
if (rows.length === 0) return null;
|
||||
const bySport = new Map<
|
||||
string,
|
||||
{
|
||||
sessions: number;
|
||||
durationSec: number;
|
||||
distanceM: number;
|
||||
distanceSessions: number;
|
||||
energyKcal: number;
|
||||
energySessions: number;
|
||||
hrSum: number;
|
||||
hrSessions: number;
|
||||
}
|
||||
>();
|
||||
let totalDurationSec = 0;
|
||||
for (const r of rows) {
|
||||
totalDurationSec += r.durationSec;
|
||||
const s = bySport.get(r.sportType) ?? {
|
||||
sessions: 0,
|
||||
durationSec: 0,
|
||||
distanceM: 0,
|
||||
distanceSessions: 0,
|
||||
energyKcal: 0,
|
||||
energySessions: 0,
|
||||
hrSum: 0,
|
||||
hrSessions: 0,
|
||||
};
|
||||
s.sessions += 1;
|
||||
s.durationSec += r.durationSec;
|
||||
if (r.totalDistanceM !== null && r.totalDistanceM > 0) {
|
||||
s.distanceM += r.totalDistanceM;
|
||||
s.distanceSessions += 1;
|
||||
}
|
||||
if (r.totalEnergyKcal !== null && r.totalEnergyKcal > 0) {
|
||||
s.energyKcal += r.totalEnergyKcal;
|
||||
s.energySessions += 1;
|
||||
}
|
||||
if (r.avgHeartRate !== null && r.avgHeartRate > 0) {
|
||||
s.hrSum += r.avgHeartRate;
|
||||
s.hrSessions += 1;
|
||||
}
|
||||
bySport.set(r.sportType, s);
|
||||
}
|
||||
const weeks = Math.max(days, 1) / 7;
|
||||
return {
|
||||
sessions: rows.length,
|
||||
totalDurationSec,
|
||||
sessionsPerWeek: rows.length / weeks,
|
||||
minutesPerWeek: totalDurationSec / 60 / weeks,
|
||||
sportCount: bySport.size,
|
||||
bySport: [...bySport.entries()]
|
||||
.sort((a, b) => b[1].durationSec - a[1].durationSec)
|
||||
.slice(0, REPORT_WORKOUT_SPORT_CAP)
|
||||
.map(([sportType, s]) => ({
|
||||
sportType,
|
||||
sessions: s.sessions,
|
||||
durationSec: s.durationSec,
|
||||
// A sum over the sessions that reported it, and how many did: a
|
||||
// partial distance never reads as the sport's whole distance.
|
||||
distanceM: s.distanceSessions > 0 ? s.distanceM : null,
|
||||
distanceSessions: s.distanceSessions,
|
||||
energyKcal: s.energySessions > 0 ? s.energyKcal : null,
|
||||
avgHeartRate: s.hrSessions > 0 ? s.hrSum / s.hrSessions : null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
leafLabelKey,
|
||||
LEAF_MODULE,
|
||||
MEASUREMENT_LEAF_GROUP,
|
||||
reportMeasurementLabelKey,
|
||||
} from "../catalogue";
|
||||
import { STANDARD_TEMPLATE_LEAVES, standardTemplateFor } from "../template";
|
||||
|
||||
@@ -42,6 +43,15 @@ const EN = JSON.parse(
|
||||
),
|
||||
) as Record<string, unknown>;
|
||||
|
||||
function labelOf(key: string): unknown {
|
||||
let node: unknown = EN;
|
||||
for (const part of key.split(".")) {
|
||||
if (typeof node !== "object" || node === null) return undefined;
|
||||
node = (node as Record<string, unknown>)[part];
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function resolves(key: string): boolean {
|
||||
let node: unknown = EN;
|
||||
for (const part of key.split(".")) {
|
||||
@@ -54,8 +64,8 @@ function resolves(key: string): boolean {
|
||||
describe("report selection catalogue", () => {
|
||||
it("carries every measurement type and every structured leaf", () => {
|
||||
expect(MEASUREMENT_LEAF_IDS).toHaveLength(86);
|
||||
expect(STRUCTURED_LEAF_IDS).toHaveLength(18);
|
||||
expect(ALL_LEAF_IDS).toHaveLength(104);
|
||||
expect(STRUCTURED_LEAF_IDS).toHaveLength(20);
|
||||
expect(ALL_LEAF_IDS).toHaveLength(106);
|
||||
});
|
||||
|
||||
it("resolves a label for every leaf", () => {
|
||||
@@ -205,6 +215,15 @@ describe("heart-rate variability in the report", () => {
|
||||
// own cardio leaf beside SDNN, never folded into it, and like SDNN no
|
||||
// module gates it: a report with Recovery off must not lose the account's
|
||||
// only HRV series for the estimator its device reports.
|
||||
it("names the statistic on both rows, so the two never read as one series", () => {
|
||||
const sdnn = reportMeasurementLabelKey("HEART_RATE_VARIABILITY");
|
||||
const rmssd = reportMeasurementLabelKey("HRV_RMSSD");
|
||||
expect(sdnn).not.toBe(rmssd);
|
||||
expect(leafLabelKey("HEART_RATE_VARIABILITY")).toBe(sdnn);
|
||||
expect(labelOf(sdnn)).toBe("HRV (SDNN)");
|
||||
expect(labelOf(rmssd)).toBe("HRV (RMSSD)");
|
||||
});
|
||||
|
||||
it("files SDNN and RMSSD as separate, ungated cardio leaves", () => {
|
||||
for (const type of ["HEART_RATE_VARIABILITY", "HRV_RMSSD"] as const) {
|
||||
expect(MEASUREMENT_LEAF_GROUP[type]).toBe("cardio");
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
* A LEAF is the smallest releasable unit. There are exactly two kinds: every
|
||||
* `MeasurementType` enum member, and the structured sections of the report
|
||||
* payload that are not measurement series (identity, insurance, the glucose
|
||||
* panel, labs, the three medication blocks, GLP-1, allergies, illness
|
||||
* episodes, family history, mood, cycle).
|
||||
* panel, labs, the medication blocks, GLP-1, the medication effect, workouts,
|
||||
* allergies, illness episodes, family history, mood, cycle).
|
||||
*
|
||||
* One catalogue drives the selection panel, the aggregator, the PDF, the
|
||||
* clinician share view and the guard tests, so a control can never again exist
|
||||
@@ -44,7 +44,9 @@ export type StructuredLeafId =
|
||||
| "MEDICATION_LIST"
|
||||
| "MEDICATION_ADMINISTRATIONS"
|
||||
| "MEDICATION_COMPLIANCE"
|
||||
| "MEDICATION_EFFECT"
|
||||
| "GLP1_THERAPY"
|
||||
| "WORKOUTS"
|
||||
| "ALLERGIES"
|
||||
| "ILLNESS_EPISODES"
|
||||
| "VISITS"
|
||||
@@ -213,7 +215,9 @@ export const STRUCTURED_LEAF_GROUP: Record<StructuredLeafId, ReportGroupId> = {
|
||||
MEDICATION_LIST: "medications",
|
||||
MEDICATION_ADMINISTRATIONS: "medications",
|
||||
MEDICATION_COMPLIANCE: "medications",
|
||||
MEDICATION_EFFECT: "medications",
|
||||
GLP1_THERAPY: "medications",
|
||||
WORKOUTS: "activity",
|
||||
ALLERGIES: "history",
|
||||
ILLNESS_EPISODES: "history",
|
||||
VISITS: "history",
|
||||
@@ -225,7 +229,7 @@ export const STRUCTURED_LEAF_GROUP: Record<StructuredLeafId, ReportGroupId> = {
|
||||
ANAMNESIS: "sensitive",
|
||||
};
|
||||
|
||||
/** i18n label keys for the 18 structured leaves. */
|
||||
/** i18n label keys for the structured leaves. */
|
||||
export const STRUCTURED_LEAF_LABEL_KEYS: Record<StructuredLeafId, string> = {
|
||||
PATIENT_IDENTITY: "reportSelection.leafPatientIdentity",
|
||||
EMERGENCY: "reportSelection.leafEmergency",
|
||||
@@ -235,7 +239,9 @@ export const STRUCTURED_LEAF_LABEL_KEYS: Record<StructuredLeafId, string> = {
|
||||
MEDICATION_LIST: "reportSelection.leafMedicationList",
|
||||
MEDICATION_ADMINISTRATIONS: "reportSelection.leafMedicationAdministrations",
|
||||
MEDICATION_COMPLIANCE: "reportSelection.leafMedicationCompliance",
|
||||
MEDICATION_EFFECT: "reportSelection.leafMedicationEffect",
|
||||
GLP1_THERAPY: "reportSelection.leafGlp1Therapy",
|
||||
WORKOUTS: "reportSelection.leafWorkouts",
|
||||
ALLERGIES: "reportSelection.leafAllergies",
|
||||
ILLNESS_EPISODES: "reportSelection.leafIllnessEpisodes",
|
||||
VISITS: "reportSelection.leafVisits",
|
||||
@@ -294,6 +300,8 @@ export const LEAF_MODULE: Partial<Record<ReportLeafId, ModuleKey>> = {
|
||||
STRAIN_SCORE: "workouts",
|
||||
DAY_STRAIN: "workouts",
|
||||
WORKOUT_STRAIN: "workouts",
|
||||
// The per-sport workout block.
|
||||
WORKOUTS: "workouts",
|
||||
// Readiness signals.
|
||||
RECOVERY_SCORE: "recovery",
|
||||
STRESS_SCORE: "recovery",
|
||||
@@ -339,6 +347,7 @@ export const LEAF_MODULE: Partial<Record<ReportLeafId, ModuleKey>> = {
|
||||
MEDICATION_LIST: "medications",
|
||||
MEDICATION_ADMINISTRATIONS: "medications",
|
||||
MEDICATION_COMPLIANCE: "medications",
|
||||
MEDICATION_EFFECT: "medications",
|
||||
GLP1_THERAPY: "medications",
|
||||
// The immunization log is its own opt-out module: the leaf hides when the
|
||||
// `vaccinations` module is off, and the deselected leaf hides regardless of
|
||||
@@ -433,11 +442,33 @@ export const SENSITIVE_LEAF_IDS: readonly ReportLeafId[] = ALL_LEAF_IDS.filter(
|
||||
: MEASUREMENT_LEAF_GROUP[leaf] === SENSITIVE_GROUP_ID,
|
||||
);
|
||||
|
||||
/**
|
||||
* Measurement labels the report artefacts print differently from the app.
|
||||
* Heart-rate variability comes as two statistics that are not
|
||||
* interchangeable (SDNN from Apple Health and Withings, RMSSD from Oura,
|
||||
* WHOOP, Polar and Fitbit); the app's generic "Heart-rate variability" label
|
||||
* on the SDNN series sits beside "HRV (RMSSD)" on a clinician's page, so the
|
||||
* report names the statistic on both.
|
||||
*/
|
||||
const REPORT_MEASUREMENT_LABEL_OVERRIDES: Partial<
|
||||
Record<MeasurementType, string>
|
||||
> = {
|
||||
HEART_RATE_VARIABILITY: "reportSelection.leafHrvSdnn",
|
||||
};
|
||||
|
||||
/** The i18n label key a report artefact prints a measurement type under. */
|
||||
export function reportMeasurementLabelKey(type: MeasurementType): string {
|
||||
return (
|
||||
REPORT_MEASUREMENT_LABEL_OVERRIDES[type] ??
|
||||
MEASUREMENT_TYPE_LABEL_KEYS[type]
|
||||
);
|
||||
}
|
||||
|
||||
/** The i18n label key for any leaf. */
|
||||
export function leafLabelKey(leaf: ReportLeafId): string {
|
||||
return isStructuredLeafId(leaf)
|
||||
? STRUCTURED_LEAF_LABEL_KEYS[leaf]
|
||||
: MEASUREMENT_TYPE_LABEL_KEYS[leaf];
|
||||
: reportMeasurementLabelKey(leaf);
|
||||
}
|
||||
|
||||
/** The group a leaf belongs to. */
|
||||
|
||||
@@ -16,13 +16,21 @@ import { type ReportLeafId } from "./catalogue";
|
||||
/**
|
||||
* Identity and insurer (the cover page a practice files the document under),
|
||||
* the three classic vitals, weight and BMI, the glucose series and its panel,
|
||||
* labs, the drug list and adherence, allergies, past illness episodes, the
|
||||
* surgical history and the immunization record.
|
||||
* labs, the drug list, adherence and the effect around each start, sleep,
|
||||
* both heart-rate-variability statistics, daily steps and the workout summary,
|
||||
* allergies, past illness episodes, the surgical history and the immunization
|
||||
* record.
|
||||
*
|
||||
* Deliberately not in it: everything a wearable produces, gait and mobility,
|
||||
* environment exposure, the per-dose administration ledger (a clinician asks
|
||||
* for adherence, not for every logged dose), the GLP-1 block, and the whole
|
||||
* fenced tier.
|
||||
* The wearable picks (v1.44) are the four a general visit reads: sleep
|
||||
* duration, SDNN and RMSSD as two labelled rows, steps and the per-sport
|
||||
* workout block. Each is one table row or one compact block, and each still
|
||||
* rides its module: an account with sleep or workouts switched off gets none
|
||||
* of it.
|
||||
*
|
||||
* Deliberately not in it: the rest of what a wearable produces, gait and
|
||||
* mobility, environment exposure, the per-dose administration ledger (a
|
||||
* clinician asks for adherence, not for every logged dose), the GLP-1 block,
|
||||
* and the whole fenced tier.
|
||||
*/
|
||||
export const STANDARD_TEMPLATE_LEAVES: readonly ReportLeafId[] = [
|
||||
"PATIENT_IDENTITY",
|
||||
@@ -38,6 +46,12 @@ export const STANDARD_TEMPLATE_LEAVES: readonly ReportLeafId[] = [
|
||||
"LAB_RESULTS",
|
||||
"MEDICATION_LIST",
|
||||
"MEDICATION_COMPLIANCE",
|
||||
"MEDICATION_EFFECT",
|
||||
"SLEEP_DURATION",
|
||||
"HEART_RATE_VARIABILITY",
|
||||
"HRV_RMSSD",
|
||||
"ACTIVITY_STEPS",
|
||||
"WORKOUTS",
|
||||
"ALLERGIES",
|
||||
"ILLNESS_EPISODES",
|
||||
"SURGICAL_HISTORY",
|
||||
|
||||
Reference in new issue
Block a user