diff --git a/src/components/medications/wizard/__tests__/wizard-payload.test.ts b/src/components/medications/wizard/__tests__/wizard-payload.test.ts index a127f689e..167d80a0b 100644 --- a/src/components/medications/wizard/__tests__/wizard-payload.test.ts +++ b/src/components/medications/wizard/__tests__/wizard-payload.test.ts @@ -373,7 +373,7 @@ describe("buildCreateBody", () => { expect(body.schedules[0].rollingIntervalDays).toBeUndefined(); }); - it("threads notificationsEnabled through", () => { + it("threads notificationsEnabled through on create", () => { const p: WizardPayload = { ...withCadence("daily"), notificationsEnabled: false, @@ -381,6 +381,24 @@ describe("buildCreateBody", () => { startsOn: new Date(Date.UTC(2026, 4, 28)), }; expect(buildCreateBody(p).notificationsEnabled).toBe(false); + expect(buildCreateBody(p, "create").notificationsEnabled).toBe(false); + }); + + // v1.5.5 D-3 §10 invariant 16 — the wizard never round-trips the + // notifications default back to the API on edit. The detail-page + // notifications switch is the single source of truth post-create; + // omitting the field on edit keeps the wizard from clobbering it + // when the user opens + saves without changing anything else. + it("omits notificationsEnabled on edit", () => { + const p: WizardPayload = { + ...withCadence("daily"), + notificationsEnabled: false, + timesOfDay: ["08:00"], + startsOn: new Date(Date.UTC(2026, 4, 28)), + }; + const body = buildCreateBody(p, "edit"); + expect(body.notificationsEnabled).toBeUndefined(); + expect("notificationsEnabled" in body).toBe(false); }); }); @@ -704,6 +722,59 @@ describe("compose-mode — multi-schedule encoder + hydrator", () => { expect(landingStepForEdit(single)).toBe(1); }); + // v1.5.5 C-E2-2 — `landingStepForEdit` gains an `intent` arg so the + // detail-page cadence-summary row can drop the user directly on + // Step 5 instead of bouncing them through Step 1 first. One-shot + // payloads fall back to Step 8 because the cadence picker is not on + // the one-shot path. + it("lands on Step 5 for the cadence intent", () => { + const payload = hydrateWizardPayload({ + id: "m_cadence", + name: "Test", + dose: "10", + category: "OTHER", + treatmentClass: "GENERIC", + notificationsEnabled: true, + oneShot: false, + schedules: [ + { + id: "sch_a", + windowStart: "08:00", + windowEnd: "09:00", + timesOfDay: ["08:00"], + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + }, + ], + }); + expect(landingStepForEdit(payload, "cadence")).toBe(5); + expect(landingStepForEdit(payload, "name")).toBe(1); + expect(landingStepForEdit(payload, "summary")).toBe(8); + }); + + it("falls back to Step 8 on cadence intent for a one-shot medication", () => { + const payload = hydrateWizardPayload({ + id: "m_oneshot", + name: "Test", + dose: "10", + category: "OTHER", + treatmentClass: "GENERIC", + notificationsEnabled: true, + oneShot: true, + schedules: [ + { + id: "sch_a", + windowStart: "08:00", + windowEnd: "09:00", + timesOfDay: ["08:00"], + rrule: null, + rollingIntervalDays: null, + }, + ], + }); + expect(landingStepForEdit(payload, "cadence")).toBe(8); + }); + it("removeSchedule refuses when schedules.length === 1", () => { const payload = emptyWizardPayload(); expect(payload.schedules).toHaveLength(1); diff --git a/src/components/medications/wizard/wizard-payload.ts b/src/components/medications/wizard/wizard-payload.ts index 34b4ed459..f6d0cafc1 100644 --- a/src/components/medications/wizard/wizard-payload.ts +++ b/src/components/medications/wizard/wizard-payload.ts @@ -443,7 +443,15 @@ export interface CreateMedicationBody { dose: string; category: MedicationCategoryValue; treatmentClass: MedicationTreatmentClass; - notificationsEnabled: boolean; + /** + * v1.5.5 D-3 §10 invariant 16 — the wizard owns the create-time + * default for `notificationsEnabled` but never overwrites it on + * edit. The detail page's notifications switch is the single source + * of truth post-create; the wizard PUT body omits the field so a + * user-toggled value never round-trips back to the wizard default. + * `buildCreateBody(payload, "edit")` returns `undefined` here. + */ + notificationsEnabled?: boolean; startsOn?: string; endsOn?: string; oneShot: boolean; @@ -544,7 +552,10 @@ export function encodeScheduleDraft( * recurrence fields are suppressed across the board and the route * normalises `endsOn` to equal `startsOn`. */ -export function buildCreateBody(payload: WizardPayload): CreateMedicationBody { +export function buildCreateBody( + payload: WizardPayload, + forMode: "create" | "edit" = "create", +): CreateMedicationBody { const committed = commitActiveDraft(payload); const dose = composeDose(committed.doseAmount, committed.doseUnit); const mapping = @@ -565,7 +576,13 @@ export function buildCreateBody(payload: WizardPayload): CreateMedicationBody { dose, category: mapping.category, treatmentClass: mapping.treatmentClass, - notificationsEnabled: committed.notificationsEnabled, + // v1.5.5 D-3 §10 invariant 16 — `notificationsEnabled` only on + // create. Edits never round-trip the wizard's default because the + // detail page's notifications switch is the single source of + // truth post-create. + ...(forMode === "create" && { + notificationsEnabled: committed.notificationsEnabled, + }), oneShot: isOneShot, schedules: draftsToEmit.map((draft) => encodeScheduleDraft(draft, isOneShot)), }; @@ -863,11 +880,35 @@ export function hydrateWizardPayload(initial: MedicationPayload): WizardPayload /** * Decision the dialog uses to pick the landing step on edit-hydrate. - * Multi-schedule medications open on the list view so the user sees - * every parallel schedule immediately; single-schedule edits keep the - * Step 1 entry to preserve muscle memory. + * + * Three call-site intents: + * + * - `"name"` (default header pencil): land on Step 1 so the user + * edits the medication's name, dose, treatment-class as a single + * top-down walk. Multi-schedule medications still jump to the + * summary list because the muscle memory there is "see every + * parallel schedule first". + * - `"cadence"`: land directly on Step 5 (the cadence picker). The + * v1.5.5 detail page's cadence-summary row uses this so a tap on + * the row's edit pencil drops the user one screen away from the + * cadence they wanted to change, not two screens away after the + * intro + summary. + * - undefined: legacy behaviour — Step 1 for single-schedule, Step 8 + * for multi-schedule. + * + * One-shot medications collapse Step 5 out of the path, so a + * `"cadence"` intent on a one-shot payload falls back to Step 8 + * (the summary, where the user can flip the one-shot toggle). */ -export function landingStepForEdit(payload: WizardPayload): number { +export function landingStepForEdit( + payload: WizardPayload, + intent?: "cadence" | "summary" | "name", +): number { + if (intent === "cadence") { + return payload.mode === "oneShot" ? 8 : 5; + } + if (intent === "summary") return 8; + if (intent === "name") return 1; return payload.schedules.length > 1 ? 8 : 1; }