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 +>;