docs(03): finalize phase plan (8 plans, verified)

This commit is contained in:
Lucas Berger
2026-06-05 17:08:16 -04:00
parent 9dd08d28d1
commit 93302cf942
7 changed files with 696 additions and 57 deletions
@@ -31,6 +31,10 @@ must_haves:
to: "/api/events/create"
via: "createEvent mutation"
pattern: "createEvent"
- from: "apps/pwa/src/api/client.ts"
to: "/api/events/writable-calendars"
via: "fetchWritableCalendars GET"
pattern: "writable-calendars"
- from: "apps/pwa/src/components/CalendarShell.tsx"
to: "EventForm"
via: "New Event FAB toggles eventFormOpen"
@@ -47,6 +51,9 @@ open the form and submit a write (delete + sync feedback land in Plan 06).
Purpose: CAL-04 (create timed/all-day) and CAL-07 (create recurring) become user-reachable.
Built against the UI Design Contract (03-UI-SPEC.md) for fields, copy, tokens, and
interaction; reuses the Phase 2 EventDetailPopover overlay/focus-trap/responsive pattern (D-10).
The calendar picker is populated from the authoritative `GET /api/events/writable-calendars`
endpoint (added in Plan 03) — the writable set (D-03) is owned by the server, not derived
on the client.
Output: EventForm + client write calls + store keys + FAB, all wired to the Plan 03 API.
</objective>
@@ -75,28 +82,30 @@ Output: EventForm + client write calls + store keys + FAB, all wired to the Plan
- apps/pwa/src/store/calendarStore.ts (existing — CalendarStore interface + create() pattern)
- .planning/phases/03-event-write-back-pwa-install/03-UI-SPEC.md (§State Management Contract — Zustand keys; §EventForm fields → request shape)
- .planning/phases/03-event-write-back-pwa-install/03-PATTERNS.md (§client.ts — POST/PATCH fetch shape; §Zustand UI state)
- .planning/phases/03-event-write-back-pwa-install/03-03-PLAN.md (Task 3 — GET /api/events/writable-calendars response shape `{ calendars: [{ url, displayName, color, isShared }] }`)
</read_first>
<behavior>
Tests (extend pwa test suite where one exists, else add a small client unit test):
- createEvent posts to /api/events/create with credentials:'include' and JSON body; returns { uid } on 202.
- updateEvent PATCHes /api/events/:uid/edit.
- fetchWritableCalendars GETs the writable-calendar set.
- fetchWritableCalendars GETs /api/events/writable-calendars and returns the WritableCalendar[] from the response's `calendars` array.
- The Zustand store exposes the new keys with correct defaults.
</behavior>
<action>
In client.ts add exported interfaces `CreateEventPayload` (title, allDay, start, end, optional location, description, recurrence: 'none'|'daily'|'weekly'|'monthly'|'yearly', calendarUrl?), `CreateEventResponse` ({ uid }), `WritableCalendar` ({ url, displayName, color, isShared }). Add `createEvent(payload): Promise<CreateEventResponse>` (POST), `updateEvent(uid, payload): Promise<CreateEventResponse>` (PATCH `/api/events/${uid}/edit`), and `fetchWritableCalendars(): Promise<WritableCalendar[]>` (GET `/api/events/writable-calendars` — if Plan 03 did not add this endpoint, derive the writable set on the client from the existing calendars data; document which). All follow the existing fetch shape with credentials:'include' and `if (!res.ok) throw`.
In client.ts add exported interfaces `CreateEventPayload` (title, allDay, start, end, optional location, description, recurrence: 'none'|'daily'|'weekly'|'monthly'|'yearly', calendarUrl?), `CreateEventResponse` ({ uid }), `WritableCalendar` ({ url, displayName, color, isShared }). Add `createEvent(payload): Promise<CreateEventResponse>` (POST), `updateEvent(uid, payload): Promise<CreateEventResponse>` (PATCH `/api/events/${uid}/edit`), and `fetchWritableCalendars(): Promise<WritableCalendar[]>` (GET `/api/events/writable-calendars`, added by Plan 03 Task 3 — call it unconditionally; parse the JSON `{ calendars }` envelope and return `body.calendars`). The server is the authoritative owner of the D-03 writable set; do NOT derive the writable set on the client. All follow the existing fetch shape with credentials:'include' and `if (!res.ok) throw`.
In calendarStore.ts extend `CalendarStore` with `eventFormOpen: boolean`, `eventFormMode: 'create'|'edit'`, `eventFormUid: string|null`, plus setters `setEventForm(open, mode?, uid?)`. Defaults: closed, mode 'create', uid null. Keep all server data out of Zustand (D — server state stays in TanStack Query).
</action>
<verify>
<automated>cd /home/luc/Projects/familysync && pnpm --filter @familysync/pwa exec tsc --noEmit && grep -q "createEvent" apps/pwa/src/api/client.ts && grep -q "eventFormOpen" apps/pwa/src/store/calendarStore.ts && pnpm --filter @familysync/pwa test</automated>
<automated>cd /home/luc/Projects/familysync && pnpm --filter @familysync/pwa exec tsc --noEmit && grep -q "createEvent" apps/pwa/src/api/client.ts && grep -q "writable-calendars" apps/pwa/src/api/client.ts && grep -q "eventFormOpen" apps/pwa/src/store/calendarStore.ts && pnpm --filter @familysync/pwa test</automated>
</verify>
<acceptance_criteria>
- `grep -Eq "createEvent|updateEvent" apps/pwa/src/api/client.ts`.
- `grep -q "writable-calendars" apps/pwa/src/api/client.ts` (calls the Plan 03 endpoint; no client-side derivation).
- `grep -q "eventFormOpen" apps/pwa/src/store/calendarStore.ts`.
- PWA tsc --noEmit passes; existing PWA tests stay green.
</acceptance_criteria>
<done>Write client calls and form-state Zustand keys exist and type-check.</done>
<done>Write client calls (including fetchWritableCalendars against the Plan 03 endpoint) and form-state Zustand keys exist and type-check.</done>
</task>
<task type="auto" tdd="true">
@@ -109,10 +118,10 @@ Output: EventForm + client write calls + store keys + FAB, all wired to the Plan
- apps/pwa/src/api/client.ts (createEvent/updateEvent/fetchWritableCalendars from Task 1)
</read_first>
<behavior>
Tests (EventForm.test.tsx): renders title/all-day/start/end/recurrence/location/description fields; toggling "All day" hides time inputs; calendar picker is absent when one writable calendar and present when two (D-02); empty title shows "Title is required"; end-before-start shows "End time must be after start"; submitting calls the createEvent mutation in create mode and updateEvent in edit mode; Escape and backdrop close the form.
Tests (EventForm.test.tsx): renders title/all-day/start/end/recurrence/location/description fields; toggling "All day" hides time inputs; calendar picker is absent when fetchWritableCalendars returns one calendar and present when it returns two (D-02); empty title shows "Title is required"; end-before-start shows "End time must be after start"; submitting calls the createEvent mutation in create mode and updateEvent in edit mode; Escape and backdrop close the form.
</behavior>
<action>
Implement `EventForm.tsx` as a modal overlay reusing the EventDetailPopover backdrop+dialog+focus-trap+responsive pattern (bottom sheet on phone, centered 480px dialog on desktop). Fields and order exactly per UI-SPEC §EventForm. All-day toggle (`role="switch"`) hides start/end time inputs and applies the auto-advance rule; defaults start 09:00/end 10:00 when toggled off. Recurrence as a segmented select (`role="radiogroup"` or `<select>`) of None/Daily/Weekly/Monthly/Yearly (D-11 whole-series; map to the recurrence enum). Calendar picker rendered only when `fetchWritableCalendars()` returns >1 (D-02); default selection = last-used (read from a localStorage key) else personal (D-01). Use `useMutation` (TanStack Query) calling `createEvent`/`updateEvent` by `eventFormMode`; on success close the form (`setEventForm(false)`) and set `lastSyncedUid` (added in Plan 06; if absent, store the returned uid in a placeholder for now). Validation: empty title and end-before-start show the exact UI-SPEC error copy in `--color-destructive`. All spacing/color via tokens; all field values rendered as plain-text JSX children (XSS guard); 44px min touch targets; `role="dialog"` `aria-modal="true"` `aria-label` "New Event"/"Edit Event"; focus the Title input on open; Escape/backdrop close. Edit mode pre-populates fields from the occurrence identified by `eventFormUid` (read from the TanStack `['events']` cache like EventDetailPopover does).
Implement `EventForm.tsx` as a modal overlay reusing the EventDetailPopover backdrop+dialog+focus-trap+responsive pattern (bottom sheet on phone, centered 480px dialog on desktop). Fields and order exactly per UI-SPEC §EventForm. All-day toggle (`role="switch"`) hides start/end time inputs and applies the auto-advance rule; defaults start 09:00/end 10:00 when toggled off. Recurrence as a segmented select (`role="radiogroup"` or `<select>`) of None/Daily/Weekly/Monthly/Yearly (D-11 whole-series; map to the recurrence enum). Calendar picker rendered only when `fetchWritableCalendars()` (TanStack Query, key `['writable-calendars']`) returns >1 (D-02); default selection = last-used (read from a localStorage key) else personal (D-01). Use `useMutation` (TanStack Query) calling `createEvent`/`updateEvent` by `eventFormMode`; on success close the form (`setEventForm(false)`) and set `lastSyncedUid` (added in Plan 06; if absent, store the returned uid in a placeholder for now). Validation: empty title and end-before-start show the exact UI-SPEC error copy in `--color-destructive`. All spacing/color via tokens; all field values rendered as plain-text JSX children (XSS guard); 44px min touch targets; `role="dialog"` `aria-modal="true"` `aria-label` "New Event"/"Edit Event"; focus the Title input on open; Escape/backdrop close. Edit mode pre-populates fields from the occurrence identified by `eventFormUid` (read from the TanStack `['events']` cache like EventDetailPopover does).
</action>
<verify>
<automated>cd /home/luc/Projects/familysync && pnpm --filter @familysync/pwa test -- EventForm && pnpm --filter @familysync/pwa exec tsc --noEmit</automated>
@@ -160,18 +169,18 @@ Output: EventForm + client write calls + store keys + FAB, all wired to the Plan
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-15 | Tampering | XSS via event title/location/description in the form | mitigate | All values rendered as plain-text JSX children; never dangerouslySetInnerHTML (Phase 2 T-02e-01 pattern); server re-validates with zod (Plan 03) |
| T-03-16 | Elevation of Privilege | client offering a non-writable calendar in the picker | mitigate | Picker is populated only from the member's writable set; server enforces D-03 ownership regardless (Plan 03 is authoritative) |
| T-03-16 | Elevation of Privilege | client offering a non-writable calendar in the picker | mitigate | Picker is populated only from the authoritative `GET /api/events/writable-calendars` set (Plan 03, D-03 enforced server-side); the client never derives writability, and the write endpoints re-enforce D-03 ownership on enqueue regardless |
</threat_model>
<verification>
- `pnpm --filter @familysync/pwa test` green (EventForm + existing).
- `pnpm --filter @familysync/pwa exec tsc --noEmit` passes.
- EventForm reachable from CalendarShell; D-02 picker conditional; D-11 recurrence presets present.
- EventForm reachable from CalendarShell; D-02 picker conditional (driven by the writable-calendars endpoint); D-11 recurrence presets present.
</verification>
<success_criteria>
- CAL-04 and CAL-07 create paths are user-reachable through EventForm → POST /api/events/create.
- Edit mode pre-populates and PATCHes; calendar picker honors D-01/D-02.
- Edit mode pre-populates and PATCHes; calendar picker honors D-01/D-02, sourced from the Plan 03 writable-calendars endpoint.
</success_criteria>
<output>