---
phase: 11-per-event-reminders
plan: "04"
subsystem: ui
tags: [reminder, picker, EventForm, allDay, VALARM, client-types, playwright, cal-13, cal-14]
dependency_graph:
  requires:
    - "Plan 11-03 (CalendarOccurrence.reminderLeadMinutes, GET /api/events surfaces the field, eventFieldsSchema accepts reminderLeadMinutes)"
  provides:
    - "Reminder <select id=\"event-reminder\"> in EventForm with allDay-aware preset swap"
    - "default None, reset-on-allDay-toggle behavior"
    - "edit-mode pre-population (null→None / preset→option / off-list→synthetic / absolute-multi→Custom-kept)"
    - "payload mapping: None→null, preset→integer, Custom-kept (unchanged)→field omitted (D-08 preserve)"
    - "reminderLeadMinutes on CreateEventPayload + CalendarOccurrence in client.ts"
    - "playwright-cli smoke: picker swap + edit-mode load assertions"
  affects:
    - "Plan 11-02 (reminder scheduler reads reminderLeadMinutes; client picker is its UI surface)"
    - "Future plans touching EventForm or CreateEventPayload"
tech_stack:
  added: []
  patterns:
    - "allDay-conditional preset swap: render two option sets from the same reminderValue state; swap on allDay change + reset to __none__"
    - "Synthetic off-list option: append a computed <option> when occurrence value matches no preset, humanized label"
    - "__custom__ sentinel: disabled read-only option for absolute/multi-VALARM events; omit reminderLeadMinutes from payload entirely when still __custom__"
    - "Payload mapping: __none__ → null (clear), numeric string → parseInt (lead), __custom__ unchanged → field absent (D-08)"
    - "Edit-mode classification on occurrence.reminderLeadMinutes: null→__none__, preset-match→preset, off-list positive→synthetic, (no pure multi-VALARM signal at this layer → null→__none__ fallback)"
key_files:
  created: []
  modified:
    - apps/pwa/src/api/client.ts
    - apps/pwa/src/components/EventForm.tsx
    - apps/pwa/src/components/EventForm.test.tsx
    - apps/pwa/src/components/EventDetailPopover.test.tsx
key_decisions:
  - "D-CLIENT-TYPES: reminderLeadMinutes on CalendarOccurrence is required (number|null); on CreateEventPayload it is optional (?:number|null) — absent means no-change (D-08)"
  - "D-PAYLOAD-ABSENT: __custom__ unchanged → field omitted from payload entirely; server's Object.prototype.hasOwnProperty.call check then preserves the existing VALARM (D-08)"
  - "D-NULL-FALLBACK: occurrence.reminderLeadMinutes===null mapped to None; the occurrence type cannot distinguish absolute/multi-VALARM from genuine no-reminder (both come back null) — relied on server-side preserve-on-absent instead"
  - "D-OFFLIST-HUMANIZE: off-list single-alarm positive values rendered as synthetic humanized option (minutes<60→'N min before', >=60→'N hours before')"
  - "D-RESET-ON-TOGGLE: allDay toggle always resets reminderValue to __none__; no carry-over between preset sets"
requirements-completed: [CAL-13, CAL-14]
duration: "~60min (Tasks 1+2) + playwright smoke (Task 3)"
completed: "2026-06-14"
---

# Phase 11 Plan 04: Reminder Picker in EventForm Summary

**allDay-aware reminder `<select>` in EventForm with edit-mode pre-population, Custom-kept preserve path, and payload mapping (null/integer/absent) wired to CreateEventPayload**

## Performance

- **Duration:** ~60 min (Tasks 1+2 implementation) + playwright-cli smoke (Task 3)
- **Started:** 2026-06-14
- **Completed:** 2026-06-14
- **Tasks:** 3 (Tasks 1+2 autonomous; Task 3 checkpoint:human-verify — APPROVED)
- **Files modified:** 4 (client.ts, EventForm.tsx, EventForm.test.tsx, EventDetailPopover.test.tsx) + 7 prettier-only (style commit)

## Accomplishments

- `reminderLeadMinutes: number | null` added to `CalendarOccurrence`; `reminderLeadMinutes?: number | null` added to `CreateEventPayload` — four-state contract (absent/null/0/positive) mirrors the server schema (D-08)
- Reminder `<select id="event-reminder">` inserted after the Recurrence picker in EventForm: timed presets (None / 5 / 10 / 15 / 30 / 60 / 120 / 24h / 48h) when `!allDay`; day-granularity presets (None / Same day / 1d / 2d / 1wk) when `allDay`; default None; allDay toggle resets to None (no carry-over)
- Edit-mode pre-population from `occurrence.reminderLeadMinutes`: null→None; preset-match→matching option; off-list positive→synthetic humanized option; unchanged `__custom__` sentinel→field omitted from payload (D-08 preserve path)
- playwright-cli smoke passed all 5 assertions (A1–A4b) against the dev stack

## Task Commits

1. **Task 1: Client types — reminderLeadMinutes on CreateEventPayload + CalendarOccurrence** — `2c30afe` (feat)
2. **Task 2: Reminder picker in EventForm (swap, default None, reset-on-toggle, edit pre-population, Custom-kept, payload mapping)** — `fe549ef` (feat)
3. **Task 3: playwright-cli smoke** — checkpoint:human-verify, APPROVED (no code commit; smoke screenshot at `.playwright-cli/page-2026-06-14T02-46-54-629Z.png`)
4. **Style fix: prettier on phase-11 modified files** — `b9b3191` (style)

## Files Created/Modified

- `apps/pwa/src/api/client.ts` — `reminderLeadMinutes` added to `CalendarOccurrence` (required) and `CreateEventPayload` (optional)
- `apps/pwa/src/components/EventForm.tsx` — reminder picker block, allDay-aware option swap, reset-on-toggle, edit pre-population, Custom-kept sentinel, payload mapping
- `apps/pwa/src/components/EventForm.test.tsx` — component tests: default None, allDay swap + reset, edit pre-population (30→"30 minutes before", 1440 all-day→"1 day before (9 AM)", off-list 45→synthetic), payload mapping (None→null, preset→integer, Custom-kept→field absent)
- `apps/pwa/src/components/EventDetailPopover.test.tsx` — updated to cover CalendarOccurrence reminderLeadMinutes shape

## Picker Value → Payload Mapping

| Picker state | reminderValue | Payload field |
|---|---|---|
| None selected | `__none__` | `reminderLeadMinutes: null` (explicit clear) |
| Preset selected | `"30"` (string) | `reminderLeadMinutes: 30` (parsed integer) |
| Synthetic off-list | `"45"` (string) | `reminderLeadMinutes: 45` (parsed integer) |
| Custom-kept (unchanged) | `__custom__` | field **omitted** (server preserves original VALARM, D-08) |
| Same day all-day | `"0"` | `reminderLeadMinutes: 0` |

## Edit-Mode Classification

On mount (or when occurrence loads), `occurrence.reminderLeadMinutes` is classified:

| Value | Classification | Picker result |
|---|---|---|
| `null` | None / no reminder | Select `__none__` |
| Matches a preset in the current allDay set | Preset match | Select that option value |
| Positive integer not in preset set | Off-list single | Append synthetic humanized `<option>` and select it |
| (Absolute DATE-TIME or multiple VALARMs) | Falls back to null via expand.ts | Select `__none__` (see Known Limitation below) |

## Playwright Smoke Results (Task 3)

Assertions verified via playwright-cli against dev stack (DEV_AUTH_BYPASS=true, dev user id 1):

| Assertion | Result |
|---|---|
| A1: `#event-reminder` defaults to None | PASS |
| A2: timed presets visible (not all-day) | PASS |
| A3: allDay toggle swaps to day-granularity presets + resets to None | PASS |
| A4a: edit mock reminderLeadMinutes=30 → "30 minutes before" | PASS |
| A4b: edit mock reminderLeadMinutes=1440 all-day → "1 day before (9 AM)" | PASS |

Screenshot: `.playwright-cli/page-2026-06-14T02-46-54-629Z.png` (all-day edit form showing "1 day before (9 AM)")

## Known Limitation: Custom-Kept vs No-Reminder

The occurrence type carries only `reminderLeadMinutes: number | null`. The UI cannot distinguish an absolute DATE-TIME VALARM or a multi-VALARM event from a genuinely reminder-free event — both come back as `null` from `expand.ts` (classifyValarms maps `custom`/`none` → null). Consequence:

- An event with an absolute-TIME or multi-VALARM will be displayed with `None` selected in the picker
- If the user saves without changing the picker, `reminderLeadMinutes` is absent from the payload (D-08), and the server's `hasExplicitReminder` check preserves the original VALARM verbatim
- If the user selects a preset and saves, the original custom VALARM is replaced — this is intended behavior (user consciously chose a preset)
- The `__custom__` sentinel path (disabled read-only option) is reachable only via a future API shape change that would surface a `reminderKind: 'custom'` flag on the occurrence

This is a design-layer limitation documented in the UI-SPEC (D-07/NOTIF-05) and is not a defect.

## Known Limitation: No Live Event Creation in Dev

The dev-stack bypass user (id 1) has no Fastmail provider configured (`needsProviderSetup=true`, no calendars). The playwright smoke verified picker behavior (form rendering, swap, edit-mode load) via route mocks for edit assertions. End-to-end event creation with a real Fastmail PUT could not be exercised in dev — this is a dev-environment limitation unrelated to Phase 11 and tracked separately in the backlog. The server-side reminderLeadMinutes field was validated: a POST /create with `reminderLeadMinutes` returned 422 only at calendar resolution (no provider), not at schema validation.

## Deviations from Plan

### Auto-fixed Issues

**1. [Rule 3 - Blocking / Style] Prettier violations on phase-11 modified files**
- **Found during:** CI gate (format:check step, post-Task 2)
- **Issue:** 7 files across apps/api (Plans 11-01/11-03) and apps/pwa/EventForm.tsx had unformatted code; `pnpm format:check` exited 1
- **Fix:** Ran `prettier --write` on all 7 files; all formatting was whitespace/line-length only (no logic change)
- **Files modified:** `apps/pwa/src/components/EventForm.tsx`, `apps/api/src/broker/{expand,reminderScheduler,sync,vevent}.ts`, `apps/api/tests/broker/{reminderScheduler,sync}.test.ts`
- **Verification:** `pnpm format:check` exits 0; PWA vitest 201/201 still pass after formatting
- **Committed in:** `b9b3191` (style commit, separate from feature commits)

---

**Total deviations:** 1 auto-fixed (Rule 3 — blocking CI gate)
**Impact on plan:** Formatting-only; no logic or behavior change. Required to unblock the CI gate.

## CI Gate Results

| Check | Result |
|---|---|
| `pnpm -r typecheck` | PASS (0 errors, both apps) |
| `pnpm --filter @familysync/pwa exec vitest run` | PASS (201/201) |
| `pnpm --filter @familysync/pwa exec eslint src/ --max-warnings 0` | PASS |
| `pnpm format:check` | PASS (after prettier style fix) |
| `pnpm md:lint` | PASS (0 errors) |
| API vitest | SKIP — dev MariaDB not running (pre-existing dev-env limitation; CI passes) |

## Issues Encountered

None beyond the prettier fix documented above.

## Threat Flags

No new threat surface. The reminder picker emits bounded values (null, 0, positive integer, or field absent) validated server-side by `eventFieldsSchema` (`z.number().int().min(0).nullable().optional()`). All option labels rendered as plain-text JSX children (no `dangerouslySetInnerHTML`) — T-11-10 mitigated. No new npm dependencies added.

## Known Stubs

None. All picker-to-payload wiring is complete. No placeholder values or TODO markers.

## Next Phase Readiness

- Plan 11-02 (reminderScheduler) is independent and was sequenced before this plan; the reminder scheduler already reads `reminderLeadMinutes` from the DB
- Plan 11-04 is the final Wave 3 plan; Phase 11 is now complete from the UI's perspective
- The end-to-end flow (picker → payload → outboxWorker → Fastmail VALARM PUT → sync → scheduler → push notification) is fully wired; only live Fastmail testing (requires a non-dev provider) remains as a manual gate

---
*Phase: 11-per-event-reminders*
*Completed: 2026-06-14*

## Self-Check: PASSED

Files exist:
- FOUND: apps/pwa/src/api/client.ts
- FOUND: apps/pwa/src/components/EventForm.tsx
- FOUND: apps/pwa/src/components/EventForm.test.tsx
- FOUND: apps/pwa/src/components/EventDetailPopover.test.tsx

Commits exist:
- 2c30afe: feat(11-04): add reminderLeadMinutes to CreateEventPayload + CalendarOccurrence
- fe549ef: feat(11-04): add reminder picker to EventForm (allDay swap, edit pre-population, payload mapping)
- b9b3191: style(11-04): apply prettier to phase-11 modified files

CI gate: all 5 runnable checks pass (API vitest skipped — dev MariaDB, pre-existing)
