From f3762cf0f9dfbc096a70a40657bf748b5a083306 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 9 Jul 2026 12:36:59 +0200 Subject: [PATCH 1/4] feat(medications): extend the drug-target resolver with ATC-prefix and lab-analyte tiers Add an additive efficacy-target resolver alongside the existing metric-only class map: a discriminated metric|lab target type, a WHO ATC class-prefix table (consuming Medication.atcCode), and the statin/thyroid/supplement lab classes the v1.22 map documented as a follow-on. Resolution order is ATC prefix then whole-word name inference, conservative-fail to no target. The existing MED_TARGET_MAP / inferMedTargetClass path stays byte-identical so the adherence-storyline safety posture is untouched. --- .../__tests__/med-target-map.test.ts | 82 ++++++++ src/lib/medications/med-target-map.ts | 199 ++++++++++++++++++ 2 files changed, 281 insertions(+) diff --git a/src/lib/medications/__tests__/med-target-map.test.ts b/src/lib/medications/__tests__/med-target-map.test.ts index 81e7103ff..3c3bf6ecf 100644 --- a/src/lib/medications/__tests__/med-target-map.test.ts +++ b/src/lib/medications/__tests__/med-target-map.test.ts @@ -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", + }); + }); +}); diff --git a/src/lib/medications/med-target-map.ts b/src/lib/medications/med-target-map.ts index 7aca54bf4..500ede128 100644 --- a/src/lib/medications/med-target-map.ts +++ b/src/lib/medications/med-target-map.ts @@ -158,3 +158,202 @@ 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 +> = { + 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 = [ + // 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]; +} From 725f69097fd24485d6cb400dc97c6328a7d8a1c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 9 Jul 2026 12:45:50 +0200 Subject: [PATCH 2/4] feat(medications): server-authoritative medication-efficacy view + API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the migration (0232), Prisma model MedicationEfficacyTarget (persists ONLY the user's explicit target override; everything else derives each read), and the server builder buildMedicationEfficacy that composes the target's series, a before/after-start comparison, the cadence-aware adherence lane (never recomputed), an optional conservative level-shift note, honest data-floor states and the retarget options into one strictly-descriptive DTO — no verdict/score field by construction. Expose GET /api/medications/[id]/efficacy and PUT /api/medications/[id]/efficacy/target, regenerate the OpenAPI contract. --- docs/api/openapi.yaml | 505 +++++++++++++++ .../migration.sql | 49 ++ prisma/schema.prisma | 43 +- .../api/medications/[id]/efficacy/route.ts | 42 ++ .../medications/[id]/efficacy/target/route.ts | 100 +++ .../efficacy/__tests__/before-after.test.ts | 64 ++ .../medications/efficacy/build-efficacy.ts | 608 ++++++++++++++++++ src/lib/openapi/routes/medications/paths.ts | 83 +++ src/lib/validations/medication-efficacy.ts | 137 ++++ 9 files changed, 1629 insertions(+), 2 deletions(-) create mode 100644 prisma/migrations/0232_medication_efficacy_targets/migration.sql create mode 100644 src/app/api/medications/[id]/efficacy/route.ts create mode 100644 src/app/api/medications/[id]/efficacy/target/route.ts create mode 100644 src/lib/medications/efficacy/__tests__/before-after.test.ts create mode 100644 src/lib/medications/efficacy/build-efficacy.ts create mode 100644 src/lib/validations/medication-efficacy.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 2a30750d1..4ac166721 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/prisma/migrations/0232_medication_efficacy_targets/migration.sql b/prisma/migrations/0232_medication_efficacy_targets/migration.sql new file mode 100644 index 000000000..517f8cf6c --- /dev/null +++ b/prisma/migrations/0232_medication_efficacy_targets/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dcbe9c1a2..ca3f42dad 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) diff --git a/src/app/api/medications/[id]/efficacy/route.ts b/src/app/api/medications/[id]/efficacy/route.ts new file mode 100644 index 000000000..5bc75939d --- /dev/null +++ b/src/app/api/medications/[id]/efficacy/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); + }, +); diff --git a/src/app/api/medications/[id]/efficacy/target/route.ts b/src/app/api/medications/[id]/efficacy/target/route.ts new file mode 100644 index 000000000..31474f446 --- /dev/null +++ b/src/app/api/medications/[id]/efficacy/target/route.ts @@ -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 }); + }, +); diff --git a/src/lib/medications/efficacy/__tests__/before-after.test.ts b/src/lib/medications/efficacy/__tests__/before-after.test.ts new file mode 100644 index 000000000..8ea9bae12 --- /dev/null +++ b/src/lib/medications/efficacy/__tests__/before-after.test.ts @@ -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"); + }); +}); diff --git a/src/lib/medications/efficacy/build-efficacy.ts b/src/lib/medications/efficacy/build-efficacy.ts new file mode 100644 index 000000000..7c825aa5e --- /dev/null +++ b/src/lib/medications/efficacy/build-efficacy.ts @@ -0,0 +1,608 @@ +/** + * 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 +> = { + 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>, +): Promise { + 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 { + 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 { + 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, + }; +} diff --git a/src/lib/openapi/routes/medications/paths.ts b/src/lib/openapi/routes/medications/paths.ts index 4107f5c8f..ba7c41bae 100644 --- a/src/lib/openapi/routes/medications/paths.ts +++ b/src/lib/openapi/routes/medications/paths.ts @@ -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 = { }, }, }, + "/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"], diff --git a/src/lib/validations/medication-efficacy.ts b/src/lib/validations/medication-efficacy.ts new file mode 100644 index 000000000..36da6b89a --- /dev/null +++ b/src/lib/validations/medication-efficacy.ts @@ -0,0 +1,137 @@ +/** + * 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 +>; From 8617fa7129d725f24bee6136937c1cd14f0002ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 9 Jul 2026 13:02:02 +0200 Subject: [PATCH 3/4] feat(medications): Wirkung tab + Insights efficacy summary Add the per-medication "Wirkung" tab (hidden for one-shot and no-target meds) rendering the target series with a start marker, dose-change markers, shaded pause bands and a faint reference band, an adherence lane, a neutral before/after-start card with honest empty states, an optional level-shift note, and a retarget chooser. Weave a compact, read-only efficacy summary into the Insights medication surface that reads the same DTO and links through to the tab. All copy is descriptive; add a banned-verdict-verb guard test over the medications.efficacy namespace across all six locales. --- messages/de.json | 39 +- messages/en.json | 39 +- messages/es.json | 39 +- messages/fr.json | 39 +- messages/it.json | 39 +- messages/pl.json | 39 +- .../medications-efficacy-verb-guard.test.ts | 105 +++++ src/app/insights/medications/page.tsx | 8 + src/components/charts/chart-runtime.ts | 1 + .../insights/medication-efficacy-summary.tsx | 126 ++++++ .../detail/efficacy/efficacy-chart.tsx | 244 +++++++++++ .../detail/efficacy/efficacy-tab.tsx | 381 ++++++++++++++++++ .../detail/medication-detail-tabs.tsx | 33 +- src/lib/query-keys/medications.ts | 9 + 14 files changed, 1127 insertions(+), 14 deletions(-) create mode 100644 src/__tests__/medications-efficacy-verb-guard.test.ts create mode 100644 src/components/insights/medication-efficacy-summary.tsx create mode 100644 src/components/medications/detail/efficacy/efficacy-chart.tsx create mode 100644 src/components/medications/detail/efficacy/efficacy-tab.tsx diff --git a/messages/de.json b/messages/de.json index 65d74b3dc..9d6e5c66c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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…", diff --git a/messages/en.json b/messages/en.json index c7fac3b9b..15bd8dfc7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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…", diff --git a/messages/es.json b/messages/es.json index 17567f082..053ecf2bb 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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…", diff --git a/messages/fr.json b/messages/fr.json index b15587733..16d1ff066 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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…", diff --git a/messages/it.json b/messages/it.json index 71a0a2c87..b2a5954fa 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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…", diff --git a/messages/pl.json b/messages/pl.json index ad032cec2..5d26255a0 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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…", diff --git a/src/__tests__/medications-efficacy-verb-guard.test.ts b/src/__tests__/medications-efficacy-verb-guard.test.ts new file mode 100644 index 000000000..a85e3db8c --- /dev/null +++ b/src/__tests__/medications-efficacy-verb-guard.test.ts @@ -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)) { + 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([]); + }); +}); diff --git a/src/app/insights/medications/page.tsx b/src/app/insights/medications/page.tsx index 02eef163f..fc8eade3a 100644 --- a/src/app/insights/medications/page.tsx +++ b/src/app/insights/medications/page.tsx @@ -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() { })} + {/* 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. */} + ({ id: m.id, name: m.name }))} + /> + {/* v1.12.2 — the assessment is the LAST block on every bespoke diff --git a/src/components/charts/chart-runtime.ts b/src/components/charts/chart-runtime.ts index f5498f07d..bcc70bfc1 100644 --- a/src/components/charts/chart-runtime.ts +++ b/src/components/charts/chart-runtime.ts @@ -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"; diff --git a/src/components/insights/medication-efficacy-summary.tsx b/src/components/insights/medication-efficacy-summary.tsx new file mode 100644 index 000000000..a68d5ae54 --- /dev/null +++ b/src/components/insights/medication-efficacy-summary.tsx @@ -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(`/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 ( + + + +

+ {t("medications.efficacy.insights.subtitle")} +

+
+ + {rows.length === 0 ? ( +

+ {t("medications.efficacy.insights.empty")} +

+ ) : ( +
    + {rows.map(({ med, target }) => { + const ba = target!.beforeAfter; + const unit = target!.unit ? ` ${target!.unit}` : ""; + const arrow = (ba.delta?.mean ?? 0) > 0 ? "+" : ""; + return ( +
  • +
    +

    + {med.name} +

    +

    +

    +
    + + {t("medications.efficacy.insights.link")} + +
  • + ); + })} +
+ )} +
+
+ ); +} diff --git a/src/components/medications/detail/efficacy/efficacy-chart.tsx b/src/components/medications/detail/efficacy/efficacy-chart.tsx new file mode 100644 index 000000000..e6e8b1d69 --- /dev/null +++ b/src/components/medications/detail/efficacy/efficacy-chart.tsx @@ -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 ( +
+ + + + fmt.date(new Date(v))} + /> + + + + {/* Population reference band — faint context, labelled "typical + range", explicitly NOT a target line. */} + {target.referenceBand ? ( + + ) : null} + + {/* Shaded pause bands. An open pause runs to now. */} + {pauses.map((p, i) => ( + + ))} + + {/* Adherence lane — faint, hidden right axis. */} + + + {/* The target series. */} + + + {/* Start marker. */} + {startMs !== null ? ( + + ) : null} + + {/* Dose-change markers — quiet, dashed. */} + {doseChanges.map((d, i) => ( + + ))} + + 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)]; + }} + /> + + +
+ ); +} diff --git a/src/components/medications/detail/efficacy/efficacy-tab.tsx b/src/components/medications/detail/efficacy/efficacy-tab.tsx new file mode 100644 index 000000000..2d465ecca --- /dev/null +++ b/src/components/medications/detail/efficacy/efficacy-tab.tsx @@ -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: () => }, +); + +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( + `/api/medications/${medicationId}/efficacy`, + ), + enabled: active, + staleTime: 60_000, + }); + + if (isLoading || !data) { + return ( + + + + ); + } + + // The tab is hidden upstream when ineligible; this is a defensive fallback. + if (!data.eligible) { + return ( + +

+ {t("medications.efficacy.notEligible")} +

+
+ ); + } + + return ( +
+ +
+

+ {t("medications.efficacy.intro")} +

+ {data.markers.startSource === "firstReading" ? ( +

+ {t("medications.efficacy.startFallback")} +

+ ) : null} + {data.markers.start === null ? ( +

+ {t("medications.efficacy.noStart")} +

+ ) : null} + + queryClient.invalidateQueries({ + queryKey: queryKeys.medicationEfficacy(medicationId), + }) + } + /> +
+
+ + {data.targets.map((target) => ( + + ))} + +

+ {t("medications.efficacy.disclaimer")} +

+
+ ); +} + +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 ( + + + + {t("medications.efficacy.primaryBadge")} + + ) : undefined + } + /> + + + {target.series.length > 0 ? ( + ({ 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")} + /> + ) : ( +

+ {t("medications.efficacy.noSeries")} +

+ )} + + + + {target.levelShift?.present && target.levelShift.nearStart ? ( +

+ {t("medications.efficacy.levelShift", { + date: formatShort(target.levelShift.at), + })} +

+ ) : null} +
+
+ ); +} + +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 ( +
+

+ {t(reasonKey, { weeks: minWeeks })} +

+
+ ); + } + + const arrow = ba.delta.mean > 0 ? "+" : ""; + return ( +
+

+ {t("medications.efficacy.beforeAfter.summary", { + before: `${ba.before.mean}${unit}`, + after: `${ba.after.mean}${unit}`, + delta: `${arrow}${ba.delta.mean}${unit}`, + })} +

+

+ {t("medications.efficacy.beforeAfter.counts", { + beforeCount: ba.before.count, + afterCount: ba.after.count, + })} +

+
+ ); +} + +function RetargetControl({ + medicationId, + options, + isOverride, + onChanged, +}: { + medicationId: string; + options: MedicationEfficacyDTO["overrideOptions"]; + isOverride: boolean; + onChanged: () => void; +}) { + const { t } = useTranslations(); + const [value, setValue] = useState(""); + 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 ( +
+
+ ); +} + +function formatShort(iso?: string): string { + if (!iso) return ""; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? "" : d.toISOString().slice(0, 10); +} diff --git a/src/components/medications/detail/medication-detail-tabs.tsx b/src/components/medications/detail/medication-detail-tabs.tsx index f9a803ae7..fddcd212d 100644 --- a/src/components/medications/detail/medication-detail-tabs.tsx +++ b/src/components/medications/detail/medication-detail-tabs.tsx @@ -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({ )} + {/* 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 && ( + + + + )} + {/* 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 diff --git a/src/lib/query-keys/medications.ts b/src/lib/query-keys/medications.ts index 429f5ac08..979aefde5 100644 --- a/src/lib/query-keys/medications.ts +++ b/src/lib/query-keys/medications.ts @@ -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, }; From 783255d8333ecd08a4f1bbeb7531cde061e2b723 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 9 Jul 2026 13:14:07 +0200 Subject: [PATCH 4/4] =?UTF-8?q?chore(release):=20v1.28.4=20=E2=80=94=20med?= =?UTF-8?q?ication=20efficacy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-medication effect view: plots the target metric/lab with start, dose changes, and pauses marked, a before/after-start comparison, and adherence overlaid — descriptive only, never a verdict or dose advice. Compact summary on the medication insights area. Target resolved from ATC class or name, with a per-user override. Migration 0232. --- CHANGELOG.md | 10 +++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- .../module-route-gate-inventory.test.ts | 6 ++ .../medications/efficacy/build-efficacy.ts | 88 +++++++++++++++---- src/lib/medications/med-target-map.ts | 9 +- src/lib/openapi/routes/medications/paths.ts | 2 +- src/lib/validations/medication-efficacy.ts | 8 +- 9 files changed, 96 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f340e7822..955e7cb9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 4ac166721..8d60cb066 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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. diff --git a/package.json b/package.json index e1d16a182..af0eebd99 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/sw.js b/public/sw.js index fbed073e5..ba3e14d3e 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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/*` diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts index 432602444..c7c294aa3 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -204,6 +204,12 @@ const EXEMPT_ROUTES: ReadonlyArray = [ "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", diff --git a/src/lib/medications/efficacy/build-efficacy.ts b/src/lib/medications/efficacy/build-efficacy.ts index 7c825aa5e..3d35b8f3a 100644 --- a/src/lib/medications/efficacy/build-efficacy.ts +++ b/src/lib/medications/efficacy/build-efficacy.ts @@ -63,7 +63,8 @@ export interface EfficacySeriesPoint { 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"; + 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. */ @@ -143,7 +144,10 @@ const OVERRIDE_METRIC_OPTIONS: { key: MeasurementType; label: string }[] = [ /** Fixed reference bands for the metric targets `resolveRichMetric` omits. */ const METRIC_META_FALLBACK: Partial< - Record + Record< + MeasurementType, + { label: string; unit: string; band: { low: number; high: number } | null } + > > = { BLOOD_PRESSURE_SYS: { label: "Systolic blood pressure", @@ -217,7 +221,8 @@ export function beforeAfterFromSeries( }, delta: { mean: round1(diff), - pct: beforeMean !== 0 ? round1((diff / Math.abs(beforeMean)) * 100) : null, + pct: + beforeMean !== 0 ? round1((diff / Math.abs(beforeMean)) * 100) : null, }, }; } @@ -233,20 +238,37 @@ async function buildMetricTarget( coverage: Awaited>, ): Promise { const meta = resolveMetricMeta(mt); - const { points } = await readDayMeanSeries(userId, mt, windowDays, now, coverage); + 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 })); + 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 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]; @@ -282,7 +304,7 @@ async function buildLabTarget( pivotMs: number | null, ): Promise { const hist = await getLabHistory(userId, { analyte, limit: 50 }); - const readings = (hist.present ? hist.readings ?? [] : []) + const readings = (hist.present ? (hist.readings ?? []) : []) .filter((r) => r.value !== null) .slice() // getLabHistory returns newest-first; the view + before/after want oldest-first. @@ -293,7 +315,9 @@ async function buildLabTarget( (r) => r.referenceLow !== null || r.referenceHigh !== null, ); const referenceBand = - withBounds && withBounds.referenceLow !== null && withBounds.referenceHigh !== null + withBounds && + withBounds.referenceLow !== null && + withBounds.referenceHigh !== null ? { low: withBounds.referenceLow, high: withBounds.referenceHigh } : null; @@ -369,7 +393,11 @@ async function resolveEffectiveTargets(med: { atcCode: med.atcCode, }); if (derived) { - return { tier: derived.tier, cls: derived.cls, targets: [...derived.targets] }; + return { + tier: derived.tier, + cls: derived.cls, + targets: [...derived.targets], + }; } return { tier: "none", cls: null, targets: [] }; } @@ -407,7 +435,11 @@ export async function buildMedicationEfficacy( // 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 }, + action: { + name: "medication.efficacy.build", + entity_type: "medication", + entity_id: med.id, + }, meta: { eligible: false, reason: "one_shot" }, }); return { @@ -437,7 +469,10 @@ export async function buildMedicationEfficacy( select: { id: true, name: true, unit: true }, }); const overrideOptions = { - metrics: OVERRIDE_METRIC_OPTIONS.map((m) => ({ key: m.key, label: m.label })), + metrics: OVERRIDE_METRIC_OPTIONS.map((m) => ({ + key: m.key, + label: m.label, + })), biomarkers: biomarkerRows.map((b) => ({ id: b.id, label: b.name, @@ -447,7 +482,11 @@ export async function buildMedicationEfficacy( if (resolved.targets.length === 0) { annotate({ - action: { name: "medication.efficacy.build", entity_type: "medication", entity_id: med.id }, + action: { + name: "medication.efficacy.build", + entity_type: "medication", + entity_id: med.id, + }, meta: { eligible: false, reason: "no_target" }, }); return { @@ -476,7 +515,10 @@ export async function buildMedicationEfficacy( 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)), + 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. @@ -550,8 +592,16 @@ export async function buildMedicationEfficacy( lastNonSkippedTakenAt(events), timezone, ); - const bundle = buildMedicationComplianceBundle(events, med.schedules, ctx, now); - const windowFloorMs = pivotMs !== null ? pivotMs - BEFORE_WINDOW_DAYS * DAY_MS : fetchFrom.getTime(); + 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, @@ -579,7 +629,11 @@ export async function buildMedicationEfficacy( })); annotate({ - action: { name: "medication.efficacy.build", entity_type: "medication", entity_id: med.id }, + action: { + name: "medication.efficacy.build", + entity_type: "medication", + entity_id: med.id, + }, meta: { eligible: true, tier: resolved.tier, diff --git a/src/lib/medications/med-target-map.ts b/src/lib/medications/med-target-map.ts index 500ede128..01d360a65 100644 --- a/src/lib/medications/med-target-map.ts +++ b/src/lib/medications/med-target-map.ts @@ -192,11 +192,7 @@ export type MedTarget = * thyroid→TSH) and the two supplement markers named in the plan. */ export type EfficacyMedClass = - | MedTargetClass - | "statin" - | "thyroid" - | "vitamin_d" - | "iron"; + MedTargetClass | "statin" | "thyroid" | "vitamin_d" | "iron"; /** Class → its ordered target list (primary first). Closed + conservative. */ const EFFICACY_TARGETS: Readonly< @@ -308,7 +304,8 @@ function classFromName( 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 (THYROID_NEEDLES.some((n) => containsWord(normalised, n))) + return "thyroid"; if (VITAMIN_D_NEEDLES.some((n) => containsWord(normalised, n))) { return "vitamin_d"; } diff --git a/src/lib/openapi/routes/medications/paths.ts b/src/lib/openapi/routes/medications/paths.ts index ba7c41bae..dd0e226b1 100644 --- a/src/lib/openapi/routes/medications/paths.ts +++ b/src/lib/openapi/routes/medications/paths.ts @@ -558,7 +558,7 @@ export const medicationPaths: NonNullable = { "/api/medications/{id}/efficacy": { get: { tags: ["Medications"], - summary: "Efficacy view for a medication (\"Wirkung\")", + 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: { diff --git a/src/lib/validations/medication-efficacy.ts b/src/lib/validations/medication-efficacy.ts index 36da6b89a..86826ef7b 100644 --- a/src/lib/validations/medication-efficacy.ts +++ b/src/lib/validations/medication-efficacy.ts @@ -87,9 +87,7 @@ const targetViewSchema = z.object({ label: z.string(), unit: z.string().nullable(), primary: z.boolean(), - referenceBand: z - .object({ low: z.number(), high: z.number() }) - .nullable(), + referenceBand: z.object({ low: z.number(), high: z.number() }).nullable(), series: z.array(seriesPointSchema), beforeAfter: beforeAfterSchema, levelShift: levelShiftSchema, @@ -111,9 +109,7 @@ export const medicationEfficacyResponseSchema = 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() }), - ), + pauses: z.array(z.object({ from: z.string(), to: z.string().nullable() })), }), targets: z.array(targetViewSchema), adherence: z.array(