---
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<void>` — DELETE `/api/events/:uid`, credentials:include, throws on !ok
- `fetchSyncStatus(uid): Promise<SyncStatus>` — 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`:** `<SyncStateToast />` 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`:** `<DeleteConfirmationDialog />` 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)
