docs(03-05): complete event write UI plan — EventForm + client calls + FAB

This commit is contained in:
Lucas Berger
2026-06-05 18:32:37 -04:00
parent 69eac90bab
commit dbd09bc2bb
3 changed files with 143 additions and 9 deletions
@@ -0,0 +1,130 @@
---
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 |