---
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 <select> (allDay-aware swap, edit pre-population, Custom-kept handling)"
      contains: "event-reminder"
    - path: "apps/pwa/src/api/client.ts"
      provides: "reminderLeadMinutes on CreateEventPayload + CalendarOccurrence"
      contains: "reminderLeadMinutes"
  key_links:
    - from: "EventForm reminder select"
      to: "CreateEventPayload.reminderLeadMinutes"
      via: "submit handler maps picker value → number | null | absent"
      pattern: "reminderLeadMinutes"
    - from: "edit-mode load"
      to: "occurrence.reminderLeadMinutes"
      via: "preset/offlist/custom classification on mount"
      pattern: "reminderLeadMinutes"
---

<objective>
Add the reminder `<select>` 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.
</objective>

<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>

<context>
@.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
</context>

<artifacts_this_phase_produces>
This plan creates (exclude from drift checks — NEW):
- Reminder `<select id="event-reminder">` block + its local state + onChange handler — EventForm.tsx
- allDay-toggle reset of the reminder state — EventForm.tsx
- edit-mode classification → picker value (synthetic off-list option, "Custom (kept)" disabled option, helper text) — EventForm.tsx
- `reminderLeadMinutes?: number | null` on `CreateEventPayload` — client.ts
- `reminderLeadMinutes: number | null` on `CalendarOccurrence` (atomic mirror of expand.ts) — client.ts
- Playwright smoke spec for the picker swap + edit-mode load — apps/pwa (Wave 0 gap)
</artifacts_this_phase_produces>

<tasks>

<task type="execute">
  <name>Task 1: Client types — reminderLeadMinutes on CreateEventPayload + CalendarOccurrence</name>
  <files>apps/pwa/src/api/client.ts</files>
  <read_first>
    - apps/pwa/src/api/client.ts lines 108-134 (CalendarOccurrence, hasRrule mirror note) and 175-198 (CreateEventPayload)
    - apps/api/src/broker/expand.ts CalendarOccurrence (Plan 11-03: the field this mirrors)
    - 11-PATTERNS.md § client.ts (exact field additions + the atomic-mirror comment style)
  </read_first>
  <action>
    Add `reminderLeadMinutes: number | null` to `CalendarOccurrence` (after `hasRrule`) with a comment noting it mirrors expand.ts (atomic mirror, NULL=no reminder / 0=same-day all-day / positive=lead). Add `reminderLeadMinutes?: number | null` to `CreateEventPayload` (after `description`) documenting the four states: absent/undefined=no-change (edit omits → server preserves, D-08), null=explicit None (clear), 0=same-day all-day, positive=lead. Do not change createEvent/editEvent call signatures — they already spread CreateEventPayload.
  </action>
  <verify>
    <automated>pnpm --filter @familysync/pwa exec tsc --noEmit</automated>
  </verify>
  <acceptance_criteria>
    - client.ts CalendarOccurrence has a required `reminderLeadMinutes: number | null`; CreateEventPayload has an optional `reminderLeadMinutes?: number | null`.
    - `pnpm --filter @familysync/pwa exec tsc --noEmit` exits 0 (any occurrence consumer that destructures the type still compiles, or is updated).
  </acceptance_criteria>
  <done>Frontend types carry reminderLeadMinutes with the absent/null/0/positive contract mirroring the server.</done>
</task>

<task type="execute">
  <name>Task 2: Reminder picker in EventForm (swap, default None, reset-on-toggle, edit pre-population, Custom-kept, payload mapping)</name>
  <files>apps/pwa/src/components/EventForm.tsx, apps/pwa/src/components/EventForm.test.tsx</files>
  <read_first>
    - apps/pwa/src/components/EventForm.tsx lines 848-885 (Recurrence picker — the exact structural template) and the existing allDay state + allDay onChange handler + the form submit/payload-assembly site
    - 11-UI-SPEC.md (Component Inventory: option tables, Copywriting Contract, Interaction Contract state machine, helper text style/copy, accessibility)
    - 11-PATTERNS.md § EventForm.tsx (allDay-conditional option swap, reset pattern, differences from Recurrence picker)
  </read_first>
  <action>
    Insert a reminder field after the Recurrence picker block (~line 885), styled identically (`<div style={fieldStyle}>`, `<label htmlFor="event-reminder" style={labelStyle}>Reminder</label>`, `<select id="event-reminder" style={{...inputStyle, padding:'0 var(--space-3)', cursor:'pointer'}}>`). 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 `<option>` whose label is humanized ("N min before" / "N hours before" using the UI-SPEC thresholds) and select it (off-list single, D-07); for the absolute/multi "Custom (kept)" case the occurrence cannot express a single lead → represent it with a special sentinel value `__custom__` rendered as a read-only `disabled` `<option value="__custom__">Custom (kept)</option>` 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: <number>`; 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.
  </action>
  <verify>
    <automated>pnpm --filter @familysync/pwa exec vitest run src/components/EventForm.test.tsx</automated>
  </verify>
  <acceptance_criteria>
    - 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.
  </acceptance_criteria>
  <done>Reminder picker matches the UI-SPEC: swap, default None, reset-on-toggle, edit pre-population, Custom-kept preserve via omitted payload field.</done>
</task>

<task type="checkpoint:human-verify" gate="blocking">
  <name>Task 3: Playwright smoke — picker swap + edit-mode load (Wave 0 gap)</name>
  <files>apps/pwa/tests/ (playwright-cli smoke spec for the reminder picker)</files>
  <read_first>
    - .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
  </read_first>
  <what-built>
    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.
  </what-built>
  <how-to-verify>
    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.
  </how-to-verify>
  <action>
    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.
  </action>
  <verify>
    <automated>cd apps/pwa && playwright-cli run smoke for the reminder picker (swap + edit-load) against the dev stack; capture pass/fail + screenshot</automated>
    <human-check>Confirm the playwright-cli smoke passed (picker swap + edit-mode labels) or report the failing assertion.</human-check>
  </verify>
  <acceptance_criteria>
    - 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.
  </acceptance_criteria>
  <resume-signal>Type "approved" once the playwright-cli smoke passes, or describe the failing assertion.</resume-signal>
</task>

</tasks>

<threat_model>
## 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 `<select>` emitting bounded values; validation authority remains server-side.
</threat_model>

<verification>
- `pnpm --filter @familysync/pwa exec vitest run` green; `pnpm --filter @familysync/pwa exec tsc --noEmit` clean.
- Playwright smoke (picker swap + edit-mode load) passes via playwright-cli.
- Full CI fast-checks parity locally before declaring done: lint + typecheck + test + format:check + md:lint (MEMORY: CI fast-checks runs the whole gate, not just lint/typecheck/test).
</verification>

<success_criteria>
- Reminder picker present and behaves per UI-SPEC (swap, default None, reset-on-toggle, edit pre-population, Custom-kept preserve).
- Payload mapping yields null / integer / omitted correctly.
- Playwright smoke green; no new frontend dependencies.
</success_criteria>

<output>
Create `.planning/phases/11-per-event-reminders/11-04-SUMMARY.md` when done. Record the picker value→payload mapping, the edit-mode classification handling, the Custom-kept limitation note, and the playwright-cli smoke result + screenshot path.
</output>
