--- phase: 03-event-write-back-pwa-install plan: '06' subsystem: pwa-frontend tags: [delete, sync-feedback, toast, confirmation-dialog, tdd, zustand, tanstack-query] dependency_graph: requires: ['03-03', '03-05'] provides: ['edit/delete vertical slices', 'polled sync-state feedback toast'] affects: ['apps/pwa/src/components/CalendarShell.tsx', 'apps/pwa/src/components/EventDetailPopover.tsx'] tech_stack: added: [] patterns: - 'refetchInterval: (query) => pending ? 3000 : false — conditional poll for SyncStateToast' - 'useCalendarStore selector form for new keys — avoids CalendarShell re-renders' - 'DeleteConfirmationDialog: useMutation + onSuccess wires lastSyncedUid then closes' - 'SyncStateToast invalidateQueries on done/conflict (D-06/D-08); EventForm no longer self-invalidates' key_files: created: - apps/pwa/src/components/SyncStateToast.tsx - apps/pwa/src/components/DeleteConfirmationDialog.tsx - apps/pwa/src/components/SyncStateToast.test.tsx - apps/pwa/src/components/DeleteConfirmationDialog.test.tsx modified: - apps/pwa/src/api/client.ts - apps/pwa/src/store/calendarStore.ts - apps/pwa/src/components/EventDetailPopover.tsx - apps/pwa/src/components/CalendarShell.tsx - apps/pwa/src/components/EventForm.tsx - apps/pwa/src/components/EventDetailPopover.test.tsx - apps/pwa/src/components/EventForm.test.tsx decisions: - 'EventForm.onSuccess calls setLastSyncedUid(uid) instead of invalidateQueries — SyncStateToast owns the cache invalidation on done/conflict (D-06/D-08)' - 'DeleteConfirmationDialog mounts unconditionally in CalendarShell (like SyncStateToast); renders null when closed — avoids conditional mount logic in shell' - 'SyncStateToast refetchInterval callback form used (not a static number) so it reads current query data for the pending check' - 'EventDetailPopover footer tests updated to support selector-form useCalendarStore calls (selector-aware mock pattern)' metrics: duration_minutes: 70 completed: '2026-06-05' tasks: 3 files_created: 4 files_modified: 7 --- # Phase 03 Plan 06: Edit/Delete + SyncStateToast Summary **One-liner:** Polled sync-state toast (D-05/D-06/D-08/D-09) + two-tap delete confirmation wired to EventDetailPopover footer, completing the edit/delete write-back vertical slices for CAL-05 and CAL-06. ## Tasks Completed | Task | Name | Commit | Files | | ---- | ---------------------------------------------------------------- | --------- | ----------------------------------------------------------------------- | | 1 | deleteEvent + fetchSyncStatus + Zustand delete/sync keys | `8aeacc8` | client.ts, calendarStore.ts | | 2 | SyncStateToast with polled sync-status (D-05/D-06/D-08/D-09) | `aa7c4c3` | SyncStateToast.tsx, CalendarShell.tsx, EventForm.tsx | | 3 | EventDetailPopover Edit/Delete footer + DeleteConfirmationDialog | `40322e1` | EventDetailPopover.tsx, DeleteConfirmationDialog.tsx, CalendarShell.tsx | ## What Was Built ### Task 1 — Client calls + Zustand keys (RED: `8357cf9`, GREEN: `8aeacc8`) **`apps/pwa/src/api/client.ts`:** - `deleteEvent(uid): Promise` — DELETE `/api/events/:uid`, credentials:include, throws on !ok - `fetchSyncStatus(uid): Promise` — GET `/api/events/sync-status?uid=` - Exported types: `SyncStatusValue`, `SyncStatus` **`apps/pwa/src/store/calendarStore.ts`:** - `deleteDialogOpen: boolean` — default false - `deleteDialogUid: string | null` — default null - `lastSyncedUid: string | null` — drives SyncStateToast polling - `setDeleteDialog(open, uid?)` — setter - `setLastSyncedUid(uid)` — setter (null to dismiss toast) ### Task 2 — SyncStateToast (RED: `6874e1a`, GREEN: `aa7c4c3`) **`apps/pwa/src/components/SyncStateToast.tsx`** (210 lines): - `useQuery(['syncStatus', lastSyncedUid], fetchSyncStatus)` with `refetchInterval` callback — 3000ms while pending, disabled on terminal status - States per UI-SPEC: pending (Loader2 spinner, "Syncing…"), done (Check, "Saved"), failed generic (AlertCircle, "Didn't save. Try again."), failed conflict/412 (conflict copy), dead ("Not saved. Check your connection.") - `role="status"` for pending/done; `role="alert"` for failed/dead - `done` auto-dismisses after 2s via `setTimeout` + `setLastSyncedUid(null)` - `failed`/`dead` persist until user taps dismiss (X button, 44px touch target) - `done` + 412 conflict both call `queryClient.invalidateQueries({ queryKey: ['events'] })` (D-06/D-08) - No EventSource / SSE (D-09: polling only) **`apps/pwa/src/components/EventForm.tsx`:** `onSuccess` now calls `setLastSyncedUid(data.uid)` instead of self-invalidating. SyncStateToast owns cache invalidation on done/conflict. **`apps/pwa/src/components/CalendarShell.tsx`:** `` mounted in both phone and tablet/desktop layouts. ### Task 3 — EventDetailPopover footer + DeleteConfirmationDialog (RED: `2fbeffe`, GREEN: `40322e1`) **`apps/pwa/src/components/EventDetailPopover.tsx`:** - Replaced `aria-hidden="true"` reserved footer placeholder with a live flex row - Left: "Edit" ghost button (Edit2 icon, `--color-text-primary`) — calls `setEventForm(true, 'edit', uid)` + closes popover - Right: "Delete" ghost button (Trash2 icon, `--color-destructive`) — calls `setDeleteDialog(true, uid)` - Both buttons: 44px touch targets, plain-text label children **`apps/pwa/src/components/DeleteConfirmationDialog.tsx`** (208 lines): - Centered modal, max-width 320px, `--color-overlay` backdrop, focus trap - `role="dialog"`, `aria-modal="true"`, Escape to cancel - Heading "Delete event?" (18px/600), body "This will be removed from your Fastmail calendar." - Cancel (ghost, 44px) closes without deleting; Delete (filled `--color-destructive`, 48px, Trash2) fires `deleteEvent` mutation - `onSuccess`: `setLastSyncedUid(uid)` → SyncStateToast tracks it; closes dialog (`setDeleteDialog(false)`) and popover (`setOpenEventId(null)`) - T-03-17: mandatory two-tap; no single-tap delete; no "don't ask again" **`apps/pwa/src/components/CalendarShell.tsx`:** `` mounted unconditionally in both layouts. ## Verification ``` pnpm --filter @familysync/pwa test Test Files 10 passed (10) Tests 120 passed (120) pnpm --filter @familysync/pwa exec tsc --noEmit (no output — clean) grep -c "EventSource" apps/pwa/src/components/SyncStateToast.tsx → 0 grep -q "refetchInterval" apps/pwa/src/components/SyncStateToast.tsx → PASS grep -q "invalidateQueries" apps/pwa/src/components/SyncStateToast.tsx → PASS grep -q "Delete event?" apps/pwa/src/components/DeleteConfirmationDialog.tsx → PASS ``` ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 1 - Bug] EventForm.test.tsx mock lacked setLastSyncedUid** - **Found during:** Task 2 GREEN - **Issue:** EventForm calls `useCalendarStore((s) => s.setLastSyncedUid)` (selector form). The existing test mock returned a static object regardless of selector, so the selector call returned the full mock object instead of the setter function. - **Fix:** Updated both the `vi.mock` factory and the `renderForm` helper's `mockImplementation` to support the selector call pattern — `if (typeof selector === 'function') return selector(state)`. - **Files modified:** `apps/pwa/src/components/EventForm.test.tsx` - **Commit:** `aa7c4c3` **2. [Rule 2 - Missing] SyncStateToast test used @testing-library/user-event not installed** - **Found during:** Task 2 RED - **Issue:** Test imported `userEvent` but only `@testing-library/react` is installed. - **Fix:** Replaced with `fireEvent.click` from `@testing-library/react` (already in project). - **Files modified:** `apps/pwa/src/components/SyncStateToast.test.tsx` **3. [Rule 1 - Bug] Fake timers blocked waitFor in SyncStateToast tests** - **Found during:** Task 2 GREEN - **Issue:** `vi.useFakeTimers()` in `beforeEach` caused all `waitFor` calls to timeout because `waitFor` uses `setTimeout` internally. - **Fix:** Use `vi.useFakeTimers({ shouldAdvanceTime: true })` only for the specific auto-dismiss and refetch interval tests; use real timers for all query-resolution tests. `afterEach` calls `vi.useRealTimers()`. - **Files modified:** `apps/pwa/src/components/SyncStateToast.test.tsx` **4. [Rule 1 - Bug] SyncStateToast JSDoc comment contained "EventSource"** - **Found during:** Task 2 verify - **Issue:** Acceptance criteria `grep -c "EventSource" ... returns 0` would have failed due to a comment mentioning "No SSE / EventSource". - **Fix:** Changed comment to "No SSE — polling only (D-09)". - **Files modified:** `apps/pwa/src/components/SyncStateToast.tsx` **5. [Rule 3 - Required] EventDetailPopover.test.tsx needed selector-aware mock** - **Found during:** Task 3 GREEN (during mock update for new selector calls) - **Issue:** EventDetailPopover now calls `useCalendarStore` in selector form for `setEventForm` and `setDeleteDialog`. Old mock was not selector-aware. - **Fix:** Updated all mock implementations in `EventDetailPopover.test.tsx` to support both selector and non-selector call patterns. - **Files modified:** `apps/pwa/src/components/EventDetailPopover.test.tsx` ## Known Stubs None — all data is wired from real TanStack Query + Zustand state. No placeholder text or hardcoded empty values flow to UI rendering. ## Threat Flags No new network endpoints, auth paths, or schema changes introduced. All threats in plan's threat register are mitigated: - T-03-17: Two-tap DeleteConfirmationDialog enforced; no inline single-tap delete - T-03-18: failed/dead toast persists until dismissed; server refetch restores event on conflict - T-03-19: fetchSyncStatus is member-scoped server-side (Plan 03-03 T-03-07); client queries current member's uid only ## Self-Check: PASSED Files exist: - apps/pwa/src/components/SyncStateToast.tsx — FOUND - apps/pwa/src/components/DeleteConfirmationDialog.tsx — FOUND Commits exist: - 8357cf9 — FOUND (test RED task 1) - 8aeacc8 — FOUND (feat GREEN task 1) - 6874e1a — FOUND (test RED task 2) - aa7c4c3 — FOUND (feat GREEN task 2) - 2fbeffe — FOUND (test RED task 3) - 40322e1 — FOUND (feat GREEN task 3)