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" && (
+
setDeleteTarget(revision)}
+ data-slot="zeitplan-history-delete"
+ >
+
+
+ )}
+
+
+ );
+ })}
+
+
+
+ {revisions.length > 0 && (
+
setExpanded((v) => !v)}
+ aria-expanded={expanded}
+ data-slot="zeitplan-history-toggle"
+ >
+
+ {expanded
+ ? t("medications.detail.zeitplan.history.hideEras")
+ : revisions.length === 1
+ ? t("medications.detail.zeitplan.history.showErasOne")
+ : t("medications.detail.zeitplan.history.showErasOther", {
+ count: revisions.length,
+ })}
+
+ )}
+
setAddOpen(true)}
+ data-slot="zeitplan-history-add"
+ >
+
+ {t("medications.detail.zeitplan.history.addEra")}
+
+
+
+
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 (
+
+
+ {railVisible && (
+
+ )}
+ {children}
+
+ );
+}
+
+/**
+ * "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")}
+
+
+
+
+
+ );
+}
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