--- phase: 11-per-event-reminders plan: 04 type: execute wave: 3 depends_on: [11-03] files_modified: - apps/pwa/src/api/client.ts - apps/pwa/src/components/EventForm.tsx autonomous: false requirements: [CAL-13, CAL-14] must_haves: truths: - "When creating/editing a timed event the user picks a reminder lead from the timed preset list (default None)" - "When the event is all-day the picker swaps to day-granularity presets (None / Same day / 1d / 2d / 1wk), default None" - "Toggling all-day resets the picker to None (no carry-over between preset sets)" - "Edit mode pre-populates the picker from the occurrence: none→None, preset→matching option, off-list single→synthetic option, absolute/multi→read-only Custom (kept)" - "Leaving a Custom (kept) selection on save preserves the original VALARM (absent reminderLeadMinutes on the payload)" - "Selecting None sends explicit null; selecting a preset sends the integer" artifacts: - path: "apps/pwa/src/components/EventForm.tsx" provides: "Reminder ` to `EventForm.tsx` per the UI-SPEC: allDay-aware preset swap (D-02/D-03), default None (D-01), reset-on-allDay-toggle, and edit-mode pre-population (none / preset / synthetic off-list / read-only "Custom (kept)") driven by `occurrence.reminderLeadMinutes`. Extend `CreateEventPayload` and `CalendarOccurrence` in `client.ts` with `reminderLeadMinutes`, and map the picker value to the payload (None→null, preset→integer, Custom-kept/unchanged→omit field for server-side preserve, D-08). Verify with a Playwright smoke (Wave 0 gap). Purpose: Deliver the user-facing reminder choice (CAL-13) and the preserve-on-no-change behavior at the UI layer (CAL-14). Consumes the occurrence shape from Plan 11-03. Output: Reminder picker + payload mapping; extended client types; Playwright smoke spec/run. @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/11-per-event-reminders/11-UI-SPEC.md @.planning/phases/11-per-event-reminders/11-PATTERNS.md @.planning/phases/11-per-event-reminders/11-VALIDATION.md @.planning/phases/11-per-event-reminders/11-03-SUMMARY.md @apps/pwa/src/components/EventForm.tsx @apps/pwa/src/api/client.ts This plan creates (exclude from drift checks — NEW): - Reminder ``). The select is NOT disabled in edit mode (reminders are editable, unlike Repeat). Render options conditionally on the existing `allDay` state: timed presets (None=`__none__`, 5/10/15/30/60/120/1440/2880 with the exact labels in the Copywriting Contract) when `!allDay`; all-day presets (None, 0=`Same day (9 AM)`, 1440=`1 day before (9 AM)`, 2880=`2 days before (9 AM)`, 10080=`1 week before (9 AM)`) when `allDay`. Default selected value `__none__` (D-01). Add a `reminderValue` state (string). In the existing allDay onChange handler, also `setReminderValue('__none__')` so toggling all-day resets the picker (D-03 — no carry-over). Edit-mode pre-population on mount/when the occurrence loads, from `occurrence.reminderLeadMinutes`: null → `__none__`; a value matching a preset for the current allDay set → that option; a positive value NOT in the preset set → append a synthetic `` selected by default, with the select itself still enabled. Show the helper text `Custom reminder kept — select a preset to replace it.` (style: fontSize var(--text-label-size), color var(--color-text-secondary), marginTop var(--space-1)) only in edit mode when the value is `__custom__` (or the synthetic off-list option). Selecting any real preset removes the synthetic/custom option from selection. Payload mapping in the submit handler: `__none__` → `reminderLeadMinutes: null` (explicit clear); a numeric preset/synthetic value → `reminderLeadMinutes: `; if the selection is still `__custom__` (unchanged) → OMIT `reminderLeadMinutes` from the payload entirely so the server preserves the original VALARM (D-08 — absent = no-change). Render all option labels as plain-text JSX children (XSS guard T-03-15). Determining "Custom (kept)" vs off-list synthetic from the occurrence: since the occurrence only carries a number-or-null, the frontend treats null as None and any number as preset-or-offlist; the `__custom__` state is reached only when the occurrence signals an unresolvable alarm — if the occurrence reminderLeadMinutes is null but the event is known to carry a kept custom alarm, follow the UI-SPEC: with only number|null available, map null→None and rely on the server-side preserve (absent payload) — document this limitation in the SUMMARY. pnpm --filter @familysync/pwa exec vitest run src/components/EventForm.test.tsx - A component test asserts: in create mode with allDay=false the picker shows the timed preset labels and defaults to None; toggling allDay swaps to the day-granularity labels and resets selection to None. - test asserts edit mode with occurrence.reminderLeadMinutes=30 (timed) selects "30 minutes before"; =1440 all-day selects "1 day before (9 AM)"; =45 (off-list) shows a synthetic "45 min before" option selected. - test asserts the submitted payload: None→reminderLeadMinutes:null; a preset→the integer; an unchanged Custom-kept→field omitted (Object.prototype.hasOwnProperty is false). - pwa vitest + `tsc --noEmit` exit 0. Reminder picker matches the UI-SPEC: swap, default None, reset-on-toggle, edit pre-population, Custom-kept preserve via omitted payload field. Task 3: Playwright smoke — picker swap + edit-mode load (Wave 0 gap) apps/pwa/tests/ (playwright-cli smoke spec for the reminder picker) - .claude/skills/playwright-cli/SKILL.md (browser-driving usage; project convention: prefer playwright-cli over manual human verification for desktop/Chromium checks) - 11-VALIDATION.md § Wave 0 Requirements (Playwright smoke) and § Manual-Only Verifications (device-only items NOT covered here) - MEMORY: dev stack bring-up (dev compose exposes 3306, API `dev` needs separate build, DEV_AUTH_BYPASS=true → Dev User id 1); Dev user 1 has no calendars — verify form-rendering/picker behavior, not live event create A Playwright smoke (driven via the playwright-cli skill, Chromium/desktop) that: opens the New Event form, asserts the Reminder select defaults to None and shows timed presets; toggles All-day and asserts the option set swaps to the day-granularity presets and selection resets to None. Plus an edit-mode load assertion against a route-mocked occurrence carrying reminderLeadMinutes (e.g. 30 → "30 minutes before"; 1440 all-day → "1 day before (9 AM)"), since dev user 1 has no real calendars. Automate first: bring up the host-side dev stack per the documented command (DEV_AUTH_BYPASS=true, DB_HOST=localhost), build the API dev bundle, then run the smoke headlessly. This is autonomous via playwright-cli — the human only confirms the run results if the harness cannot self-assert. 1. Start the dev stack (two terminals or background): API dev (with DEV_AUTH_BYPASS=true DB_HOST=localhost, env-sourced) + PWA dev server. 2. Drive Chromium via playwright-cli to the calendar, open New Event, assert: Reminder select present (id=event-reminder), value None, timed option labels visible. 3. Toggle All-day; assert the swapped day-granularity labels are present and the selection is None. 4. With a page.route mock returning an occurrence whose reminderLeadMinutes=30 (timed) and a second whose reminderLeadMinutes=1440 (all-day), open edit and assert the picker shows the matching labels. 5. Confirm no console errors and the screenshot shows the picker rendered per the UI-SPEC. Automate the verification end-to-end via the playwright-cli skill before pausing for human confirmation: (1) bring up the host-side dev stack per docs/deployment.md (env-sourced, DEV_AUTH_BYPASS=true DB_HOST=localhost; rebuild the API dev bundle since dist can be stale) plus the PWA dev server; (2) drive Chromium with playwright-cli to open the New Event form and assert the Reminder select (id=event-reminder) defaults to None and shows the timed preset labels; (3) toggle All-day and assert the option set swaps to the day-granularity labels and the selection resets to None; (4) use page.route to mock occurrences with reminderLeadMinutes=30 (timed) and =1440 (all-day) and assert edit mode loads "30 minutes before" and "1 day before (9 AM)" respectively (dev user 1 has no real calendars, so mock rather than create live); (5) capture a screenshot and the pass/fail of each assertion. Only after the smoke runs do you surface the result for human sign-off. Do NOT ask the human to perform steps the playwright-cli skill can drive. cd apps/pwa && playwright-cli run smoke for the reminder picker (swap + edit-load) against the dev stack; capture pass/fail + screenshot Confirm the playwright-cli smoke passed (picker swap + edit-mode labels) or report the failing assertion. - playwright-cli smoke asserts the timed→all-day option swap and reset-to-None (CAL-13 browser row in 11-VALIDATION.md Per-Task Verification Map). - playwright-cli smoke asserts edit-mode picker loads the correct label for a route-mocked occurrence (30→"30 minutes before", 1440 all-day→"1 day before (9 AM)"). - run completes with zero console errors; screenshot attached to the SUMMARY. Type "approved" once the playwright-cli smoke passes, or describe the failing assertion. ## Trust Boundaries | Boundary | Description | |----------|-------------| | EventForm select value → payload | User-chosen value mapped to a bounded integer / null / omitted; re-validated server-side (Plan 03 eventFieldsSchema) | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-11-09 | Tampering | reminder picker → payload | mitigate | Client emits only null / a preset integer / omitted field; the server is the real boundary (eventFieldsSchema validates `z.number().int().min(0).nullable().optional()`); client trust is not relied upon | | T-11-10 | XSS | option labels + helper text | mitigate | All labels/helper text rendered as plain-text JSX children, no dangerouslySetInnerHTML (T-03-15 precedent) | | T-11-SC | Tampering | npm installs | accept | No new frontend npm dependencies (UI-SPEC: "Phase 11 adds no new npm dependencies on the frontend") | No new security surface on the frontend: the picker is a native `