---
phase: '03'
plan: '05'
subsystem: pwa-event-write-ui
tags: [react, tanstack-query, zustand, eventform, caldav-write, pwa]
dependency_graph:
  requires: ['03-03']
  provides:
    [
      'EventForm component',
      'createEvent/updateEvent/fetchWritableCalendars client calls',
      'eventFormOpen/eventFormMode/eventFormUid Zustand keys',
    ]
  affects: ['CalendarShell', 'EventDetailPopover (future edit trigger)']
tech_stack:
  added: []
  patterns:
    [
      'TanStack Query useMutation + useQuery',
      'Zustand UI-only state',
      'bottom-sheet/dialog responsive overlay',
      'vi.hoisted() for mock TDZ',
      'D-01 last-used calendar localStorage',
      'D-02 conditional calendar picker',
      'D-11 whole-series recurrence presets',
      'T-03-15 plain-text JSX XSS guard',
    ]
key_files:
  created:
    - apps/pwa/src/components/EventForm.tsx
    - apps/pwa/src/api/client.test.ts
    - apps/pwa/src/components/EventForm.test.tsx
  modified:
    - apps/pwa/src/api/client.ts
    - apps/pwa/src/store/calendarStore.ts
    - apps/pwa/src/components/CalendarShell.tsx
decisions:
  - 'D-01 calendar default: last-used URL from localStorage (eventForm.lastCalendarUrl), first writable calendar as fallback'
  - 'D-02 calendar picker: hidden when writableCalendars.length === 1, shown when >1 — driven by GET /api/events/writable-calendars'
  - 'T-03-15 XSS: all field values as plain-text JSX children in EventForm; no dangerouslySetInnerHTML in code'
  - 'vi.hoisted() required for mock factory variables to avoid TDZ (D-03-04-hoisting pattern)'
  - 'eventFormOpen selector added to CalendarShell per-field selector pattern (Bug B guard preserved)'
metrics:
  duration_minutes: 6
  completed_date: '2026-06-05'
  tasks_completed: 3
  files_created: 3
  files_modified: 3
---

# Phase 03 Plan 05: Event Write UI (EventForm + Client Calls) Summary

**One-liner:** EventForm modal with timed/all-day/recurring fields, conditional calendar picker (D-02), and typed write client (createEvent/updateEvent/fetchWritableCalendars) wired to the Plan 03 write API via TanStack Query mutations.

## What Was Built

### Task 1: Typed write client calls + Zustand form-state keys

Extended `apps/pwa/src/api/client.ts` with:

- `CreateEventPayload` interface (title, allDay, start, end, recurrence, optional location/description/calendarUrl)
- `CreateEventResponse` interface ({ uid })
- `WritableCalendar` interface ({ url, displayName, color, isShared }) — D-03 server-authoritative shape
- `RecurrencePreset` type ('none'|'daily'|'weekly'|'monthly'|'yearly')
- `createEvent(payload)` — POST /api/events/create, credentials:include, returns {uid}
- `updateEvent(uid, payload)` — PATCH /api/events/:uid/edit
- `fetchWritableCalendars()` — GET /api/events/writable-calendars, parses `{ calendars }` envelope, returns `WritableCalendar[]`

Extended `apps/pwa/src/store/calendarStore.ts` with:

- `eventFormOpen: boolean` (default: false)
- `eventFormMode: 'create' | 'edit'` (default: 'create')
- `eventFormUid: string | null` (default: null)
- `setEventForm(open, mode?, uid?)` setter — no server data in Zustand

### Task 2: EventForm modal

New `apps/pwa/src/components/EventForm.tsx` (715 lines):

- Bottom sheet on phone (≤767px), centered 480px dialog on tablet/desktop — reuses EventDetailPopover pattern
- Fields per UI-SPEC §EventForm order: title, all-day toggle, start date/time, end date/time, calendar picker (conditional), recurrence, location, description
- All-day toggle (`role="switch"`, aria-checked): hides time inputs when on, restores 09:00/10:00 defaults when off
- Recurrence: `<select>` with None/Daily/Weekly/Monthly/Yearly (D-11 whole-series only)
- Calendar picker (D-02): hidden when `writableCalendars.length === 1`, shown when >1; populated from TanStack Query `['writableCalendars']` key using `fetchWritableCalendars()`
- D-01 default: last-used calendar URL from `localStorage.getItem('eventForm.lastCalendarUrl')`, falls back to first writable calendar
- Validation: "Title is required" + "End time must be after start" with `--color-destructive` styling
- `useMutation` from TanStack Query: calls `createEvent` in create mode, `updateEvent` in edit mode
- On success: `queryClient.invalidateQueries({ queryKey: ['events'] })`, writes last-used calendar to localStorage, `setEventForm(false)`
- Edit mode: pre-populates all fields from TanStack Query cache by eventFormUid
- `role="dialog"` `aria-modal="true"` `aria-label="New Event"/"Edit Event"`
- Focus moves to title input on open; Escape/backdrop/Cancel close without confirmation
- Save button: dark neutral fill (`--color-text-primary`), white label, shows Loader2 spinner + "Saving…" while pending
- T-03-15: all values as plain-text JSX children — no `dangerouslySetInnerHTML` anywhere

### Task 3: Mount EventForm + "New Event" FAB/toolbar in CalendarShell

Updated `apps/pwa/src/components/CalendarShell.tsx`:

- Added `setEventForm` and `eventFormOpen` per-field selectors (Bug B guard preserved)
- Phone layout: fixed FAB bottom-right (56×56px, dark neutral fill, Plus icon, 56px ≥ 44px touch target)
- Tablet/desktop layout: toolbar button above calendar content (dark neutral fill, Plus icon + "New Event" label)
- Both call `setEventForm(true, 'create')` via Zustand
- `{eventFormOpen && <EventForm />}` conditionally rendered in both phone and desktop layouts

## Test Coverage

- `apps/pwa/src/api/client.test.ts` (14 tests): write client calls POST/PATCH/GET, credentials, return shapes, error throws; Zustand form-state defaults and setEventForm setter
- `apps/pwa/src/components/EventForm.test.tsx` (23 tests): dialog role/aria, all required fields, all-day toggle, D-02 picker visibility, validation errors, create/edit mutations, close behaviors, edit mode pre-population

**Full suite: 81 tests, 8 test files — all green. TypeScript: tsc --noEmit passes.**

## Deviations from Plan

### Auto-fixed Issues

**1. [Rule 3 - Blocking] vi.hoisted() required for EventForm.test.tsx mock factory variables**

- **Found during:** Task 2 GREEN phase
- **Issue:** `vi.mock('../api/client.js', ...)` factory captured `mockCreateEvent` etc. before initialization (TDZ), causing `ReferenceError: Cannot access 'mockCreateEvent' before initialization`
- **Fix:** Moved mock function declarations into `vi.hoisted()` call per decision D-03-04-hoisting
- **Files modified:** `apps/pwa/src/components/EventForm.test.tsx`
- **Commit:** 86cefff

None — plan executed with one auto-fixed TDZ blocker.

## Threat Surface Scan

| Flag             | File                                  | Description                                                                                                                      |
| ---------------- | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| T-03-15 verified | apps/pwa/src/components/EventForm.tsx | All event field values rendered as plain-text JSX children; no `dangerouslySetInnerHTML` in code (appears only in JSDoc comment) |
| T-03-16 verified | apps/pwa/src/api/client.ts            | `fetchWritableCalendars` reads server-authoritative D-03 set verbatim; no client-side writability derivation                     |

No new threat surface introduced beyond what was planned.

## Known Stubs

None. All API calls are wired to real Plan 03 endpoints. SyncStateToast feedback (post-202 sync polling) is intentionally deferred to Plan 03-06 per plan scope.

## Self-Check: PASSED

| Item                                       | Status |
| ------------------------------------------ | ------ |
| apps/pwa/src/components/EventForm.tsx      | FOUND  |
| apps/pwa/src/api/client.test.ts            | FOUND  |
| apps/pwa/src/components/EventForm.test.tsx | FOUND  |
| 6400ce6 test(03-05): RED client calls      | FOUND  |
| 6ffcdcb feat(03-05): client calls GREEN    | FOUND  |
| df416a4 test(03-05): RED EventForm         | FOUND  |
| 86cefff feat(03-05): EventForm GREEN       | FOUND  |
| 69eac90 feat(03-05): CalendarShell wired   | FOUND  |
