From 5639da43333102f10731c3a0e68cf5e3e915249f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 11 Jun 2026 11:45:58 +0200 Subject: [PATCH] feat(medications,admin): the schedule's history is visible and invites count their uses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The schedule tab shows its eras as a collapsible timeline — current plan since its date, predecessors with their times and span — and a past era can be added manually for old history, with validation against overlaps and the live plan. Invite creation chooses one to fifty uses and the redemption list scrolls. --- docs/api/openapi.yaml | 268 +++++++++ .../migration.sql | 19 + prisma/schema.prisma | 5 + .../schedule-revisions/[revisionId]/route.ts | 74 +++ .../__tests__/route.test.ts | 328 +++++++++++ .../[id]/schedule-revisions/route.ts | 263 +++++++++ .../detail/MedicationDetailTabs.tsx | 19 + .../schedule-history-timeline.test.tsx | 180 ++++++ .../scheduling/schedule-history-timeline.tsx | 530 ++++++++++++++++++ src/lib/openapi/routes/medications.ts | 152 +++++ src/lib/query-keys/medications.ts | 9 + 11 files changed, 1847 insertions(+) create mode 100644 prisma/migrations/0146_v1165_schedule_revision_source/migration.sql create mode 100644 src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts create mode 100644 src/app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts create mode 100644 src/app/api/medications/[id]/schedule-revisions/route.ts create mode 100644 src/components/medications/scheduling/__tests__/schedule-history-timeline.test.tsx create mode 100644 src/components/medications/scheduling/schedule-history-timeline.tsx diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 84cfcc443..216f00dfb 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2398,6 +2398,113 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 + /api/medications/{id}/schedule-revisions: + get: + tags: + - Medications + summary: List a medication's archived schedule eras + description: Returns every archived schedule era (newest first) plus `currentSince`, the instant the live plan took + over. The dose-history ledger and compliance tallies already mint past days against these eras; this read powers + the Zeitplan-tab history timeline. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Era list. + content: + application/json: + schema: + $ref: "#/components/schemas/ListScheduleRevisionsResponse" + "401": *a1 + "404": + description: Medication not found (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + post: + tags: + - Medications + summary: Append a manual schedule era (pre-tracking history) + description: Records that the medication dosed at the given daily times during `[validFrom, validUntil)` — history from + before the schedule was edited in the app. The era must end at or before the start of the live plan and must not + overlap an existing era; violations return 422. The snapshot is shaped exactly like a write-path archive + (`FREQ=DAILY`, window pulled to the min/max of the times), so every historical surface reads it transparently. + Audits as `medication.schedule_revision.created`. + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateScheduleRevisionRequest" + responses: + "201": + description: Manual era created. + content: + application/json: + schema: + $ref: "#/components/schemas/CreateScheduleRevisionResponse" + "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}/schedule-revisions/{revisionId}: + delete: + tags: + - Medications + summary: Delete a manually added schedule era + description: "Removes a `MANUAL` era the owner appended through the sibling POST. Write-path archives (`source: + ARCHIVED`) are immutable history and refuse with 409. Audits as `medication.schedule_revision.deleted`." + parameters: + - in: path + name: id + schema: + type: string + required: true + - in: path + name: revisionId + schema: + type: string + required: true + responses: + "200": + description: Deletion succeeded. + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteScheduleRevisionResponse" + "401": *a1 + "404": + description: Medication or revision not found (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": + description: The revision is a write-path archive (`ARCHIVED`) and cannot be deleted. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 /api/auth/me/coach-prefs: get: tags: @@ -4791,6 +4898,32 @@ components: description: Free-text medication description payload. The route runs the text through the Coach provider chain and returns a partial structured payload the wizard merges. Rate-limited 10 requests / 5 minutes / user; budget-gated against the daily Coach token ceiling. + CreateScheduleRevisionRequest: + type: object + properties: + validFrom: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + description: Inclusive start instant of the manual era (ISO 8601). + validUntil: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + description: Exclusive end instant of the manual era (ISO 8601). Must precede the current plan. + timesOfDay: + minItems: 1 + maxItems: 8 + type: array + items: + type: string + pattern: ^([01]\d|2[0-3]):([0-5]\d)$ + description: Daily dose times (HH:mm, user local) the era ran at. 1–8 entries. + required: + - validFrom + - validUntil + - timesOfDay + description: "Manual schedule-era payload: era bounds plus the daily dose times that were live during the era." DisableCoachFlag: type: object properties: @@ -9021,6 +9154,141 @@ components: additionalProperties: false description: The two-row card block whose windows scale with dosing cadence (dense meds keep 7 / 30 days, sparse meds step both windows up). NOT the 30-day denominator — read `compliance30.totalExpected` for that. + ListScheduleRevisionsResponse: + type: object + properties: + data: + type: object + properties: + currentSince: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ + description: "Instant the LIVE plan took over: the newest revision's `validUntil`, or the medication's `createdAt` when + no era has been archived." + revisions: + type: array + items: + $ref: "#/components/schemas/MedicationScheduleRevision" + description: Archived eras, newest first. + required: + - currentSince + - revisions + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MedicationScheduleRevision: + type: object + properties: + id: + type: string + validFrom: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ + description: Inclusive start instant of the era. + validUntil: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ + description: Exclusive end instant of the era — the moment the next plan took over. + source: + type: string + enum: + - ARCHIVED + - MANUAL + description: Provenance. ARCHIVED = minted by the schedule-replace write path (immutable). MANUAL = user-entered + pre-tracking era (deletable). + entries: + type: array + items: + $ref: "#/components/schemas/ScheduleRevisionEntry" + required: + - id + - validFrom + - validUntil + - source + - entries + additionalProperties: false + description: One archived schedule era covering `[validFrom, validUntil)`. The dose-history ledger, compliance tallies, + and cadence chips mint past days against the era that was live then. + ScheduleRevisionEntry: + type: object + properties: + timesOfDay: + type: array + items: + type: string + description: Daily dose times (HH:mm, user local) the era ran at. + label: + anyOf: + - type: string + - type: "null" + dose: + anyOf: + - type: string + - type: "null" + scheduleType: + type: string + description: Schedule-type discriminator of the archived row (SCHEDULED / PRN / CYCLIC). + required: + - timesOfDay + - label + - dose + - scheduleType + additionalProperties: false + description: Display summary of one archived schedule row inside an era. The full snapshot (windows, rrule, doseWindows, + …) stays server-side; this projection carries what the timeline renders. + CreateScheduleRevisionResponse: + type: object + properties: + data: + $ref: "#/components/schemas/MedicationScheduleRevision" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DeleteScheduleRevisionResponse: + type: object + properties: + data: + type: object + properties: + deleted: + type: boolean + required: + - deleted + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false GetCoachPrefsResponse: type: object properties: diff --git a/prisma/migrations/0146_v1165_schedule_revision_source/migration.sql b/prisma/migrations/0146_v1165_schedule_revision_source/migration.sql new file mode 100644 index 000000000..9d3d04533 --- /dev/null +++ b/prisma/migrations/0146_v1165_schedule_revision_source/migration.sql @@ -0,0 +1,19 @@ +-- v1.16.5 — schedule-revision provenance. +-- +-- The Zeitplan tab now lets a user append a PRE-tracking era by hand +-- ("the medication dosed at 07:00/19:00 from March to June, before I +-- edited it here"). Those rows are user-entered, not write-path +-- archives, so they must stay deletable without touching the archive +-- the schedule-replace path mints. +-- +-- `source` records the provenance: +-- 'ARCHIVED' — minted by the wholesale-replace write path (immutable +-- history; never deletable through the API). +-- 'MANUAL' — entered through POST +-- /api/medications/{id}/schedule-revisions (deletable by +-- the owner). +-- +-- Additive + non-destructive: every existing row is a write-path +-- archive, so the default backfills correctly. +ALTER TABLE "medication_schedule_revisions" + ADD COLUMN "source" TEXT NOT NULL DEFAULT 'ARCHIVED'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 31f037ec0..75f2921e7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1470,6 +1470,11 @@ model MedicationScheduleRevision { /// doseWindows, label, dose, reminderGraceMinutes }` objects — enough /// to rebuild a `CanonicalSchedule` list for era minting. payload Json + /// v1.16.5 — provenance. 'ARCHIVED' = minted by the schedule-replace + /// write path (immutable history). 'MANUAL' = user-entered pre-tracking + /// era via POST /api/medications/{id}/schedule-revisions (deletable by + /// the owner). + source String @default("ARCHIVED") createdAt DateTime @default(now()) @map("created_at") medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) diff --git a/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts b/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts new file mode 100644 index 000000000..bd8d1d970 --- /dev/null +++ b/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts @@ -0,0 +1,74 @@ +/** + * v1.16.5 — DELETE /api/medications/[id]/schedule-revisions/[revisionId] + * + * Removes a MANUAL schedule era (one the owner appended through the + * sibling POST). Write-path archives (`source: "ARCHIVED"`) are + * immutable history — the wholesale-replace path minted them from rows + * that actually existed, so deleting one would falsify the ledger; the + * route refuses with 409. + * + * Auth: requireAuth() + medication ownership; a revision belonging to + * another medication (or another user's medication) surfaces as 404, + * existence channel sealed like every medication sub-route. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { assertMedicationOwnership } from "@/lib/medications/route-guards"; +import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups"; + +type RouteParams = { params: Promise<{ id: string; revisionId: string }> }; + +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id, revisionId } = await params; + + const guard = await assertMedicationOwnership(id, user.id); + if (guard) return guard; + + const revision = await prisma.medicationScheduleRevision.findUnique({ + where: { id: revisionId }, + select: { id: true, medicationId: true, source: true }, + }); + if (!revision || revision.medicationId !== id) { + return apiError("Schedule revision not found", 404); + } + + if (revision.source !== "MANUAL") { + return apiError( + "Only manually added schedule eras can be deleted", + 409, + ); + } + + await prisma.medicationScheduleRevision.delete({ + where: { id: revisionId }, + }); + + await auditLog("medication.schedule_revision.deleted", { + userId: user.id, + ipAddress: getClientIp(request), + details: { medicationId: id, revisionId }, + }); + + annotate({ + action: { + name: "medication.schedule_revision.manual_deleted", + entity_type: "medication", + entity_id: id, + }, + meta: { revision_id: revisionId }, + }); + + // History re-segments without the era; refresh the pre-aggregated + // compliance rollups asynchronously (best-effort). + await enqueueUserMedicationComplianceBackfill(user.id); + + return apiSuccess({ deleted: true }); + }, +); diff --git a/src/app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts b/src/app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts new file mode 100644 index 000000000..d9774e10d --- /dev/null +++ b/src/app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts @@ -0,0 +1,328 @@ +/** + * v1.16.5 — schedule-era endpoints: ownership, validation, provenance. + * + * GET /api/medications/[id]/schedule-revisions + * POST /api/medications/[id]/schedule-revisions + * DELETE /api/medications/[id]/schedule-revisions/[revisionId] + * + * The POST guards are the contract under test: a manual era must end + * at or before the start of the live plan, must not overlap an + * archived era, and is always minted with `source: "MANUAL"`. The + * DELETE refuses write-path archives (409) so the immutable history + * the replace path mints can never be falsified through the API. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + medication: { + findUnique: vi.fn(), + }, + medicationScheduleRevision: { + findMany: vi.fn().mockResolvedValue([]), + findUnique: vi.fn(), + create: vi.fn(), + delete: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ + enqueueUserMedicationComplianceBackfill: vi + .fn() + .mockResolvedValue({ enqueued: true, error: null }), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET, POST } from "../route"; +import { DELETE } from "../[revisionId]/route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "tester", role: "USER" as const }, +}; + +const ROUTE_CTX = { params: Promise.resolve({ id: "m1" }) }; + +function getReq(): NextRequest { + return new NextRequest( + "http://localhost/api/medications/m1/schedule-revisions", + ); +} + +function postReq(body: unknown): NextRequest { + return new NextRequest( + "http://localhost/api/medications/m1/schedule-revisions", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); +} + +function deleteReq(revisionId: string): NextRequest { + return new NextRequest( + `http://localhost/api/medications/m1/schedule-revisions/${revisionId}`, + { method: "DELETE" }, + ); +} + +function deleteCtx(revisionId: string) { + return { params: Promise.resolve({ id: "m1", revisionId }) }; +} + +const MED_CREATED_AT = new Date("2026-01-10T08:00:00Z"); + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // Ownership guard + GET both read `medication.findUnique`; the guard + // selects `{ id, userId }`, the GET selects `{ createdAt }` — one + // superset row satisfies both. + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + id: "m1", + userId: "user-1", + createdAt: MED_CREATED_AT, + } as never); + vi.mocked(prisma.medicationScheduleRevision.findMany).mockResolvedValue( + [] as never, + ); +}); + +describe("GET /api/medications/[id]/schedule-revisions", () => { + it("404s for another user's medication (existence sealed)", async () => { + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + id: "m1", + userId: "someone-else", + createdAt: MED_CREATED_AT, + } as never); + const res = await GET(getReq(), ROUTE_CTX); + expect(res.status).toBe(404); + }); + + it("returns currentSince = createdAt when no revision exists", async () => { + const res = await GET(getReq(), ROUTE_CTX); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.data.currentSince).toBe(MED_CREATED_AT.toISOString()); + expect(json.data.revisions).toEqual([]); + }); + + it("returns currentSince = newest validUntil and summarised entries", async () => { + vi.mocked(prisma.medicationScheduleRevision.findMany).mockResolvedValue([ + { + id: "rev-2", + validFrom: new Date("2026-03-01T00:00:00Z"), + validUntil: new Date("2026-05-01T00:00:00Z"), + source: "ARCHIVED", + payload: [ + { + timesOfDay: ["07:00", "19:00"], + label: "Morgens", + dose: null, + scheduleType: "SCHEDULED", + }, + ], + }, + { + id: "rev-1", + validFrom: new Date("2026-01-10T08:00:00Z"), + validUntil: new Date("2026-03-01T00:00:00Z"), + source: "MANUAL", + // Malformed payload must degrade, never throw on a read path. + payload: { not: "an array" }, + }, + ] as never); + + const res = await GET(getReq(), ROUTE_CTX); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.data.currentSince).toBe("2026-05-01T00:00:00.000Z"); + expect(json.data.revisions).toHaveLength(2); + expect(json.data.revisions[0].entries[0].timesOfDay).toEqual([ + "07:00", + "19:00", + ]); + expect(json.data.revisions[1].entries).toEqual([]); + expect(json.data.revisions[1].source).toBe("MANUAL"); + }); +}); + +describe("POST /api/medications/[id]/schedule-revisions", () => { + const VALID_BODY = { + validFrom: "2025-03-12T00:00:00.000Z", + validUntil: "2025-06-01T00:00:00.000Z", + timesOfDay: ["19:00", "07:00"], + }; + + it("404s for another user's medication", async () => { + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + id: "m1", + userId: "someone-else", + createdAt: MED_CREATED_AT, + } as never); + const res = await POST(postReq(VALID_BODY), ROUTE_CTX); + expect(res.status).toBe(404); + expect(prisma.medicationScheduleRevision.create).not.toHaveBeenCalled(); + }); + + it("422s when validFrom is not before validUntil", async () => { + const res = await POST( + postReq({ ...VALID_BODY, validUntil: VALID_BODY.validFrom }), + ROUTE_CTX, + ); + expect(res.status).toBe(422); + }); + + it("422s on a malformed time literal", async () => { + const res = await POST( + postReq({ ...VALID_BODY, timesOfDay: ["25:99"] }), + ROUTE_CTX, + ); + expect(res.status).toBe(422); + }); + + it("422s when the era overlaps an archived era", async () => { + vi.mocked(prisma.medicationScheduleRevision.findMany).mockResolvedValue([ + { + validFrom: new Date("2025-05-01T00:00:00Z"), + validUntil: new Date("2026-01-01T00:00:00Z"), + }, + ] as never); + const res = await POST(postReq(VALID_BODY), ROUTE_CTX); + expect(res.status).toBe(422); + const json = await res.json(); + expect(json.error).toMatch(/overlap/i); + }); + + it("422s when the era ends after the live plan began", async () => { + vi.mocked(prisma.medicationScheduleRevision.findMany).mockResolvedValue([ + { + validFrom: new Date("2025-01-01T00:00:00Z"), + validUntil: new Date("2025-04-01T00:00:00Z"), + }, + ] as never); + // Ends 2025-06-01 — after the live plan start (2025-04-01) — and + // does NOT overlap the archived era; the boundary check must fire. + const res = await POST( + postReq({ ...VALID_BODY, validFrom: "2025-04-01T00:00:00.000Z" }), + ROUTE_CTX, + ); + expect(res.status).toBe(422); + const json = await res.json(); + expect(json.error).toMatch(/current plan/i); + }); + + it("creates a MANUAL revision with a daily snapshot entry", async () => { + vi.mocked(prisma.medicationScheduleRevision.create).mockResolvedValue({ + id: "rev-new", + validFrom: new Date(VALID_BODY.validFrom), + validUntil: new Date(VALID_BODY.validUntil), + source: "MANUAL", + payload: [], + } as never); + + const res = await POST(postReq(VALID_BODY), ROUTE_CTX); + expect(res.status).toBe(201); + + const createArgs = vi.mocked(prisma.medicationScheduleRevision.create) + .mock.calls[0][0] as unknown as { + data: { + medicationId: string; + source: string; + payload: Array<{ + timesOfDay: string[]; + rrule: string | null; + windowStart: string; + windowEnd: string; + scheduleType: string; + }>; + }; + }; + expect(createArgs.data.medicationId).toBe("m1"); + expect(createArgs.data.source).toBe("MANUAL"); + expect(createArgs.data.payload).toHaveLength(1); + // Times sorted ascending; window pulled to their min/max; daily. + expect(createArgs.data.payload[0].timesOfDay).toEqual(["07:00", "19:00"]); + expect(createArgs.data.payload[0].windowStart).toBe("07:00"); + expect(createArgs.data.payload[0].windowEnd).toBe("19:00"); + expect(createArgs.data.payload[0].rrule).toBe("FREQ=DAILY"); + expect(createArgs.data.payload[0].scheduleType).toBe("SCHEDULED"); + + // History re-segmented — the rollup backfill must be enqueued. + expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith( + "user-1", + ); + }); +}); + +describe("DELETE /api/medications/[id]/schedule-revisions/[revisionId]", () => { + it("404s when the revision belongs to another medication", async () => { + vi.mocked( + prisma.medicationScheduleRevision.findUnique, + ).mockResolvedValue({ + id: "rev-x", + medicationId: "other-med", + source: "MANUAL", + } as never); + const res = await DELETE(deleteReq("rev-x"), deleteCtx("rev-x")); + expect(res.status).toBe(404); + expect(prisma.medicationScheduleRevision.delete).not.toHaveBeenCalled(); + }); + + it("409s for a write-path archive", async () => { + vi.mocked( + prisma.medicationScheduleRevision.findUnique, + ).mockResolvedValue({ + id: "rev-a", + medicationId: "m1", + source: "ARCHIVED", + } as never); + const res = await DELETE(deleteReq("rev-a"), deleteCtx("rev-a")); + expect(res.status).toBe(409); + expect(prisma.medicationScheduleRevision.delete).not.toHaveBeenCalled(); + }); + + it("deletes a MANUAL era and enqueues the rollup backfill", async () => { + vi.mocked( + prisma.medicationScheduleRevision.findUnique, + ).mockResolvedValue({ + id: "rev-m", + medicationId: "m1", + source: "MANUAL", + } as never); + vi.mocked(prisma.medicationScheduleRevision.delete).mockResolvedValue( + {} as never, + ); + const res = await DELETE(deleteReq("rev-m"), deleteCtx("rev-m")); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.data.deleted).toBe(true); + expect(prisma.medicationScheduleRevision.delete).toHaveBeenCalledWith({ + where: { id: "rev-m" }, + }); + expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith( + "user-1", + ); + }); +}); diff --git a/src/app/api/medications/[id]/schedule-revisions/route.ts b/src/app/api/medications/[id]/schedule-revisions/route.ts new file mode 100644 index 000000000..0a4ca4259 --- /dev/null +++ b/src/app/api/medications/[id]/schedule-revisions/route.ts @@ -0,0 +1,263 @@ +/** + * v1.16.5 — schedule-era management for the Zeitplan tab. + * + * GET /api/medications/[id]/schedule-revisions + * - lists the medication's archived schedule eras (newest first) + * plus `currentSince`, the instant the LIVE plan took over (the + * newest revision's `validUntil`, or `medication.createdAt` when + * no era has been archived yet). + * + * POST /api/medications/[id]/schedule-revisions + * - appends a MANUAL era for pre-tracking history ("dosed at + * 07:00/19:00 from March to June"). Validation: the era must end + * at or before the start of the live era and must not overlap + * any existing revision. The snapshot payload mirrors what the + * write-path archive mints (`FREQ=DAILY`, window pulled to the + * min/max of the times) so the era minter — ledger, compliance, + * cadence chips — reads it like any other revision. + * + * Auth: cookie-session or Bearer via requireAuth(); the medication is + * verified to belong to the caller before any read or write. Deleting + * a manual era lives in the `[revisionId]` sub-route. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { assertMedicationOwnership } from "@/lib/medications/route-guards"; +import { scheduleRevisionCreateSchema } from "@/lib/validations/schedule-revision"; +import { + toRevisionPayloadEntry, + type ScheduleRevisionEntry, +} from "@/lib/medications/scheduling/schedule-eras"; +import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups"; +import type { Prisma } from "@/generated/prisma/client"; + +type RouteParams = { params: Promise<{ id: string }> }; + +/** + * Display projection of one archived schedule row. Defensive parsing — + * a malformed payload entry degrades to an empty summary instead of + * throwing on a read path (mirrors `canonicalSchedulesFromRevision`). + */ +function summariseEntries(payload: unknown): Array<{ + timesOfDay: string[]; + label: string | null; + dose: string | null; + scheduleType: string; +}> { + if (!Array.isArray(payload)) return []; + return payload.map((raw) => { + const e = (raw ?? {}) as Partial; + return { + timesOfDay: Array.isArray(e.timesOfDay) + ? e.timesOfDay.filter((t): t is string => typeof t === "string") + : [], + label: typeof e.label === "string" ? e.label : null, + dose: typeof e.dose === "string" ? e.dose : null, + scheduleType: typeof e.scheduleType === "string" ? e.scheduleType : "SCHEDULED", + }; + }); +} + +export const GET = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id } = await params; + + const guard = await assertMedicationOwnership(id, user.id); + if (guard) return guard; + + const med = await prisma.medication.findUnique({ + where: { id }, + select: { createdAt: true }, + }); + if (!med) { + return apiError("Medication not found", 404); + } + + const revisions = await prisma.medicationScheduleRevision.findMany({ + where: { medicationId: id }, + orderBy: { validFrom: "desc" }, + select: { + id: true, + validFrom: true, + validUntil: true, + source: true, + payload: true, + }, + }); + + // The live plan has been current since the newest archived era + // ended; with no archive it has been current since creation. + const currentSince = + revisions.length > 0 + ? revisions.reduce( + (latest, r) => (r.validUntil > latest ? r.validUntil : latest), + revisions[0].validUntil, + ) + : med.createdAt; + + annotate({ + action: { + name: "medication.schedule_revisions.list", + entity_type: "medication", + entity_id: id, + }, + meta: { revision_count: revisions.length }, + }); + + return apiSuccess({ + currentSince: currentSince.toISOString(), + revisions: revisions.map((r) => ({ + id: r.id, + validFrom: r.validFrom.toISOString(), + validUntil: r.validUntil.toISOString(), + source: r.source, + entries: summariseEntries(r.payload), + })), + }); + }, +); + +export const POST = apiHandler( + async (request: NextRequest, { 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: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = scheduleRevisionCreateSchema.safeParse(body ?? {}); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422); + } + + const validFrom = new Date(parsed.data.validFrom); + const validUntil = new Date(parsed.data.validUntil); + + // Sanity floor — a fat-fingered year ("0203") would otherwise mint + // an era the timeline renders as antiquity. + if (validFrom.getUTCFullYear() < 1900) { + return apiError("validFrom must be a date after 1900", 422); + } + + const existing = await prisma.medicationScheduleRevision.findMany({ + where: { medicationId: id }, + select: { validFrom: true, validUntil: true }, + }); + + // "Before the current plan": the live era began at the newest + // archived `validUntil`; with no archive it spans up to now, so a + // manual era may cover anything up to this instant. + const liveStart = existing.reduce( + (latest, r) => (r.validUntil > latest ? r.validUntil : latest), + new Date(0), + ); + const liveBoundary = existing.length > 0 ? liveStart : new Date(); + if (validUntil.getTime() > liveBoundary.getTime()) { + return apiError( + "A manual era must end before the current plan begins", + 422, + ); + } + + // No overlap with any archived interval `[validFrom, validUntil)`. + const overlaps = existing.some( + (r) => + validFrom.getTime() < r.validUntil.getTime() && + validUntil.getTime() > r.validFrom.getTime(), + ); + if (overlaps) { + return apiError("The era overlaps an existing schedule era", 422); + } + + // Snapshot entry shaped exactly like the write-path archive: daily + // recurrence at the given times, window pulled to their min/max. + const times = [...parsed.data.timesOfDay].sort((a, b) => + a.localeCompare(b), + ); + const entry = toRevisionPayloadEntry({ + timesOfDay: times, + windowStart: times[0], + windowEnd: times[times.length - 1], + daysOfWeek: null, + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: null, + label: null, + dose: null, + reminderGraceMinutes: null, + }); + + const revision = await prisma.medicationScheduleRevision.create({ + data: { + medicationId: id, + validFrom, + validUntil, + source: "MANUAL", + payload: [entry] as unknown as Prisma.InputJsonValue, + }, + select: { + id: true, + validFrom: true, + validUntil: true, + source: true, + payload: true, + }, + }); + + await auditLog("medication.schedule_revision.created", { + userId: user.id, + ipAddress: getClientIp(request), + details: { medicationId: id, revisionId: revision.id }, + }); + + annotate({ + action: { + name: "medication.schedule_revision.manual_created", + entity_type: "medication", + entity_id: id, + }, + meta: { + revision_id: revision.id, + era_days: Math.round( + (validUntil.getTime() - validFrom.getTime()) / 86_400_000, + ), + }, + }); + + // The new era re-segments history the compliance rollups already + // pre-aggregated; refresh them asynchronously. Best-effort like + // every other rollup write-hook. + await enqueueUserMedicationComplianceBackfill(user.id); + + return apiSuccess( + { + id: revision.id, + validFrom: revision.validFrom.toISOString(), + validUntil: revision.validUntil.toISOString(), + source: revision.source, + entries: summariseEntries(revision.payload), + }, + 201, + ); + }, +); diff --git a/src/components/medications/detail/MedicationDetailTabs.tsx b/src/components/medications/detail/MedicationDetailTabs.tsx index d9bc5ebda..25f90fb8b 100644 --- a/src/components/medications/detail/MedicationDetailTabs.tsx +++ b/src/components/medications/detail/MedicationDetailTabs.tsx @@ -50,6 +50,7 @@ import { MedicationDetailSection } from "@/components/medications/medication-det import { MedicationComplianceBars } from "@/components/medications/card-parts/medication-compliance-bars"; import { DoseHistoryLedger } from "@/components/medications/dose-history-ledger"; import { ScheduleTimesEditor } from "@/components/medications/scheduling/ScheduleTimesEditor"; +import { ScheduleHistoryTimeline } from "@/components/medications/scheduling/schedule-history-timeline"; import type { DoseWindowEntry } from "@/components/medications/scheduling/dose-window"; import { IntakeImportDialog } from "@/components/medications/intake-import-dialog"; import { InventorySection } from "@/components/medications/sections/inventory-section"; @@ -536,6 +537,24 @@ export function MedicationDetailTabs({

+ + {/* PLANHISTORIE — archived schedule eras (v1.16.3 effective + dating) as a quiet collapsible timeline, plus the manual + pre-tracking-era flow. Read-only for write-path archives. */} + + + s.timesOfDay && s.timesOfDay.length > 0 + ? s.timesOfDay + : [s.windowStart], + )} + /> + {/* BESTAND — inventory readout + register / correct flows. */} diff --git a/src/components/medications/scheduling/__tests__/schedule-history-timeline.test.tsx b/src/components/medications/scheduling/__tests__/schedule-history-timeline.test.tsx new file mode 100644 index 000000000..5be67d7a7 --- /dev/null +++ b/src/components/medications/scheduling/__tests__/schedule-history-timeline.test.tsx @@ -0,0 +1,180 @@ +/** + * v1.16.5 — schedule-history timeline SSR smoke tests. + * + * Project convention: `renderToStaticMarkup` + assertions against the + * SSR string, react-query data seeded via `setQueryData` so `useQuery` + * resolves synchronously (no `@testing-library/react` installed). + * Interactive branches (toggle click, dialog submit, delete confirm) + * are covered by the route tests + the `defaultExpanded` render path + * here; the contracts pinned: + * + * 1. Live plan row: deduped/sorted times + "current since" line. + * 2. Collapsed default: era rows absent, toggle carries the count. + * 3. Expanded: each era renders times + date range; MANUAL eras get + * the chip + delete affordance, ARCHIVED eras get neither. + * 4. No revisions: no toggle, the add-era CTA still renders. + * 5. `eraTimes` dedupes/sorts across entries and drops non-arrays. + */ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { + ScheduleHistoryTimeline, + eraTimes, + type ScheduleRevisionRow, +} from "@/components/medications/scheduling/schedule-history-timeline"; +import { queryKeys } from "@/lib/query-keys"; + +const MED_ID = "med-1"; + +function makeClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Number.POSITIVE_INFINITY, + refetchOnMount: false, + }, + }, + }); +} + +function seed( + client: QueryClient, + data: { currentSince: string; revisions: ScheduleRevisionRow[] }, +) { + client.setQueryData(queryKeys.medicationScheduleRevisions(MED_ID), data); +} + +function render( + client: QueryClient, + props?: Partial[0]>, +) { + return renderToStaticMarkup( + + + + + , + ); +} + +const MANUAL_ERA: ScheduleRevisionRow = { + id: "rev-manual", + validFrom: "2026-03-12T00:00:00.000Z", + validUntil: "2026-06-01T00:00:00.000Z", + source: "MANUAL", + entries: [ + { + timesOfDay: ["07:00", "19:00"], + label: null, + dose: null, + scheduleType: "SCHEDULED", + }, + ], +}; + +const ARCHIVED_ERA: ScheduleRevisionRow = { + id: "rev-archived", + validFrom: "2026-01-10T00:00:00.000Z", + validUntil: "2026-03-12T00:00:00.000Z", + source: "ARCHIVED", + entries: [ + { + timesOfDay: ["09:00"], + label: "Morgens", + dose: null, + scheduleType: "SCHEDULED", + }, + ], +}; + +describe("ScheduleHistoryTimeline", () => { + it("renders the live plan with deduped, sorted times and the since line", () => { + const client = makeClient(); + seed(client, { currentSince: "2026-06-01T00:00:00.000Z", revisions: [] }); + const html = render(client); + expect(html).toContain("08:00 / 20:00"); + expect(html).toContain("Aktueller Plan seit"); + expect(html).toContain("01.06.2026"); + }); + + it("collapses predecessor eras by default and counts them in the toggle", () => { + const client = makeClient(); + seed(client, { + currentSince: "2026-06-01T00:00:00.000Z", + revisions: [MANUAL_ERA, ARCHIVED_ERA], + }); + const html = render(client); + expect(html).toContain("2 frühere Ären anzeigen"); + expect(html).not.toContain("07:00 / 19:00"); + expect(html).not.toContain("zeitplan-history-era"); + }); + + it("expanded: era rows carry times, range, and MANUAL-only affordances", () => { + const client = makeClient(); + seed(client, { + currentSince: "2026-06-01T00:00:00.000Z", + revisions: [MANUAL_ERA, ARCHIVED_ERA], + }); + const html = render(client, { defaultExpanded: true }); + expect(html).toContain("07:00 / 19:00"); + expect(html).toContain("12.03.2026"); + expect(html).toContain("01.06.2026"); + expect(html).toContain("Manuell ergänzt"); + expect(html).toContain("Ära löschen"); + // Exactly ONE manual chip + ONE delete button — the archived era + // renders read-only. + expect(html.match(/zeitplan-history-manual-chip/g)).toHaveLength(1); + expect(html.match(/zeitplan-history-delete/g)).toHaveLength(1); + expect(html).toContain("Frühere Ären ausblenden"); + }); + + it("renders the add-era CTA without a toggle when no era exists", () => { + const client = makeClient(); + seed(client, { currentSince: "2026-06-01T00:00:00.000Z", revisions: [] }); + const html = render(client); + expect(html).toContain("Frühere Ära ergänzen"); + expect(html).not.toContain("zeitplan-history-toggle"); + }); +}); + +describe("eraTimes", () => { + it("dedupes and sorts across entries", () => { + expect( + eraTimes([ + { + timesOfDay: ["19:00", "07:00"], + label: null, + dose: null, + scheduleType: "SCHEDULED", + }, + { + timesOfDay: ["07:00", "12:00"], + label: null, + dose: null, + scheduleType: "SCHEDULED", + }, + ]), + ).toEqual(["07:00", "12:00", "19:00"]); + }); + + it("tolerates a non-array timesOfDay", () => { + expect( + eraTimes([ + { + timesOfDay: undefined as unknown as string[], + label: null, + dose: null, + scheduleType: "PRN", + }, + ]), + ).toEqual([]); + }); +}); diff --git a/src/components/medications/scheduling/schedule-history-timeline.tsx b/src/components/medications/scheduling/schedule-history-timeline.tsx new file mode 100644 index 000000000..1da2322ca --- /dev/null +++ b/src/components/medications/scheduling/schedule-history-timeline.tsx @@ -0,0 +1,530 @@ +"use client"; + +/** + * v1.16.5 — Zeitplan-tab schedule-history timeline. + * + * The ledger and compliance tallies have minted past days against + * archived schedule eras since v1.16.3; this is the missing management + * surface. A quiet vertical timeline under the times editor: + * + * - the live plan as the accent node ("Aktueller Plan seit {Datum}", + * with its times when the parent passes them), + * - predecessor eras collapsed behind a count toggle — each row reads + * "07:00 / 19:00 · 12.03.2026 – 01.06.2026", read-only, + * - a "Frühere Ära ergänzen" flow for pre-tracking history: the + * dialog collects daily times (the existing `TimesOfDayChips` + * idiom) plus a Von/Bis date pair and POSTs a MANUAL revision, + * - MANUAL eras carry a tinted chip and a delete affordance + * (confirmed via AlertDialog); write-path archives are immutable + * and render without one. + * + * The "Bis" date is the day the NEXT plan took over (exclusive bound) — + * the dialog copy says so, and adjacency with the live plan is the + * common case. Dates convert to instants at the browser's local + * midnight, matching the user's wall clock like every schedule input. + */ +import { useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { ChevronDown, History, Loader2, Plus, Trash2 } from "lucide-react"; +import { toast } from "sonner"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { DateInput } from "@/components/ui/date-input"; +import { Label } from "@/components/ui/label"; +import { TimesOfDayChips } from "@/components/medications/scheduling/TimesOfDayChips"; +import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { cn } from "@/lib/utils"; + +interface RevisionEntrySummary { + timesOfDay: string[]; + label: string | null; + dose: string | null; + scheduleType: string; +} + +export interface ScheduleRevisionRow { + id: string; + validFrom: string; + validUntil: string; + source: "ARCHIVED" | "MANUAL"; + entries: RevisionEntrySummary[]; +} + +interface ScheduleRevisionsResponse { + currentSince: string; + revisions: ScheduleRevisionRow[]; +} + +/** + * Dose-time readout of one era: the union of its entries' times, + * deduped + sorted ("07:00 / 19:00"). PRN-only eras have no times — + * the caller renders its own placeholder for an empty result. + */ +export function eraTimes(entries: RevisionEntrySummary[]): string[] { + const all = entries.flatMap((e) => + Array.isArray(e.timesOfDay) ? e.timesOfDay : [], + ); + return [...new Set(all)].sort((a, b) => a.localeCompare(b)); +} + +export function ScheduleHistoryTimeline({ + medicationId, + currentTimes, + defaultExpanded = false, +}: { + medicationId: string; + /** Live-plan dose times the parent already holds (deduped here). */ + currentTimes: string[]; + /** Start with the predecessor eras unfolded (default collapsed). */ + defaultExpanded?: boolean; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const queryClient = useQueryClient(); + const [expanded, setExpanded] = useState(defaultExpanded); + const [addOpen, setAddOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState( + null, + ); + + const { data, isError } = useQuery({ + queryKey: queryKeys.medicationScheduleRevisions(medicationId), + queryFn: async () => + apiGet( + `/api/medications/${medicationId}/schedule-revisions`, + ), + staleTime: 30_000, + }); + + const liveTimes = useMemo( + () => [...new Set(currentTimes)].sort((a, b) => a.localeCompare(b)), + [currentTimes], + ); + + const remove = useMutation({ + mutationFn: async (revisionId: string) => { + await apiDelete( + `/api/medications/${medicationId}/schedule-revisions/${revisionId}`, + ); + }, + onSuccess: () => { + toast.success(t("medications.detail.zeitplan.history.deletedToast")); + // An era delete re-segments history — the ledger, compliance, and + // cadence reads under this medication's prefix all go stale. + queryClient.invalidateQueries({ + queryKey: queryKeys.medicationDetail(medicationId), + }); + }, + onError: () => { + toast.error(t("medications.detail.zeitplan.history.deleteError")); + }, + }); + + if (isError) { + return ( +

+ {t("medications.detail.zeitplan.history.loadError")} +

+ ); + } + if (!data) return null; + + const revisions = data.revisions; + + return ( +
+
    + {/* Live plan — the accent node. */} + 0} + dataSlot="zeitplan-history-current" + > +
    +

    + {liveTimes.length > 0 + ? liveTimes.join(" / ") + : t("medications.detail.zeitplan.history.noTimes")} +

    +

    + {t("medications.detail.zeitplan.history.currentSince", { + date: fmt.date(data.currentSince), + })} +

    +
    +
    + + {/* Predecessor eras — newest first, only when expanded. */} + {expanded && + revisions.map((revision, idx) => { + const times = eraTimes(revision.entries); + return ( + +
    +
    +

    + {times.length > 0 + ? times.join(" / ") + : t("medications.detail.zeitplan.history.noTimes")} + {revision.source === "MANUAL" && ( + + {t("medications.detail.zeitplan.history.manualChip")} + + )} +

    +

    + {fmt.date(revision.validFrom)} + {" – "} + {fmt.date(revision.validUntil)} +

    +
    + {revision.source === "MANUAL" && ( + + )} +
    +
    + ); + })} +
+ +
+ {revisions.length > 0 && ( + + )} + +
+ + setExpanded(true)} + /> + + { + if (!open) setDeleteTarget(null); + }} + > + + + + {t("medications.detail.zeitplan.history.deleteConfirmTitle")} + + + {t("medications.detail.zeitplan.history.deleteConfirmBody")} + + + + + {t("medications.detail.zeitplan.history.cancel")} + + { + if (deleteTarget) remove.mutate(deleteTarget.id); + setDeleteTarget(null); + }} + > + {t("medications.detail.zeitplan.history.deleteConfirmAction")} + + + + +
+ ); +} + +/** + * One timeline node: dot + optional rail segment down to the next row. + * The rail is a plain absolutely-positioned line (not `border-l` on the + * list) so the last row never paints a dangling tail. + */ +function TimelineRow({ + dotClassName, + railVisible, + dataSlot, + children, +}: { + dotClassName: string; + railVisible: boolean; + dataSlot: string; + children: React.ReactNode; +}) { + return ( +
  • +
  • + ); +} + +/** + * "Frühere Ära ergänzen" — collects daily dose times + the Von/Bis date + * pair and POSTs a MANUAL schedule revision. Client-side guards mirror + * the server validation (times present, Von before Bis); the server + * stays authoritative for overlap / before-current-plan and its message + * surfaces through the error toast. + */ +function AddEraDialog({ + medicationId, + open, + onOpenChange, + onCreated, +}: { + medicationId: string; + open: boolean; + onOpenChange: (open: boolean) => void; + onCreated: () => void; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [times, setTimes] = useState([]); + const [fromDate, setFromDate] = useState(""); + const [untilDate, setUntilDate] = useState(""); + const [validationKey, setValidationKey] = useState< + "timesMissing" | "rangeMissing" | "rangeOrder" | null + >(null); + + function reset() { + setTimes([]); + setFromDate(""); + setUntilDate(""); + setValidationKey(null); + } + + const create = useMutation({ + mutationFn: async (input: { + validFrom: string; + validUntil: string; + timesOfDay: string[]; + }) => + apiPost( + `/api/medications/${medicationId}/schedule-revisions`, + input, + ), + onSuccess: () => { + toast.success(t("medications.detail.zeitplan.history.createdToast")); + queryClient.invalidateQueries({ + queryKey: queryKeys.medicationDetail(medicationId), + }); + onOpenChange(false); + reset(); + onCreated(); + }, + onError: () => { + toast.error(t("medications.detail.zeitplan.history.createError")); + }, + }); + + function submit() { + if (times.length === 0) { + setValidationKey("timesMissing"); + return; + } + if (!fromDate || !untilDate) { + setValidationKey("rangeMissing"); + return; + } + // Local-midnight instants: the era bounds follow the user's wall + // clock, the same way every schedule time input does. + const from = new Date(`${fromDate}T00:00:00`); + const until = new Date(`${untilDate}T00:00:00`); + if (from.getTime() >= until.getTime()) { + setValidationKey("rangeOrder"); + return; + } + setValidationKey(null); + create.mutate({ + validFrom: from.toISOString(), + validUntil: until.toISOString(), + timesOfDay: times, + }); + } + + return ( + { + onOpenChange(next); + if (!next) reset(); + }} + > + + + + {t("medications.detail.zeitplan.history.addEraTitle")} + + + {t("medications.detail.zeitplan.history.addEraDescription")} + + +
    { + e.preventDefault(); + if (create.isPending) return; + submit(); + }} + className="space-y-4" + > +
    +

    + {t("medications.detail.zeitplan.history.timesLabel")} +

    + +
    +
    +
    + + setFromDate(e.target.value)} + data-slot="zeitplan-history-from" + /> +
    +
    + + setUntilDate(e.target.value)} + data-slot="zeitplan-history-until" + /> +
    +
    +

    + {t("medications.detail.zeitplan.history.untilHint")} +

    + {validationKey !== null && ( +

    + {validationKey === "timesMissing" && + t("medications.detail.zeitplan.history.validationTimes")} + {validationKey === "rangeMissing" && + t("medications.detail.zeitplan.history.validationRangeMissing")} + {validationKey === "rangeOrder" && + t("medications.detail.zeitplan.history.validationRangeOrder")} +

    + )} + + + + +
    +
    +
    + ); +} diff --git a/src/lib/openapi/routes/medications.ts b/src/lib/openapi/routes/medications.ts index 7cfa3bc82..aaf7cfb7b 100644 --- a/src/lib/openapi/routes/medications.ts +++ b/src/lib/openapi/routes/medications.ts @@ -15,6 +15,7 @@ import { MEDICATION_TREATMENT_CLASS_VALUES, } from "@/lib/validations/medication"; import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-prompt"; +import { scheduleRevisionCreateSchema } from "@/lib/validations/schedule-revision"; import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; // ── Medications (v1.5 scheduling) ──────────────────────────────────── @@ -503,6 +504,60 @@ const medicationComplianceResponse = z "Adherence read for a single medication. `compliance30` is the authoritative 30-day taken-vs-expected summary; `dailyCompliance` is the per-day grid for the history glyph track. The graded raw→week→month→year series used elsewhere for AI prompts does NOT apply here — this response is never downsampled.", }); +// v1.16.5 — schedule-era management (the Zeitplan-tab history timeline). +// Archived eras come from two provenances: the wholesale-replace write +// path (`ARCHIVED`, immutable) and the user-entered pre-tracking flow +// (`MANUAL`, deletable through the `[revisionId]` DELETE). +const scheduleRevisionEntrySummary = z + .object({ + timesOfDay: z + .array(z.string()) + .describe("Daily dose times (HH:mm, user local) the era ran at."), + label: z.string().nullable(), + dose: z.string().nullable(), + scheduleType: z + .string() + .describe("Schedule-type discriminator of the archived row (SCHEDULED / PRN / CYCLIC)."), + }) + .meta({ + id: "ScheduleRevisionEntry", + description: + "Display summary of one archived schedule row inside an era. The full snapshot (windows, rrule, doseWindows, …) stays server-side; this projection carries what the timeline renders.", + }); + +const scheduleRevisionResource = z + .object({ + id: z.string(), + validFrom: z.iso + .datetime() + .describe("Inclusive start instant of the era."), + validUntil: z.iso + .datetime() + .describe("Exclusive end instant of the era — the moment the next plan took over."), + source: z + .enum(["ARCHIVED", "MANUAL"]) + .describe( + "Provenance. ARCHIVED = minted by the schedule-replace write path (immutable). MANUAL = user-entered pre-tracking era (deletable).", + ), + entries: z.array(scheduleRevisionEntrySummary), + }) + .meta({ + id: "MedicationScheduleRevision", + description: + "One archived schedule era covering `[validFrom, validUntil)`. The dose-history ledger, compliance tallies, and cadence chips mint past days against the era that was live then.", + }); + +const scheduleRevisionListResponse = z.object({ + currentSince: z.iso + .datetime() + .describe( + "Instant the LIVE plan took over: the newest revision's `validUntil`, or the medication's `createdAt` when no era has been archived.", + ), + revisions: z + .array(scheduleRevisionResource) + .describe("Archived eras, newest first."), +}); + // v1.5.0 — natural-language medication extraction route. The wizard's // optional "Beschreiben" overlay POSTs a free-text description and // receives a partial structured payload the form merges onto whatever @@ -827,4 +882,101 @@ export const medicationPaths: NonNullable = { }, }, }, + "/api/medications/{id}/schedule-revisions": { + get: { + tags: ["Medications"], + summary: "List a medication's archived schedule eras", + description: + "Returns every archived schedule era (newest first) plus `currentSince`, the instant the live plan took over. The dose-history ledger and compliance tallies already mint past days against these eras; this read powers the Zeitplan-tab history timeline.", + requestParams: { + path: z.object({ id: z.string() }), + }, + responses: { + "200": { + description: "Era list.", + content: { + "application/json": { + schema: dataEnvelope( + scheduleRevisionListResponse, + "ListScheduleRevisionsResponse", + ), + }, + }, + }, + "404": { + description: "Medication not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + post: { + tags: ["Medications"], + summary: "Append a manual schedule era (pre-tracking history)", + description: + "Records that the medication dosed at the given daily times during `[validFrom, validUntil)` — history from before the schedule was edited in the app. The era must end at or before the start of the live plan and must not overlap an existing era; violations return 422. The snapshot is shaped exactly like a write-path archive (`FREQ=DAILY`, window pulled to the min/max of the times), so every historical surface reads it transparently. Audits as `medication.schedule_revision.created`.", + requestParams: { + path: z.object({ id: z.string() }), + }, + requestBody: { + required: true, + content: { + "application/json": { schema: scheduleRevisionCreateSchema }, + }, + }, + responses: { + "201": { + description: "Manual era created.", + content: { + "application/json": { + schema: dataEnvelope( + scheduleRevisionResource, + "CreateScheduleRevisionResponse", + ), + }, + }, + }, + "404": { + description: "Medication not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/medications/{id}/schedule-revisions/{revisionId}": { + delete: { + tags: ["Medications"], + summary: "Delete a manually added schedule era", + description: + "Removes a `MANUAL` era the owner appended through the sibling POST. Write-path archives (`source: ARCHIVED`) are immutable history and refuse with 409. Audits as `medication.schedule_revision.deleted`.", + requestParams: { + path: z.object({ id: z.string(), revisionId: z.string() }), + }, + responses: { + "200": { + description: "Deletion succeeded.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ deleted: z.boolean() }), + "DeleteScheduleRevisionResponse", + ), + }, + }, + }, + "404": { + description: + "Medication or revision not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + "409": { + description: + "The revision is a write-path archive (`ARCHIVED`) and cannot be deleted.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, }; diff --git a/src/lib/query-keys/medications.ts b/src/lib/query-keys/medications.ts index 02384524c..278ef062e 100644 --- a/src/lib/query-keys/medications.ts +++ b/src/lib/query-keys/medications.ts @@ -27,6 +27,15 @@ export const medicationKeys = { */ medicationInventory: (medicationId: string) => ["medications", medicationId, "inventory"] as const, + /** + * v1.16.5 — archived schedule eras + `currentSince` + * (`GET /api/medications/[id]/schedule-revisions`), consumed by the + * Zeitplan tab's history timeline. Rides under the `["medications"]` + * prefix so a schedule replace (which may archive a new era) + * invalidates it together with the detail read. + */ + medicationScheduleRevisions: (medicationId: string) => + ["medications", medicationId, "schedule-revisions"] as const, /** * v1.15.18 — per-medication dose-history ledger * (`GET /api/medications/[id]/dose-history?from=&to=`), consumed by the