diff --git a/e2e/medications-wizard-compose.spec.ts b/e2e/medications-wizard-compose.spec.ts new file mode 100644 index 000000000..47a751fb6 --- /dev/null +++ b/e2e/medications-wizard-compose.spec.ts @@ -0,0 +1,179 @@ +import { expect, test, type Route } from "@playwright/test"; + +import { + STUB_MEDICATION_ID, + clickNext, + clickSave, + expectMedicationOnList, + expectRedirectToMedication, + expectStep, + fillStep1Name, + fillStep3Dose, + openCreateWizard, + pickCadenceRow, + pickTreatmentRow, + stubDashboardAnalytics, +} from "./medications-wizard-helpers"; +import { STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * v1.5.4 — Medication wizard end-to-end — compose-mode. + * + * Walks the create flow through to Step 8, then taps + * "Weiteren Zeitplan hinzufügen" to append a second weekly Wednesday + * schedule on the same medication. The list view surfaces both + * summary cards before save, and the POST body carries both + * schedules so the route can persist them in parallel. + * + * Use case the maintainer cares about: short-acting + long-acting + * insulin on a single medication. Today the wizard collapses to one + * schedule on save; compose-mode closes that data-loss gap. + */ +test.describe("medication wizard — compose-mode", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test("creates a 2-schedule medication end-to-end", async ({ page }) => { + await stubDashboardAnalytics(page); + + const listEntry = { + id: STUB_MEDICATION_ID, + name: "Insulin", + dose: "10 IE", + category: "DIABETES", + active: true, + notificationsEnabled: true, + pausedAt: null, + lastTakenAt: null, + startsOn: null, + endsOn: null, + oneShot: false, + schedules: [ + { + id: "sch_e2e_1", + windowStart: "08:00", + windowEnd: "09:00", + label: null, + dose: null, + daysOfWeek: null, + timesOfDay: ["08:00"], + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + reminderGraceMinutes: null, + }, + { + id: "sch_e2e_2", + windowStart: "20:00", + windowEnd: "21:00", + label: null, + dose: null, + daysOfWeek: null, + timesOfDay: ["20:00"], + rrule: "FREQ=WEEKLY;BYDAY=WE", + rollingIntervalDays: null, + reminderGraceMinutes: null, + }, + ], + }; + + const capture: { postBody: unknown } = { postBody: null }; + void page.route("**/api/medications**", async (route: Route) => { + const req = route.request(); + const url = new URL(req.url()); + if (url.pathname !== "/api/medications") { + await route.fallback(); + return; + } + if (req.method() === "POST") { + capture.postBody = JSON.parse(req.postData() ?? "null"); + await route.fulfill({ + status: 201, + contentType: "application/json", + body: JSON.stringify({ data: { ...listEntry }, error: null }), + }); + return; + } + if (req.method() === "GET") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data: [listEntry], error: null }), + }); + return; + } + await route.fallback(); + }); + + await openCreateWizard(page); + + // Steps 1-4 — medication-global. + await fillStep1Name(page, { name: "Insulin" }); + await clickNext(page); + await expectStep(page, 2); + await pickTreatmentRow(page, "diabetes"); + await clickNext(page); + await expectStep(page, 3); + await fillStep3Dose(page, { amount: "10" }); + await clickNext(page); + await expectStep(page, 4); + await clickNext(page); + + // Steps 5-7 — first schedule, daily cadence. + await expectStep(page, 5, 8); + await pickCadenceRow(page, "daily"); + await expectStep(page, 5, 7); + await clickNext(page); + await expectStep(page, 6, 7); + await clickNext(page); + + // Step 8 — list view with the first schedule rendered. Tap + // "Weiteren Zeitplan hinzufügen" to append a second draft. + await expectStep(page, 7, 7); + let cards = page.locator('[data-slot="wizard-schedule-card"]'); + await expect(cards).toHaveCount(1); + await page.locator('[data-slot="wizard-schedule-add"]').click(); + + // Second schedule — weekdays on Wednesday. + await expectStep(page, 5); + await pickCadenceRow(page, "weekdays"); + await clickNext(page); + await expectStep(page, 6); + const weChip = page.locator( + '[data-slot="wizard-weekday-chip"][data-token="WE"]', + ); + if ((await weChip.getAttribute("data-active")) === "false") { + await weChip.click(); + } + // Default MO chip — toggle off so the rrule is WE-only. + const moChip = page.locator( + '[data-slot="wizard-weekday-chip"][data-token="MO"]', + ); + if ((await moChip.getAttribute("data-active")) === "true") { + await moChip.click(); + } + await clickNext(page); + await expectStep(page, 7); + await clickNext(page); + + // Back on Step 8 — both summary cards should land before save. + await expectStep(page, 8); + cards = page.locator('[data-slot="wizard-schedule-card"]'); + await expect(cards).toHaveCount(2); + + await clickSave(page); + + await expectRedirectToMedication(page, STUB_MEDICATION_ID); + expect(capture.postBody).toMatchObject({ + name: "Insulin", + oneShot: false, + schedules: [ + { rrule: "FREQ=DAILY" }, + { rrule: "FREQ=WEEKLY;BYDAY=WE" }, + ], + }); + + await expectMedicationOnList(page, { + name: "Insulin", + withNextDueChip: true, + }); + }); +}); diff --git a/src/components/medications/wizard/__tests__/wizard-payload.test.ts b/src/components/medications/wizard/__tests__/wizard-payload.test.ts index 3caf5bd74..a127f689e 100644 --- a/src/components/medications/wizard/__tests__/wizard-payload.test.ts +++ b/src/components/medications/wizard/__tests__/wizard-payload.test.ts @@ -1,12 +1,17 @@ import { describe, expect, it } from "vitest"; import { + addSchedule, buildCreateBody, + commitActiveDraft, emptyWizardPayload, hydrateWizardPayload, + landingStepForEdit, type MedicationPayload, progressIndices, + removeSchedule, rowFromTreatment, + setActiveSchedule, summariseCadence, validateStep, type WizardPayload, @@ -576,3 +581,158 @@ describe("hydrateWizardPayload", () => { expect(out.subControls.intervalWeeks).toBe(2); }); }); + +describe("compose-mode — multi-schedule encoder + hydrator", () => { + it("buildCreateBody emits every schedule for a 2-schedule medication", () => { + // Active draft is a recurring daily schedule (the wizard flat + // mirror). A second draft sits in the list as a weekly Wednesday + // schedule. Both must land in the encoded body. + const base = withCadence("daily"); + const second = withCadence("weekdays", { weekdays: ["WE"] }); + const payload: WizardPayload = { + ...base, + treatmentRow: "diabetes", + timesOfDay: ["08:00"], + startsOn: new Date(Date.UTC(2026, 4, 28)), + schedules: [ + // First draft mirrors the flat fields — buildCreateBody + // commits the active draft before encoding. + { + mode: base.mode, + cadence: base.cadence, + subControls: base.subControls, + timesOfDay: ["08:00"], + }, + { + mode: second.mode, + cadence: second.cadence, + subControls: second.subControls, + timesOfDay: ["20:00"], + }, + ], + activeScheduleIndex: 0, + }; + const body = buildCreateBody(payload); + expect(body.schedules).toHaveLength(2); + expect(body.schedules[0].rrule).toBe("FREQ=DAILY"); + expect(body.schedules[0].timesOfDay).toEqual(["08:00"]); + expect(body.schedules[1].rrule).toBe("FREQ=WEEKLY;BYDAY=WE"); + expect(body.schedules[1].timesOfDay).toEqual(["20:00"]); + }); + + it("buildUpdateBody preserves schedule.id on edit", () => { + // Hydrate from a multi-schedule medication, then encode; every + // schedule must carry its persisted `id` back through. + const initial: MedicationPayload = { + id: "med_compose", + name: "Insulin", + dose: "10 IE", + category: "DIABETES", + treatmentClass: "GENERIC", + notificationsEnabled: true, + startsOn: new Date(Date.UTC(2026, 0, 1)), + endsOn: null, + oneShot: false, + schedules: [ + { + id: "sch_short", + windowStart: "08:00", + windowEnd: "09:00", + timesOfDay: ["08:00", "12:00", "18:00"], + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + }, + { + id: "sch_long", + windowStart: "22:00", + windowEnd: "23:00", + timesOfDay: ["22:00"], + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + }, + ], + }; + const payload = hydrateWizardPayload(initial); + const body = buildCreateBody(payload); + expect(body.schedules).toHaveLength(2); + expect(body.schedules[0].id).toBe("sch_short"); + expect(body.schedules[1].id).toBe("sch_long"); + }); + + it("hydrateWizardPayload reads N schedules and lands on Step 8 when N > 1", () => { + const initial: MedicationPayload = { + id: "med_compose", + name: "Insulin", + dose: "10 IE", + category: "DIABETES", + treatmentClass: "GENERIC", + notificationsEnabled: true, + startsOn: new Date(Date.UTC(2026, 0, 1)), + endsOn: null, + oneShot: false, + schedules: [ + { + id: "sch_a", + windowStart: "08:00", + windowEnd: "09:00", + timesOfDay: ["08:00"], + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + }, + { + id: "sch_b", + windowStart: "22:00", + windowEnd: "23:00", + timesOfDay: ["22:00"], + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + }, + ], + }; + const payload = hydrateWizardPayload(initial); + expect(payload.schedules).toHaveLength(2); + expect(payload.activeScheduleIndex).toBe(0); + expect(payload.schedules[0].id).toBe("sch_a"); + expect(payload.schedules[1].id).toBe("sch_b"); + expect(landingStepForEdit(payload)).toBe(8); + + // Single-schedule still lands on Step 1. + const single = hydrateWizardPayload({ + ...initial, + schedules: [initial.schedules[0]], + }); + expect(landingStepForEdit(single)).toBe(1); + }); + + it("removeSchedule refuses when schedules.length === 1", () => { + const payload = emptyWizardPayload(); + expect(payload.schedules).toHaveLength(1); + const after = removeSchedule(payload, 0); + // Returns the same payload reference / shape — no mutation. + expect(after.schedules).toHaveLength(1); + expect(after.activeScheduleIndex).toBe(0); + }); + + it("addSchedule appends and bumps activeScheduleIndex", () => { + const payload = emptyWizardPayload(); + const after = addSchedule(payload); + expect(after.schedules).toHaveLength(2); + expect(after.activeScheduleIndex).toBe(1); + + // setActiveSchedule routes the flat mirror back to the first + // draft, and a removeSchedule then drops the second. + const back = setActiveSchedule(after, 0); + expect(back.activeScheduleIndex).toBe(0); + const dropped = removeSchedule(back, 1); + expect(dropped.schedules).toHaveLength(1); + + // commitActiveDraft writes the flat mirror onto the active slot. + const edited = commitActiveDraft({ + ...after, + timesOfDay: ["20:00"], + }); + expect(edited.schedules[after.activeScheduleIndex].timesOfDay).toEqual([ + "20:00", + ]); + }); +});