mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge pull request #448 from MBombeck/release/v1.28.4
release/v1.28.4 — medication efficacy
This commit is contained in:
29 files changed
+3103
-19
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
|
||||
>;
|
||||
Reference in new issue
Block a user