Merge pull request #448 from MBombeck/release/v1.28.4

release/v1.28.4 — medication efficacy
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-09 13:30:50 +02:00
commit 48baea2375
29 files changed
+3103 -19

No files matched your search

+10
View File
@@ -2,6 +2,16 @@
## [Unreleased]
## [1.28.4] — 2026-07-09 — Medication efficacy
A per-medication "effect" view: see whether a medication is moving the outcome
it targets, against your own data. The detail page gains a tab that plots the
target metric (or lab) with the start date, dose changes, and pauses marked, a
before/after-start comparison, and your adherence overlaid — strictly
descriptive, never a verdict or dose advice. A compact version surfaces on the
medication insights area. The target is resolved from the drug's class or its
name, and you can override which metric a medication is tracked against.
## [1.28.3] — 2026-07-09 — Consistent spacing and alignment
A consistency pass across the app, driven by a layout audit. Insight cards now
+506 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.28.3
version: 1.28.4
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -3389,6 +3389,163 @@ paths:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a3
"429": *a4
/api/medications/{id}/efficacy:
get:
tags:
- Medications
summary: Efficacy view for a medication ("Wirkung")
description: "Returns the resolved, strictly-descriptive efficacy DTO: the outcome metric(s)/lab(s) the medication's
class targets, the target series with start/dose-change/pause markers, a before/after-start comparison, the
cadence-aware adherence lane, an optional conservative level-shift note, the data-floor state, and the retarget
options. Association-only — there is no verdict / score field. `eligible:false` (with `reason`) marks a one-shot
or no-target medication whose tab is hidden."
parameters:
- in: path
name: id
schema:
type: string
required: true
responses:
"200":
description: Efficacy view.
content:
application/json:
schema:
$ref: "#/components/schemas/GetMedicationEfficacyResponse"
"401": *a1
"404":
description: Medication not found (or owned by another user).
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a3
"429": *a4
/api/medications/{id}/efficacy/target:
put:
tags:
- Medications
summary: Set or clear the efficacy-target override
description: Persists the user's explicit efficacy target for a medication (the only thing the view stores; everything
else is derived each read). Pin exactly one of `measurementType` / `biomarkerId`, or pass `clear:true` to revert
to the derived target.
parameters:
- in: path
name: id
schema:
type: string
required: true
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
clear:
type: boolean
measurementType:
type: string
enum:
- WEIGHT
- BLOOD_PRESSURE_SYS
- BLOOD_PRESSURE_DIA
- PULSE
- BODY_FAT
- SLEEP_DURATION
- ACTIVITY_STEPS
- BLOOD_GLUCOSE
- TOTAL_BODY_WATER
- BONE_MASS
- OXYGEN_SATURATION
- HEART_RATE_VARIABILITY
- RESTING_HEART_RATE
- ACTIVE_ENERGY_BURNED
- FLIGHTS_CLIMBED
- WALKING_RUNNING_DISTANCE
- VO2_MAX
- BODY_TEMPERATURE
- FAT_FREE_MASS
- FAT_MASS
- MUSCLE_MASS
- SKIN_TEMPERATURE
- PULSE_WAVE_VELOCITY
- VASCULAR_AGE
- VISCERAL_FAT
- AUDIO_EXPOSURE_ENV
- AUDIO_EXPOSURE_HEADPHONE
- TIME_IN_DAYLIGHT
- WALKING_STEADINESS
- AUDIO_EXPOSURE_EVENT
- RESPIRATORY_RATE
- BODY_MASS_INDEX
- LEAN_BODY_MASS
- WALKING_HEART_RATE_AVERAGE
- WALKING_ASYMMETRY
- WALKING_DOUBLE_SUPPORT
- WALKING_STEP_LENGTH
- WALKING_SPEED
- CARDIO_RECOVERY
- WRIST_TEMPERATURE
- FALL_COUNT
- SIX_MINUTE_WALK_DISTANCE
- STAIR_ASCENT_SPEED
- STAIR_DESCENT_SPEED
- BREATHING_DISTURBANCES
- IRREGULAR_RHYTHM_NOTIFICATION
- HIGH_HEART_RATE_EVENT
- LOW_HEART_RATE_EVENT
- WALKING_STEADINESS_EVENT
- BREATHING_DISTURBANCE_EVENT
- RECOVERY_SCORE
- STRESS_SCORE
- STRAIN_SCORE
- HRV_RMSSD
- DAY_STRAIN
- WORKOUT_STRAIN
- SLEEP_PERFORMANCE
- SLEEP_EFFICIENCY
- SLEEP_CONSISTENCY
- SLEEP_NEED
- ENERGY_EXPENDITURE_KJ
- AVERAGE_HEART_RATE
- MAX_HEART_RATE
- SLEEP_DISTURBANCE_COUNT
- ANS_CHARGE
- CARDIO_LOAD
- SLEEP_SCORE
- BODY_TEMPERATURE_DEVIATION
- RESILIENCE
- PHQ9_SCORE
- GAD7_SCORE
- GRIP_STRENGTH
- PAIN_NRS
- WAIST_CIRCUMFERENCE
- WAIST_TO_HEIGHT
- WHO5_SCORE
- SCI_SCORE
biomarkerId:
type: string
minLength: 1
maxLength: 64
primary:
type: boolean
responses:
"200":
description: Override set or cleared.
content:
application/json:
schema:
$ref: "#/components/schemas/SetMedicationEfficacyTargetResponse"
"401": *a1
"404":
description: Medication or biomarker not found.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a3
"429": *a4
/api/medications/{id}/schedule-revisions:
get:
tags:
@@ -17712,6 +17869,354 @@ components:
- veryLate
additionalProperties: false
description: Per-day compliance cell with the timing breakdown that drives the history glyph track.
GetMedicationEfficacyResponse:
type: object
properties:
data:
type: object
properties:
medicationId:
type: string
medicationName:
type: string
eligible:
type: boolean
reason:
type: string
enum:
- one_shot
- no_target
startsOn:
anyOf:
- type: string
- type: "null"
resolution:
type: object
properties:
tier:
type: string
enum:
- override
- atc
- name
- none
cls:
anyOf:
- type: string
- type: "null"
required:
- tier
- cls
additionalProperties: false
windowDays:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
minWeeksPerSide:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
markers:
type: object
properties:
start:
anyOf:
- type: string
- type: "null"
startSource:
anyOf:
- type: string
enum:
- startsOn
- firstReading
- type: "null"
doseChanges:
type: array
items:
type: object
properties:
at:
type: string
label:
type: string
required:
- at
- label
additionalProperties: false
pauses:
type: array
items:
type: object
properties:
from:
type: string
to:
anyOf:
- type: string
- type: "null"
required:
- from
- to
additionalProperties: false
required:
- start
- startSource
- doseChanges
- pauses
additionalProperties: false
targets:
type: array
items:
type: object
properties:
kind:
type: string
enum:
- metric
- lab
key:
type: string
label:
type: string
unit:
anyOf:
- type: string
- type: "null"
primary:
type: boolean
referenceBand:
anyOf:
- type: object
properties:
low:
type: number
high:
type: number
required:
- low
- high
additionalProperties: false
- type: "null"
series:
type: array
items:
type: object
properties:
t:
type: string
value:
type: number
status:
type: string
enum:
- in-range
- below
- above
- unknown
required:
- t
- value
additionalProperties: false
beforeAfter:
type: object
properties:
present:
type: boolean
reason:
type: string
enum:
- insufficient_before
- insufficient_after
- no_start
- no_data
before:
type: object
properties:
mean:
type: number
count:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
from:
type: string
to:
type: string
required:
- mean
- count
- from
- to
additionalProperties: false
after:
type: object
properties:
mean:
type: number
count:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
from:
type: string
to:
type: string
required:
- mean
- count
- from
- to
additionalProperties: false
delta:
anyOf:
- type: object
properties:
mean:
type: number
pct:
anyOf:
- type: number
- type: "null"
required:
- mean
- pct
additionalProperties: false
- type: "null"
required:
- present
additionalProperties: false
levelShift:
anyOf:
- type: object
properties:
present:
type: boolean
at:
type: string
nearStart:
type: boolean
required:
- present
additionalProperties: false
- type: "null"
required:
- kind
- key
- label
- unit
- primary
- referenceBand
- series
- beforeAfter
- levelShift
additionalProperties: false
adherence:
type: array
items:
type: object
properties:
date:
type: string
rate:
type: number
taken:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
missed:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
required:
- date
- rate
- taken
- missed
additionalProperties: false
overrideOptions:
type: object
properties:
metrics:
type: array
items:
type: object
properties:
key:
type: string
label:
type: string
required:
- key
- label
additionalProperties: false
biomarkers:
type: array
items:
type: object
properties:
id:
type: string
label:
type: string
unit:
type: string
required:
- id
- label
- unit
additionalProperties: false
required:
- metrics
- biomarkers
additionalProperties: false
required:
- medicationId
- medicationName
- eligible
- startsOn
- resolution
- windowDays
- minWeeksPerSide
- markers
- targets
- adherence
- overrideOptions
additionalProperties: false
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
SetMedicationEfficacyTargetResponse:
type: object
properties:
data:
type: object
properties:
ok:
type: boolean
cleared:
type: boolean
additionalProperties: false
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
ListScheduleRevisionsResponse:
type: object
properties:
+37 -2
View File
@@ -1593,7 +1593,8 @@
"verlauf": "Verlauf",
"injektion": "Injektion",
"api": "API",
"erweitert": "Erweitert"
"erweitert": "Erweitert",
"wirkung": "Wirkung"
},
"shell": {
"loadFailed": "Dieses Medikament konnte nicht geladen werden.",
@@ -1791,7 +1792,41 @@
"reorderDescription": "Bestimme die Reihenfolge deiner Medikamente – sie gilt für die Karten- und die Tabellenansicht.",
"reorderSaved": "Reihenfolge gespeichert",
"reorderSaveFailed": "Reihenfolge konnte nicht gespeichert werden",
"customize": "Medikamente anpassen"
"customize": "Medikamente anpassen",
"efficacy": {
"title": "Wirkung",
"intro": "Wie sich die Messwerte, die dieses Medikament beeinflussen soll, rund um den Beginn der Einnahme entwickelt haben. Es zeigt den zeitlichen Zusammenhang – eine Assoziation, keine Ursache – und keine Empfehlung zu Behandlung oder Dosis.",
"notEligible": "Für dieses Medikament gibt es keinen zugehörigen Messwert.",
"startFallback": "Es ist kein Startdatum hinterlegt, daher dient der erste erfasste Wert als Bezugspunkt.",
"noStart": "Hinterlege ein Startdatum, um die Werte vor und nach Beginn zu vergleichen.",
"disclaimer": "Nur ein Zusammenhang: Die Zahlen zeigen, was sich im selben Zeitraum verändert hat, nicht dass das Medikament die Veränderung verursacht hat. Besprich Entscheidungen zu deiner Behandlung mit deiner Ärztin oder deinem Arzt.",
"primaryBadge": "Hauptziel",
"noSeries": "Für diesen Zielwert sind noch keine Messungen erfasst.",
"startMarker": "Beginn",
"adherenceLane": "Einnahmetreue",
"typicalRange": "Üblicher Bereich",
"levelShift": "Um {date} wurde eine Verschiebung des Niveaus erkannt.",
"retarget": {
"label": "Nicht der richtige Messwert?",
"placeholder": "Verfolgen anhand…",
"apply": "Übernehmen",
"reset": "Auf Standard zurücksetzen"
},
"beforeAfter": {
"summary": "Ø {before} davor → Ø {after} seitdem – eine Veränderung von {delta}.",
"counts": "Basierend auf {beforeCount} Werten davor und {afterCount} seitdem.",
"needBefore": "Noch zu wenige Werte vor dem Beginn – für einen Vergleich werden etwa {weeks} Wochen davor und danach benötigt.",
"needAfter": "Noch zu wenige Werte seit dem Beginn – für einen Vergleich werden etwa {weeks} Wochen davor und danach benötigt.",
"needStart": "Hinterlege ein Startdatum, um die Werte vor und nach Beginn zu vergleichen.",
"needData": "Noch zu wenige Werte – für einen Vergleich werden etwa {weeks} Wochen vor und nach dem Beginn benötigt."
},
"insights": {
"title": "Medikamenten-Wirkung",
"subtitle": "Was mit den Zielwerten deiner Medikamente zeitlich zusammenfällt – nur ein Zusammenhang, kein Urteil.",
"link": "Wirkung öffnen",
"empty": "Noch nicht genug Daten, um deine Medikamente mit ihren Zielwerten in Beziehung zu setzen."
}
}
},
"charts": {
"loadingLabel": "Diagramm wird geladen…",
+37 -2
View File
@@ -1593,7 +1593,8 @@
"verlauf": "History",
"injektion": "Injection",
"api": "API",
"erweitert": "Advanced"
"erweitert": "Advanced",
"wirkung": "Effect"
},
"shell": {
"loadFailed": "This medication could not be loaded.",
@@ -1791,7 +1792,41 @@
"reorderDescription": "Set the order of your medications – it applies to both the card and the table view.",
"reorderSaved": "Order saved",
"reorderSaveFailed": "Order could not be saved",
"customize": "Customize medications"
"customize": "Customize medications",
"efficacy": {
"title": "Effect",
"intro": "How the measurements this medication is meant to influence have moved around the time you started it. It shows what lines up in time — an association, not a cause — and never advice about your treatment or dose.",
"notEligible": "There is no outcome to relate this medication to.",
"startFallback": "No start date is set, so the comparison uses your first recorded reading as the reference point.",
"noStart": "Set a start date for this medication to compare the readings before and after it began.",
"disclaimer": "Association only: these figures show what changed over the same period, not that the medication caused the change. Discuss any decision about your treatment with your doctor.",
"primaryBadge": "Main target",
"noSeries": "No readings recorded for this target yet.",
"startMarker": "Start",
"adherenceLane": "Adherence",
"typicalRange": "Typical range",
"levelShift": "A shift in the level was detected around {date}.",
"retarget": {
"label": "Not the right measurement?",
"placeholder": "Track against…",
"apply": "Apply",
"reset": "Reset to default"
},
"beforeAfter": {
"summary": "Ø {before} before → Ø {after} since — a change of {delta}.",
"counts": "Based on {beforeCount} readings before and {afterCount} since.",
"needBefore": "Not enough readings before the start yet — about {weeks} weeks are needed before and after to compare.",
"needAfter": "Not enough readings since the start yet — about {weeks} weeks are needed before and after to compare.",
"needStart": "Set a start date to compare the readings before and after it began.",
"needData": "Not enough readings yet — about {weeks} weeks are needed before and after the start to compare."
},
"insights": {
"title": "Medication effect",
"subtitle": "What lines up with the measurements your medications target — association only, never a verdict.",
"link": "Open the effect view",
"empty": "Not enough data yet to relate your medications to their target measurements."
}
}
},
"charts": {
"loadingLabel": "Loading chart…",
+37 -2
View File
@@ -1593,7 +1593,8 @@
"verlauf": "Historial",
"injektion": "Inyección",
"api": "API",
"erweitert": "Avanzado"
"erweitert": "Avanzado",
"wirkung": "Efecto"
},
"shell": {
"loadFailed": "No se pudo cargar este medicamento.",
@@ -1791,7 +1792,41 @@
"reorderDescription": "Define el orden de tus medicamentos: se aplica a la vista de tarjetas y a la de tabla.",
"reorderSaved": "Orden guardado",
"reorderSaveFailed": "No se pudo guardar el orden",
"customize": "Personalizar medicamentos"
"customize": "Personalizar medicamentos",
"efficacy": {
"title": "Efecto",
"intro": "Cómo han evolucionado las mediciones sobre las que actúa este medicamento en torno al momento en que empezaste a tomarlo. Muestra lo que coincide en el tiempo: una asociación, no una causa, y nunca un consejo sobre tu tratamiento o dosis.",
"notEligible": "No hay ninguna medición que relacionar con este medicamento.",
"startFallback": "No hay fecha de inicio, así que la comparación usa tu primera lectura registrada como referencia.",
"noStart": "Añade una fecha de inicio para comparar las lecturas antes y después de empezar.",
"disclaimer": "Solo asociación: estas cifras muestran lo que cambió en el mismo periodo, no que el medicamento causara el cambio. Consulta cualquier decisión sobre tu tratamiento con tu médico.",
"primaryBadge": "Objetivo principal",
"noSeries": "Aún no hay mediciones registradas para este objetivo.",
"startMarker": "Inicio",
"adherenceLane": "Adherencia",
"typicalRange": "Rango habitual",
"levelShift": "Se detectó un cambio de nivel en torno a {date}.",
"retarget": {
"label": "¿No es la medición correcta?",
"placeholder": "Seguir según…",
"apply": "Aplicar",
"reset": "Restablecer al valor predeterminado"
},
"beforeAfter": {
"summary": "Ø {before} antes → Ø {after} desde entonces: un cambio de {delta}.",
"counts": "Basado en {beforeCount} lecturas antes y {afterCount} desde entonces.",
"needBefore": "Aún no hay suficientes lecturas antes del inicio: se necesitan unas {weeks} semanas antes y después para comparar.",
"needAfter": "Aún no hay suficientes lecturas desde el inicio: se necesitan unas {weeks} semanas antes y después para comparar.",
"needStart": "Añade una fecha de inicio para comparar las lecturas antes y después de empezar.",
"needData": "Aún no hay suficientes lecturas: se necesitan unas {weeks} semanas antes y después del inicio para comparar."
},
"insights": {
"title": "Efecto de la medicación",
"subtitle": "Lo que coincide con las mediciones que persiguen tus medicamentos: solo asociación, nunca un veredicto.",
"link": "Abrir la vista de efecto",
"empty": "Aún no hay suficientes datos para relacionar tus medicamentos con sus mediciones objetivo."
}
}
},
"charts": {
"loadingLabel": "Cargando la gráfica…",
+37 -2
View File
@@ -1593,7 +1593,8 @@
"verlauf": "Historique",
"injektion": "Injection",
"api": "API",
"erweitert": "Avancé"
"erweitert": "Avancé",
"wirkung": "Effet"
},
"shell": {
"loadFailed": "Ce médicament n'a pas pu être chargé.",
@@ -1791,7 +1792,41 @@
"reorderDescription": "Définis l'ordre de tes médicaments – il s'applique à la vue cartes et à la vue tableau.",
"reorderSaved": "Ordre enregistré",
"reorderSaveFailed": "L'ordre n'a pas pu être enregistré",
"customize": "Personnaliser les médicaments"
"customize": "Personnaliser les médicaments",
"efficacy": {
"title": "Effet",
"intro": "Comment les mesures que ce médicament vise ont évolué autour du moment où vous avez commencé à le prendre. Cela montre ce qui coïncide dans le temps : une association, pas une cause, et jamais un conseil sur votre traitement ou votre dose.",
"notEligible": "Aucune mesure à relier à ce médicament.",
"startFallback": "Aucune date de début n'est renseignée ; la comparaison utilise donc votre première mesure enregistrée comme référence.",
"noStart": "Renseignez une date de début pour comparer les mesures avant et après le début.",
"disclaimer": "Association seulement : ces chiffres montrent ce qui a changé sur la même période, pas que le médicament a causé ce changement. Discutez de toute décision concernant votre traitement avec votre médecin.",
"primaryBadge": "Cible principale",
"noSeries": "Aucune mesure enregistrée pour cette cible pour le moment.",
"startMarker": "Début",
"adherenceLane": "Observance",
"typicalRange": "Plage habituelle",
"levelShift": "Un changement de niveau a été détecté autour du {date}.",
"retarget": {
"label": "Ce n'est pas la bonne mesure ?",
"placeholder": "Suivre selon…",
"apply": "Appliquer",
"reset": "Réinitialiser par défaut"
},
"beforeAfter": {
"summary": "Ø {before} avant → Ø {after} depuis — un écart de {delta}.",
"counts": "D'après {beforeCount} mesures avant et {afterCount} depuis.",
"needBefore": "Pas encore assez de mesures avant le début — il faut environ {weeks} semaines avant et après pour comparer.",
"needAfter": "Pas encore assez de mesures depuis le début — il faut environ {weeks} semaines avant et après pour comparer.",
"needStart": "Renseignez une date de début pour comparer les mesures avant et après le début.",
"needData": "Pas encore assez de mesures — il faut environ {weeks} semaines avant et après le début pour comparer."
},
"insights": {
"title": "Effet des médicaments",
"subtitle": "Ce qui coïncide avec les mesures visées par vos médicaments — association seulement, jamais un verdict.",
"link": "Ouvrir la vue Effet",
"empty": "Pas encore assez de données pour relier vos médicaments à leurs mesures cibles."
}
}
},
"charts": {
"loadingLabel": "Chargement du graphique…",
+37 -2
View File
@@ -1593,7 +1593,8 @@
"verlauf": "Cronologia",
"injektion": "Iniezione",
"api": "API",
"erweitert": "Avanzate"
"erweitert": "Avanzate",
"wirkung": "Effetto"
},
"shell": {
"loadFailed": "Impossibile caricare questo farmaco.",
@@ -1791,7 +1792,41 @@
"reorderDescription": "Imposta l'ordine dei tuoi farmaci: vale per la vista a schede e per la tabella.",
"reorderSaved": "Ordine salvato",
"reorderSaveFailed": "Impossibile salvare l’ordine",
"customize": "Personalizza i farmaci"
"customize": "Personalizza i farmaci",
"efficacy": {
"title": "Effetto",
"intro": "Come sono cambiate le misurazioni su cui agisce questo farmaco intorno al momento in cui hai iniziato a prenderlo. Mostra ciò che coincide nel tempo: un'associazione, non una causa, e mai un consiglio sul trattamento o sulla dose.",
"notEligible": "Non c'è alcuna misurazione da collegare a questo farmaco.",
"startFallback": "Non è impostata una data di inizio, quindi il confronto usa la tua prima misurazione registrata come riferimento.",
"noStart": "Imposta una data di inizio per confrontare le misurazioni prima e dopo l'avvio.",
"disclaimer": "Solo associazione: questi numeri mostrano ciò che è cambiato nello stesso periodo, non che il farmaco abbia causato il cambiamento. Discuti ogni decisione sul tuo trattamento con il medico.",
"primaryBadge": "Obiettivo principale",
"noSeries": "Nessuna misurazione registrata per questo obiettivo.",
"startMarker": "Inizio",
"adherenceLane": "Aderenza",
"typicalRange": "Intervallo abituale",
"levelShift": "È stato rilevato un cambio di livello intorno al {date}.",
"retarget": {
"label": "Non è la misurazione giusta?",
"placeholder": "Monitora in base a…",
"apply": "Applica",
"reset": "Ripristina predefinito"
},
"beforeAfter": {
"summary": "Ø {before} prima → Ø {after} da allora — una variazione di {delta}.",
"counts": "In base a {beforeCount} misurazioni prima e {afterCount} da allora.",
"needBefore": "Ancora troppe poche misurazioni prima dell'inizio: servono circa {weeks} settimane prima e dopo per confrontare.",
"needAfter": "Ancora troppe poche misurazioni dall'inizio: servono circa {weeks} settimane prima e dopo per confrontare.",
"needStart": "Imposta una data di inizio per confrontare le misurazioni prima e dopo l'avvio.",
"needData": "Ancora troppe poche misurazioni: servono circa {weeks} settimane prima e dopo l'inizio per confrontare."
},
"insights": {
"title": "Effetto dei farmaci",
"subtitle": "Ciò che coincide con le misurazioni a cui mirano i tuoi farmaci: solo associazione, mai un verdetto.",
"link": "Apri la vista Effetto",
"empty": "Ancora non ci sono dati sufficienti per collegare i tuoi farmaci alle loro misurazioni obiettivo."
}
}
},
"charts": {
"loadingLabel": "Caricamento del grafico…",
+37 -2
View File
@@ -1593,7 +1593,8 @@
"verlauf": "Historia",
"injektion": "Iniekcja",
"api": "API",
"erweitert": "Zaawansowane"
"erweitert": "Zaawansowane",
"wirkung": "Działanie"
},
"shell": {
"loadFailed": "Nie udało się załadować tego leku.",
@@ -1791,7 +1792,41 @@
"reorderDescription": "Ustal kolejność swoich leków – obowiązuje w widoku kart i w tabeli.",
"reorderSaved": "Kolejność zapisana",
"reorderSaveFailed": "Nie udało się zapisać kolejności",
"customize": "Dostosuj leki"
"customize": "Dostosuj leki",
"efficacy": {
"title": "Działanie",
"intro": "Jak zmieniały się pomiary, na które ma wpływać ten lek, w okolicach rozpoczęcia jego przyjmowania. Pokazuje to, co zbiega się w czasie — powiązanie, nie przyczynę — i nigdy porady dotyczącej leczenia ani dawki.",
"notEligible": "Brak pomiaru, który można powiązać z tym lekiem.",
"startFallback": "Nie ustawiono daty rozpoczęcia, więc porównanie używa pierwszego zapisanego pomiaru jako punktu odniesienia.",
"noStart": "Ustaw datę rozpoczęcia, aby porównać pomiary sprzed i po rozpoczęciu.",
"disclaimer": "Tylko powiązanie: te liczby pokazują, co zmieniło się w tym samym okresie, a nie że lek spowodował tę zmianę. Wszelkie decyzje dotyczące leczenia omów z lekarzem.",
"primaryBadge": "Główny cel",
"noSeries": "Brak zapisanych pomiarów dla tego celu.",
"startMarker": "Początek",
"adherenceLane": "Regularność",
"typicalRange": "Typowy zakres",
"levelShift": "Wykryto zmianę poziomu w okolicach {date}.",
"retarget": {
"label": "To nie ten pomiar?",
"placeholder": "Śledź według…",
"apply": "Zastosuj",
"reset": "Przywróć domyślne"
},
"beforeAfter": {
"summary": "Ø {before} przedtem → Ø {after} od tego czasu — zmiana o {delta}.",
"counts": "Na podstawie {beforeCount} pomiarów przedtem i {afterCount} od tego czasu.",
"needBefore": "Za mało pomiarów przed rozpoczęciem — do porównania potrzeba około {weeks} tygodni przed i po.",
"needAfter": "Za mało pomiarów od rozpoczęcia — do porównania potrzeba około {weeks} tygodni przed i po.",
"needStart": "Ustaw datę rozpoczęcia, aby porównać pomiary sprzed i po rozpoczęciu.",
"needData": "Za mało pomiarów — do porównania potrzeba około {weeks} tygodni przed i po rozpoczęciu."
},
"insights": {
"title": "Działanie leków",
"subtitle": "Co zbiega się w czasie z pomiarami, na które celują Twoje leki — tylko powiązanie, nigdy werdykt.",
"link": "Otwórz widok działania",
"empty": "Za mało danych, aby powiązać Twoje leki z docelowymi pomiarami."
}
}
},
"charts": {
"loadingLabel": "Ładowanie wykresu…",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.28.3",
"version": "1.28.4",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
@@ -0,0 +1,49 @@
-- Medication efficacy tracking ("Wirkung"): persist ONLY the user's explicit
-- target override.
--
-- `medication_efficacy_targets` — a lean child of `medications`. The
-- drug-class → outcome resolution (WHO ATC-prefix → whole-word name
-- inference) is computed each read from the closed in-repo lookup table, so
-- nothing about a class→outcome association is denormalised (it cannot drift
-- as the map improves). The ONE thing that must survive is the user's own
-- pick when they retarget a medication or choose a target for a med the
-- resolver could not classify — that is this table.
--
-- Exactly one of `measurement_type` (a first-class metric series) /
-- `biomarker_id` (a lab analyte) is set per row; `primary` marks the
-- storyline-leading target when several are pinned. `medication_id`
-- cascades (the override dies with the medication); `biomarker_id` sets
-- null (a deleted biomarker leaves an orphan row the resolver treats as no
-- override). `user_id` is never a column here — ownership is narrowed
-- through the parent medication, never a body field.
--
-- Additive; applies clean on a 0231 database and on a fresh database as part
-- of the ordered migration chain.
CREATE TABLE "medication_efficacy_targets" (
"id" TEXT NOT NULL,
"medication_id" TEXT NOT NULL,
"measurement_type" "measurement_type",
"biomarker_id" TEXT,
"primary" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "medication_efficacy_targets_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "medication_efficacy_targets_medication_id_idx"
ON "medication_efficacy_targets" ("medication_id");
CREATE INDEX "medication_efficacy_targets_biomarker_id_idx"
ON "medication_efficacy_targets" ("biomarker_id");
ALTER TABLE "medication_efficacy_targets"
ADD CONSTRAINT "medication_efficacy_targets_medication_id_fkey"
FOREIGN KEY ("medication_id") REFERENCES "medications" ("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "medication_efficacy_targets"
ADD CONSTRAINT "medication_efficacy_targets_biomarker_id_fkey"
FOREIGN KEY ("biomarker_id") REFERENCES "biomarkers" ("id")
ON DELETE SET NULL ON UPDATE CASCADE;
+41 -2
View File
@@ -2244,6 +2244,11 @@ model Medication {
/// single-pause marker the reminder/today surfaces read; the era table
/// is the additive durable record the rate paths need.
pauseEras MedicationPauseEra[]
/// v1.28 — user-pinned efficacy target(s) for the "Wirkung" view. Rows
/// exist ONLY when the user explicitly overrides the derived (ATC / name)
/// target resolution; absence means the resolver derives the target each
/// read. Nothing about a class→outcome association is denormalised here.
efficacyTargets MedicationEfficacyTarget[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -2252,6 +2257,37 @@ model Medication {
@@map("medications")
}
/// v1.28 — the user's explicit override of a medication's efficacy target,
/// the ONE thing the "Wirkung" view persists. Class→outcome resolution
/// (ATC-prefix → name inference) is computed each read from the closed
/// `med-target-map` table, so nothing drifts as that map improves; the only
/// durable state is the user's own pick when they retarget a medication or
/// choose a target for a med the resolver could not classify. Exactly one of
/// `measurementType` (a first-class metric series) / `biomarkerId` (a lab
/// analyte) is set per row. Cascade-deletes with the medication.
model MedicationEfficacyTarget {
id String @id @default(cuid())
medicationId String @map("medication_id")
/// Metric target — one of the closed `MeasurementType` series. NULL when
/// this row targets a lab analyte instead.
measurementType MeasurementType? @map("measurement_type")
/// Lab target — a user-scoped Biomarker. NULL when this row targets a
/// metric series instead. SetNull on biomarker delete so the row survives
/// as an orphan the resolver then treats as "no override".
biomarkerId String? @map("biomarker_id")
/// The storyline-leading target when several are pinned. Default true.
primary Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
biomarker Biomarker? @relation(fields: [biomarkerId], references: [id], onDelete: SetNull)
@@index([medicationId])
@@index([biomarkerId])
@@map("medication_efficacy_targets")
}
model MedicationSchedule {
id String @id @default(cuid())
medicationId String @map("medication_id")
@@ -4268,8 +4304,11 @@ model Biomarker {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
results LabResult[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
results LabResult[]
/// v1.28 — medications the user has pinned to this biomarker as their
/// efficacy target. SetNull on delete keeps the override rows as orphans.
efficacyTargets MedicationEfficacyTarget[]
@@unique([userId, name])
@@index([userId])
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.28.3";
/* @sw-version-fallback */ "v1.28.4";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
@@ -0,0 +1,105 @@
/**
* v1.28 — banned-verb guard for the `medications.efficacy.*` namespace.
*
* The medication-efficacy view ("Wirkung") is a medication-adjacent claim, so
* its safety boundary is structural, not left to reviewer vigilance: the DTO
* carries no verdict / score field, and the copy must stay strictly
* descriptive — an association, never "the drug works / is effective", never a
* dose change. This test walks every efficacy string in all six locales and
* fails if any value contains a verdict or dose-advice verb, so a forbidden
* word can never creep into the bundle. Mirrors the `i18n-english-leak-guard`
* flatten + filter + `toEqual([])` pattern.
*
* If this fails: rewrite the offending string to descriptive phrasing (numbers
* + neutral connectives). Do NOT weaken the denylist to pass.
*/
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { describe, it, expect } from "vitest";
const MESSAGES = join(__dirname, "..", "..", "messages");
const LOCALES = readdirSync(MESSAGES).filter((f) => f.endsWith(".json"));
/** Verdict + efficacy-adjective + dose-advice verbs across the six locales. */
const BANNED: RegExp[] = [
// English
/\bworks?\b/i,
/\bworking\b/i,
/\beffective(ly)?\b/i,
/\bineffective\b/i,
/\bcures?\b/i,
/\bcured\b/i,
/\bheals?\b/i,
/\bhealed\b/i,
/\bimprove(d|s|ment)?\b/i,
/\bworsen(ed|s)?\b/i,
/\bbetter\b/i,
/\bworse\b/i,
/\bincrease\b/i,
/\bdecrease\b/i,
/\breduce\b/i,
/\braise\b/i,
/\bsuccess(ful)?\b/i,
// German
/\bwirksam\b/i,
/\bunwirksam\b/i,
/\banschlägt\b/i,
/\bheilt\b/i,
/\bverbessert\b/i,
/\bverschlechtert\b/i,
/\berhöhen\b/i,
/\bsenken\b/i,
/\babsetzen\b/i,
// Spanish
/\bfunciona\b/i,
/\beficaz\b/i,
/\bineficaz\b/i,
/\bmejora\b/i,
/\bempeora\b/i,
// French
/\befficace\b/i,
/\binefficace\b/i,
/\baméliore\b/i,
/\baggrave\b/i,
// Italian
/\bmigliora\b/i,
/\bpeggiora\b/i,
// Polish
/\bskuteczn\w*\b/i,
/\bpoprawia\b/i,
/\bpogarsza\b/i,
];
function flatten(obj: unknown, prefix: string, out: [string, string][]): void {
if (obj === null || typeof obj !== "object") return;
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
const key = prefix ? `${prefix}.${k}` : k;
if (typeof v === "string") out.push([key, v]);
else flatten(v, key, out);
}
}
describe("medications.efficacy — banned verdict/advice verbs", () => {
it.each(LOCALES)("%s carries no forbidden verb", (file) => {
const bundle = JSON.parse(readFileSync(join(MESSAGES, file), "utf8")) as {
medications?: { efficacy?: unknown };
};
const efficacy = bundle.medications?.efficacy;
expect(efficacy, `${file} is missing medications.efficacy`).toBeDefined();
const flat: [string, string][] = [];
flatten(efficacy, "medications.efficacy", flat);
expect(flat.length).toBeGreaterThan(0);
const violations = flat
.filter(([, value]) => BANNED.some((re) => re.test(value)))
.map(([key, value]) => `${key} = ${JSON.stringify(value)}`);
expect(
violations,
`Forbidden verdict/advice verb in ${file} — the efficacy view is ` +
`association-only. Rewrite to descriptive phrasing:\n` +
violations.map((v) => ` ${v}`).join("\n"),
).toEqual([]);
});
});
@@ -204,6 +204,12 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
"src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts",
"src/app/api/medications/[id]/side-effects/route.ts",
"src/app/api/medications/[id]/side-effects/[logId]/route.ts",
// The efficacy read + user-override target share the medication data-layer
// reasoning: they compute over the user's own medication + metric/lab rows
// and persist only a per-user override. The module gate governs the Wirkung
// SURFACE (the detail-page tab + the insights summary), not the row store.
"src/app/api/medications/[id]/efficacy/route.ts",
"src/app/api/medications/[id]/efficacy/target/route.ts",
// ── INFRA / UI-ONLY ───────────────────────────────────────────────
// Static FHIR CapabilityStatement — server metadata, no user data.
"src/app/api/fhir/metadata/route.ts",
@@ -0,0 +1,42 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { buildMedicationEfficacy } from "@/lib/medications/efficacy/build-efficacy";
type RouteParams = { params: Promise<{ id: string }> };
/**
* GET /api/medications/[id]/efficacy — the resolved, server-authoritative
* efficacy DTO for one medication: target(s), the target's series with
* start/dose/pause markers, the before/after-start comparison, the adherence
* lane, the optional level-shift note, the data-floor state and the retarget
* options. Cookie- or Bearer-authenticated (the iOS client reads it). The DTO
* is strictly descriptive — no verdict / score field by construction.
*
* The builder's `findFirst({ where: { id, userId } })` is the ownership gate;
* a missing or foreign id yields the sealed "Medication not found" 404, the
* same shape `assertMedicationOwnership` mints.
*/
export const GET = apiHandler(
async (_request: Request, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const rl = await checkRateLimit(
`medication-efficacy:${user.id}`,
60,
60_000,
);
if (!rl.allowed) {
return apiError("Too many efficacy requests. Please retry later.", 429);
}
const userTz = user.timezone || "Europe/Berlin";
const dto = await buildMedicationEfficacy(user.id, id, userTz);
if (!dto) {
return apiError("Medication not found", 404);
}
return apiSuccess(dto);
},
);
@@ -0,0 +1,100 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
apiSuccess,
apiError,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { efficacyTargetOverrideSchema } from "@/lib/validations/medication-efficacy";
type RouteParams = { params: Promise<{ id: string }> };
/**
* PUT /api/medications/[id]/efficacy/target — set or clear the user's explicit
* efficacy-target override for a medication. `clear:true` removes the override
* rows so the resolver reverts to the derived (ATC / name) target; otherwise
* exactly one of `measurementType` / `biomarkerId` pins the new target. The
* override is the ONLY thing the "Wirkung" view persists — everything else is
* derived each read.
*
* `userId` is never a body field: ownership is narrowed through the parent
* medication (and the biomarker, when a lab target is pinned). The write
* builds its `data` object field-by-field (no mass assignment).
*/
export const PUT = apiHandler(
async (request: Request, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 8 * 1024,
});
if (jsonError) return jsonError;
const parsed = efficacyTargetOverrideSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
const { clear, measurementType, biomarkerId, primary } = parsed.data;
// Clearing reverts to the derived resolution — drop every override row.
if (clear) {
await prisma.medicationEfficacyTarget.deleteMany({
where: { medicationId: id },
});
annotate({
action: {
name: "medication.efficacy.target.clear",
entity_type: "medication",
entity_id: id,
},
meta: {},
});
return apiSuccess({ cleared: true });
}
// A lab target must reference a biomarker the caller owns.
if (biomarkerId) {
const biomarker = await prisma.biomarker.findFirst({
where: { id: biomarkerId, userId: user.id },
select: { id: true },
});
if (!biomarker) {
return apiError("Biomarker not found", 404);
}
}
// Replace the override with the single pinned target (field-by-field data).
await prisma.$transaction([
prisma.medicationEfficacyTarget.deleteMany({
where: { medicationId: id },
}),
prisma.medicationEfficacyTarget.create({
data: {
medicationId: id,
measurementType: measurementType ?? null,
biomarkerId: biomarkerId ?? null,
primary: primary ?? true,
},
select: { id: true },
}),
]);
annotate({
action: {
name: "medication.efficacy.target.set",
entity_type: "medication",
entity_id: id,
},
meta: { kind: measurementType ? "metric" : "lab" },
});
return apiSuccess({ ok: true });
},
);
+8
View File
@@ -17,6 +17,7 @@ import { ComplianceHeatmap } from "@/components/charts/compliance-heatmap";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { MedicationEfficacySummary } from "@/components/insights/medication-efficacy-summary";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { TileHeader } from "@/components/insights/tile-header";
import { apiGet } from "@/lib/api/api-fetch";
@@ -280,6 +281,13 @@ export default function InsightsMedikamentePage() {
})}
</div>
{/* v1.28 — compact efficacy summary: reuses the same server-computed
efficacy DTO the detail-page "Wirkung" tab reads (never a second
computation), association-only, linking through to the full view. */}
<MedicationEfficacySummary
medications={medications.map((m) => ({ id: m.id, name: m.name }))}
/>
<MetricTargetSummary slug="medications" />
{/* v1.12.2 — the assessment is the LAST block on every bespoke
+1
View File
@@ -40,4 +40,5 @@ export { SleepStageStackedBar } from "@/components/insights/sleep-stage-stacked-
export { LabBiomarkerChart } from "@/components/labs/lab-biomarker-chart";
export { DoseStrengthCurve } from "@/components/medications/dose-strength-curve";
export { DrugLevelChart } from "@/components/medications/drug-level-chart";
export { EfficacyChart } from "@/components/medications/detail/efficacy/efficacy-chart";
export { AssessmentHistoryChart } from "@/components/mental-health/assessment-history-chart";
@@ -0,0 +1,126 @@
"use client";
/**
* v1.28 — compact, read-only medication-efficacy summary for the Insights
* medication surface. Reads the SAME server-computed efficacy DTO the detail
* page's "Wirkung" tab reads (no second computation) and shows, per eligible
* medication with enough data, a one-line before/after against its target —
* strictly association-framed, never a verdict. Each row links through to the
* medication's Wirkung tab for the full view.
*
* Card anatomy per UI-STANDARDS §8: TileHeader + the inherited `px-4 md:px-6`
* inset (never re-declared), foreground for content, muted for meta.
*/
import Link from "next/link";
import { Activity, TrendingUp } from "lucide-react";
import { useQueries } from "@tanstack/react-query";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import type {
MedicationEfficacyDTO,
EfficacyTargetView,
} from "@/lib/medications/efficacy/build-efficacy";
function primaryWithDelta(
dto: MedicationEfficacyDTO,
): EfficacyTargetView | null {
const withDelta = dto.targets.find(
(t) => t.primary && t.beforeAfter.present && t.beforeAfter.delta,
);
return withDelta ?? null;
}
export function MedicationEfficacySummary({
medications,
}: {
medications: { id: string; name: string }[];
}) {
const { t } = useTranslations();
const results = useQueries({
queries: medications.map((m) => ({
queryKey: queryKeys.medicationEfficacy(m.id),
queryFn: () =>
apiGet<MedicationEfficacyDTO>(`/api/medications/${m.id}/efficacy`),
staleTime: 60_000,
})),
});
if (medications.length === 0) return null;
const rows = results
.map((r, i) => ({ med: medications[i], dto: r.data }))
.filter(
(
x,
): x is {
med: { id: string; name: string };
dto: MedicationEfficacyDTO;
} => !!x.dto && x.dto.eligible,
)
.map((x) => ({ med: x.med, target: primaryWithDelta(x.dto) }))
.filter((x) => x.target !== null);
return (
<Card data-slot="insights-medication-efficacy">
<CardHeader>
<TileHeader
icon={Activity}
title={t("medications.efficacy.insights.title")}
/>
<p className="text-muted-foreground mt-1 text-xs">
{t("medications.efficacy.insights.subtitle")}
</p>
</CardHeader>
<CardContent className="space-y-3">
{rows.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("medications.efficacy.insights.empty")}
</p>
) : (
<ul className="divide-border/60 divide-y">
{rows.map(({ med, target }) => {
const ba = target!.beforeAfter;
const unit = target!.unit ? ` ${target!.unit}` : "";
const arrow = (ba.delta?.mean ?? 0) > 0 ? "+" : "";
return (
<li
key={med.id}
className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 py-2.5 first:pt-0 last:pb-0"
data-slot="insights-medication-efficacy-row"
>
<div className="min-w-0">
<p className="text-foreground text-sm font-medium">
{med.name}
</p>
<p className="text-muted-foreground flex items-center gap-1.5 text-xs">
<TrendingUp aria-hidden="true" className="h-3.5 w-3.5" />
<span>
{target!.label}: {ba.before?.mean}
{unit} → {ba.after?.mean}
{unit} ({arrow}
{ba.delta?.mean}
{unit})
</span>
</p>
</div>
<Link
href={`/medications/${med.id}?tab=wirkung`}
className="text-primary focus-visible:ring-ring shrink-0 rounded-sm text-xs underline-offset-2 hover:underline focus-visible:ring-2 focus-visible:outline-none"
data-slot="insights-medication-efficacy-link"
>
{t("medications.efficacy.insights.link")}
</Link>
</li>
);
})}
</ul>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,244 @@
"use client";
/**
* v1.28 — the "Wirkung" target chart. Recharts consumer, so it is exported
* through `chart-runtime` and loaded via `next/dynamic` at the call site (the
* one-shared-chunk rule). Renders the target series over a span around the
* medication's start with:
* - a faint population reference band (context, never a goal line),
* - shaded pause bands,
* - a vertical start marker + quiet dose-change markers,
* - the target line,
* - a faint adherence lane on a hidden right axis.
* Strictly descriptive — no colour-coded verdict, no "good/bad" zones.
*/
import { useMemo } from "react";
import {
ResponsiveContainer,
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ReferenceLine,
ReferenceArea,
} from "recharts";
import { makeFormatters } from "@/lib/format-locale";
import { useTranslations } from "@/lib/i18n/context";
export interface EfficacyChartTarget {
label: string;
unit: string | null;
referenceBand: { low: number; high: number } | null;
series: { t: string; value: number }[];
}
export interface EfficacyChartProps {
target: EfficacyChartTarget;
startMs: number | null;
doseChanges: { at: string; label: string }[];
pauses: { from: string; to: string | null }[];
adherence: { date: string; rate: number }[];
timezone: string;
startLabel: string;
adherenceLabel: string;
typicalRangeLabel: string;
}
const toMs = (iso: string): number => Date.parse(iso);
export function EfficacyChart({
target,
startMs,
doseChanges,
pauses,
adherence,
timezone,
startLabel,
adherenceLabel,
typicalRangeLabel,
}: EfficacyChartProps) {
const { locale } = useTranslations();
const fmt = useMemo(
() => makeFormatters(locale, timezone),
[locale, timezone],
);
const seriesData = useMemo(
() =>
target.series
.map((p) => ({ t: toMs(p.t), value: p.value }))
.filter((p) => Number.isFinite(p.t))
.sort((a, b) => a.t - b.t),
[target.series],
);
const adherenceData = useMemo(
() =>
adherence
.map((p) => ({ t: toMs(p.date), rate: p.rate }))
.filter((p) => Number.isFinite(p.t))
.sort((a, b) => a.t - b.t),
[adherence],
);
const domain = useMemo<[number, number] | null>(() => {
const xs = [
...seriesData.map((p) => p.t),
...adherenceData.map((p) => p.t),
];
if (xs.length === 0) return null;
return [Math.min(...xs), Math.max(...xs)];
}, [seriesData, adherenceData]);
if (seriesData.length === 0 || !domain) return null;
// An open pause runs to the chart's right edge (the latest datum); using the
// domain max keeps the render pure (no `Date.now()` in the render body).
const rightEdge = domain[1];
return (
<div className="touch-pan-y">
<ResponsiveContainer width="100%" height={220}>
<LineChart margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
opacity={0.5}
/>
<XAxis
dataKey="t"
type="number"
scale="time"
domain={domain}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
tickFormatter={(v: number) => fmt.date(new Date(v))}
/>
<YAxis
yAxisId="target"
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
width={40}
unit={target.unit ? ` ${target.unit}` : undefined}
/>
<YAxis
yAxisId="adherence"
orientation="right"
domain={[0, 100]}
hide
/>
{/* Population reference band — faint context, labelled "typical
range", explicitly NOT a target line. */}
{target.referenceBand ? (
<ReferenceArea
yAxisId="target"
y1={target.referenceBand.low}
y2={target.referenceBand.high}
fill="var(--muted-foreground)"
fillOpacity={0.08}
stroke="none"
label={{
value: typicalRangeLabel,
position: "insideTopLeft",
fill: "var(--muted-foreground)",
fontSize: 10,
}}
/>
) : null}
{/* Shaded pause bands. An open pause runs to now. */}
{pauses.map((p, i) => (
<ReferenceArea
key={`pause-${i}`}
yAxisId="target"
x1={toMs(p.from)}
x2={p.to ? toMs(p.to) : rightEdge}
fill="var(--foreground)"
fillOpacity={0.06}
stroke="none"
/>
))}
{/* Adherence lane — faint, hidden right axis. */}
<Line
yAxisId="adherence"
data={adherenceData}
dataKey="rate"
name={adherenceLabel}
type="stepAfter"
stroke="var(--chart-4)"
strokeWidth={1.5}
strokeOpacity={0.5}
dot={false}
connectNulls
isAnimationActive={false}
/>
{/* The target series. */}
<Line
yAxisId="target"
data={seriesData}
dataKey="value"
name={target.label}
type="monotone"
stroke="var(--chart-1)"
strokeWidth={2}
dot={{ r: 2, fill: "var(--chart-1)" }}
activeDot={{ r: 4 }}
connectNulls
isAnimationActive={false}
/>
{/* Start marker. */}
{startMs !== null ? (
<ReferenceLine
yAxisId="target"
x={startMs}
stroke="var(--primary)"
strokeWidth={1.5}
label={{
value: startLabel,
position: "insideTopRight",
fill: "var(--foreground)",
fontSize: 10,
}}
/>
) : null}
{/* Dose-change markers — quiet, dashed. */}
{doseChanges.map((d, i) => (
<ReferenceLine
key={`dose-${i}`}
yAxisId="target"
x={toMs(d.at)}
stroke="var(--muted-foreground)"
strokeDasharray="4 4"
strokeOpacity={0.6}
/>
))}
<Tooltip
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
fontSize: "0.8125rem",
}}
labelFormatter={(v) => fmt.date(new Date(Number(v)))}
formatter={(value, name) => {
const num = Number(value);
const text =
name === adherenceLabel
? `${Math.round(num)}%`
: `${num}${target.unit ? ` ${target.unit}` : ""}`;
return [text, String(name)];
}}
/>
</LineChart>
</ResponsiveContainer>
</div>
);
}
@@ -0,0 +1,381 @@
"use client";
/**
* v1.28 — the "Wirkung" tab body: a strictly-descriptive, association-only
* view relating a medication to the outcome metric(s)/lab(s) its class is
* prescribed to move, around its start. Reads the server-authoritative
* efficacy DTO (`GET /api/medications/[id]/efficacy`) and renders it — it
* never recomputes a delta, mean, or adherence rate. No verdict, no score, no
* "working / not working" language, no dose advice: the copy is numbers +
* neutral connective phrasing only, mirroring the adherence-storyline posture.
*/
import { useMemo, useState } from "react";
import dynamic from "next/dynamic";
import { Activity, TrendingUp } from "lucide-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
import { TileHeader } from "@/components/insights/tile-header";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { queryKeys } from "@/lib/query-keys";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import type {
MedicationEfficacyDTO,
EfficacyTargetView,
} from "@/lib/medications/efficacy/build-efficacy";
const EfficacyChart = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.EfficacyChart,
})),
{ ssr: false, loading: () => <ChartSkeleton /> },
);
export function EfficacyTab({
medicationId,
active,
}: {
medicationId: string;
active: boolean;
}) {
const { t } = useTranslations();
const { user } = useAuth();
const timezone = user?.timezone || DEFAULT_TIMEZONE;
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: queryKeys.medicationEfficacy(medicationId),
queryFn: () =>
apiGet<MedicationEfficacyDTO>(
`/api/medications/${medicationId}/efficacy`,
),
enabled: active,
staleTime: 60_000,
});
if (isLoading || !data) {
return (
<MedicationDetailSection
titleId="medication-wirkung-heading"
title={t("medications.efficacy.title")}
dataSlot="medication-wirkung-section"
>
<ChartSkeleton />
</MedicationDetailSection>
);
}
// The tab is hidden upstream when ineligible; this is a defensive fallback.
if (!data.eligible) {
return (
<MedicationDetailSection
titleId="medication-wirkung-heading"
title={t("medications.efficacy.title")}
dataSlot="medication-wirkung-section"
>
<p className="text-muted-foreground text-sm">
{t("medications.efficacy.notEligible")}
</p>
</MedicationDetailSection>
);
}
return (
<div className="space-y-6" data-slot="medication-wirkung-section">
<MedicationDetailSection
titleId="medication-wirkung-heading"
title={t("medications.efficacy.title")}
dataSlot="medication-wirkung-intro"
>
<div className="space-y-3">
<p className="text-muted-foreground text-sm">
{t("medications.efficacy.intro")}
</p>
{data.markers.startSource === "firstReading" ? (
<p className="text-muted-foreground text-xs">
{t("medications.efficacy.startFallback")}
</p>
) : null}
{data.markers.start === null ? (
<p className="text-muted-foreground text-xs">
{t("medications.efficacy.noStart")}
</p>
) : null}
<RetargetControl
medicationId={medicationId}
options={data.overrideOptions}
isOverride={data.resolution.tier === "override"}
onChanged={() =>
queryClient.invalidateQueries({
queryKey: queryKeys.medicationEfficacy(medicationId),
})
}
/>
</div>
</MedicationDetailSection>
{data.targets.map((target) => (
<TargetCard
key={`${target.kind}:${target.key}`}
target={target}
dto={data}
timezone={timezone}
/>
))}
<p
className="text-muted-foreground text-xs"
data-slot="medication-wirkung-disclaimer"
>
{t("medications.efficacy.disclaimer")}
</p>
</div>
);
}
function TargetCard({
target,
dto,
timezone,
}: {
target: EfficacyTargetView;
dto: MedicationEfficacyDTO;
timezone: string;
}) {
const { t } = useTranslations();
const startMs = dto.markers.start ? Date.parse(dto.markers.start) : null;
return (
<Card data-slot={`medication-wirkung-target-${target.kind}`}>
<CardHeader>
<TileHeader
icon={TrendingUp}
title={target.label}
right={
target.primary ? (
<span className="text-muted-foreground text-xs">
{t("medications.efficacy.primaryBadge")}
</span>
) : undefined
}
/>
</CardHeader>
<CardContent className="space-y-4">
{target.series.length > 0 ? (
<EfficacyChart
target={{
label: target.label,
unit: target.unit,
referenceBand: target.referenceBand,
series: target.series.map((p) => ({ t: p.t, value: p.value })),
}}
startMs={startMs}
doseChanges={dto.markers.doseChanges}
pauses={dto.markers.pauses}
adherence={dto.adherence.map((a) => ({
date: a.date,
rate: a.rate,
}))}
timezone={timezone}
startLabel={t("medications.efficacy.startMarker")}
adherenceLabel={t("medications.efficacy.adherenceLane")}
typicalRangeLabel={t("medications.efficacy.typicalRange")}
/>
) : (
<p className="text-muted-foreground text-sm">
{t("medications.efficacy.noSeries")}
</p>
)}
<BeforeAfterCard target={target} minWeeks={dto.minWeeksPerSide} />
{target.levelShift?.present && target.levelShift.nearStart ? (
<p
className="text-muted-foreground text-xs"
data-slot="medication-wirkung-levelshift"
>
{t("medications.efficacy.levelShift", {
date: formatShort(target.levelShift.at),
})}
</p>
) : null}
</CardContent>
</Card>
);
}
function BeforeAfterCard({
target,
minWeeks,
}: {
target: EfficacyTargetView;
minWeeks: number;
}) {
const { t } = useTranslations();
const ba = target.beforeAfter;
const unit = target.unit ? ` ${target.unit}` : "";
if (!ba.present || !ba.before || !ba.after || !ba.delta) {
const reasonKey =
ba.reason === "insufficient_before"
? "medications.efficacy.beforeAfter.needBefore"
: ba.reason === "insufficient_after"
? "medications.efficacy.beforeAfter.needAfter"
: ba.reason === "no_start"
? "medications.efficacy.beforeAfter.needStart"
: "medications.efficacy.beforeAfter.needData";
return (
<div
className="border-border/60 rounded-lg border border-dashed p-3"
data-slot="medication-wirkung-beforeafter-empty"
>
<p className="text-muted-foreground text-sm">
{t(reasonKey, { weeks: minWeeks })}
</p>
</div>
);
}
const arrow = ba.delta.mean > 0 ? "+" : "";
return (
<div
className="bg-muted/40 rounded-lg p-3"
data-slot="medication-wirkung-beforeafter"
>
<p className="text-foreground text-sm">
{t("medications.efficacy.beforeAfter.summary", {
before: `${ba.before.mean}${unit}`,
after: `${ba.after.mean}${unit}`,
delta: `${arrow}${ba.delta.mean}${unit}`,
})}
</p>
<p className="text-muted-foreground mt-1 text-xs">
{t("medications.efficacy.beforeAfter.counts", {
beforeCount: ba.before.count,
afterCount: ba.after.count,
})}
</p>
</div>
);
}
function RetargetControl({
medicationId,
options,
isOverride,
onChanged,
}: {
medicationId: string;
options: MedicationEfficacyDTO["overrideOptions"];
isOverride: boolean;
onChanged: () => void;
}) {
const { t } = useTranslations();
const [value, setValue] = useState<string>("");
const [busy, setBusy] = useState(false);
const items = useMemo(
() => [
...options.metrics.map((m) => ({
value: `metric:${m.key}`,
label: m.label,
})),
...options.biomarkers.map((b) => ({
value: `lab:${b.id}`,
label: b.label,
})),
],
[options],
);
const apply = async (clear: boolean) => {
setBusy(true);
try {
const body: {
clear?: boolean;
measurementType?: string;
biomarkerId?: string;
} = {};
if (clear) {
body.clear = true;
} else if (value.startsWith("metric:")) {
body.measurementType = value.slice("metric:".length);
} else if (value.startsWith("lab:")) {
body.biomarkerId = value.slice("lab:".length);
} else {
setBusy(false);
return;
}
await apiPut(`/api/medications/${medicationId}/efficacy/target`, body);
onChanged();
if (clear) setValue("");
} finally {
setBusy(false);
}
};
if (items.length === 0) return null;
return (
<div
className="flex flex-wrap items-center gap-2"
data-slot="medication-wirkung-retarget"
>
<Activity aria-hidden="true" className="text-muted-foreground h-4 w-4" />
<span className="text-muted-foreground text-xs">
{t("medications.efficacy.retarget.label")}
</span>
<Select value={value} onValueChange={setValue}>
<SelectTrigger size="sm" className="w-56">
<SelectValue
placeholder={t("medications.efficacy.retarget.placeholder")}
/>
</SelectTrigger>
<SelectContent>
{items.map((it) => (
<SelectItem key={it.value} value={it.value}>
{it.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="outline"
disabled={busy || value === ""}
onClick={() => apply(false)}
>
{t("medications.efficacy.retarget.apply")}
</Button>
{isOverride ? (
<Button
size="sm"
variant="ghost"
disabled={busy}
onClick={() => apply(true)}
>
{t("medications.efficacy.retarget.reset")}
</Button>
) : null}
</div>
);
}
function formatShort(iso?: string): string {
if (!iso) return "";
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? "" : d.toISOString().slice(0, 10);
}
@@ -46,6 +46,8 @@ import { ArrowLeft, Pencil } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { MedicationDetailSummary } from "@/components/medications/medication-detail-summary";
import { EfficacyTab } from "@/components/medications/detail/efficacy/efficacy-tab";
import { resolveMedicationTargets } from "@/lib/medications/med-target-map";
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
import { MedicationComplianceBars } from "@/components/medications/card-parts/medication-compliance-bars";
import { DoseHistoryLedger } from "@/components/medications/dose-history-ledger";
@@ -157,6 +159,7 @@ export interface MedicationDetailSnapshot {
// the tab strip render and the `?tab=` round-trip validation.
const TAB_SLUGS = [
"uebersicht",
"wirkung",
"zeitplan",
"bestand",
"verlauf",
@@ -236,6 +239,21 @@ export function MedicationDetailTabs({
const isGlp1 = !oneShot && medication.treatmentClass === "GLP1";
const isInjectable = medication.deliveryForm === "INJECTION";
// v1.28 — the "Wirkung" tab appears only when the medication resolves to a
// known outcome target (ATC class prefix → whole-word name inference) and is
// not a single-dose med. Same pure resolver the server efficacy builder's
// derived tier uses, so the tab's presence agrees with the DTO's eligibility.
const hasEfficacyTarget = useMemo(
() =>
!oneShot &&
resolveMedicationTargets({
name: medication.name,
treatmentClass: medication.treatmentClass,
atcCode: medication.atcCode,
}) !== null,
[oneShot, medication.name, medication.treatmentClass, medication.atcCode],
);
const payload = useMemo(
() => snapshotToWizardPayload(medication),
[medication],
@@ -254,9 +272,10 @@ export function MedicationDetailTabs({
TAB_SLUGS.filter(
(slug) =>
(slug !== "injektion" || isInjectable) &&
(slug !== "zeitplan" || !asNeeded),
(slug !== "zeitplan" || !asNeeded) &&
(slug !== "wirkung" || hasEfficacyTarget),
),
[isInjectable, asNeeded],
[isInjectable, asNeeded, hasEfficacyTarget],
);
const requestedRaw = searchParams?.get("tab");
@@ -535,6 +554,16 @@ export function MedicationDetailTabs({
)}
</TabsContent>
{/* WIRKUNG — the per-medication efficacy view: the outcome metric(s)/
lab(s) the drug's class targets, over the span around its start,
with a before/after comparison + adherence lane. Strictly
descriptive; hidden for one-shot + no-target medications. */}
{hasEfficacyTarget && (
<TabsContent value="wirkung" className="space-y-6 pt-2">
<EfficacyTab medicationId={id} active={activeTab === "wirkung"} />
</TabsContent>
)}
{/* ZEITPLAN — inline edit of the everyday levers: dose times +
each dose's on-time window, then the Erinnerung card (the
notifications switch + the reminder grace, folded in from the
@@ -3,6 +3,8 @@ import {
inferMedTargetClass,
primaryTargetForClass,
MED_TARGET_MAP,
resolveMedicationTargets,
targetsForEfficacyClass,
} from "@/lib/medications/med-target-map";
describe("med-target-map — class inference", () => {
@@ -56,3 +58,83 @@ describe("med-target-map — targets", () => {
expect(MED_TARGET_MAP.glp1).toEqual(["BLOOD_GLUCOSE", "WEIGHT"]);
});
});
describe("resolveMedicationTargets — three-tier efficacy resolution", () => {
it("prefers the ATC class prefix over the name", () => {
// C09 (ACE/ARB) → blood pressure, even when the name is unrecognised.
const r = resolveMedicationTargets({
name: "SomeUnknownBrand",
atcCode: "C09AA05",
});
expect(r?.tier).toBe("atc");
expect(r?.cls).toBe("antihypertensive");
expect(r?.targets[0]).toEqual({
kind: "metric",
measurementType: "BLOOD_PRESSURE_SYS",
});
});
it("maps the lipid-modifier ATC class to an LDL lab target", () => {
const r = resolveMedicationTargets({ name: "x", atcCode: "C10AA05" });
expect(r?.cls).toBe("statin");
expect(r?.targets[0]).toEqual({
kind: "lab",
analyte: "LDL",
label: "LDL cholesterol",
});
});
it("routes the GLP-1 ATC leaf to glucose+weight, not the A10 fallback", () => {
const r = resolveMedicationTargets({ name: "x", atcCode: "A10BJ06" });
expect(r?.cls).toBe("glp1");
const other = resolveMedicationTargets({ name: "x", atcCode: "A10BA02" });
expect(other?.cls).toBe("antidiabetic");
});
it("maps thyroid ATC to a TSH lab target", () => {
const r = resolveMedicationTargets({ name: "x", atcCode: "H03AA01" });
expect(r?.cls).toBe("thyroid");
expect(r?.targets[0].kind).toBe("lab");
});
it("falls back to name inference when no valid ATC code is present", () => {
const r = resolveMedicationTargets({ name: "Atorvastatin 20mg" });
expect(r?.tier).toBe("name");
expect(r?.cls).toBe("statin");
const bp = resolveMedicationTargets({ name: "Ramipril" });
expect(bp?.tier).toBe("name");
expect(bp?.cls).toBe("antihypertensive");
});
it("infers supplement lab targets by name", () => {
expect(resolveMedicationTargets({ name: "Cholecalciferol" })?.cls).toBe(
"vitamin_d",
);
expect(
resolveMedicationTargets({ name: "Ferrous sulfate 200mg" })?.cls,
).toBe("iron");
});
it("ignores a malformed ATC code and falls through to the name", () => {
const r = resolveMedicationTargets({
name: "Ramipril",
atcCode: "not-an-atc",
});
expect(r?.tier).toBe("name");
expect(r?.cls).toBe("antihypertensive");
});
it("conservative-fails to null for an unknown med with no ATC", () => {
expect(resolveMedicationTargets({ name: "Aspirin" })).toBeNull();
expect(resolveMedicationTargets({ name: "" })).toBeNull();
});
it("exposes the ordered target list per efficacy class", () => {
expect(targetsForEfficacyClass("antihypertensive")).toHaveLength(2);
expect(targetsForEfficacyClass("thyroid")[0]).toEqual({
kind: "lab",
analyte: "TSH",
label: "TSH",
});
});
});
@@ -0,0 +1,64 @@
import { describe, it, expect } from "vitest";
import { beforeAfterFromSeries } from "@/lib/medications/efficacy/build-efficacy";
const DAY = 24 * 60 * 60 * 1000;
const pivot = Date.parse("2026-03-01T12:00:00Z");
/** Build a run of daily points at a fixed value ending `endOffsetDays` from pivot. */
function run(
value: number,
count: number,
startOffsetDays: number,
): { at: number; value: number }[] {
return Array.from({ length: count }, (_, i) => ({
at: pivot + (startOffsetDays + i) * DAY,
value,
}));
}
describe("beforeAfterFromSeries — honest before/after card", () => {
it("returns no_start when there is no pivot", () => {
expect(beforeAfterFromSeries(run(120, 20, -10), null)).toEqual({
present: false,
reason: "no_start",
});
});
it("flags insufficient_before below the per-side floor", () => {
// Only 2 readings before the pivot (floor is 5).
const before = run(150, 2, -3);
const after = run(132, 10, 0);
const r = beforeAfterFromSeries([...before, ...after], pivot);
expect(r.present).toBe(false);
expect(r.reason).toBe("insufficient_before");
});
it("flags insufficient_after below the per-side floor", () => {
const before = run(150, 10, -20);
const after = run(132, 2, 0);
const r = beforeAfterFromSeries([...before, ...after], pivot);
expect(r.present).toBe(false);
expect(r.reason).toBe("insufficient_after");
});
it("computes a neutral before/after delta when both sides clear the floor", () => {
const before = run(150, 8, -20); // within the 56-day before window
const after = run(132, 8, 0);
const r = beforeAfterFromSeries([...before, ...after], pivot);
expect(r.present).toBe(true);
expect(r.before?.mean).toBe(150);
expect(r.after?.mean).toBe(132);
expect(r.delta?.mean).toBe(-18);
expect(r.delta?.pct).toBe(-12);
});
it("excludes readings older than the 56-day before window", () => {
// 8 readings but far in the past (beyond the 56-day reach): the before
// side falls under the floor, so no fabricated delta.
const before = run(150, 8, -120);
const after = run(132, 8, 0);
const r = beforeAfterFromSeries([...before, ...after], pivot);
expect(r.present).toBe(false);
expect(r.reason).toBe("insufficient_before");
});
});
@@ -0,0 +1,662 @@
/**
* v1.28 — server-authoritative medication-efficacy builder.
*
* Composes ONE resolved DTO relating a medication to the outcome metric(s) /
* lab(s) its class is prescribed to move, over the span around its start:
*
* target resolution (override → ATC-prefix → name, `med-target-map`)
* → the target's DAY-mean series (rollup tier) or lab trajectory
* → a before / after-start comparison over the SAME series
* → an adherence lane from `dailyComplianceRatesFromLedger` (never recomputed)
* → an optional conservative level-shift note (`detectChangepoints`)
*
* Strictly descriptive. There is NO verdict / score / "working" field on the
* DTO by construction — the view can only render numbers + neutral connective
* phrasing. Honest empty states below the data floor (need N readings before
* AND after the start). Mirrors the `adherence-storyline` safety posture:
* association-only, never causal, never dose advice. iOS renders the DTO; it
* never recomputes a delta or a mean (parity rule).
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
import { readDayMeanSeries } from "@/lib/insights/derived/baseline";
import {
buildComplianceMedicationContext,
buildMedicationComplianceBundle,
dailyComplianceRatesFromLedger,
lastNonSkippedTakenAt,
} from "@/lib/analytics/compliance";
import {
resolveRichMetric,
getLabHistory,
detectChangepoints,
} from "@/lib/mcp/rich-reads";
import {
resolveMedicationTargets,
type MedTarget,
type MedTargetTier,
} from "@/lib/medications/med-target-map";
const DAY_MS = 24 * 60 * 60 * 1000;
/** Weeks of target data required on EACH side of the start for the delta. */
const MIN_WEEKS_PER_SIDE = 4;
/** Distinct readings required on each side before a before/after delta shows. */
const MIN_READINGS_PER_SIDE = 5;
/** The before-window the comparison reaches back over from the start. */
const BEFORE_WINDOW_DAYS = 56;
/** Series ceiling — the rollup tier caps timelines at one year. */
const MAX_WINDOW_DAYS = 365;
/** A detected level shift within this many days of the start reads "near". */
const CHANGEPOINT_NEAR_START_DAYS = 21;
export interface EfficacySeriesPoint {
/** ISO date (YYYY-MM-DD for a metric day-bucket, full instant for a lab). */
t: string;
value: number;
/** Lab-only per-point placement against the reference range. */
status?: "in-range" | "below" | "above" | "unknown";
}
export interface EfficacyBeforeAfter {
present: boolean;
/** Why the delta is absent — mirrors the tools' honest `{present:false}`. */
reason?:
"insufficient_before" | "insufficient_after" | "no_start" | "no_data";
before?: { mean: number; count: number; from: string; to: string };
after?: { mean: number; count: number; from: string; to: string };
/** after − before; `pct` relative to before, null when before is 0. */
delta?: { mean: number; pct: number | null } | null;
}
export interface EfficacyLevelShift {
present: boolean;
/** Bucket-start ISO of the detected shift, when one fired. */
at?: string;
/** True when the shift sits within tolerance of the start (coincidence). */
nearStart?: boolean;
}
export interface EfficacyTargetView {
kind: "metric" | "lab";
/** MeasurementType (metric) or the analyte needle (lab). */
key: string;
label: string;
unit: string | null;
primary: boolean;
referenceBand: { low: number; high: number } | null;
series: EfficacySeriesPoint[];
beforeAfter: EfficacyBeforeAfter;
/** Metric targets only; null for lab targets (changepoint reads metrics). */
levelShift: EfficacyLevelShift | null;
}
export interface EfficacyMarkers {
/** The pivot instant (ISO date) — `startsOn`, else the first-reading fallback. */
start: string | null;
startSource: "startsOn" | "firstReading" | null;
doseChanges: { at: string; label: string }[];
pauses: { from: string; to: string | null }[];
}
export interface EfficacyAdherencePoint {
date: string;
rate: number;
taken: number;
missed: number;
}
export interface MedicationEfficacyDTO {
medicationId: string;
medicationName: string;
/** False → the "Wirkung" tab is hidden (oneShot / no target & no override). */
eligible: boolean;
/** Why the view is not eligible, when it is not. */
reason?: "one_shot" | "no_target";
startsOn: string | null;
resolution: {
tier: MedTargetTier | "override" | "none";
cls: string | null;
};
windowDays: number;
minWeeksPerSide: number;
markers: EfficacyMarkers;
targets: EfficacyTargetView[];
adherence: EfficacyAdherencePoint[];
/** The "not right? pick another" chooser source (user's own signals). */
overrideOptions: {
metrics: { key: string; label: string }[];
biomarkers: { id: string; label: string; unit: string }[];
};
}
/** Curated retarget metrics the fallback chooser offers (client localises). */
const OVERRIDE_METRIC_OPTIONS: { key: MeasurementType; label: string }[] = [
{ key: "BLOOD_PRESSURE_SYS", label: "Systolic blood pressure" },
{ key: "BLOOD_PRESSURE_DIA", label: "Diastolic blood pressure" },
{ key: "BLOOD_GLUCOSE", label: "Blood glucose" },
{ key: "WEIGHT", label: "Weight" },
{ key: "PULSE", label: "Pulse" },
{ key: "BODY_MASS_INDEX", label: "Body-mass index" },
];
/** Fixed reference bands for the metric targets `resolveRichMetric` omits. */
const METRIC_META_FALLBACK: Partial<
Record<
MeasurementType,
{ label: string; unit: string; band: { low: number; high: number } | null }
>
> = {
BLOOD_PRESSURE_SYS: {
label: "Systolic blood pressure",
unit: "mmHg",
band: { low: 90, high: 120 },
},
BLOOD_PRESSURE_DIA: {
label: "Diastolic blood pressure",
unit: "mmHg",
band: { low: 60, high: 80 },
},
};
function resolveMetricMeta(mt: MeasurementType): {
label: string;
unit: string | null;
band: { low: number; high: number } | null;
} {
const rich = resolveRichMetric(mt);
if (rich) return { label: rich.label, unit: rich.unit, band: rich.band };
const fb = METRIC_META_FALLBACK[mt];
if (fb) return fb;
return { label: mt, unit: null, band: null };
}
const dayToInstant = (day: string): number =>
new Date(`${day}T12:00:00Z`).getTime();
function meanOf(xs: number[]): number {
if (xs.length === 0) return 0;
return xs.reduce((s, x) => s + x, 0) / xs.length;
}
const round1 = (n: number): number => Math.round(n * 10) / 10;
/**
* Split a chronological value+instant series into before/after the pivot and
* emit the honest before/after card. Below the per-side floor the card carries
* `{present:false}` with the reason — never a fabricated delta.
*/
export function beforeAfterFromSeries(
points: { at: number; value: number }[],
pivotMs: number | null,
): EfficacyBeforeAfter {
if (pivotMs === null) return { present: false, reason: "no_start" };
const beforeFloor = pivotMs - BEFORE_WINDOW_DAYS * DAY_MS;
const before = points.filter((p) => p.at >= beforeFloor && p.at < pivotMs);
const after = points.filter((p) => p.at >= pivotMs);
if (before.length < MIN_READINGS_PER_SIDE) {
return { present: false, reason: "insufficient_before" };
}
if (after.length < MIN_READINGS_PER_SIDE) {
return { present: false, reason: "insufficient_after" };
}
const beforeMean = meanOf(before.map((p) => p.value));
const afterMean = meanOf(after.map((p) => p.value));
const diff = afterMean - beforeMean;
return {
present: true,
before: {
mean: round1(beforeMean),
count: before.length,
from: new Date(before[0].at).toISOString(),
to: new Date(before[before.length - 1].at).toISOString(),
},
after: {
mean: round1(afterMean),
count: after.length,
from: new Date(after[0].at).toISOString(),
to: new Date(after[after.length - 1].at).toISOString(),
},
delta: {
mean: round1(diff),
pct:
beforeMean !== 0 ? round1((diff / Math.abs(beforeMean)) * 100) : null,
},
};
}
/** Build the metric-target view (day-mean series + before/after + changepoint). */
async function buildMetricTarget(
userId: string,
mt: MeasurementType,
primary: boolean,
pivotMs: number | null,
windowDays: number,
now: Date,
coverage: Awaited<ReturnType<typeof probeRollupCoverage>>,
): Promise<EfficacyTargetView> {
const meta = resolveMetricMeta(mt);
const { points } = await readDayMeanSeries(
userId,
mt,
windowDays,
now,
coverage,
);
const series: EfficacySeriesPoint[] = points.map((p) => ({
t: p.day,
value: round1(p.mean),
}));
const straddle = points.map((p) => ({
at: dayToInstant(p.day),
value: p.mean,
}));
// Level-shift note — metric targets only, and only when the metric resolves
// through the rich-read resolver (blood pressure is multi-series and does
// not, so it carries no changepoint note by construction).
let levelShift: EfficacyLevelShift | null = null;
if (resolveRichMetric(mt)) {
const cp = await detectChangepoints(userId, {
metric: mt,
window: "lastYear",
});
if (
cp.present &&
cp.changepoints &&
cp.changepoints.length > 0 &&
pivotMs !== null
) {
const nearest = cp.changepoints
.map((c) => ({ at: c.at, dist: Math.abs(Date.parse(c.at) - pivotMs) }))
.sort((a, b) => a.dist - b.dist)[0];
levelShift = {
present: true,
at: nearest.at,
nearStart: nearest.dist <= CHANGEPOINT_NEAR_START_DAYS * DAY_MS,
};
} else {
levelShift = { present: false };
}
}
return {
kind: "metric",
key: mt,
label: meta.label,
unit: meta.unit,
primary,
referenceBand: meta.band,
series,
beforeAfter: beforeAfterFromSeries(straddle, pivotMs),
levelShift,
};
}
/** Build the lab-target view (analyte trajectory + before/after over readings). */
async function buildLabTarget(
userId: string,
analyte: string,
label: string,
primary: boolean,
pivotMs: number | null,
): Promise<EfficacyTargetView> {
const hist = await getLabHistory(userId, { analyte, limit: 50 });
const readings = (hist.present ? (hist.readings ?? []) : [])
.filter((r) => r.value !== null)
.slice()
// getLabHistory returns newest-first; the view + before/after want oldest-first.
.sort((a, b) => Date.parse(a.takenAt) - Date.parse(b.takenAt));
const unit = readings[0]?.unit ?? null;
const withBounds = readings.find(
(r) => r.referenceLow !== null || r.referenceHigh !== null,
);
const referenceBand =
withBounds &&
withBounds.referenceLow !== null &&
withBounds.referenceHigh !== null
? { low: withBounds.referenceLow, high: withBounds.referenceHigh }
: null;
const series: EfficacySeriesPoint[] = readings.map((r) => ({
t: r.takenAt,
value: r.value as number,
status: r.rangeStatus,
}));
const straddle = readings.map((r) => ({
at: Date.parse(r.takenAt),
value: r.value as number,
}));
return {
kind: "lab",
key: analyte,
label,
unit,
primary,
referenceBand,
series,
beforeAfter: beforeAfterFromSeries(straddle, pivotMs),
levelShift: null,
};
}
/**
* Resolve the effective targets for a medication: the user's persisted
* 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: {
id: string;
name: string;
treatmentClass: string;
atcCode: string | null;
}): Promise<{
tier: MedTargetTier | "override" | "none";
cls: string | null;
targets: MedTarget[];
}> {
const overrides = await prisma.medicationEfficacyTarget.findMany({
where: { medicationId: med.id },
orderBy: [{ primary: "desc" }, { createdAt: "asc" }],
select: {
measurementType: true,
primary: true,
biomarker: { select: { name: true } },
},
});
const overrideTargets: MedTarget[] = [];
for (const row of overrides) {
if (row.measurementType) {
overrideTargets.push({
kind: "metric",
measurementType: row.measurementType,
});
} else if (row.biomarker) {
overrideTargets.push({
kind: "lab",
analyte: row.biomarker.name,
label: row.biomarker.name,
});
}
}
if (overrideTargets.length > 0) {
return { tier: "override", cls: null, targets: overrideTargets };
}
const derived = resolveMedicationTargets({
name: med.name,
treatmentClass: med.treatmentClass,
atcCode: med.atcCode,
});
if (derived) {
return {
tier: derived.tier,
cls: derived.cls,
targets: [...derived.targets],
};
}
return { tier: "none", cls: null, targets: [] };
}
/**
* Build the full efficacy DTO for one medication the caller owns. The caller
* (route / server component) is responsible for the ownership gate; this
* builder reads only rows scoped to `userId` / the owned medication.
*/
export async function buildMedicationEfficacy(
userId: string,
medicationId: string,
timezone: string,
now: Date = new Date(),
): Promise<MedicationEfficacyDTO | null> {
const med = await prisma.medication.findFirst({
where: { id: medicationId, userId },
include: {
schedules: true,
scheduleRevisions: { orderBy: { validFrom: "asc" } },
pauseEras: { select: { pausedAt: true, resumedAt: true } },
doseChanges: { orderBy: { effectiveFrom: "asc" } },
},
});
if (!med) return null;
const startsOnIso = med.startsOn ? med.startsOn.toISOString() : null;
const baseDto = {
medicationId: med.id,
medicationName: med.name,
startsOn: startsOnIso,
minWeeksPerSide: MIN_WEEKS_PER_SIDE,
};
// One-shot meds have a single administration and no trend to relate.
if (med.oneShot) {
annotate({
action: {
name: "medication.efficacy.build",
entity_type: "medication",
entity_id: med.id,
},
meta: { eligible: false, reason: "one_shot" },
});
return {
...baseDto,
eligible: false,
reason: "one_shot",
resolution: { tier: "none", cls: null },
windowDays: 0,
markers: { start: null, startSource: null, doseChanges: [], pauses: [] },
targets: [],
adherence: [],
overrideOptions: { metrics: [], biomarkers: [] },
};
}
const resolved = await resolveEffectiveTargets({
id: med.id,
name: med.name,
treatmentClass: med.treatmentClass,
atcCode: med.atcCode ?? null,
});
// The user's own retarget options (fallback chooser) — always attached.
const biomarkerRows = await prisma.biomarker.findMany({
where: { userId, hidden: false },
orderBy: { name: "asc" },
select: { id: true, name: true, unit: true },
});
const overrideOptions = {
metrics: OVERRIDE_METRIC_OPTIONS.map((m) => ({
key: m.key,
label: m.label,
})),
biomarkers: biomarkerRows.map((b) => ({
id: b.id,
label: b.name,
unit: b.unit,
})),
};
if (resolved.targets.length === 0) {
annotate({
action: {
name: "medication.efficacy.build",
entity_type: "medication",
entity_id: med.id,
},
meta: { eligible: false, reason: "no_target" },
});
return {
...baseDto,
eligible: false,
reason: "no_target",
resolution: { tier: "none", cls: null },
windowDays: 0,
markers: { start: null, startSource: null, doseChanges: [], pauses: [] },
targets: [],
adherence: [],
overrideOptions,
};
}
// Pivot: `startsOn` when present; else the first reading of the primary
// target (captioned as a fallback); else null (the before/after card then
// renders the honest "set a start date" empty state).
const coverage = await probeRollupCoverage(userId);
let pivotMs = med.startsOn ? med.startsOn.getTime() : null;
let startSource: "startsOn" | "firstReading" | null = med.startsOn
? "startsOn"
: null;
// Establish a window that spans well before the pivot through today.
const spanFromPivot = pivotMs !== null ? now.getTime() - pivotMs : 0;
const windowDays = Math.min(
MAX_WINDOW_DAYS,
Math.max(
90,
Math.ceil((spanFromPivot + BEFORE_WINDOW_DAYS * DAY_MS) / DAY_MS),
),
);
// Build each target view. Fallback pivot uses the primary metric's first day.
const targets: EfficacyTargetView[] = [];
for (let i = 0; i < resolved.targets.length; i++) {
const target = resolved.targets[i];
const primary = i === 0;
if (target.kind === "metric") {
const view = await buildMetricTarget(
userId,
target.measurementType,
primary,
pivotMs,
windowDays,
now,
coverage,
);
// Legacy no-start med: fall back to the primary metric's first reading.
if (primary && pivotMs === null && view.series.length > 0) {
pivotMs = dayToInstant(view.series[0].t);
startSource = "firstReading";
view.beforeAfter = beforeAfterFromSeries(
view.series.map((p) => ({ at: dayToInstant(p.t), value: p.value })),
pivotMs,
);
}
targets.push(view);
} else {
const view = await buildLabTarget(
userId,
target.analyte,
target.label,
primary,
pivotMs,
);
if (primary && pivotMs === null && view.series.length > 0) {
pivotMs = Date.parse(view.series[0].t);
startSource = "firstReading";
view.beforeAfter = beforeAfterFromSeries(
view.series.map((p) => ({ at: Date.parse(p.t), value: p.value })),
pivotMs,
);
}
targets.push(view);
}
}
// Adherence lane — the cadence-aware per-day rate from the SAME ledger the
// compliance surfaces read; never recomputed here.
const fetchFrom = new Date(
Math.max(med.createdAt.getTime(), now.getTime() - windowDays * DAY_MS),
);
const events = await prisma.medicationIntakeEvent.findMany({
where: {
medicationId: med.id,
userId,
deletedAt: null,
scheduledFor: { gte: fetchFrom },
},
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,
med.schedules,
ctx,
now,
);
const windowFloorMs =
pivotMs !== null
? pivotMs - BEFORE_WINDOW_DAYS * DAY_MS
: fetchFrom.getTime();
const adherence: EfficacyAdherencePoint[] = dailyComplianceRatesFromLedger(
bundle.ledgerRows,
timezone,
)
.filter((d) => d.date.getTime() >= windowFloorMs)
.map((d) => ({
date: d.date.toISOString(),
rate: d.rate,
taken: d.taken,
missed: d.missed,
}));
// Markers — dose changes + pause bands intersecting the window.
const doseChanges = med.doseChanges
.filter((dc) => dc.effectiveFrom.getTime() >= windowFloorMs)
.map((dc) => ({
at: dc.effectiveFrom.toISOString(),
label: `${dc.doseValue} ${dc.doseUnit}`.trim(),
}));
const pauses = med.pauseEras
.filter((p) => (p.resumedAt?.getTime() ?? now.getTime()) >= windowFloorMs)
.map((p) => ({
from: p.pausedAt.toISOString(),
to: p.resumedAt ? p.resumedAt.toISOString() : null,
}));
annotate({
action: {
name: "medication.efficacy.build",
entity_type: "medication",
entity_id: med.id,
},
meta: {
eligible: true,
tier: resolved.tier,
cls: resolved.cls,
targetCount: targets.length,
startSource: startSource ?? "none",
windowDays,
},
});
return {
...baseDto,
eligible: true,
resolution: { tier: resolved.tier, cls: resolved.cls },
windowDays,
markers: {
start: pivotMs !== null ? new Date(pivotMs).toISOString() : null,
startSource,
doseChanges,
pauses,
},
targets,
adherence,
overrideOptions,
};
}
+196
View File
@@ -158,3 +158,199 @@ export function inferMedTargetClass(
}
return null;
}
// ─── v1.28 efficacy resolver (extends the closed map above) ───────────
//
// The efficacy view ("Wirkung" tab + the Insights summary) needs a target
// resolver that (a) can point at LAB analytes (statin→LDL, thyroid→TSH,
// supplements→their marker) — the documented follow-on the v1.22 map left
// out — and (b) consults the WHO ATC class prefix on `Medication.atcCode`
// before falling back to name inference. It is ADDITIVE: the metric-only
// `MED_TARGET_MAP` / `inferMedTargetClass` / `primaryTargetForClass` above
// stay byte-identical so the adherence-storyline safety path is untouched.
//
// Same discipline as the map above: closed tables, whole-word / fixed-prefix
// matching, conservative-fail to an EMPTY target list (never a guess). The
// clinical association ("this class is prescribed to move this outcome") is
// documented, guideline-cited and vendor-blind in the external knowledge base
// (`medications/drug-class-targets.md`); this code is its mechanical mirror.
// No efficacy claim is encoded here — only what a class is prescribed to move.
/**
* A resolved target: either a first-class measurement series or a lab analyte.
* The lab variant carries a `contains`-match needle (matched against a
* `LabResult.analyte` or the linked `Biomarker.name`, exactly as
* `getLabHistory` matches) plus a human label for the DTO / UI.
*/
export type MedTarget =
| { kind: "metric"; measurementType: MeasurementType }
| { kind: "lab"; analyte: string; label: string };
/**
* The wider class set the efficacy resolver reasons about: the three
* metric classes above plus the lab-analyte classes (statin→LDL,
* thyroid→TSH) and the two supplement markers named in the plan.
*/
export type EfficacyMedClass =
MedTargetClass | "statin" | "thyroid" | "vitamin_d" | "iron";
/** Class → its ordered target list (primary first). Closed + conservative. */
const EFFICACY_TARGETS: Readonly<
Record<EfficacyMedClass, readonly MedTarget[]>
> = {
antihypertensive: [
{ kind: "metric", measurementType: "BLOOD_PRESSURE_SYS" },
{ kind: "metric", measurementType: "BLOOD_PRESSURE_DIA" },
],
antidiabetic: [{ kind: "metric", measurementType: "BLOOD_GLUCOSE" }],
glp1: [
{ kind: "metric", measurementType: "WEIGHT" },
{ kind: "metric", measurementType: "BLOOD_GLUCOSE" },
],
// Lipid-modifiers (statins et al.) are monitored on LDL — a lab analyte.
statin: [{ kind: "lab", analyte: "LDL", label: "LDL cholesterol" }],
// Thyroid therapy is titrated against TSH — a lab analyte.
thyroid: [{ kind: "lab", analyte: "TSH", label: "TSH" }],
// Supplements are tracked against their own marker.
vitamin_d: [
{ kind: "lab", analyte: "Vitamin D", label: "Vitamin D (25-OH)" },
],
iron: [{ kind: "lab", analyte: "Ferritin", label: "Ferritin" }],
} as const;
/**
* WHO ATC class-prefix → efficacy class. Prefix-level ONLY (the class the
* substance belongs to), never a specific-product claim. Longer prefixes are
* tested first so `A10BJ` (GLP-1/GIP agonists) wins over the `A10` fallback.
* The prefix is upper-cased + validated against the `atcCode` shape before use.
*/
const ATC_PREFIX_CLASS: ReadonlyArray<readonly [string, EfficacyMedClass]> = [
// Antidiabetics: GLP-1 / GIP agonists move glucose AND weight; the rest of
// A10 (metformin, sulfonylureas, SGLT2, DPP-4, insulin) move glucose.
["A10BJ", "glp1"],
["A10", "antidiabetic"],
// Cardiovascular: antihypertensives (C02 other, C03 diuretics, C07 beta
// blockers, C08 CCB, C09 ACE/ARB) → blood pressure.
["C02", "antihypertensive"],
["C03", "antihypertensive"],
["C07", "antihypertensive"],
["C08", "antihypertensive"],
["C09", "antihypertensive"],
// Lipid-modifying agents → LDL.
["C10", "statin"],
// Thyroid therapy → TSH.
["H03A", "thyroid"],
// Vitamin D / analogues (A11CC) and vitamin-D-only combos (A11CB).
["A11CC", "vitamin_d"],
["A11CB", "vitamin_d"],
// Iron preparations (oral B03AA/AB/AD/AE, parenteral B03AC).
["B03A", "iron"],
];
const ATC_CODE_RE = /^[A-Z]\d{2}[A-Z]{2}\d{2}$/;
/** Whole-word name needles for the lab classes (metric classes stay above). */
const STATIN_NEEDLES: readonly string[] = [
"atorvastatin",
"simvastatin",
"rosuvastatin",
"pravastatin",
"fluvastatin",
"lovastatin",
"pitavastatin",
"ezetimibe",
];
const THYROID_NEEDLES: readonly string[] = [
"levothyroxine",
"liothyronine",
"thyroxine",
"euthyrox",
];
const VITAMIN_D_NEEDLES: readonly string[] = [
"cholecalciferol",
"colecalciferol",
"ergocalciferol",
"calcifediol",
"calcitriol",
];
const IRON_NEEDLES: readonly string[] = [
"ferrous",
"ferric",
"iron bisglycinate",
];
/** Resolve a med's efficacy class from its ATC prefix, or `null`. */
function classFromAtc(
atcCode: string | null | undefined,
): EfficacyMedClass | null {
if (!atcCode) return null;
const code = atcCode.trim().toUpperCase();
if (!ATC_CODE_RE.test(code)) return null;
for (const [prefix, cls] of ATC_PREFIX_CLASS) {
if (code.startsWith(prefix)) return cls;
}
return null;
}
/** Resolve a med's efficacy class from its name (metric + lab needles). */
function classFromName(
name: string,
treatmentClass?: string | null,
): EfficacyMedClass | null {
// Metric classes stay authoritative through the existing inferer.
const metricClass = inferMedTargetClass(name, treatmentClass);
if (metricClass) return metricClass;
const normalised = normaliseName(name);
if (!normalised) return null;
if (STATIN_NEEDLES.some((n) => containsWord(normalised, n))) return "statin";
if (THYROID_NEEDLES.some((n) => containsWord(normalised, n)))
return "thyroid";
if (VITAMIN_D_NEEDLES.some((n) => containsWord(normalised, n))) {
return "vitamin_d";
}
if (IRON_NEEDLES.some((n) => containsWord(normalised, n))) return "iron";
return null;
}
/** The tier a target list was resolved through (provenance for the DTO/UI). */
export type MedTargetTier = "atc" | "name";
export interface ResolvedMedTargets {
cls: EfficacyMedClass;
tier: MedTargetTier;
targets: readonly MedTarget[];
}
/**
* Resolve the derived (non-override) efficacy targets for a medication:
* ATC class-prefix FIRST (the guideline-native class key), then whole-word
* name inference. Returns `null` when no class is confidently known — the
* caller then falls back to the user's own explicit pick (tier 1, persisted)
* or the "track this against…" chooser. Never guesses a target.
*
* Tier 1 (user override) is NOT resolved here — it is persisted and applied by
* the server efficacy builder, which layers it on top of this derived result.
*/
export function resolveMedicationTargets(med: {
name: string;
treatmentClass?: string | null;
atcCode?: string | null;
}): ResolvedMedTargets | null {
const viaAtc = classFromAtc(med.atcCode);
if (viaAtc) {
return { cls: viaAtc, tier: "atc", targets: EFFICACY_TARGETS[viaAtc] };
}
const viaName = classFromName(med.name, med.treatmentClass);
if (viaName) {
return { cls: viaName, tier: "name", targets: EFFICACY_TARGETS[viaName] };
}
return null;
}
/** The ordered target list for an efficacy class (primary first). */
export function targetsForEfficacyClass(
cls: EfficacyMedClass,
): readonly MedTarget[] {
return EFFICACY_TARGETS[cls];
}
@@ -17,7 +17,23 @@ import {
scheduleRevisionCreateSchema,
scheduleRevisionUpdateSchema,
} from "@/lib/validations/schedule-revision";
import {
efficacyTargetOverrideSchema,
medicationEfficacyResponseSchema,
} from "@/lib/validations/medication-efficacy";
import { dataEnvelope, errorEnvelope, stdResponses } from "../shared";
efficacyTargetOverrideSchema.meta({
id: "SetMedicationEfficacyTargetRequest",
description:
"Set or clear the user's explicit efficacy-target override for a medication. `clear:true` removes the override so the resolver reverts to the derived (ATC class prefix → name inference) target; otherwise pin exactly ONE of `measurementType` (a metric series) / `biomarkerId` (a lab analyte). `userId` is never a field — ownership is narrowed through the medication (and the biomarker for a lab target).",
});
medicationEfficacyResponseSchema.meta({
id: "MedicationEfficacyResponse",
description:
"Server-authoritative, strictly-descriptive efficacy view relating a medication to the outcome metric(s)/lab(s) its class is prescribed to move, around its start. Carries the resolved target(s) with their series, the start/dose-change/pause markers, a before/after-start comparison (honest `{present:false}` below the per-side data floor), an adherence lane (cadence-aware per-day rate, never recomputed), an optional conservative level-shift note, and the retarget options. There is NO verdict / score / assessment field by construction — the client renders numbers and neutral connective phrasing only, never a causal or dose-advice claim.",
});
import {
medicationListEntry,
medicationDetailEntry,
@@ -539,6 +555,73 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/medications/{id}/efficacy": {
get: {
tags: ["Medications"],
summary: 'Efficacy view for a medication ("Wirkung")',
description:
"Returns the resolved, strictly-descriptive efficacy DTO: the outcome metric(s)/lab(s) the medication's class targets, the target series with start/dose-change/pause markers, a before/after-start comparison, the cadence-aware adherence lane, an optional conservative level-shift note, the data-floor state, and the retarget options. Association-only — there is no verdict / score field. `eligible:false` (with `reason`) marks a one-shot or no-target medication whose tab is hidden.",
requestParams: {
path: z.object({ id: z.string() }),
},
responses: {
"200": {
description: "Efficacy view.",
content: {
"application/json": {
schema: dataEnvelope(
medicationEfficacyResponseSchema,
"GetMedicationEfficacyResponse",
),
},
},
},
"404": {
description: "Medication not found (or owned by another user).",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
},
"/api/medications/{id}/efficacy/target": {
put: {
tags: ["Medications"],
summary: "Set or clear the efficacy-target override",
description:
"Persists the user's explicit efficacy target for a medication (the only thing the view stores; everything else is derived each read). Pin exactly one of `measurementType` / `biomarkerId`, or pass `clear:true` to revert to the derived target.",
requestParams: {
path: z.object({ id: z.string() }),
},
requestBody: {
required: true,
content: {
"application/json": { schema: efficacyTargetOverrideSchema },
},
},
responses: {
"200": {
description: "Override set or cleared.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({
ok: z.boolean().optional(),
cleared: z.boolean().optional(),
}),
"SetMedicationEfficacyTargetResponse",
),
},
},
},
"404": {
description: "Medication or biomarker not found.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
},
"/api/medications/{id}/schedule-revisions": {
get: {
tags: ["Medications"],
+9
View File
@@ -138,4 +138,13 @@ export const medicationKeys = {
*/
medicationApiEndpoint: (medicationId: string) =>
["medications", medicationId, "api-endpoint"] as const,
/**
* v1.28 — per-medication efficacy view ("Wirkung")
* (`GET /api/medications/[id]/efficacy`), consumed by the detail page's
* Wirkung tab and the Insights medication summary. Rides under the
* `["medications", id, …]` prefix so an intake mutation (which moves the
* adherence lane) reaches it through `medicationDependentKeys`.
*/
medicationEfficacy: (medicationId: string) =>
["medications", medicationId, "efficacy"] as const,
};
+133
View File
@@ -0,0 +1,133 @@
/**
* v1.28 — request + response contracts for the medication-efficacy view
* ("Wirkung"). The response schema mirrors the DTO the server builder emits
* (`src/lib/medications/efficacy/build-efficacy.ts`) so the OpenAPI wire
* contract the iOS client consumes stays single-source. Strictly descriptive
* by construction — there is no verdict / score / assessment field on the DTO.
*/
import { z } from "zod/v4";
import { measurementTypeEnum } from "@/lib/validations/measurement";
/**
* Set or clear the user's explicit efficacy-target override for a medication.
* When `clear` is true the override rows are removed and the resolver reverts
* to the derived (ATC / name) target. Otherwise exactly ONE of
* `measurementType` (a metric series) / `biomarkerId` (a lab analyte) sets the
* new target. `userId` is never a body field — it is narrowed from the parent
* medication's ownership.
*/
export const efficacyTargetOverrideSchema = z
.object({
clear: z.boolean().optional(),
measurementType: measurementTypeEnum.optional(),
biomarkerId: z.string().trim().min(1).max(64).optional(),
primary: z.boolean().optional(),
})
.refine(
(v) =>
v.clear === true ||
(v.measurementType !== undefined) !== (v.biomarkerId !== undefined),
{
message:
"Set exactly one of measurementType / biomarkerId, or pass clear:true to revert to the derived target.",
path: ["measurementType"],
},
);
export type EfficacyTargetOverrideInput = z.infer<
typeof efficacyTargetOverrideSchema
>;
// ── Response DTO schema (OpenAPI mirror of the builder output) ──────────
const seriesPointSchema = z.object({
t: z.string(),
value: z.number(),
status: z.enum(["in-range", "below", "above", "unknown"]).optional(),
});
const beforeAfterSchema = z.object({
present: z.boolean(),
reason: z
.enum(["insufficient_before", "insufficient_after", "no_start", "no_data"])
.optional(),
before: z
.object({
mean: z.number(),
count: z.number().int(),
from: z.string(),
to: z.string(),
})
.optional(),
after: z
.object({
mean: z.number(),
count: z.number().int(),
from: z.string(),
to: z.string(),
})
.optional(),
delta: z
.object({ mean: z.number(), pct: z.number().nullable() })
.nullable()
.optional(),
});
const levelShiftSchema = z
.object({
present: z.boolean(),
at: z.string().optional(),
nearStart: z.boolean().optional(),
})
.nullable();
const targetViewSchema = z.object({
kind: z.enum(["metric", "lab"]),
key: z.string(),
label: z.string(),
unit: z.string().nullable(),
primary: z.boolean(),
referenceBand: z.object({ low: z.number(), high: z.number() }).nullable(),
series: z.array(seriesPointSchema),
beforeAfter: beforeAfterSchema,
levelShift: levelShiftSchema,
});
export const medicationEfficacyResponseSchema = z.object({
medicationId: z.string(),
medicationName: z.string(),
eligible: z.boolean(),
reason: z.enum(["one_shot", "no_target"]).optional(),
startsOn: z.string().nullable(),
resolution: z.object({
tier: z.enum(["override", "atc", "name", "none"]),
cls: z.string().nullable(),
}),
windowDays: z.number().int(),
minWeeksPerSide: z.number().int(),
markers: z.object({
start: z.string().nullable(),
startSource: z.enum(["startsOn", "firstReading"]).nullable(),
doseChanges: z.array(z.object({ at: z.string(), label: z.string() })),
pauses: z.array(z.object({ from: z.string(), to: z.string().nullable() })),
}),
targets: z.array(targetViewSchema),
adherence: z.array(
z.object({
date: z.string(),
rate: z.number(),
taken: z.number().int(),
missed: z.number().int(),
}),
),
overrideOptions: z.object({
metrics: z.array(z.object({ key: z.string(), label: z.string() })),
biomarkers: z.array(
z.object({ id: z.string(), label: z.string(), unit: z.string() }),
),
}),
});
export type MedicationEfficacyResponse = z.infer<
typeof medicationEfficacyResponseSchema
>;