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
+5 -5
View File
@@ -3,8 +3,8 @@ gsd_state_version: 1.0
milestone: v1.0
milestone_name: milestone
status: planning
stopped_at: Phase 3 context gathered
last_updated: "2026-06-05T20:17:26.434Z"
stopped_at: Phase 03 UI-SPEC approved
last_updated: "2026-06-05T21:08:16.025Z"
last_activity: 2026-06-05 -- Phase 2 completed
progress:
total_phases: 6
@@ -95,6 +95,6 @@ Recent decisions affecting current work:
## Session Continuity
Last session: 2026-06-05T20:17:26.429Z
Stopped at: Phase 3 context gathered
Resume file: .planning/phases/03-event-write-back-pwa-install/03-CONTEXT.md
Last session: 2026-06-05T20:40:21.988Z
Stopped at: Phase 03 UI-SPEC approved
Resume file: .planning/phases/03-event-write-back-pwa-install/03-UI-SPEC.md
@@ -75,7 +75,7 @@ five RED test files.
New symbols introduced across Phase 3 (excluded from drift verification):
- DB: `calendarOutbox` table (`calendar_outbox`), `calendarEvents.objectUrl` column (`object_url`)
- Backend files: `apps/api/src/broker/vevent.ts` (`buildVeventString`, `NewEventParams`), `apps/api/src/broker/write.ts` (`createCalendarEvent`, `updateCalendarEvent`, `deleteCalendarEvent`), `apps/api/src/broker/outboxWorker.ts` (`runOutboxDrain`, `startOutboxWorker`, `RRULE_PRESETS`)
- Backend routes: `POST /api/events/create`, `PATCH /api/events/:uid/edit`, `DELETE /api/events/:uid`, `GET /api/events/sync-status`
- Backend routes: `POST /api/events/create`, `PATCH /api/events/:uid/edit`, `DELETE /api/events/:uid`, `GET /api/events/sync-status`, `GET /api/events/writable-calendars`
- Frontend files: `apps/pwa/src/components/EventForm.tsx`, `apps/pwa/src/components/InstallPrompt.tsx`, `apps/pwa/src/components/SyncStateToast.tsx`, `apps/pwa/src/components/DeleteConfirmationDialog.tsx`
- Frontend client fns: `createEvent`, `updateEvent`, `deleteEvent`, `fetchSyncStatus`, `fetchWritableCalendars`
- Zustand keys: `eventFormOpen`, `eventFormMode`, `eventFormUid`, `deleteDialogOpen`, `deleteDialogUid`, `lastSyncedUid`
@@ -170,7 +170,7 @@ New symbols introduced across Phase 3 (excluded from drift verification):
- vevent.test.ts: buildVeventString produces VCALENDAR with VEVENT for a timed event (DTSTART with Z/UTC); for an all-day event a DATE value (no time component, no TZID) per D-13; with rruleString produces an RRULE property (CAL-04, CAL-07).
- write.test.ts: createCalendarEvent calls client.createCalendarObject with `${uid}.ics` filename; updateCalendarEvent passes etag into the calendarObject (If-Match); deleteCalendarEvent passes etag; each returns the raw Response (mock client).
- outboxWorker.test.ts: runOutboxDrain transitions pending→done on mock 204; pending→failed on mock 412 (and triggers re-sync, no retry); pending→backoff (nextAttemptAt advanced, attemptCount++) on mock 500; pending→dead at MAX_ATTEMPTS; edit-as-move emits a create row processed BEFORE the linked delete row (D-04/D-07/D-08).
- events.test.ts (extend existing): POST /api/events/create returns 202 + inserts a pending outbox row; PATCH /api/events/:uid/edit returns 202 + inserts row with etag; DELETE /api/events/:uid returns 202 + inserts delete row; GET /api/events/sync-status?uid= returns the outbox status; create rejects writing to a calendar not owned by the user with 403 (D-03 / V4 access control).
- events.test.ts (extend existing): POST /api/events/create returns 202 + inserts a pending outbox row; PATCH /api/events/:uid/edit returns 202 + inserts row with etag; DELETE /api/events/:uid returns 202 + inserts delete row; GET /api/events/sync-status?uid= returns the outbox status; GET /api/events/writable-calendars returns the member's writable set (own personal + shared `isShared=1`) and NEVER another member's read-only personal calendar (different userId, isShared=false) — D-03 / V4; create rejects writing to a calendar not owned by the user with 403 (D-03 / V4 access control).
- InstallPrompt.test.tsx: isIOSSafariNonStandalone() returns true for a mock iOS Safari non-standalone UA and false in standalone; useAndroidInstallPrompt sets canInstall=true when a mock beforeinstallprompt event dispatches.
</behavior>
<action>
@@ -182,6 +182,7 @@ New symbols introduced across Phase 3 (excluded from drift verification):
<acceptance_criteria>
- All five test files exist.
- `pnpm --filter @familysync/api test -- broker/vevent` reports failures or unresolved imports (RED — implementation not present).
- The events.test.ts scaffold includes a `writable-calendars` describe block (`grep -c "writable-calendars" apps/api/tests/routes/events.test.ts` ≥1).
- The existing GET /api/events describe block is still present in events.test.ts (`grep -c "GET /api/events" apps/api/tests/routes/events.test.ts` ≥1).
</acceptance_criteria>
<done>Five RED test files exist and fail because their target modules are unimplemented; existing tests preserved.</done>
@@ -18,10 +18,11 @@ must_haves:
- "A member cannot enqueue a write to a calendar they do not own (403) — D-03 / V4 access control"
- "GET /api/events/sync-status?uid= returns the outbox status for that member's UID"
- "Edit that changes the target calendar enqueues a linked delete+create pair in one transaction (D-04)"
- "GET /api/events/writable-calendars returns the member's writable set per D-03 — own personal + shared Family (read-write); never the other member's read-only personal"
artifacts:
- path: "apps/api/src/routes/events.ts"
provides: "create/edit/delete write endpoints + sync-status, all enqueue-only (broker boundary)"
contains: "/sync-status"
provides: "create/edit/delete write endpoints + sync-status + writable-calendars, all enqueue-only (broker boundary)"
contains: "/writable-calendars"
key_links:
- from: "apps/api/src/routes/events.ts"
to: "calendarOutbox"
@@ -35,17 +36,21 @@ must_haves:
<objective>
Add the write API surface to the events router: `POST /create`, `PATCH /:uid/edit`,
`DELETE /:uid`, and `GET /sync-status`. Every write endpoint validates with zod,
asserts the target calendar belongs to the current member (D-03), and ENQUEUES an
outbox row — it never calls Fastmail (broker boundary, D-12). The endpoints return 202
immediately so the UI can optimistically accept (D-05). sync-status exposes the outbox
state for the polled toast (D-09).
`DELETE /:uid`, `GET /sync-status`, and `GET /writable-calendars`. Every write endpoint
validates with zod, asserts the target calendar belongs to the current member (D-03), and
ENQUEUES an outbox row — it never calls Fastmail (broker boundary, D-12). The endpoints
return 202 immediately so the UI can optimistically accept (D-05). sync-status exposes the
outbox state for the polled toast (D-09). writable-calendars exposes the member's authorized
write target set (D-03) so the client picker (Plan 05) renders only legal targets and honors
the D-02 single-calendar hide rule.
Purpose: this is the backend half of the create/edit/delete vertical slices. It depends
only on the outbox schema (Plan 01); it does not import the worker or write.ts (those
drain the queue the endpoints fill).
drain the queue the endpoints fill). The writable-calendars endpoint is the authoritative
owner of the D-03 writable-set authorization — the client never derives it.
Output: extended events.ts, GREEN against the create/edit/delete/sync-status tests from Plan 01.
Output: extended events.ts, GREEN against the create/edit/delete/sync-status/writable-calendars
tests from Plan 01.
</objective>
<execution_context>
@@ -117,6 +122,36 @@ Output: extended events.ts, GREEN against the create/edit/delete/sync-status tes
<done>GET /api/events/sync-status returns the member-scoped outbox status; tests GREEN.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: GREEN — GET /api/events/writable-calendars (D-03 writable set, authoritative)</name>
<files>apps/api/src/routes/events.ts, apps/api/tests/routes/events.test.ts</files>
<read_first>
- apps/api/tests/routes/events.test.ts (extend — add a `GET /api/events/writable-calendars` describe block alongside the create/edit/delete/sync-status stubs)
- apps/api/src/routes/events.ts (existing GET / handler — mirror its auth + db.select + try/catch shape)
- apps/api/src/db/schema.ts (`calendars` table — `url`, `displayName`, `color`, `userId`, `isShared` columns)
- .planning/phases/03-event-write-back-pwa-install/03-RESEARCH.md (§Open Questions Q3 — writable-set resolution query; §Security Domain V4 — D-03 access control)
- .planning/phases/03-event-write-back-pwa-install/03-CONTEXT.md (D-02 picker-visibility, D-03 writable set)
- .planning/phases/03-event-write-back-pwa-install/03-PATTERNS.md (§events.ts, §Auth guard in write route handlers)
</read_first>
<action>
Add `eventsRouter.get('/writable-calendars', ...)`. Resolve the current member id with the same dev-bypass + `getAuth(c)` pattern as the write endpoints (401 if neither). This endpoint is the AUTHORITATIVE owner of the D-03 writable-set authorization — the client (Plan 05) consumes it verbatim and never derives the set itself.
Per RESEARCH.md Open Q3: select the writable set = rows in `calendars WHERE userId = currentUser.id` (the member's own personal calendar(s)) UNION rows WHERE `isShared = 1` (the shared Family calendar, when read-write to the household). Express this as a single Drizzle query with `WHERE eq(calendars.userId, currentUser.id) OR eq(calendars.isShared, true)`. The other member's personal calendar (a row with a different `userId` and `isShared = 0/false`) MUST NOT appear — it is a read-only overlay only (D-03), never a write target.
Map each row to the response shape `{ calendars: [{ url, displayName, color, isShared }] }` (exactly the `WritableCalendar` shape Plan 05's `fetchWritableCalendars` consumes). Wrap the db work in try/catch returning 503 per the existing GET handler pattern. Do NOT include any Fastmail call (broker boundary).
</action>
<verify>
<automated>cd /home/luc/Projects/familysync && pnpm --filter @familysync/api test -- routes/events && grep -q "/writable-calendars" apps/api/src/routes/events.ts && pnpm --filter @familysync/api exec tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- writable-calendars test GREEN: returns only the member's own personal calendar(s) plus the shared (`isShared=1`) calendar.
- The test asserts another member's personal calendar (different userId, isShared=false) is NEVER returned (D-03 / V4).
- Response items expose `url`, `displayName`, `color`, `isShared` (the picker's `WritableCalendar` shape).
- `grep -c "/writable-calendars" apps/api/src/routes/events.ts` ≥1.
</acceptance_criteria>
<done>GET /api/events/writable-calendars returns the D-03 writable set (own personal + shared Family), never another member's read-only personal; response matches the Plan 05 WritableCalendar shape; tests GREEN.</done>
</task>
</tasks>
<threat_model>
@@ -125,7 +160,7 @@ Output: extended events.ts, GREEN against the create/edit/delete/sync-status tes
| Boundary | Description |
|----------|-------------|
| client → write API | Untrusted member input (event fields, target calendar, uid) crosses here |
| member A → member B data | A member must never write to or read another member's outbox/calendar |
| member A → member B data | A member must never write to, treat-as-writable, or read another member's outbox/calendar |
## STRIDE Threat Register
@@ -136,17 +171,18 @@ Output: extended events.ts, GREEN against the create/edit/delete/sync-status tes
| T-03-08 | Tampering | XSS/oversized payload via title/location/description | mitigate | zod length bounds (title 255, location/description 2000); plain-text storage; rendered as JSX children downstream |
| T-03-09 | Tampering | SQL injection via uid/calendarUrl | mitigate | Drizzle parameterized queries; no string interpolation |
| T-03-10 | Spoofing | client-supplied etag bypassing conflict detection | mitigate | etag read from calendarEvents server-side at enqueue; client never supplies it |
| T-03-11 | Elevation of Privilege | writable-calendars surfacing another member's personal calendar as a write target | mitigate | Query restricted to `userId = currentUser.id OR isShared = true`; another member's `isShared=false` personal row is never returned; client treats the response as authoritative and the write endpoints re-enforce D-03 on enqueue |
</threat_model>
<verification>
- `pnpm --filter @familysync/api test -- routes/events` GREEN (create, edit, delete, sync-status, 403 ownership).
- `pnpm --filter @familysync/api test -- routes/events` GREEN (create, edit, delete, sync-status, writable-calendars, 403 ownership).
- `pnpm --filter @familysync/api exec tsc --noEmit` passes.
- No tsdav import in events.ts (broker boundary): `grep -c "tsdav\|createFastmailClient" apps/api/src/routes/events.ts` returns 0.
</verification>
<success_criteria>
- All four write/status endpoints enqueue-only and member-scoped.
- D-03 ownership enforced; D-04 edit-as-move pair transactional; D-09 polling endpoint live.
- All five write/status/writable-calendars endpoints enqueue-only and member-scoped.
- D-03 ownership enforced on both the write path and the writable-calendars listing; D-04 edit-as-move pair transactional; D-09 polling endpoint live.
</success_criteria>
<output>
@@ -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>
@@ -0,0 +1,566 @@
# Phase 3: Event Write-Back + PWA Install - Pattern Map
**Mapped:** 2026-06-05
**Files analyzed:** 12 new/modified files
**Analogs found:** 10 / 12
---
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `apps/api/src/db/schema.ts` | model | CRUD | `apps/api/src/db/schema.ts` (extend existing) | exact |
| `apps/api/src/broker/write.ts` | service | request-response | `apps/api/src/broker/client.ts` | role-match |
| `apps/api/src/broker/vevent.ts` | utility | transform | `apps/api/src/broker/sync.ts` (ical.js usage) | role-match |
| `apps/api/src/broker/outboxWorker.ts` | service | batch | `apps/api/src/broker/poller.ts` | exact |
| `apps/api/src/routes/events.ts` | route | request-response | `apps/api/src/routes/events.ts` (extend existing) | exact |
| `apps/pwa/src/components/EventDetailPopover.tsx` | component | request-response | `apps/pwa/src/components/EventDetailPopover.tsx` (extend) | exact |
| `apps/pwa/src/components/EventForm.tsx` | component | request-response | `apps/pwa/src/components/EventDetailPopover.tsx` | role-match |
| `apps/pwa/src/components/InstallPrompt.tsx` | component | event-driven | `apps/pwa/src/components/EmptyState.tsx` | partial |
| `apps/pwa/src/api/client.ts` | utility | request-response | `apps/pwa/src/api/client.ts` (extend existing) | exact |
| `apps/pwa/vite.config.ts` | config | — | `apps/pwa/vite.config.ts` (extend existing) | exact |
| `apps/api/tests/broker/outboxWorker.test.ts` | test | batch | `apps/api/tests/broker/sync.test.ts` | role-match |
| `apps/api/tests/routes/events.test.ts` | test | request-response | `apps/api/tests/routes/events.test.ts` (extend) | exact |
---
## Pattern Assignments
### `apps/api/src/db/schema.ts` — add `calendarOutbox` table + `objectUrl` column on `calendarEvents`
**Analog:** `apps/api/src/db/schema.ts` (lines 1112, existing file)
**Imports pattern** (lines 112):
```typescript
import {
mysqlTable,
varchar,
text,
int,
date,
timestamp,
boolean,
index,
unique,
} from 'drizzle-orm/mysql-core'
```
Add `mysqlEnum` to the import list — already used in the research pattern but not yet in schema.ts.
**Existing table pattern** (lines 86112) — copy this structure for `calendarOutbox`:
```typescript
export const calendarEvents = mysqlTable(
'calendar_events',
{
id: int().primaryKey().autoincrement(),
calendarId: int('calendar_id')
.notNull()
.references(() => calendars.id, { onDelete: 'cascade' }),
uid: varchar('uid', { length: 512 }).notNull(),
etag: varchar('etag', { length: 256 }),
// ...
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
},
(t) => [
index('idx_calendar_events_dtstart_utc').on(t.dtstartUtc),
unique('uniq_calendar_uid').on(t.calendarId, t.uid),
],
)
```
**New column on `calendarEvents`** — add `objectUrl` after `etag`:
```typescript
objectUrl: varchar('object_url', { length: 1024 }), // CalDAV object URL; populated by sync.ts from obj.url
```
**References pattern** (lines 4047) — copy for `calendarOutbox.userId`:
```typescript
userId: int('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
```
---
### `apps/api/src/broker/write.ts` — new file, tsdav PUT/DELETE wrapper
**Analog:** `apps/api/src/broker/client.ts` (lines 132)
**File header and imports pattern** (client.ts lines 112):
```typescript
/**
* [JSDoc comment with source citations]
* Source: https://...
*/
import { createDAVClient } from 'tsdav'
export type FastmailClient = Awaited<ReturnType<typeof createDAVClient>>
```
**Export pattern** — named exports, no default (matches all broker files):
```typescript
import type { FastmailClient } from './client.js'
import type { DAVCalendar } from 'tsdav'
export async function createCalendarEvent(...): Promise<Response> { ... }
export async function updateCalendarEvent(...): Promise<Response> { ... }
export async function deleteCalendarEvent(...): Promise<Response> { ... }
```
**Import extension `.js`** — all broker imports use `.js` suffix (e.g., `'./client.js'`, `'../db/client.js'`). Required for ESM with TypeScript.
---
### `apps/api/src/broker/vevent.ts` — new file, ical.js VEVENT builder
**Analog:** `apps/api/src/broker/sync.ts` (lines 1127) — existing ical.js usage
**ical.js import pattern** (sync.ts line 20):
```typescript
import ICAL from 'ical.js'
```
**ical.js parse → component pattern** (sync.ts lines 7286) — the reverse direction (build vs parse) uses the same ICAL.Component/ICAL.Time API:
```typescript
const comp = new ICAL.Component(parsed)
const vevent = comp.getFirstSubcomponent('vevent')
const dtstart = vevent.getFirstPropertyValue('dtstart') as ICAL.Time | null
```
**D-13 all-day vs timed split** (sync.ts lines 89101) — must mirror this exact split in the builder:
```typescript
// D-13 / Pitfall #3: isDate=true → DATE column; isDate=false → TIMESTAMP column
const allDay: boolean = dtstart?.isDate ?? false
const dtstartDateValue: Date | null =
allDay && dtstart ? new Date(dtstart.toString().slice(0, 10) + 'T00:00:00Z') : null
const dtstartUtcValue: Date | null = !allDay && dtstart ? dtstart.toJSDate() : null
```
**Error isolation pattern** (sync.ts lines 7478):
```typescript
try {
parsed = ICAL.parse(obj.data as string)
} catch {
// Malformed VCALENDAR — skip but do not crash the sync
continue
}
```
---
### `apps/api/src/broker/outboxWorker.ts` — new file, outbox drain loop
**Analog:** `apps/api/src/broker/poller.ts` (lines 185) — closest match, exact role
**File header JSDoc pattern** (poller.ts lines 116):
```typescript
/**
* CalDAV broker poller — runs every 5 minutes via node-cron.
*
* Responsibilities (D-13, D-02):
* - ...
*
* runPoll is exported for unit testing (inject mocks via vi.mock at the module level).
* startBrokerPoller wraps it in node-cron's 5-minute schedule.
*
* Source: https://github.com/node-cron/node-cron (v4 stable basic API)
*/
```
**Imports pattern** (poller.ts lines 1825):
```typescript
import { schedule } from 'node-cron'
import { eq } from 'drizzle-orm'
import { db } from '../db/client.js'
import { memberCredentials, calendars } from '../db/schema.js'
import { decryptPassword } from './crypto.js'
import { createFastmailClient } from './client.js'
import { syncCalendar } from './sync.js'
```
Replace with: `and`, `lte`, `eq` from `drizzle-orm`; `calendarOutbox`, `calendars` from schema; `syncCalendar` from `./sync.js`; write functions from `./write.js`.
**Exported runX + startX pair pattern** (poller.ts lines 3585):
```typescript
// runPoll exported for unit testing
export async function runPoll(): Promise<void> { ... }
// startBrokerPoller wraps it in a schedule
export function startBrokerPoller(): void {
schedule('*/5 * * * *', () => {
runPoll().catch((err: unknown) => {
console.error('[broker/poller] Unhandled runPoll error:', err)
})
})
}
```
Outbox worker follows: `export async function runOutboxDrain()` + `export function startOutboxWorker()`.
**Per-item error isolation pattern** (poller.ts lines 6572):
```typescript
} catch (err) {
// Log the error but do NOT log the app password or key (T-03-04)
console.error(
`[broker/poller] Error processing credential id=${cred.id} (${cred.fastmailEmail}):`,
err instanceof Error ? err.message : String(err),
)
}
```
**Drizzle select + where + limit pattern** (poller.ts lines 4753):
```typescript
const [stored] = await db
.select()
.from(calendars)
.where(eq(calendars.url, davCal.url))
.limit(1)
```
**Drizzle update pattern** — extend from sync.ts `onDuplicateKeyUpdate` shape:
```typescript
await db.update(calendarOutbox)
.set({ status: 'done' })
.where(eq(calendarOutbox.id, row.id))
```
---
### `apps/api/src/routes/events.ts` — extend with write endpoints + sync-status
**Analog:** `apps/api/src/routes/events.ts` (lines 1141, existing file)
**File header invariant comment** (lines 115) — copy verbatim and extend:
```typescript
/**
* Architecture invariant (T-03-02, broker-boundary):
* This route reads ONLY from the MariaDB cache. It NEVER calls Fastmail directly.
* All Fastmail I/O is owned exclusively by the broker module (src/broker/).
* No tsdav import here; no createFastmailClient import here.
*/
```
**Hono router + zValidator pattern** (lines 1741):
```typescript
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import { and, or, eq, lte, lt } from 'drizzle-orm'
import { sql } from 'drizzle-orm'
import { db } from '../db/client.js'
import { calendarEvents, calendars, users } from '../db/schema.js'
export const eventsRouter = new Hono()
const eventsQuerySchema = z.object({
start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
})
```
**Route handler + zValidator + try/catch error pattern** (lines 53141):
```typescript
eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => {
// ... input validation ...
try {
const rows = await db.select(...).from(...).where(...)
return c.json({ occurrences: allOccurrences })
} catch (err) {
console.error('[events] DB query or expansion failed:', err)
return c.json({ error: 'Service unavailable' }, 503)
}
})
```
New write endpoints follow the same shape: `eventsRouter.post('/create', zValidator('json', createSchema), async (c) => { ... })`.
**Auth identity pattern** (from me.ts lines 3344) — write endpoints need current user:
```typescript
const devUser = c.get('user')
if (devUser) {
// dev bypass path
}
const auth = await getAuth(c)
if (!auth) {
return c.json({ error: 'Unauthorized' }, 401)
}
```
---
### `apps/pwa/src/components/EventDetailPopover.tsx` — add edit/delete to reserved footer
**Analog:** `apps/pwa/src/components/EventDetailPopover.tsx` (lines 380388, reserved footer)
**Reserved footer (lines 380388)** — Phase 3 wires buttons here:
```tsx
{/* Phase 3 footer action area — Phase 3 adds edit/delete actions here (D-08) */}
<div
aria-hidden="true"
style={{
// Reserved: empty in Phase 2 (read-only); Phase 3 wires edit/delete buttons here
marginTop: 'var(--space-4)',
}}
/>
```
Replace with real content. Remove `aria-hidden="true"`.
**Button style pattern** (lines 235251) — copy close button style for action buttons:
```tsx
<button
aria-label="Close"
onClick={handleClose}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
minWidth: '44px',
minHeight: '44px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '20px',
color: 'var(--color-text-secondary)',
borderRadius: 'var(--space-1)',
padding: 0,
}}
>
```
**Design token usage** — all spacing/color uses CSS vars (not hardcoded values):
- `var(--color-surface-raised)`, `var(--color-text-primary)`, `var(--color-text-secondary)`, `var(--color-border-subtle)`
- `var(--space-2)`, `var(--space-3)`, `var(--space-4)`, `var(--space-6)`
- `var(--text-body-size)`, `var(--text-heading-size)`, `var(--font-family-base)`
**XSS guard pattern** (T-02e-01, lines 283285) — all text content as plain JSX children:
```tsx
{/* Plain text child only — XSS guard (T-02e-01) */}
{occurrence.title}
```
EventForm must follow this: all field values rendered as plain-text children, never `dangerouslySetInnerHTML`.
**Zustand + TanStack Query pattern** (lines 109137):
```tsx
const { openEventId, setOpenEventId } = useCalendarStore()
const queryClient = useQueryClient()
// Read from TanStack Query cache — do not store server data in Zustand
const allEntries = queryClient.getQueriesData<{ occurrences: CalendarOccurrence[] }>({
queryKey: ['events'],
})
```
---
### `apps/pwa/src/components/EventForm.tsx` — new file, create/edit form
**Analog:** `apps/pwa/src/components/EventDetailPopover.tsx` (role-match — same overlay surface)
**Modal/overlay structure** — copy the backdrop + dialog pattern from EventDetailPopover (lines 202221):
```tsx
<>
{/* Backdrop */}
<div
data-testid="popover-backdrop"
onClick={handleClose}
style={{ position: 'fixed', inset: 0, background: 'var(--color-overlay)', zIndex: 199 }}
/>
{/* Dialog */}
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label="..."
tabIndex={-1}
style={dialogStyle}
>
```
**Escape + focus trap useEffect pattern** (lines 143159):
```tsx
useEffect(() => {
if (!activeId) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') handleClose()
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [activeId])
useEffect(() => {
if (activeId && dialogRef.current) dialogRef.current.focus()
}, [activeId])
```
**Responsive phone/desktop detection** (lines 165199) — copy the `isPhone` / `dialogStyle` pattern.
**TanStack Query mutation pattern** — use `useMutation` from `@tanstack/react-query` (same import, already in stack):
```tsx
import { useMutation, useQueryClient } from '@tanstack/react-query'
// On success: queryClient.invalidateQueries({ queryKey: ['events'] })
```
---
### `apps/pwa/src/components/InstallPrompt.tsx` — new file, iOS/Android install
**Analog:** `apps/pwa/src/components/EmptyState.tsx` (partial — informational UI surface)
No close analog. Use the design token and component conventions from EventDetailPopover:
- CSS vars for all spacing/color
- Plain-text JSX children (no dangerouslySetInnerHTML)
- 44px minimum touch targets on all buttons
- `useEffect` for event listener cleanup (same pattern as popover Escape handler)
**Standalone detection** — no existing analog; use RESEARCH.md Pattern 6 directly.
---
### `apps/pwa/src/api/client.ts` — add write calls + sync-status poll
**Analog:** `apps/pwa/src/api/client.ts` (lines 1106, extend)
**Fetch function pattern** (lines 89102):
```typescript
export async function fetchEvents(start: string, end: string): Promise<OccurrencesResponse> {
const res = await fetch(`/api/events?start=${start}&end=${end}`, {
credentials: 'include',
})
if (!res.ok) {
throw new Error(`GET /api/events failed: ${res.status}`)
}
return res.json() as Promise<OccurrencesResponse>
}
```
New write functions follow the same shape. POST/PATCH/DELETE calls:
```typescript
export async function createEvent(payload: CreateEventPayload): Promise<CreateEventResponse> {
const res = await fetch('/api/events/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(payload),
})
if (!res.ok) throw new Error(`POST /api/events/create failed: ${res.status}`)
return res.json() as Promise<CreateEventResponse>
}
```
**Interface-first pattern** (lines 1474) — define TypeScript interfaces before the fetch functions. All request/response shapes declared as exported interfaces.
---
### `apps/pwa/vite.config.ts` — add VitePWA plugin
**Analog:** `apps/pwa/vite.config.ts` (lines 113, extend existing)
**Existing config** (lines 113):
```typescript
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/health': 'http://localhost:3000',
'/api': 'http://localhost:3000',
'/callback': 'http://localhost:3000',
},
},
})
```
Keep the proxy block exactly as-is. Add `VitePWA` to `plugins` array. The `/callback` proxy entry is critical — it must remain so the SW denylist matches the actual handler.
---
## Shared Patterns
### Auth guard in write route handlers
**Source:** `apps/api/src/routes/me.ts` lines 2949
**Apply to:** All new POST/PATCH/DELETE handlers in `routes/events.ts`
```typescript
const devUser = c.get('user')
if (devUser) {
// dev bypass — use devUser.id as userId
}
const auth = await getAuth(c)
if (!auth) {
return c.json({ error: 'Unauthorized' }, 401)
}
```
Also import `'../auth/devBypass.js'` as a side-effect to get the ContextVariableMap augmentation (see me.ts line 25).
### Error handling in route handlers
**Source:** `apps/api/src/routes/events.ts` lines 136140
**Apply to:** All route handlers
```typescript
} catch (err) {
console.error('[events] DB query or expansion failed:', err)
return c.json({ error: 'Service unavailable' }, 503)
}
```
Use consistent `[module/file] description:` log prefix format.
### ESM import extension
**Source:** All existing broker and route files
**Apply to:** All new TypeScript files
All project imports use `.js` extension suffix on relative imports:
`'./client.js'`, `'../db/client.js'`, `'../db/schema.js'`, `'./sync.js'`
### Drizzle DB mock in tests
**Source:** `apps/api/tests/routes/events.test.ts` lines 2952
**Apply to:** `outboxWorker.test.ts`, extended `events.test.ts`
```typescript
// Chain of mocks matching the Drizzle query builder
const mockWhereFn = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows))
const mockFromFn = vi.fn().mockReturnValue({ where: mockWhereFn })
const mockSelectFn = vi.fn().mockReturnValue({ from: mockFromFn })
vi.mock('../../src/db/client.js', () => ({
db: { select: mockSelectFn, insert: mockInsert, update: mockUpdate },
}))
```
### OIDC mock in tests
**Source:** `apps/api/tests/routes/events.test.ts` lines 2226
**Apply to:** All new route tests
```typescript
vi.mock('@hono/oidc-auth', () => ({
oidcAuthMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
processOAuthCallback: () => async (c: { json: (v: unknown) => unknown }) => c.json({ ok: true }),
getAuth: () => null,
}))
```
### TanStack Query integration in React components
**Source:** `apps/pwa/src/components/EventDetailPopover.tsx` lines 26, 111112
**Apply to:** `EventForm.tsx`, `InstallPrompt.tsx`
```tsx
import { useQueryClient } from '@tanstack/react-query'
// ...
const queryClient = useQueryClient()
// On write success: invalidate events cache
queryClient.invalidateQueries({ queryKey: ['events'] })
```
### Zustand UI state (not server state)
**Source:** `apps/pwa/src/components/EventDetailPopover.tsx` lines 109110
**Apply to:** `EventForm.tsx`
```tsx
const { openEventId, setOpenEventId } = useCalendarStore()
```
EventForm visibility/mode (create vs edit) is UI state → Zustand. Event data is server state → TanStack Query.
---
## No Analog Found
| File | Role | Data Flow | Reason |
|------|------|-----------|--------|
| `apps/pwa/src/components/InstallPrompt.tsx` (iOS walkthrough) | component | event-driven | No precedent for install-prompt or browser-API-driven components in codebase |
---
## Metadata
**Analog search scope:** `apps/api/src/`, `apps/pwa/src/`, `apps/api/tests/`
**Files scanned:** 14 source files read
**Pattern extraction date:** 2026-06-05
@@ -811,23 +811,26 @@ const isInstalled = window.matchMedia('(display-mode: standalone)').matches
---
## Open Questions
## Open Questions (RESOLVED)
1. **Fastmail object URL format**
- What we know: `tsdav` `fetchCalendarObjects` returns `DAVCalendarObject` with a `url` field; Fastmail CalDAV URLs follow the pattern `https://caldav.fastmail.com/dav/calendars/user/<email>/<calendar-slug>/<uid>.ics`
- What's unclear: Whether the URL is returned verbatim by `fetchCalendarObjects` or constructed — and whether the `calendarObjectUrl` stored in the outbox is stable across syncs
- Recommendation: At worker time, fetch fresh object URLs from the DB `calendarEvents.url` column (which does not exist yet — the schema needs a `url` column added to `calendarEvents` for the CalDAV object URL). Alternatively, construct it from `calendars.url + uid + '.ics'` — verify against a real REPORT response in Wave 0.
- **Action for planner:** Add `objectUrl varchar(1024)` to `calendarEvents` schema OR document URL construction convention.
- **Resolution:** RESOLVED — `objectUrl` column added to `calendarEvents` in plan 03-01 Task 2 and populated from `obj.url` in `sync.ts` (03-01 Task 3); the worker reads the stored object URL rather than reconstructing it.
2. **`calendarEvents` schema missing object URL**
- What we know: Current `calendarEvents` schema has `uid`, `etag`, `rawVevent` but no `url` field. The object URL is needed for `updateCalendarObject` and `deleteCalendarObject`.
- What's unclear: Whether `tsdav` `fetchCalendarObjects` returns a `url` field in the `DAVCalendarObject` (it does — the tsdav type shows `url: string`). So the URL can be stored at sync time.
- Recommendation: Add `objectUrl varchar(1024)` to `calendarEvents` in the schema migration. Populate it from `obj.url` in `sync.ts` alongside `etag`.
- **Resolution:** RESOLVED — same as Q1: `calendarEvents.objectUrl` (`object_url varchar(1024)`) added in plan 03-01 Task 2 and set from `obj.url` in `sync.ts` (03-01 Task 3).
3. **Writable calendar set resolution (D-03)**
- What we know: D-03 says writable = own personal + shared Family; D-16 says shared calendar not yet created; `calendars.isShared` marks the shared one.
- What's unclear: How the API knows which calendars belong to the current user vs being read-only overlays from other members. Currently, `calendars` rows are owned by `userId` — the current user's writable set is simply `WHERE userId = currentUser.id`.
- Recommendation: Writable set = `SELECT * FROM calendars WHERE user_id = :userId` (personal) UNION the row where `is_shared = 1` (shared family). This matches D-03 with no additional schema changes.
- **Resolution:** RESOLVED via Option A (server-side endpoint) — `GET /api/events/writable-calendars` (plan 03-03 Task 3) is the authoritative owner of the D-03 writable set (`userId = currentUser.id OR isShared = true`); the PWA picker consumes it verbatim (03-05 Task 1) and never derives writability client-side.
---
@@ -877,6 +880,7 @@ const isInstalled = window.matchMedia('(display-mode: standalone)').matches
| CAL-07 | `buildVeventString` with `rruleString` produces VCALENDAR with RRULE property | unit | same | ❌ Wave 0 |
| CAL-04/05/06 | Outbox worker transitions status: pending→done on mock 204, pending→failed on mock 412, pending→backoff on mock 500 | unit | `pnpm --filter @familysync/api test -- broker/outboxWorker` | ❌ Wave 0 |
| CAL-04/05/06 | GET /api/events/sync-status returns correct status from outbox row | unit | same events test | ❌ Wave 0 |
| CAL-04/05/07 | GET /api/events/writable-calendars returns D-03 writable set; never another member's read-only personal (V4) | unit | `pnpm --filter @familysync/api test -- routes/events` | ❌ Wave 0 |
| D-08 | 412 response routes to conflict (not retry), marks failed, triggers re-sync | unit | same outboxWorker test | ❌ Wave 0 |
| D-04 | Edit-as-move creates DELETE + CREATE pair; create runs first | unit | same outboxWorker test | ❌ Wave 0 |
| PWA-01 | `vite.config.ts` produces a valid `manifest.webmanifest` with required fields | smoke (build output check) | `pnpm --filter @familysync/pwa build && node -e "..."` | ❌ Wave 0 |
@@ -2,7 +2,7 @@
phase: 3
slug: event-write-back-pwa-install
status: draft
nyquist_compliant: false
nyquist_compliant: true
wave_0_complete: false
created: 2026-06-05
---
@@ -10,6 +10,7 @@ created: 2026-06-05
# Phase 3 — Validation Strategy
> Per-phase validation contract for feedback sampling during execution.
> Sourced from 03-RESEARCH.md §Validation Architecture.
---
@@ -17,28 +18,46 @@ created: 2026-06-05
| Property | Value |
|----------|-------|
| **Framework** | {pytest 7.x / jest 29.x / vitest / go test / other} |
| **Config file** | {path or "none — Wave 0 installs"} |
| **Quick run command** | `{quick command}` |
| **Full suite command** | `{full command}` |
| **Estimated runtime** | ~{N} seconds |
| **Framework (API)** | Vitest 4.x, environment: `node` |
| **Framework (PWA)** | Vitest 4.x + `jsdom` + `@testing-library/react` |
| **Config (API)** | `apps/api/vitest.config.ts` |
| **Config (PWA)** | `apps/pwa/vitest.config.ts` |
| **Quick run (API)** | `pnpm --filter @familysync/api test` |
| **Quick run (PWA)** | `pnpm --filter @familysync/pwa test` |
| **Full suite** | `pnpm test` (from repo root — runs both apps) |
| **Estimated runtime** | ~20-40 seconds (mocked DB + CalDAV; no network) |
---
## Sampling Rate
- **After every task commit:** Run `{quick run command}`
- **After every plan wave:** Run `{full suite command}`
- **Before `/gsd-verify-work`:** Full suite must be green
- **Max feedback latency:** {N} seconds
- **After every task commit:** Run the filtered quick command for the app touched
(`pnpm --filter @familysync/api test -- <path>` or `pnpm --filter @familysync/pwa test -- <name>`).
- **After every plan wave:** Run `pnpm test` (full suite, both apps).
- **Before `/gsd-verify-work`:** Full suite must be green.
- **Max feedback latency:** ~40 seconds (full suite).
---
## Per-Task Verification Map
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| {N}-01-01 | 01 | 1 | REQ-{XX} | T-{N}-01 / — | {expected secure behavior or "N/A"} | unit | `{command}` | ✅ / ❌ W0 | ⬜ pending |
| Req ID | Behavior | Plan | Wave | Test Type | Automated Command | File Exists | Status |
|--------|----------|------|------|-----------|-------------------|-------------|--------|
| CAL-04 | `buildVeventString` → VCALENDAR for a timed event (DTSTART UTC) | 02 | 2 | unit | `pnpm --filter @familysync/api test -- broker/vevent` | ❌ W0 (03-01) | ⬜ pending |
| CAL-04 | `buildVeventString` → all-day event uses DATE not DATETIME (D-13) | 02 | 2 | unit | same | ❌ W0 (03-01) | ⬜ pending |
| CAL-04 | POST /api/events/create → 202 + inserts pending outbox row | 03 | 2 | unit (mocked DB) | `pnpm --filter @familysync/api test -- routes/events` | ❌ W0 (03-01) | ⬜ pending |
| CAL-05 | PATCH /api/events/:uid/edit → 202 + inserts row with etag | 03 | 2 | unit | same | ❌ W0 (03-01) | ⬜ pending |
| CAL-06 | DELETE /api/events/:uid → 202 + inserts delete row | 03 | 2 | unit | same | ❌ W0 (03-01) | ⬜ pending |
| CAL-07 | `buildVeventString` with `rruleString` → RRULE property | 02 | 2 | unit | `pnpm --filter @familysync/api test -- broker/vevent` | ❌ W0 (03-01) | ⬜ pending |
| CAL-04/05/06 | Outbox worker: pending→done (204), pending→failed (412), pending→backoff (500), pending→dead (max attempts) | 02/04 | 2 | unit | `pnpm --filter @familysync/api test -- broker/outboxWorker` | ❌ W0 (03-01) | ⬜ pending |
| CAL-04/05/06 | GET /api/events/sync-status returns outbox status (D-09) | 03 | 2 | unit | `pnpm --filter @familysync/api test -- routes/events` | ❌ W0 (03-01) | ⬜ pending |
| CAL-04/05/07 | GET /api/events/writable-calendars returns D-03 set; never another member's read-only personal (V4) | 03 | 2 | unit | `pnpm --filter @familysync/api test -- routes/events` | ❌ W0 (03-01) | ⬜ pending |
| D-08 | 412 → conflict (not retry), mark failed, trigger re-sync | 04 | 2 | unit | `pnpm --filter @familysync/api test -- broker/outboxWorker` | ❌ W0 (03-01) | ⬜ pending |
| D-04 | Edit-as-move emits DELETE+CREATE pair; create runs first | 04 | 2 | unit | same | ❌ W0 (03-01) | ⬜ pending |
| D-03/V4 | create rejects write to non-owned/non-shared calendar (403) | 03 | 2 | unit | `pnpm --filter @familysync/api test -- routes/events` | ❌ W0 (03-01) | ⬜ pending |
| PWA-01 | `vite.config.ts` produces valid `manifest.webmanifest` with required fields | 06 | 3 | smoke (build output) | `pnpm --filter @familysync/pwa build` + manifest field check | ❌ W0 (03-01) | ⬜ pending |
| PWA-02 | `isIOSSafariNonStandalone()` true on mock iOS Safari non-standalone UA | 06 | 3 | unit | `pnpm --filter @familysync/pwa test -- InstallPrompt` | ❌ W0 (03-01) | ⬜ pending |
| PWA-02 | `useAndroidInstallPrompt` sets `canInstall=true` on `beforeinstallprompt` | 06 | 3 | unit (mock event) | same | ❌ W0 (03-01) | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
@@ -46,11 +65,16 @@ created: 2026-06-05
## Wave 0 Requirements
- [ ] `{tests/test_file.py}` — stubs for REQ-{XX}
- [ ] `{tests/conftest.py}` — shared fixtures
- [ ] `{framework install}` — if no framework detected
The Wave 0 RED test scaffold is created in **plan 03-01 Task 4** (these files import
not-yet-existing modules so they fail RED until later waves implement them):
*If none: "Existing infrastructure covers all phase requirements."*
- [ ] `apps/api/tests/broker/vevent.test.ts` — CAL-04, CAL-07 (VEVENT builder, DATE/DATETIME split, RRULE)
- [ ] `apps/api/tests/broker/write.test.ts` — tsdav call shapes, etag/If-Match, response interpretation
- [ ] `apps/api/tests/broker/outboxWorker.test.ts` — outbox state machine (done/failed/backoff/dead), edit-as-move ordering (D-04/D-07/D-08)
- [ ] `apps/api/tests/routes/events.test.ts` — EXTEND existing: POST /create, PATCH /edit, DELETE /:uid, GET /sync-status, GET /writable-calendars, 403 ownership (preserve existing GET /api/events block)
- [ ] `apps/pwa/src/components/InstallPrompt.test.tsx` — iOS detection, Android `beforeinstallprompt` capture (PWA-02)
Existing test files (`broker/sync`, `routes/events` GET block, `auth/devBypass`) remain in place.
---
@@ -58,19 +82,18 @@ created: 2026-06-05
| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------|
| {behavior} | REQ-{XX} | {reason} | {steps} |
*If none: "All phase behaviors have automated verification."*
| SW `navigateFallbackDenylist` excludes `/callback` | PWA-01 | Requires a real production build + SW registration over HTTPS | Verify against prod build; confirm `/callback` not intercepted by SW |
| iOS standalone PWA login completes without leaving standalone | Gate 2 | Requires a physical iPhone, installed PWA, Authelia OIDC round-trip | Follow `docs/deployment.md` Gate 2 checklist (Plan 07) |
---
## Validation Sign-Off
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
- [ ] Wave 0 covers all MISSING references
- [ ] No watch-mode flags
- [ ] Feedback latency < {N}s
- [ ] `nyquist_compliant: true` set in frontmatter
- [x] All tasks have `<automated>` verify or a Wave 0 RED dependency (created in 03-01 Task 4)
- [x] Sampling continuity: no 3 consecutive tasks without automated verify
- [x] Wave 0 covers all MISSING references (five RED files in 03-01)
- [x] No watch-mode flags
- [x] Feedback latency < 40s
- [x] `nyquist_compliant: true` set in frontmatter
**Approval:** {pending / approved YYYY-MM-DD}
**Approval:** approved 2026-06-05