mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
feat(medications,admin): the schedule's history is visible and invites count their uses
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.
This commit is contained in:
1 parent
a62e00cfcd
commit
5639da4333
11 files changed
+1847
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
@@ -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)
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<ScheduleRevisionEntry>;
|
||||
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,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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({
|
||||
</p>
|
||||
</div>
|
||||
</MedicationDetailSection>
|
||||
|
||||
{/* 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. */}
|
||||
<MedicationDetailSection
|
||||
titleId="medication-zeitplan-history-heading"
|
||||
title={t("medications.detail.zeitplan.history.title")}
|
||||
dataSlot="medication-zeitplan-history"
|
||||
>
|
||||
<ScheduleHistoryTimeline
|
||||
medicationId={id}
|
||||
currentTimes={medication.schedules.flatMap((s) =>
|
||||
s.timesOfDay && s.timesOfDay.length > 0
|
||||
? s.timesOfDay
|
||||
: [s.windowStart],
|
||||
)}
|
||||
/>
|
||||
</MedicationDetailSection>
|
||||
</TabsContent>
|
||||
|
||||
{/* BESTAND — inventory readout + register / correct flows. */}
|
||||
|
||||
@@ -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<Parameters<typeof ScheduleHistoryTimeline>[0]>,
|
||||
) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="de">
|
||||
<QueryClientProvider client={client}>
|
||||
<ScheduleHistoryTimeline
|
||||
medicationId={MED_ID}
|
||||
currentTimes={["20:00", "08:00", "08:00"]}
|
||||
{...props}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<ScheduleRevisionRow | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const { data, isError } = useQuery({
|
||||
queryKey: queryKeys.medicationScheduleRevisions(medicationId),
|
||||
queryFn: async () =>
|
||||
apiGet<ScheduleRevisionsResponse>(
|
||||
`/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 (
|
||||
<p className="text-muted-foreground text-sm" data-slot="zeitplan-history">
|
||||
{t("medications.detail.zeitplan.history.loadError")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (!data) return null;
|
||||
|
||||
const revisions = data.revisions;
|
||||
|
||||
return (
|
||||
<div className="space-y-3" data-slot="zeitplan-history">
|
||||
<ol className="space-y-0">
|
||||
{/* Live plan — the accent node. */}
|
||||
<TimelineRow
|
||||
dotClassName="bg-dracula-green"
|
||||
railVisible={expanded && revisions.length > 0}
|
||||
dataSlot="zeitplan-history-current"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-foreground text-sm font-medium tabular-nums">
|
||||
{liveTimes.length > 0
|
||||
? liveTimes.join(" / ")
|
||||
: t("medications.detail.zeitplan.history.noTimes")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.detail.zeitplan.history.currentSince", {
|
||||
date: fmt.date(data.currentSince),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</TimelineRow>
|
||||
|
||||
{/* Predecessor eras — newest first, only when expanded. */}
|
||||
{expanded &&
|
||||
revisions.map((revision, idx) => {
|
||||
const times = eraTimes(revision.entries);
|
||||
return (
|
||||
<TimelineRow
|
||||
key={revision.id}
|
||||
dotClassName="bg-muted-foreground/50"
|
||||
railVisible={idx < revisions.length - 1}
|
||||
dataSlot="zeitplan-history-era"
|
||||
>
|
||||
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-foreground text-sm tabular-nums">
|
||||
{times.length > 0
|
||||
? times.join(" / ")
|
||||
: t("medications.detail.zeitplan.history.noTimes")}
|
||||
{revision.source === "MANUAL" && (
|
||||
<span
|
||||
className="border-dracula-purple/30 bg-dracula-purple/10 text-dracula-purple ml-2 inline-flex rounded-full border px-2 py-px align-middle text-[11px] font-medium"
|
||||
data-slot="zeitplan-history-manual-chip"
|
||||
>
|
||||
{t("medications.detail.zeitplan.history.manualChip")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs tabular-nums">
|
||||
{fmt.date(revision.validFrom)}
|
||||
{" – "}
|
||||
{fmt.date(revision.validUntil)}
|
||||
</p>
|
||||
</div>
|
||||
{revision.source === "MANUAL" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-dracula-red size-8 shrink-0"
|
||||
aria-label={t(
|
||||
"medications.detail.zeitplan.history.deleteAria",
|
||||
)}
|
||||
disabled={remove.isPending}
|
||||
onClick={() => setDeleteTarget(revision)}
|
||||
data-slot="zeitplan-history-delete"
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TimelineRow>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{revisions.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
aria-expanded={expanded}
|
||||
data-slot="zeitplan-history-toggle"
|
||||
>
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"size-4 transition-transform motion-reduce:transition-none",
|
||||
expanded && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
{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,
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAddOpen(true)}
|
||||
data-slot="zeitplan-history-add"
|
||||
>
|
||||
<Plus className="size-4" aria-hidden="true" />
|
||||
{t("medications.detail.zeitplan.history.addEra")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AddEraDialog
|
||||
medicationId={medicationId}
|
||||
open={addOpen}
|
||||
onOpenChange={setAddOpen}
|
||||
onCreated={() => setExpanded(true)}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={deleteTarget !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteTarget(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("medications.detail.zeitplan.history.deleteConfirmTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("medications.detail.zeitplan.history.deleteConfirmBody")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("medications.detail.zeitplan.history.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
if (deleteTarget) remove.mutate(deleteTarget.id);
|
||||
setDeleteTarget(null);
|
||||
}}
|
||||
>
|
||||
{t("medications.detail.zeitplan.history.deleteConfirmAction")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<li className="relative pb-4 pl-5 last:pb-0" data-slot={dataSlot}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"absolute top-1.5 left-0 size-2 rounded-full",
|
||||
dotClassName,
|
||||
)}
|
||||
/>
|
||||
{railVisible && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="bg-border/70 absolute top-4 bottom-0 left-[3.5px] w-px"
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "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<string[]>([]);
|
||||
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<ScheduleRevisionRow>(
|
||||
`/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 (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
onOpenChange(next);
|
||||
if (!next) reset();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("medications.detail.zeitplan.history.addEraTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("medications.detail.zeitplan.history.addEraDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (create.isPending) return;
|
||||
submit();
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-sm font-medium">
|
||||
{t("medications.detail.zeitplan.history.timesLabel")}
|
||||
</p>
|
||||
<TimesOfDayChips value={times} onChange={setTimes} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="zeitplan-history-from">
|
||||
{t("medications.detail.zeitplan.history.fromLabel")}
|
||||
</Label>
|
||||
<DateInput
|
||||
id="zeitplan-history-from"
|
||||
value={fromDate}
|
||||
onChange={(e) => setFromDate(e.target.value)}
|
||||
data-slot="zeitplan-history-from"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="zeitplan-history-until">
|
||||
{t("medications.detail.zeitplan.history.untilLabel")}
|
||||
</Label>
|
||||
<DateInput
|
||||
id="zeitplan-history-until"
|
||||
value={untilDate}
|
||||
onChange={(e) => setUntilDate(e.target.value)}
|
||||
data-slot="zeitplan-history-until"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.detail.zeitplan.history.untilHint")}
|
||||
</p>
|
||||
{validationKey !== null && (
|
||||
<p
|
||||
className="text-dracula-red text-sm"
|
||||
role="alert"
|
||||
data-slot="zeitplan-history-validation"
|
||||
>
|
||||
{validationKey === "timesMissing" &&
|
||||
t("medications.detail.zeitplan.history.validationTimes")}
|
||||
{validationKey === "rangeMissing" &&
|
||||
t("medications.detail.zeitplan.history.validationRangeMissing")}
|
||||
{validationKey === "rangeOrder" &&
|
||||
t("medications.detail.zeitplan.history.validationRangeOrder")}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t("medications.detail.zeitplan.history.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={create.isPending}
|
||||
aria-busy={create.isPending || undefined}
|
||||
data-slot="zeitplan-history-submit"
|
||||
>
|
||||
{create.isPending ? (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<History className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
{t("medications.detail.zeitplan.history.submit")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user