diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 339c7b7c5..a5bea3f73 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -52711,6 +52711,216 @@ components: - present additionalProperties: false - type: "null" + segmented: + anyOf: + - type: object + properties: + present: + type: boolean + reason: + type: string + enum: + - no_start + - insufficient_before + - insufficient_after + - fit_failed + level: + type: number + window: + anyOf: + - type: object + properties: + from: + type: string + to: + type: string + endReason: + type: string + enum: + - today + - window + - dose_change + required: + - from + - to + - endReason + additionalProperties: false + - type: "null" + days: + type: object + properties: + before: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + after: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + excludedLowAdherence: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + excludedNotTaking: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - before + - after + - excludedLowAdherence + - excludedNotTaking + additionalProperties: false + adherence: + type: object + properties: + status: + type: string + enum: + - tracked + - not_tracked + floorPct: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + lookbackDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - status + - floorPct + - lookbackDays + additionalProperties: false + levelChange: + anyOf: + - type: object + properties: + estimate: + type: number + ciLow: + type: number + ciHigh: + type: number + standardError: + type: number + includesZero: + type: boolean + required: + - estimate + - ciLow + - ciHigh + - standardError + - includesZero + additionalProperties: false + - type: "null" + slopeChangePerWeek: + anyOf: + - type: object + properties: + estimate: + type: number + ciLow: + type: number + ciHigh: + type: number + standardError: + type: number + includesZero: + type: boolean + required: + - estimate + - ciLow + - ciHigh + - standardError + - includesZero + additionalProperties: false + - type: "null" + preSlopePerWeek: + anyOf: + - type: object + properties: + estimate: + type: number + ciLow: + type: number + ciHigh: + type: number + standardError: + type: number + includesZero: + type: boolean + required: + - estimate + - ciLow + - ciHigh + - standardError + - includesZero + additionalProperties: false + - type: "null" + fit: + anyOf: + - type: object + properties: + n: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + df: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + rSquared: + type: number + residualAutocorrelation: + type: number + hacLag: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - n + - df + - rSquared + - residualAutocorrelation + - hacLag + additionalProperties: false + - type: "null" + confounders: + type: array + items: + type: object + properties: + medicationName: + type: string + event: + type: string + enum: + - started + - stopped + - dose_change + - paused + - resumed + date: + type: string + required: + - medicationName + - event + - date + additionalProperties: false + required: + - present + - level + - window + - days + - adherence + - levelChange + - slopeChangePerWeek + - preSlopePerWeek + - fit + - confounders + additionalProperties: false + - type: "null" required: - kind - key @@ -52721,6 +52931,7 @@ components: - series - beforeAfter - levelShift + - segmented additionalProperties: false adherence: type: array diff --git a/messages/de.json b/messages/de.json index a72dcb006..365d2274c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2359,7 +2359,30 @@ }, "scaleAbsolute": "Absolut", "scalePercentSinceStart": "% seit Start", - "scaleToggleLabel": "Diagrammskala" + "scaleToggleLabel": "Diagrammskala", + "segmented": { + "title": "Veränderung zum Start", + "level": "Direkt zum Start hat sich das Niveau um {value} {unit} verschoben (95-%-Bereich {low} bis {high}).", + "slope": "Nach dem Start hat sich der Wochentrend um {value} {unit} pro Woche verändert (95-%-Bereich {low} bis {high}).", + "preSlope": "Vor dem Start lag der Trend bei {value} {unit} pro Woche.", + "includesZero": "Wo ein Bereich die Null einschließt, passen diese Werte auch zu gar keiner Veränderung.", + "basis": "Grundlage: {before} Tage vor und {after} Tage nach dem Start ({from} bis {to}).", + "excludedAdherence": "{count} Tage nach dem Start wurden ausgelassen, weil in den {lookback} Tagen davor weniger als {floor} % der Dosen eingenommen wurden.", + "excludedNotTaking": "{count} Tage wurden ausgelassen, weil das Medikament pausiert war oder zwischen zwei Einnahmezeiträumen lag.", + "notTracked": "Für dieses Medikament wird die Einnahme nicht erfasst, deshalb wurden keine Tage wegen ausgelassener Dosen entfernt.", + "endDoseChange": "Der Zeitraum endet vor der ersten Dosisänderung und beschreibt deshalb die Anfangsdosis.", + "confoundersTitle": "Weitere Medikamentenänderungen in diesem Zeitraum, die diese Ansicht nicht von diesem Medikament trennen kann:", + "confounderStarted": "{name} begonnen am {date}", + "confounderStopped": "{name} beendet am {date}", + "confounderDoseChange": "{name}: Dosis geändert am {date}", + "confounderPaused": "{name} pausiert am {date}", + "confounderResumed": "{name} fortgesetzt am {date}", + "method": "So wird gerechnet: Eine Gerade wird durch die Tageswerte vor dem Start gelegt, eine zweite durch die danach. Der Sprung zwischen beiden am Start ist die Niveauänderung, der Unterschied ihrer Steigungen die Trendänderung. Die Bereiche berücksichtigen, dass Werte von einem Tag auf den nächsten nachwirken.", + "needStart": "Lege ein Startdatum fest, um die Veränderung zum Start zu sehen.", + "needBefore": "Für die Veränderung zum Start braucht es Werte an mindestens {days} Tagen, verteilt über {span} Tage vor dem Start.", + "needAfter": "Für die Veränderung zum Start braucht es Werte an mindestens {days} Tagen, verteilt über {span} Tage seit dem Start und an Tagen mit eingenommenen Dosen.", + "fitFailed": "Diese Werte ließen sich nicht auswerten, deshalb wird keine Veränderung zum Start gezeigt." + } }, "import": { "resultSuccess": "{imported} Einnahmen importiert", diff --git a/messages/en.json b/messages/en.json index 8ca1e04c3..4881ff789 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2359,7 +2359,30 @@ }, "scaleAbsolute": "Absolute", "scalePercentSinceStart": "% since start", - "scaleToggleLabel": "Chart scale" + "scaleToggleLabel": "Chart scale", + "segmented": { + "title": "Change at the start", + "level": "Right at the start, the level moved by {value} {unit} (95 % range {low} to {high}).", + "slope": "After the start, the weekly trend changed by {value} {unit} per week (95 % range {low} to {high}).", + "preSlope": "Before the start, the trend was {value} {unit} per week.", + "includesZero": "Where a range includes zero, these readings are also consistent with no change.", + "basis": "Based on {before} days before and {after} days after the start ({from} to {to}).", + "excludedAdherence": "{count} days after the start were left out because fewer than {floor} % of the doses in the {lookback} days before them were taken.", + "excludedNotTaking": "{count} days were left out because the medication was paused or between courses.", + "notTracked": "Intake is not tracked for this medication, so no days were left out for missed doses.", + "endDoseChange": "The window ends before the first dose change, so this describes the starting dose.", + "confoundersTitle": "Other medication changes in this window, which this view cannot separate from this one:", + "confounderStarted": "{name} started on {date}", + "confounderStopped": "{name} stopped on {date}", + "confounderDoseChange": "{name}: dose changed on {date}", + "confounderPaused": "{name} paused on {date}", + "confounderResumed": "{name} resumed on {date}", + "method": "How this is worked out: one line is fitted to the daily values before the start and one after it. The jump between them at the start is the level change, the difference in their slopes the trend change. The ranges allow for readings that carry over from one day to the next.", + "needStart": "Set a start date to see the change at the start.", + "needBefore": "The change at the start needs readings on at least {days} days spread over {span} days before the start.", + "needAfter": "The change at the start needs readings on at least {days} days spread over {span} days since the start, on days the doses were taken.", + "fitFailed": "These readings could not be fitted, so no change at the start is shown." + } }, "import": { "resultSuccess": "{imported} intakes imported", diff --git a/messages/es.json b/messages/es.json index 3239be811..449ea8372 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2359,7 +2359,30 @@ }, "scaleAbsolute": "Absoluto", "scalePercentSinceStart": "% desde el inicio", - "scaleToggleLabel": "Escala del gráfico" + "scaleToggleLabel": "Escala del gráfico", + "segmented": { + "title": "Cambio al inicio", + "level": "Justo al inicio, el nivel se desplazó {value} {unit} (intervalo del 95 %: {low} a {high}).", + "slope": "Después del inicio, la tendencia semanal cambió {value} {unit} por semana (intervalo del 95 %: {low} a {high}).", + "preSlope": "Antes del inicio, la tendencia era de {value} {unit} por semana.", + "includesZero": "Cuando un intervalo incluye el cero, estos valores también son compatibles con ningún cambio.", + "basis": "Basado en {before} días antes y {after} días después del inicio ({from} a {to}).", + "excludedAdherence": "Se omitieron {count} días después del inicio porque en los {lookback} días anteriores se tomó menos del {floor} % de las dosis.", + "excludedNotTaking": "Se omitieron {count} días porque el medicamento estaba en pausa o entre dos tandas.", + "notTracked": "La toma de este medicamento no se registra, así que no se omitió ningún día por dosis olvidadas.", + "endDoseChange": "El periodo termina antes del primer cambio de dosis, así que describe la dosis inicial.", + "confoundersTitle": "Otros cambios de medicación en este periodo, que esta vista no puede separar de este:", + "confounderStarted": "{name} iniciado el {date}", + "confounderStopped": "{name} finalizado el {date}", + "confounderDoseChange": "{name}: dosis cambiada el {date}", + "confounderPaused": "{name} en pausa desde el {date}", + "confounderResumed": "{name} reanudado el {date}", + "method": "Cómo se calcula: se ajusta una recta a los valores diarios antes del inicio y otra a los posteriores. El salto entre ambas al inicio es el cambio de nivel y la diferencia de sus pendientes, el cambio de tendencia. Los intervalos tienen en cuenta que los valores de un día influyen en el siguiente.", + "needStart": "Indica una fecha de inicio para ver el cambio al inicio.", + "needBefore": "El cambio al inicio necesita valores de al menos {days} días repartidos en {span} días antes del inicio.", + "needAfter": "El cambio al inicio necesita valores de al menos {days} días repartidos en {span} días desde el inicio, en días con las dosis tomadas.", + "fitFailed": "No se pudieron ajustar estos valores, así que no se muestra el cambio al inicio." + } }, "import": { "resultSuccess": "{imported} tomas importadas", diff --git a/messages/fr.json b/messages/fr.json index 7f395b5e5..d9c64674a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2359,7 +2359,30 @@ }, "scaleAbsolute": "Absolu", "scalePercentSinceStart": "% depuis le début", - "scaleToggleLabel": "Échelle du graphique" + "scaleToggleLabel": "Échelle du graphique", + "segmented": { + "title": "Changement au démarrage", + "level": "Dès le démarrage, le niveau s'est déplacé de {value} {unit} (intervalle à 95 % : {low} à {high}).", + "slope": "Après le démarrage, la tendance hebdomadaire a changé de {value} {unit} par semaine (intervalle à 95 % : {low} à {high}).", + "preSlope": "Avant le démarrage, la tendance était de {value} {unit} par semaine.", + "includesZero": "Lorsqu'un intervalle inclut zéro, ces valeurs sont aussi compatibles avec une absence de changement.", + "basis": "Sur la base de {before} jours avant et {after} jours après le démarrage ({from} au {to}).", + "excludedAdherence": "{count} jours après le démarrage ont été écartés, car moins de {floor} % des doses ont été prises dans les {lookback} jours précédents.", + "excludedNotTaking": "{count} jours ont été écartés, car le médicament était en pause ou entre deux périodes de prise.", + "notTracked": "Les prises de ce médicament ne sont pas suivies, aucun jour n'a donc été écarté pour des doses manquées.", + "endDoseChange": "La période s'arrête avant le premier changement de dose et décrit donc la dose de départ.", + "confoundersTitle": "Autres changements de médicaments sur cette période, que cette vue ne peut pas distinguer de celui-ci :", + "confounderStarted": "{name} commencé le {date}", + "confounderStopped": "{name} arrêté le {date}", + "confounderDoseChange": "{name} : dose modifiée le {date}", + "confounderPaused": "{name} mis en pause le {date}", + "confounderResumed": "{name} repris le {date}", + "method": "Méthode : une droite est ajustée aux valeurs quotidiennes avant le démarrage, une autre à celles d'après. Le saut entre les deux au démarrage est le changement de niveau, l'écart de leurs pentes le changement de tendance. Les intervalles tiennent compte du fait que les valeurs d'un jour influencent le lendemain.", + "needStart": "Indique une date de début pour voir le changement au démarrage.", + "needBefore": "Le changement au démarrage demande des valeurs sur au moins {days} jours répartis sur {span} jours avant le démarrage.", + "needAfter": "Le changement au démarrage demande des valeurs sur au moins {days} jours répartis sur {span} jours depuis le démarrage, les jours où les doses ont été prises.", + "fitFailed": "Ces valeurs n'ont pas pu être ajustées, aucun changement au démarrage n'est donc affiché." + } }, "import": { "resultSuccess": "{imported} prises importées", diff --git a/messages/it.json b/messages/it.json index 85e890708..15be9a0fa 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2359,7 +2359,30 @@ }, "scaleAbsolute": "Assoluto", "scalePercentSinceStart": "% dall'inizio", - "scaleToggleLabel": "Scala del grafico" + "scaleToggleLabel": "Scala del grafico", + "segmented": { + "title": "Variazione all'inizio", + "level": "Subito all'inizio il livello si è spostato di {value} {unit} (intervallo al 95 %: da {low} a {high}).", + "slope": "Dopo l'inizio la tendenza settimanale è cambiata di {value} {unit} a settimana (intervallo al 95 %: da {low} a {high}).", + "preSlope": "Prima dell'inizio la tendenza era di {value} {unit} a settimana.", + "includesZero": "Quando un intervallo include lo zero, questi valori sono compatibili anche con nessuna variazione.", + "basis": "Basato su {before} giorni prima e {after} giorni dopo l'inizio (dal {from} al {to}).", + "excludedAdherence": "{count} giorni dopo l'inizio sono stati esclusi perché nei {lookback} giorni precedenti è stato assunto meno del {floor} % delle dosi.", + "excludedNotTaking": "{count} giorni sono stati esclusi perché il farmaco era in pausa o tra due cicli.", + "notTracked": "L'assunzione di questo farmaco non viene registrata, quindi nessun giorno è stato escluso per dosi saltate.", + "endDoseChange": "Il periodo termina prima della prima variazione di dose, quindi descrive la dose iniziale.", + "confoundersTitle": "Altre variazioni di farmaci in questo periodo, che questa vista non può separare da questo:", + "confounderStarted": "{name} iniziato il {date}", + "confounderStopped": "{name} terminato il {date}", + "confounderDoseChange": "{name}: dose modificata il {date}", + "confounderPaused": "{name} sospeso il {date}", + "confounderResumed": "{name} ripreso il {date}", + "method": "Come si calcola: una retta viene adattata ai valori giornalieri prima dell'inizio e un'altra a quelli successivi. Il salto tra le due all'inizio è la variazione di livello, la differenza delle pendenze la variazione di tendenza. Gli intervalli tengono conto del fatto che i valori di un giorno influenzano il successivo.", + "needStart": "Imposta una data di inizio per vedere la variazione all'inizio.", + "needBefore": "La variazione all'inizio richiede valori in almeno {days} giorni distribuiti su {span} giorni prima dell'inizio.", + "needAfter": "La variazione all'inizio richiede valori in almeno {days} giorni distribuiti su {span} giorni dall'inizio, nei giorni con le dosi assunte.", + "fitFailed": "Non è stato possibile elaborare questi valori, quindi non viene mostrata alcuna variazione all'inizio." + } }, "import": { "resultSuccess": "{imported} assunzioni importate", diff --git a/messages/ko.json b/messages/ko.json index 0b844ffae..d943eb2f3 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -2359,7 +2359,30 @@ }, "scaleAbsolute": "절대값", "scalePercentSinceStart": "시작 이후 %", - "scaleToggleLabel": "차트 눈금" + "scaleToggleLabel": "차트 눈금", + "segmented": { + "title": "시작 시점의 변화", + "level": "시작 직후 수준이 {value} {unit} 이동했습니다(95% 범위 {low}~{high}).", + "slope": "시작 후 주간 추세가 주당 {value} {unit} 변했습니다(95% 범위 {low}~{high}).", + "preSlope": "시작 전 추세는 주당 {value} {unit}였습니다.", + "includesZero": "범위에 0이 포함되면 이 측정값은 변화가 없다는 것과도 일치합니다.", + "basis": "시작 전 {before}일과 시작 후 {after}일 기준({from}~{to}).", + "excludedAdherence": "직전 {lookback}일 동안 복용한 용량이 {floor}% 미만이어서 시작 후 {count}일을 제외했습니다.", + "excludedNotTaking": "약을 일시 중지했거나 복용 기간 사이였던 {count}일을 제외했습니다.", + "notTracked": "이 약은 복용을 기록하지 않으므로 놓친 용량 때문에 제외한 날이 없습니다.", + "endDoseChange": "기간이 첫 용량 변경 전에 끝나므로 시작 용량을 나타냅니다.", + "confoundersTitle": "이 기간의 다른 약 변경 사항으로, 이 화면에서는 이 약과 구분할 수 없습니다.", + "confounderStarted": "{name} {date} 시작", + "confounderStopped": "{name} {date} 중단", + "confounderDoseChange": "{name}: {date} 용량 변경", + "confounderPaused": "{name} {date} 일시 중지", + "confounderResumed": "{name} {date} 재개", + "method": "계산 방법: 시작 전 일별 값에 직선 하나를, 시작 후 값에 또 하나를 맞춥니다. 시작 시점의 두 직선 사이 간격이 수준 변화이고, 기울기 차이가 추세 변화입니다. 범위는 하루의 값이 다음 날로 이어지는 점을 반영합니다.", + "needStart": "시작 시점의 변화를 보려면 시작일을 설정하세요.", + "needBefore": "시작 시점의 변화를 보려면 시작 전 {span}일에 걸쳐 최소 {days}일의 측정값이 필요합니다.", + "needAfter": "시작 시점의 변화를 보려면 시작 후 {span}일에 걸쳐, 용량을 복용한 날 기준으로 최소 {days}일의 측정값이 필요합니다.", + "fitFailed": "이 측정값으로는 계산할 수 없어 시작 시점의 변화를 표시하지 않습니다." + } }, "import": { "resultSuccess": "복용 {imported}건을 불러왔어요", diff --git a/messages/nl.json b/messages/nl.json index c44560507..2bd95f72c 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -2359,7 +2359,30 @@ }, "scaleAbsolute": "Absoluut", "scalePercentSinceStart": "% sinds start", - "scaleToggleLabel": "Schaal van grafiek" + "scaleToggleLabel": "Schaal van grafiek", + "segmented": { + "title": "Verandering bij de start", + "level": "Direct bij de start verschoof het niveau met {value} {unit} (95%-bereik {low} tot {high}).", + "slope": "Na de start veranderde de weektrend met {value} {unit} per week (95%-bereik {low} tot {high}).", + "preSlope": "Voor de start was de trend {value} {unit} per week.", + "includesZero": "Waar een bereik nul omvat, passen deze waarden ook bij geen verandering.", + "basis": "Gebaseerd op {before} dagen voor en {after} dagen na de start ({from} tot {to}).", + "excludedAdherence": "{count} dagen na de start zijn weggelaten, omdat in de {lookback} dagen ervoor minder dan {floor}% van de doses is ingenomen.", + "excludedNotTaking": "{count} dagen zijn weggelaten, omdat het medicijn gepauzeerd was of tussen twee kuren lag.", + "notTracked": "De inname van dit medicijn wordt niet bijgehouden, dus er zijn geen dagen weggelaten om gemiste doses.", + "endDoseChange": "De periode eindigt vóór de eerste dosiswijziging en beschrijft dus de startdosis.", + "confoundersTitle": "Andere medicijnwijzigingen in deze periode, die deze weergave niet van dit medicijn kan scheiden:", + "confounderStarted": "{name} gestart op {date}", + "confounderStopped": "{name} gestopt op {date}", + "confounderDoseChange": "{name}: dosis gewijzigd op {date}", + "confounderPaused": "{name} gepauzeerd op {date}", + "confounderResumed": "{name} hervat op {date}", + "method": "Zo wordt het berekend: door de dagwaarden vóór de start wordt een lijn gelegd en een tweede door die erna. De sprong tussen beide bij de start is de niveauverandering, het verschil in hun helling de trendverandering. De bereiken houden er rekening mee dat waarden van de ene dag doorwerken in de volgende.", + "needStart": "Stel een startdatum in om de verandering bij de start te zien.", + "needBefore": "Voor de verandering bij de start zijn waarden op minstens {days} dagen nodig, verspreid over {span} dagen vóór de start.", + "needAfter": "Voor de verandering bij de start zijn waarden op minstens {days} dagen nodig, verspreid over {span} dagen sinds de start en op dagen waarop de doses zijn ingenomen.", + "fitFailed": "Deze waarden konden niet worden verwerkt, dus er wordt geen verandering bij de start getoond." + } }, "import": { "resultSuccess": "{imported} innames geïmporteerd", diff --git a/messages/pl.json b/messages/pl.json index c1812715f..2467c66a8 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2359,7 +2359,30 @@ }, "scaleAbsolute": "Bezwzględne", "scalePercentSinceStart": "% od początku", - "scaleToggleLabel": "Skala wykresu" + "scaleToggleLabel": "Skala wykresu", + "segmented": { + "title": "Zmiana na początku", + "level": "Zaraz na początku poziom przesunął się o {value} {unit} (zakres 95%: od {low} do {high}).", + "slope": "Po rozpoczęciu trend tygodniowy zmienił się o {value} {unit} na tydzień (zakres 95%: od {low} do {high}).", + "preSlope": "Przed rozpoczęciem trend wynosił {value} {unit} na tydzień.", + "includesZero": "Gdy zakres obejmuje zero, te pomiary są zgodne także z brakiem zmiany.", + "basis": "Na podstawie {before} dni przed i {after} dni po rozpoczęciu (od {from} do {to}).", + "excludedAdherence": "Pominięto {count} dni po rozpoczęciu, bo w ciągu {lookback} dni przed nimi przyjęto mniej niż {floor}% dawek.", + "excludedNotTaking": "Pominięto {count} dni, bo lek był wstrzymany albo przypadały między kuracjami.", + "notTracked": "Przyjmowanie tego leku nie jest rejestrowane, więc nie pominięto żadnych dni z powodu opuszczonych dawek.", + "endDoseChange": "Okres kończy się przed pierwszą zmianą dawki, więc opisuje dawkę początkową.", + "confoundersTitle": "Inne zmiany leków w tym okresie, których ten widok nie potrafi oddzielić od tego leku:", + "confounderStarted": "{name} rozpoczęty {date}", + "confounderStopped": "{name} zakończony {date}", + "confounderDoseChange": "{name}: zmiana dawki {date}", + "confounderPaused": "{name} wstrzymany {date}", + "confounderResumed": "{name} wznowiony {date}", + "method": "Jak to jest liczone: przez dzienne wartości sprzed rozpoczęcia prowadzi się jedną prostą, a przez te późniejsze drugą. Skok między nimi w dniu rozpoczęcia to zmiana poziomu, różnica ich nachyleń to zmiana trendu. Zakresy uwzględniają, że wartości z jednego dnia przenoszą się na następny.", + "needStart": "Ustaw datę rozpoczęcia, aby zobaczyć zmianę na początku.", + "needBefore": "Do zmiany na początku potrzeba pomiarów z co najmniej {days} dni rozłożonych na {span} dni przed rozpoczęciem.", + "needAfter": "Do zmiany na początku potrzeba pomiarów z co najmniej {days} dni rozłożonych na {span} dni od rozpoczęcia, w dni z przyjętymi dawkami.", + "fitFailed": "Nie udało się dopasować tych pomiarów, więc zmiana na początku nie jest pokazana." + } }, "import": { "resultSuccess": "Zaimportowano przyjęcia: {imported}", diff --git a/src/components/medications/detail/efficacy/__tests__/change-at-start-summary.test.tsx b/src/components/medications/detail/efficacy/__tests__/change-at-start-summary.test.tsx new file mode 100644 index 000000000..b9fa7eb63 --- /dev/null +++ b/src/components/medications/detail/efficacy/__tests__/change-at-start-summary.test.tsx @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import enMessages from "../../../../../../messages/en.json"; +import type { EfficacySegmented } from "@/lib/medications/efficacy/segmented-effect"; +import { ChangeAtStartSummary } from "../change-at-start-summary"; + +function est(estimate: number, se: number) { + const ciLow = estimate - 2 * se; + const ciHigh = estimate + 2 * se; + return { + estimate, + ciLow, + ciHigh, + standardError: se, + includesZero: ciLow <= 0 && ciHigh >= 0, + }; +} + +const present: EfficacySegmented = { + present: true, + level: 0.95, + window: { from: "2026-01-01", to: "2026-05-30", endReason: "dose_change" }, + days: { + before: 80, + after: 70, + excludedLowAdherence: 6, + excludedNotTaking: 0, + }, + adherence: { status: "tracked", floorPct: 80, lookbackDays: 14 }, + levelChange: est(-7.34, 0.6), + slopeChangePerWeek: est(-0.26, 0.16), + preSlopePerWeek: est(0.05, 0.1), + fit: { + n: 150, + df: 146, + rSquared: 0.6, + residualAutocorrelation: 0.3, + hacLag: 4, + }, + confounders: [ + { medicationName: "Example B", event: "started", date: "2026-02-10" }, + ], +}; + +function render(segmented: EfficacySegmented) { + return renderToStaticMarkup( + + + , + ); +} + +describe("ChangeAtStartSummary", () => { + it("states the level and trend change with their ranges in plain words", () => { + const html = render(present); + expect(html).toContain("the level moved by -7.3 mmHg"); + expect(html).toContain("95 % range -8.5 to -6.1"); + expect(html).toContain("changed by -0.3 mmHg per week"); + // The slope interval spans zero, so the no-change reading is said aloud. + expect(html).toContain("also consistent with no change"); + expect(html).toContain("Based on 80 days before and 70 days after"); + expect(html).toContain("6 days after the start were left out"); + expect(html).toContain("describes the starting dose"); + expect(html).toContain("Example B started on"); + expect(html).not.toMatch(/work(s|ing)|effective|successful/i); + }); + + it("names the missing floor instead of a number", () => { + const html = render({ + ...present, + present: false, + reason: "insufficient_before", + levelChange: null, + slopeChangePerWeek: null, + preSlopePerWeek: null, + fit: null, + }); + expect(html).toContain("at least 14 days spread over 21 days before"); + expect(html).not.toContain("level moved"); + }); +}); diff --git a/src/components/medications/detail/efficacy/change-at-start-summary.tsx b/src/components/medications/detail/efficacy/change-at-start-summary.tsx new file mode 100644 index 000000000..8f05249c0 --- /dev/null +++ b/src/components/medications/detail/efficacy/change-at-start-summary.tsx @@ -0,0 +1,155 @@ +"use client"; + +/** + * The segmented-regression read-out on a Wirkung target card: the level + * change and the change in weekly trend at the start, each with its 95 % + * range, in plain sentences, followed by what the fit rests on (days used, + * days left out and why) and the other medication changes it cannot separate + * from this one. Renders the server DTO; recomputes nothing. + */ +import type { EfficacySegmented } from "@/lib/medications/efficacy/segmented-effect"; +import { formatEstimate } from "@/lib/medications/efficacy/segmented-format"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; + +const CONFOUNDER_KEYS = { + started: "medications.efficacy.segmented.confounderStarted", + stopped: "medications.efficacy.segmented.confounderStopped", + dose_change: "medications.efficacy.segmented.confounderDoseChange", + paused: "medications.efficacy.segmented.confounderPaused", + resumed: "medications.efficacy.segmented.confounderResumed", +} as const; + +const NEED_KEYS = { + no_start: "medications.efficacy.segmented.needStart", + insufficient_before: "medications.efficacy.segmented.needBefore", + insufficient_after: "medications.efficacy.segmented.needAfter", + fit_failed: "medications.efficacy.segmented.fitFailed", +} as const; + +export function ChangeAtStartSummary({ + segmented, + unit, + minDays, + minSpanDays, +}: { + segmented: EfficacySegmented; + unit: string | null; + minDays: number; + minSpanDays: number; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const num = (v: number, d: number) => fmt.number(v, d); + const u = unit ?? ""; + + if ( + !segmented.present || + !segmented.levelChange || + !segmented.slopeChangePerWeek || + !segmented.preSlopePerWeek || + !segmented.window + ) { + return ( +
+

+ {t(NEED_KEYS[segmented.reason ?? "fit_failed"], { + days: minDays, + span: minSpanDays, + })} +

+
+ ); + } + + const level = formatEstimate(segmented.levelChange, num); + const slope = formatEstimate(segmented.slopeChangePerWeek, num); + const pre = formatEstimate(segmented.preSlopePerWeek, num); + const anyZero = + segmented.levelChange.includesZero || + segmented.slopeChangePerWeek.includesZero; + + return ( +
+

+ {t("medications.efficacy.segmented.title")} +

+

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

+

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

+

+ {t("medications.efficacy.segmented.preSlope", { + value: pre.value, + unit: u, + })} +

+ {anyZero ? ( +

+ {t("medications.efficacy.segmented.includesZero")} +

+ ) : null} +

+ {t("medications.efficacy.segmented.basis", { + before: segmented.days.before, + after: segmented.days.after, + from: fmt.dateShortSmartCalendar(segmented.window.from), + to: fmt.dateShortSmartCalendar(segmented.window.to), + })} +

+ {segmented.days.excludedLowAdherence > 0 ? ( +

+ {t("medications.efficacy.segmented.excludedAdherence", { + count: segmented.days.excludedLowAdherence, + floor: segmented.adherence.floorPct, + lookback: segmented.adherence.lookbackDays, + })} +

+ ) : null} + {segmented.days.excludedNotTaking > 0 ? ( +

+ {t("medications.efficacy.segmented.excludedNotTaking", { + count: segmented.days.excludedNotTaking, + })} +

+ ) : null} + {segmented.adherence.status === "not_tracked" ? ( +

+ {t("medications.efficacy.segmented.notTracked")} +

+ ) : null} + {segmented.window.endReason === "dose_change" ? ( +

+ {t("medications.efficacy.segmented.endDoseChange")} +

+ ) : null} + {segmented.confounders.length > 0 ? ( +
+

+ {t("medications.efficacy.segmented.confoundersTitle")} +

+ +
+ ) : null} +

+ {t("medications.efficacy.segmented.method")} +

+
+ ); +} diff --git a/src/components/medications/detail/efficacy/efficacy-tab.tsx b/src/components/medications/detail/efficacy/efficacy-tab.tsx index a4d1cdde4..fde9cbe77 100644 --- a/src/components/medications/detail/efficacy/efficacy-tab.tsx +++ b/src/components/medications/detail/efficacy/efficacy-tab.tsx @@ -43,6 +43,11 @@ import type { EfficacyTargetView, } from "@/lib/medications/efficacy/build-efficacy"; import { EmptyState } from "@/components/ui/empty-state"; +import { + SEGMENTED_MIN_DAYS_PER_SIDE, + SEGMENTED_MIN_SPAN_DAYS, +} from "@/lib/medications/efficacy/segmented-effect"; +import { ChangeAtStartSummary } from "./change-at-start-summary"; // v1.30 UI audit (M8) — `EfficacyChart` mounts inside `TargetCard`'s own // `` as a bare 220px `ResponsiveContainer` with no chrome of its own. @@ -310,6 +315,15 @@ function TargetCard({ + {target.segmented ? ( + + ) : null} + {target.levelShift?.present && target.levelShift.nearStart && target.levelShift.at ? ( diff --git a/src/lib/medications/efficacy/__tests__/segmented-effect.test.ts b/src/lib/medications/efficacy/__tests__/segmented-effect.test.ts new file mode 100644 index 000000000..6f099d6bc --- /dev/null +++ b/src/lib/medications/efficacy/__tests__/segmented-effect.test.ts @@ -0,0 +1,208 @@ +/** + * The medication-effect assembly around the segmented regression: the window, + * the adherence and not-taking exclusions, the dose-change cut, the confounder + * list and the minimum-data floor. The regression itself is pinned to + * reference values in `src/lib/stats/__tests__/segmented-regression.test.ts`. + */ +import { describe, expect, it } from "vitest"; + +import { + buildSegmentedEffect, + notTakingPredicate, + type SegmentedEffectInput, +} from "@/lib/medications/efficacy/segmented-effect"; + +const DAY = 24 * 60 * 60 * 1000; +const PIVOT = "2026-03-01"; + +function dayAt(offset: number): string { + return new Date(Date.parse(`${PIVOT}T00:00:00Z`) + offset * DAY) + .toISOString() + .slice(0, 10); +} + +/** 140 before, a clean −10 shift and −0.7/week (−0.1/day) slope after. */ +function series(fromOffset: number, toOffset: number) { + const out: { day: string; value: number }[] = []; + for (let t = fromOffset; t <= toOffset; t++) { + out.push({ + day: dayAt(t), + value: t < 0 ? 140 : 130 - 0.1 * t, + }); + } + return out; +} + +function input(over: Partial = {}): SegmentedEffectInput { + return { + dayValues: series(-60, 59), + pivotDay: PIVOT, + lastDay: dayAt(59), + adherenceDays: null, + notTakingOn: () => false, + ownDoseChangeDays: [], + otherMedicationEvents: [], + ...over, + }; +} + +describe("buildSegmentedEffect", () => { + it("recovers the known level and weekly slope change", () => { + const r = buildSegmentedEffect(input()); + expect(r.present).toBe(true); + expect(r.levelChange!.estimate).toBeCloseTo(-10, 9); + expect(r.slopeChangePerWeek!.estimate).toBeCloseTo(-0.7, 9); + expect(r.preSlopePerWeek!.estimate).toBeCloseTo(0, 9); + expect(r.days).toEqual({ + before: 60, + after: 60, + excludedLowAdherence: 0, + excludedNotTaking: 0, + }); + expect(r.window).toEqual({ + from: dayAt(-90), + to: dayAt(59), + endReason: "today", + }); + expect(r.adherence.status).toBe("not_tracked"); + expect(r.level).toBe(0.95); + }); + + it("caps the post window at 180 days", () => { + const r = buildSegmentedEffect( + input({ dayValues: series(-30, 300), lastDay: dayAt(300) }), + ); + expect(r.window).toEqual({ + from: dayAt(-90), + to: dayAt(179), + endReason: "window", + }); + expect(r.days.after).toBe(180); + }); + + it("drops values older than the 90-day pre window", () => { + const r = buildSegmentedEffect(input({ dayValues: series(-200, 59) })); + expect(r.days.before).toBe(90); + }); + + it("ends the window the day before the first own dose change", () => { + const r = buildSegmentedEffect( + input({ ownDoseChangeDays: [PIVOT, dayAt(40)] }), + ); + expect(r.window?.to).toBe(dayAt(39)); + expect(r.window?.endReason).toBe("dose_change"); + expect(r.days.after).toBe(40); + }); + + it("leaves out post-start days whose trailing 14-day adherence is below 80 %", () => { + // One dose a day; every dose from day 20 to day 29 missed. + const adherenceDays = Array.from({ length: 60 }, (_, t) => ({ + day: dayAt(t), + taken: t >= 20 && t < 30 ? 0 : 1, + missed: t >= 20 && t < 30 ? 1 : 0, + })); + const r = buildSegmentedEffect(input({ adherenceDays })); + expect(r.adherence.status).toBe("tracked"); + // Day d is low when misses in [d-13, d] exceed 2 (3/14 = 78.6 %): from + // day 22 (3 misses) through day 29+13-2 = day 40 (3 misses left). + expect(r.days.excludedLowAdherence).toBe(19); + expect(r.days.after).toBe(60 - 19); + // The fit on the remaining days still recovers the clean effect. + expect(r.levelChange!.estimate).toBeCloseTo(-10, 9); + }); + + it("treats a lookback without any expected dose as unknown, not low", () => { + const r = buildSegmentedEffect(input({ adherenceDays: [] })); + expect(r.days.excludedLowAdherence).toBe(0); + expect(r.present).toBe(true); + }); + + it("leaves out days the medication was not being taken", () => { + const r = buildSegmentedEffect( + input({ notTakingOn: (d) => d >= dayAt(10) && d < dayAt(20) }), + ); + expect(r.days.excludedNotTaking).toBe(10); + }); + + it("needs 14 days on each side", () => { + const before = buildSegmentedEffect(input({ dayValues: series(-13, 59) })); + expect(before.present).toBe(false); + expect(before.reason).toBe("insufficient_before"); + expect(before.levelChange).toBeNull(); + const after = buildSegmentedEffect( + input({ dayValues: series(-60, 12), lastDay: dayAt(12) }), + ); + expect(after.reason).toBe("insufficient_after"); + }); + + it("needs each side to span 21 days, not just hold 14 values", () => { + // 14 consecutive days right before the start: enough values, too short. + const r = buildSegmentedEffect(input({ dayValues: series(-14, 59) })); + expect(r.days.before).toBe(14); + expect(r.reason).toBe("insufficient_before"); + }); + + it("answers no_start without a start date", () => { + const r = buildSegmentedEffect(input({ pivotDay: null })); + expect(r).toMatchObject({ present: false, reason: "no_start" }); + expect(r.window).toBeNull(); + }); + + it("lists only the other medications' changes inside the window, oldest first", () => { + const r = buildSegmentedEffect( + input({ + otherMedicationEvents: [ + { medicationName: "B", event: "dose_change", date: dayAt(30) }, + { medicationName: "A", event: "started", date: dayAt(-20) }, + { medicationName: "C", event: "stopped", date: dayAt(-120) }, + ], + }), + ); + expect(r.confounders).toEqual([ + { medicationName: "A", event: "started", date: dayAt(-20) }, + { medicationName: "B", event: "dose_change", date: dayAt(30) }, + ]); + }); + + it("flags an interval that spans zero", () => { + const flat = series(-60, 59).map((p, i) => ({ + day: p.day, + value: 120 + (i % 2 === 0 ? 3 : -3), + })); + const r = buildSegmentedEffect(input({ dayValues: flat })); + expect(r.present).toBe(true); + expect(r.levelChange!.includesZero).toBe(true); + }); +}); + +describe("notTakingPredicate", () => { + it("covers a pause up to the resume day and the gaps between courses", () => { + const pred = notTakingPredicate( + { + pauseEras: [ + { + pausedAt: new Date("2026-03-10T08:00:00Z"), + resumedAt: new Date("2026-03-15T08:00:00Z"), + }, + ], + courses: [ + { + startsOn: new Date("2026-03-01T00:00:00Z"), + endsOn: new Date("2026-03-31T00:00:00Z"), + }, + { + startsOn: new Date("2026-05-01T00:00:00Z"), + endsOn: null, + }, + ], + } as never, + "UTC", + ); + expect(pred("2026-03-09")).toBe(false); + expect(pred("2026-03-10")).toBe(true); + expect(pred("2026-03-14")).toBe(true); + expect(pred("2026-03-15")).toBe(false); + expect(pred("2026-04-10")).toBe(true); + expect(pred("2026-05-02")).toBe(false); + }); +}); diff --git a/src/lib/medications/efficacy/build-efficacy.ts b/src/lib/medications/efficacy/build-efficacy.ts index 2841c24ad..a1dde1418 100644 --- a/src/lib/medications/efficacy/build-efficacy.ts +++ b/src/lib/medications/efficacy/build-efficacy.ts @@ -24,7 +24,6 @@ import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage"; import { readDayMeanSeries } from "@/lib/insights/derived/baseline"; import { buildComplianceMedicationContext, - COURSES_COMPLIANCE_SELECT, buildMedicationComplianceBundle, dailyComplianceRatesFromLedger, lastNonSkippedTakenAt, @@ -40,6 +39,11 @@ import { type MedTargetTier, } from "@/lib/medications/med-target-map"; import { dueSchedules } from "@/lib/medications/intake-tracking"; +import { + EFFECT_MEDICATION_INCLUDE, + loadSegmentedEffects, +} from "@/lib/medications/efficacy/load-segmented-effects"; +import type { EfficacySegmented } from "@/lib/medications/efficacy/segmented-effect"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -93,6 +97,12 @@ export interface EfficacyTargetView { beforeAfter: EfficacyBeforeAfter; /** Metric targets only; null for lab targets (changepoint reads metrics). */ levelShift: EfficacyLevelShift | null; + /** + * The segmented-regression effect (level + slope change with 95 % + * intervals, adherence-adjusted). Metric targets only; null for a lab, + * whose readings are too sparse for a daily time series. + */ + segmented: EfficacySegmented | null; } export interface EfficacyMarkers { @@ -294,6 +304,7 @@ async function buildMetricTarget( series, beforeAfter: beforeAfterFromSeries(straddle, pivotMs), levelShift, + segmented: null, }; } @@ -343,6 +354,7 @@ async function buildLabTarget( series, beforeAfter: beforeAfterFromSeries(straddle, pivotMs), levelShift: null, + segmented: null, }; } @@ -351,7 +363,7 @@ async function buildLabTarget( * override wins (tier "override"); otherwise the derived ATC/name resolution. * Returns the tier + class provenance for the DTO alongside the target list. */ -async function resolveEffectiveTargets(med: { +export async function resolveEffectiveTargets(med: { id: string; name: string; treatmentClass: string; @@ -417,14 +429,8 @@ export async function buildMedicationEfficacy( ): Promise { const med = await prisma.medication.findFirst({ where: { id: medicationId, userId }, - include: { - schedules: true, - scheduleRevisions: { orderBy: { validFrom: "asc" } }, - pauseEras: { select: { pausedAt: true, resumedAt: true } }, - // v1.40 (#1024) — the courses, so a gap between two expects nothing. - courses: COURSES_COMPLIANCE_SELECT, - doseChanges: { orderBy: { effectiveFrom: "asc" } }, - }, + // The courses ride along, so a gap between two expects nothing (#1024). + include: EFFECT_MEDICATION_INCLUDE, }); if (!med) return null; @@ -570,6 +576,23 @@ export async function buildMedicationEfficacy( } } + // The segmented effect, per metric target, around `startsOn` only: the + // first-reading fallback has no "before" by definition. + const segmented = await loadSegmentedEffects({ + userId, + med, + targets: targets + .filter((t) => t.kind === "metric") + .map((t) => t.key as MeasurementType), + timezone, + now, + }); + for (const view of targets) { + if (view.kind === "metric") { + view.segmented = segmented.get(view.key as MeasurementType) ?? null; + } + } + // Adherence lane — the cadence-aware per-day rate from the SAME ledger the // compliance surfaces read; never recomputed here. const fetchFrom = new Date( diff --git a/src/lib/medications/efficacy/load-segmented-effects.ts b/src/lib/medications/efficacy/load-segmented-effects.ts new file mode 100644 index 000000000..91923c93a --- /dev/null +++ b/src/lib/medications/efficacy/load-segmented-effects.ts @@ -0,0 +1,247 @@ +/** + * The database half of the segmented medication effect: one intake-event + * read, one confounder read and one per-day SQL fold per target, handed to the + * pure assembly in `./segmented-effect.ts`. + */ +import type { MeasurementType, Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { + buildComplianceMedicationContext, + buildMedicationComplianceBundle, + COURSES_COMPLIANCE_SELECT, + dailyComplianceRatesFromLedger, + lastNonSkippedTakenAt, +} from "@/lib/analytics/compliance"; +import { dueSchedules } from "@/lib/medications/intake-tracking"; +import { dayKeyOfDate } from "@/lib/medications/course-window"; +import { readDayAggregates } from "@/lib/measurements/day-aggregates"; +import { plausibleMetricRange } from "@/lib/measurements/value-domain"; +import { userDayKey } from "@/lib/tz/format"; +import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; +import { + buildSegmentedEffect, + notTakingPredicate, + SEGMENTED_POST_WINDOW_DAYS, + SEGMENTED_PRE_WINDOW_DAYS, + type EfficacyConfounder, + type EfficacySegmented, + type SegmentedEffectInput, +} from "./segmented-effect"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** The medication shape the effect needs: the efficacy builder's include. */ +export const EFFECT_MEDICATION_INCLUDE = { + schedules: true, + scheduleRevisions: { orderBy: { validFrom: "asc" } }, + pauseEras: { select: { pausedAt: true, resumedAt: true } }, + courses: COURSES_COMPLIANCE_SELECT, + doseChanges: { orderBy: { effectiveFrom: "asc" } }, +} satisfies Prisma.MedicationInclude; + +export type EffectMedication = Prisma.MedicationGetPayload<{ + include: typeof EFFECT_MEDICATION_INCLUDE; +}>; + +/** Other medications' starts, stops, dose changes and pauses inside a window. */ +async function loadConfounders( + userId: string, + medicationId: string, + windowFrom: Date, + windowTo: Date, + timezone: string, +): Promise { + const inWindow = { gte: windowFrom, lte: windowTo }; + const others = await prisma.medication.findMany({ + where: { + userId, + id: { not: medicationId }, + OR: [ + { startsOn: inWindow }, + { endsOn: inWindow }, + { doseChanges: { some: { effectiveFrom: inWindow } } }, + { pauseEras: { some: { pausedAt: inWindow } } }, + { pauseEras: { some: { resumedAt: inWindow } } }, + ], + }, + select: { + name: true, + startsOn: true, + endsOn: true, + doseChanges: { + where: { effectiveFrom: inWindow }, + select: { effectiveFrom: true }, + }, + pauseEras: { select: { pausedAt: true, resumedAt: true } }, + }, + take: 50, + }); + const out: EfficacyConfounder[] = []; + const within = (d: Date | null): d is Date => + d !== null && + d.getTime() >= windowFrom.getTime() && + d.getTime() <= windowTo.getTime(); + for (const m of others) { + if (within(m.startsOn)) { + out.push({ + medicationName: m.name, + event: "started", + date: dayKeyOfDate(m.startsOn), + }); + } + if (within(m.endsOn)) { + out.push({ + medicationName: m.name, + event: "stopped", + date: dayKeyOfDate(m.endsOn), + }); + } + for (const dc of m.doseChanges) { + out.push({ + medicationName: m.name, + event: "dose_change", + date: userDayKey(dc.effectiveFrom, timezone), + }); + } + for (const p of m.pauseEras) { + if (within(p.pausedAt)) { + out.push({ + medicationName: m.name, + event: "paused", + date: userDayKey(p.pausedAt, timezone), + }); + } + if (within(p.resumedAt)) { + out.push({ + medicationName: m.name, + event: "resumed", + date: userDayKey(p.resumedAt, timezone), + }); + } + } + } + return out; +} + +/** + * Load and fit the segmented effect for each metric target of one medication. + * `now` bounds every read: a report pinned in the past passes its own end so + * nothing after it is used. Reads: one intake-event query, one confounder + * query, and one per-day SQL fold per target. + */ +export async function loadSegmentedEffects(params: { + userId: string; + med: EffectMedication; + targets: readonly MeasurementType[]; + timezone: string; + now: Date; +}): Promise> { + const { userId, med, targets, timezone, now } = params; + const out = new Map(); + if (targets.length === 0) return out; + + const pivotDay = med.startsOn ? dayKeyOfDate(med.startsOn) : null; + const lastDay = userDayKey(now, timezone); + if (pivotDay === null || pivotDay > lastDay) { + const empty = buildSegmentedEffect({ + dayValues: [], + pivotDay: null, + lastDay, + adherenceDays: null, + notTakingOn: () => false, + ownDoseChangeDays: [], + otherMedicationEvents: [], + }); + for (const t of targets) out.set(t, empty); + return out; + } + + // A day of margin either side: the SQL folds on local days, the window is + // cut on day keys afterwards. + const pivotMs = dayKeyAsUtcMidnight(pivotDay).getTime(); + const since = new Date(pivotMs - (SEGMENTED_PRE_WINDOW_DAYS + 1) * DAY_MS); + const until = new Date( + Math.min( + now.getTime(), + pivotMs + (SEGMENTED_POST_WINDOW_DAYS + 1) * DAY_MS, + ), + ); + + let adherenceDays: SegmentedEffectInput["adherenceDays"] = null; + if (med.trackIntake) { + const events = await prisma.medicationIntakeEvent.findMany({ + where: { + medicationId: med.id, + userId, + deletedAt: null, + scheduledFor: { gte: since, lte: until }, + }, + orderBy: { scheduledFor: "desc" }, + select: { + takenAt: true, + skipped: true, + scheduledFor: true, + autoMissed: true, + attributionSource: true, + }, + }); + const ctx = buildComplianceMedicationContext( + med, + lastNonSkippedTakenAt(events), + timezone, + ); + const bundle = buildMedicationComplianceBundle( + events, + dueSchedules(med), + ctx, + until, + ); + adherenceDays = dailyComplianceRatesFromLedger( + bundle.ledgerRows, + timezone, + ).map((d) => ({ + day: userDayKey(d.date, timezone), + taken: d.taken, + missed: d.missed, + })); + } + + const confounders = await loadConfounders( + userId, + med.id, + since, + until, + timezone, + ); + const notTakingOn = notTakingPredicate(med, timezone); + const ownDoseChangeDays = med.doseChanges.map((dc) => + userDayKey(dc.effectiveFrom, timezone), + ); + + for (const target of targets) { + const rows = await readDayAggregates({ + userId, + type: target, + since, + until, + timeZone: timezone, + valueRange: plausibleMetricRange(target), + }); + out.set( + target, + buildSegmentedEffect({ + dayValues: rows.map((r) => ({ + day: r.day, + value: r.dayMean ?? r.sum / r.n, + })), + pivotDay, + lastDay, + adherenceDays, + notTakingOn, + ownDoseChangeDays, + otherMedicationEvents: confounders, + }), + ); + } + return out; +} diff --git a/src/lib/medications/efficacy/segmented-effect.ts b/src/lib/medications/efficacy/segmented-effect.ts new file mode 100644 index 000000000..028f4a55b --- /dev/null +++ b/src/lib/medications/efficacy/segmented-effect.ts @@ -0,0 +1,345 @@ +/** + * The medication effect as an interrupted time series: the target metric's + * daily values over a window around the start, fitted with a segmented + * regression (level change + slope change, Newey–West intervals). + * + * Adherence-adjusted by exclusion. After the start, a day is left out when the + * doses expected over the trailing fourteen days were taken less than 80 % of + * the time, and when the medication was paused or between courses that day. + * The adherence comes from the cadence-aware dose ledger the compliance + * surfaces read (`dailyComplianceRatesFromLedger`), never a recount. With + * intake tracking off nothing is excluded and the DTO says so. + * + * The window is ninety days before the start and up to 180 after it, cut short + * at the medication's own first dose change (the estimate then describes the + * starting dose, not a blend of two). Other medications that started, stopped, + * changed dose or paused inside the window are listed as caveats: the model + * cannot tell their effect from this one. + * + * Descriptive and association-only, like the rest of the efficacy DTO: there + * is no verdict field. The renderer phrases the numbers and the caveats; it + * never decides whether a medication "works". + * + * Pure: the database reads live in `./load-segmented-effects.ts`, so the + * client renderer can import the constants and types from here. Computed on + * request, without AI. + */ +import { dayKeyOfDate } from "@/lib/medications/course-window"; +import { userDayKey } from "@/lib/tz/format"; +import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; +import { + fitSegmentedRegression, + type SegmentedEstimate, +} from "@/lib/stats/segmented-regression"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** Days of target data the window reaches back over before the start. */ +export const SEGMENTED_PRE_WINDOW_DAYS = 90; +/** Days after the start the window reaches forward over, at most. */ +export const SEGMENTED_POST_WINDOW_DAYS = 180; +/** Days with a value required on each side of the start. */ +export const SEGMENTED_MIN_DAYS_PER_SIDE = 14; +/** Calendar span (first to last value) required on each side. */ +export const SEGMENTED_MIN_SPAN_DAYS = 21; +/** Trailing window the per-day adherence is judged over. */ +export const ADHERENCE_LOOKBACK_DAYS = 14; +/** Adherence below this share leaves a post-start day out. */ +export const ADHERENCE_FLOOR = 0.8; +/** Confidence level of every interval. */ +export const SEGMENTED_LEVEL = 0.95; + +/** An estimate in display terms: the unit per day for a level, per week for a slope. */ +export interface EfficacyEstimate { + estimate: number; + ciLow: number; + ciHigh: number; + standardError: number; + /** True when the interval spans zero (the data is also consistent with no change). */ + includesZero: boolean; +} + +export type EfficacyConfounderEvent = + "started" | "stopped" | "dose_change" | "paused" | "resumed"; + +export interface EfficacyConfounder { + medicationName: string; + event: EfficacyConfounderEvent; + /** YYYY-MM-DD. */ + date: string; +} + +export interface EfficacySegmented { + present: boolean; + reason?: + "no_start" | "insufficient_before" | "insufficient_after" | "fit_failed"; + /** Confidence level of the intervals (0.95). */ + level: number; + /** The window actually fitted (YYYY-MM-DD, inclusive). */ + window: { + from: string; + to: string; + endReason: "today" | "window" | "dose_change"; + } | null; + /** Days with a value used on each side, and the days left out after the start. */ + days: { + before: number; + after: number; + excludedLowAdherence: number; + excludedNotTaking: number; + }; + adherence: { + /** "not_tracked" when the medication logs no intake, so nothing was excluded. */ + status: "tracked" | "not_tracked"; + floorPct: number; + lookbackDays: number; + }; + /** Immediate shift at the start, in the target's unit. */ + levelChange: EfficacyEstimate | null; + /** Change in the weekly trend after the start, unit per week. */ + slopeChangePerWeek: EfficacyEstimate | null; + /** The weekly trend before the start, unit per week. */ + preSlopePerWeek: EfficacyEstimate | null; + fit: { + n: number; + df: number; + rSquared: number; + residualAutocorrelation: number; + hacLag: number; + } | null; + /** Other medication changes inside the window, oldest first. */ + confounders: EfficacyConfounder[]; +} + +function dayIndex(day: string): number { + return Math.round(dayKeyAsUtcMidnight(day).getTime() / DAY_MS); +} + +function dayKeyOfIndex(index: number): string { + return dayKeyOfDate(new Date(index * DAY_MS)); +} + +function scaled(e: SegmentedEstimate, factor: number): EfficacyEstimate { + const ciLow = e.ciLow * factor; + const ciHigh = e.ciHigh * factor; + return { + estimate: e.estimate * factor, + ciLow, + ciHigh, + standardError: e.standardError * factor, + includesZero: ciLow <= 0 && ciHigh >= 0, + }; +} + +/** The input to the pure assembly — every day already a local day key. */ +export interface SegmentedEffectInput { + /** One value per local day (any order). */ + dayValues: ReadonlyArray<{ day: string; value: number }>; + /** The start day, or null when the medication carries no start date. */ + pivotDay: string | null; + /** The last day the window may reach (today, or a report's last day). */ + lastDay: string; + /** Per-day dose tallies from the ledger; null when intake is not tracked. */ + adherenceDays: ReadonlyArray<{ + day: string; + taken: number; + missed: number; + }> | null; + /** Whether the medication was not being taken on a day (paused, between courses). */ + notTakingOn: (day: string) => boolean; + /** The medication's own dose changes (YYYY-MM-DD). */ + ownDoseChangeDays: readonly string[]; + /** Other medications' changes (YYYY-MM-DD), any order. */ + otherMedicationEvents: readonly EfficacyConfounder[]; +} + +const EMPTY_DAYS = { + before: 0, + after: 0, + excludedLowAdherence: 0, + excludedNotTaking: 0, +}; + +/** + * Assemble the segmented effect from already-resolved day data. Pure, so the + * window, exclusion and minimum-data rules are testable without a database. + */ +export function buildSegmentedEffect( + input: SegmentedEffectInput, +): EfficacySegmented { + const adherence = { + status: input.adherenceDays === null ? "not_tracked" : "tracked", + floorPct: Math.round(ADHERENCE_FLOOR * 100), + lookbackDays: ADHERENCE_LOOKBACK_DAYS, + } as const; + const absent = ( + reason: NonNullable, + window: EfficacySegmented["window"] = null, + days = EMPTY_DAYS, + confounders: EfficacyConfounder[] = [], + ): EfficacySegmented => ({ + present: false, + reason, + level: SEGMENTED_LEVEL, + window, + days, + adherence, + levelChange: null, + slopeChangePerWeek: null, + preSlopePerWeek: null, + fit: null, + confounders, + }); + + if (input.pivotDay === null) return absent("no_start"); + + const pivot = dayIndex(input.pivotDay); + const from = pivot - SEGMENTED_PRE_WINDOW_DAYS; + let to = Math.min( + dayIndex(input.lastDay), + pivot + SEGMENTED_POST_WINDOW_DAYS - 1, + ); + let endReason: "today" | "window" | "dose_change" = + to === dayIndex(input.lastDay) ? "today" : "window"; + // The first own dose change after the start ends the window the day + // before it: one estimate per dose. A dose recorded on the start day is + // the starting dose, not a change. + const firstChange = input.ownDoseChangeDays + .map(dayIndex) + .filter((d) => d > pivot) + .sort((a, b) => a - b)[0]; + if (firstChange !== undefined && firstChange - 1 < to) { + to = firstChange - 1; + endReason = "dose_change"; + } + const window = { + from: dayKeyOfIndex(from), + to: dayKeyOfIndex(to), + endReason, + }; + + const confounders = input.otherMedicationEvents + .filter((c) => { + const d = dayIndex(c.date); + return d >= from && d <= to; + }) + .slice() + .sort((a, b) => a.date.localeCompare(b.date)); + + // Per-day adherence, judged over the trailing lookback. + const adherenceByDay = new Map(); + for (const a of input.adherenceDays ?? []) { + adherenceByDay.set(dayIndex(a.day), { taken: a.taken, missed: a.missed }); + } + const lowAdherenceOn = (d: number): boolean => { + if (input.adherenceDays === null) return false; + let taken = 0; + let missed = 0; + for (let k = d - ADHERENCE_LOOKBACK_DAYS + 1; k <= d; k++) { + const a = adherenceByDay.get(k); + if (!a) continue; + taken += a.taken; + missed += a.missed; + } + // No dose expected in the lookback: adherence is unknown, not low. + if (taken + missed === 0) return false; + return taken / (taken + missed) < ADHERENCE_FLOOR; + }; + + const points: { t: number; y: number }[] = []; + const days = { ...EMPTY_DAYS }; + const sorted = input.dayValues + .map((v) => ({ d: dayIndex(v.day), day: v.day, value: v.value })) + .filter((v) => v.d >= from && v.d <= to && Number.isFinite(v.value)) + .sort((a, b) => a.d - b.d); + let beforeFirst: number | null = null; + let beforeLast: number | null = null; + let afterFirst: number | null = null; + let afterLast: number | null = null; + for (const v of sorted) { + const t = v.d - pivot; + if (t >= 0) { + if (input.notTakingOn(v.day)) { + days.excludedNotTaking += 1; + continue; + } + if (lowAdherenceOn(v.d)) { + days.excludedLowAdherence += 1; + continue; + } + days.after += 1; + afterFirst ??= t; + afterLast = t; + } else { + days.before += 1; + beforeFirst ??= t; + beforeLast = t; + } + points.push({ t, y: v.value }); + } + + const span = (a: number | null, b: number | null) => + a === null || b === null ? 0 : b - a + 1; + if ( + days.before < SEGMENTED_MIN_DAYS_PER_SIDE || + span(beforeFirst, beforeLast) < SEGMENTED_MIN_SPAN_DAYS + ) { + return absent("insufficient_before", window, days, confounders); + } + if ( + days.after < SEGMENTED_MIN_DAYS_PER_SIDE || + span(afterFirst, afterLast) < SEGMENTED_MIN_SPAN_DAYS + ) { + return absent("insufficient_after", window, days, confounders); + } + + const fit = fitSegmentedRegression(points, { level: SEGMENTED_LEVEL }); + if (!fit) return absent("fit_failed", window, days, confounders); + + return { + present: true, + level: SEGMENTED_LEVEL, + window, + days, + adherence, + levelChange: scaled(fit.levelChange, 1), + slopeChangePerWeek: scaled(fit.slopeChange, 7), + preSlopePerWeek: scaled(fit.preSlope, 7), + fit: { + n: fit.n, + df: fit.df, + rSquared: fit.rSquared, + residualAutocorrelation: fit.residualAutocorrelation, + hacLag: fit.hacLag, + }, + confounders, + }; +} + +/** Day keys on which a medication was not being taken (paused, between courses). */ +export function notTakingPredicate( + med: { + pauseEras: ReadonlyArray<{ pausedAt: Date; resumedAt: Date | null }>; + courses: ReadonlyArray<{ startsOn: Date; endsOn: Date | null }>; + }, + timezone: string, +): (day: string) => boolean { + const pauses = med.pauseEras.map((p) => ({ + from: userDayKey(p.pausedAt, timezone), + to: p.resumedAt ? userDayKey(p.resumedAt, timezone) : null, + })); + const courses = med.courses.map((c) => ({ + from: dayKeyOfDate(c.startsOn), + to: c.endsOn ? dayKeyOfDate(c.endsOn) : null, + })); + return (day: string) => { + // Paused from the pause day up to (not including) the resume day. + if (pauses.some((p) => day >= p.from && (p.to === null || day < p.to))) { + return true; + } + if (courses.length === 0) return false; + return !courses.some( + (c) => day >= c.from && (c.to === null || day <= c.to), + ); + }; +} diff --git a/src/lib/medications/efficacy/segmented-format.ts b/src/lib/medications/efficacy/segmented-format.ts new file mode 100644 index 000000000..8b144fcd3 --- /dev/null +++ b/src/lib/medications/efficacy/segmented-format.ts @@ -0,0 +1,38 @@ +/** + * How a segmented-effect estimate is printed, shared by the efficacy tab, the + * PDF report and the clinician view so all three show the same figure. + * + * The precision follows the uncertainty: one significant digit of the standard + * error decides the decimals (an SE of 0.6 mmHg prints one decimal, 0.02 kg + * prints two). More digits than that would claim a precision the data does not + * have; fewer would hide a real interval. Capped at three decimals. + */ +import type { EfficacyEstimate } from "./segmented-effect"; + +export function estimateDecimals(e: Pick) { + const se = Math.abs(e.standardError); + if (!Number.isFinite(se) || se === 0) return 1; + return Math.min(3, Math.max(0, -Math.floor(Math.log10(se)))); +} + +/** + * The three numbers of an estimate as display strings, the point estimate + * signed. `num` is the caller's locale formatter. + */ +export function formatEstimate( + e: EfficacyEstimate, + num: (value: number, decimals: number) => string, +): { value: string; low: string; high: string } { + const decimals = estimateDecimals(e); + // A value that rounds to zero prints unsigned, never "-0.0". + const signed = (v: number) => { + const text = num(v, decimals); + const rounded = Number(v.toFixed(decimals)); + return rounded > 0 ? `+${text}` : rounded === 0 ? num(0, decimals) : text; + }; + return { + value: signed(e.estimate), + low: num(e.ciLow, decimals), + high: num(e.ciHigh, decimals), + }; +} diff --git a/src/lib/validations/medication-efficacy.ts b/src/lib/validations/medication-efficacy.ts index 86826ef7b..743e3b42a 100644 --- a/src/lib/validations/medication-efficacy.ts +++ b/src/lib/validations/medication-efficacy.ts @@ -81,6 +81,81 @@ const levelShiftSchema = z }) .nullable(); +const estimateSchema = z + .object({ + estimate: z.number(), + ciLow: z.number(), + ciHigh: z.number(), + standardError: z.number(), + includesZero: z.boolean(), + }) + .nullable(); + +/** + * The segmented-regression (interrupted time series) effect: the immediate + * level change and the change in weekly trend at the start, each with a 95 % + * interval (Newey–West), fitted over up to 90 days before and 180 after with + * low-adherence and not-taking days after the start left out. Descriptive + * only; `present: false` carries the reason the data floor was not met. + */ +const segmentedSchema = z + .object({ + present: z.boolean(), + reason: z + .enum([ + "no_start", + "insufficient_before", + "insufficient_after", + "fit_failed", + ]) + .optional(), + level: z.number(), + window: z + .object({ + from: z.string(), + to: z.string(), + endReason: z.enum(["today", "window", "dose_change"]), + }) + .nullable(), + days: z.object({ + before: z.number().int(), + after: z.number().int(), + excludedLowAdherence: z.number().int(), + excludedNotTaking: z.number().int(), + }), + adherence: z.object({ + status: z.enum(["tracked", "not_tracked"]), + floorPct: z.number().int(), + lookbackDays: z.number().int(), + }), + levelChange: estimateSchema, + slopeChangePerWeek: estimateSchema, + preSlopePerWeek: estimateSchema, + fit: z + .object({ + n: z.number().int(), + df: z.number().int(), + rSquared: z.number(), + residualAutocorrelation: z.number(), + hacLag: z.number().int(), + }) + .nullable(), + confounders: z.array( + z.object({ + medicationName: z.string(), + event: z.enum([ + "started", + "stopped", + "dose_change", + "paused", + "resumed", + ]), + date: z.string(), + }), + ), + }) + .nullable(); + const targetViewSchema = z.object({ kind: z.enum(["metric", "lab"]), key: z.string(), @@ -91,6 +166,7 @@ const targetViewSchema = z.object({ series: z.array(seriesPointSchema), beforeAfter: beforeAfterSchema, levelShift: levelShiftSchema, + segmented: segmentedSchema, }); export const medicationEfficacyResponseSchema = z.object({