---
phase: 03-event-write-back-pwa-install
plan: 06
type: execute
wave: 4
depends_on: ["03-05", "03-03"]
files_modified:
- apps/pwa/src/api/client.ts
- apps/pwa/src/store/calendarStore.ts
- apps/pwa/src/components/EventDetailPopover.tsx
- apps/pwa/src/components/DeleteConfirmationDialog.tsx
- apps/pwa/src/components/SyncStateToast.tsx
- apps/pwa/src/components/CalendarShell.tsx
autonomous: true
requirements: [CAL-05, CAL-06]
user_setup: []
must_haves:
truths:
- "The EventDetailPopover footer shows Edit and Delete actions (D-10)"
- "Tapping Edit opens EventForm pre-populated; tapping Delete opens a two-tap confirmation dialog"
- "Confirming delete calls DELETE /api/events/:uid and shows the sync toast"
- "After any write the SyncStateToast polls /api/events/sync-status and shows Syncing/Saved/Didn't save; on done it invalidates the events query (D-06/D-09)"
- "A 412 conflict shows the conflict copy and re-fetches the calendar (D-08)"
artifacts:
- path: "apps/pwa/src/components/SyncStateToast.tsx"
provides: "polled sync-state feedback toast (D-05/D-09)"
min_lines: 40
- path: "apps/pwa/src/components/DeleteConfirmationDialog.tsx"
provides: "two-tap destructive delete confirmation"
key_links:
- from: "apps/pwa/src/components/SyncStateToast.tsx"
to: "/api/events/sync-status"
via: "useQuery refetchInterval while pending"
pattern: "syncStatus|sync-status"
- from: "apps/pwa/src/components/EventDetailPopover.tsx"
to: "DeleteConfirmationDialog"
via: "Delete footer button opens deleteDialog"
pattern: "deleteDialogOpen"
---
Complete the edit/delete vertical slices and the write-feedback loop: wire the
EventDetailPopover reserved footer to Edit/Delete actions (D-10), add the two-tap
`DeleteConfirmationDialog`, and add the `SyncStateToast` that polls `/api/events/sync-status`
(D-09) to surface Syncing → Saved / Didn't save, invalidating the events cache on
confirm (D-06) and showing the conflict copy on 412 (D-08).
Purpose: CAL-05 (edit) and CAL-06 (delete) become user-reachable, and every write
(create from Plan 05 included) gets the non-blocking optimistic feedback the
non-technical member depends on (D-05). No SSE — polling only (D-09).
Output: edit/delete footer, delete dialog, sync toast + polling, all per the UI Design Contract.
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
@.planning/phases/03-event-write-back-pwa-install/03-UI-SPEC.md
@.planning/phases/03-event-write-back-pwa-install/03-PATTERNS.md
@apps/pwa/src/components/EventDetailPopover.tsx
@apps/pwa/src/api/client.ts
@apps/pwa/src/store/calendarStore.ts
Task 1: deleteEvent + fetchSyncStatus client calls; delete/sync Zustand keys
apps/pwa/src/api/client.ts, apps/pwa/src/store/calendarStore.ts
- apps/pwa/src/api/client.ts (existing + Plan 05 additions — fetch shape)
- apps/pwa/src/store/calendarStore.ts (existing + Plan 05 form keys)
- .planning/phases/03-event-write-back-pwa-install/03-RESEARCH.md (§Pattern 8 — sync-status response { uid, status, error? })
- .planning/phases/03-event-write-back-pwa-install/03-UI-SPEC.md (§State Management Contract — deleteDialogOpen/deleteDialogUid/lastSyncedUid; ['syncStatus', uid] key)
Add `deleteEvent(uid): Promise` (DELETE `/api/events/${uid}`, credentials:'include', throw on !ok) and `fetchSyncStatus(uid): Promise<{ uid: string; status: 'pending'|'done'|'failed'|'dead'; error?: string }>` (GET `/api/events/sync-status?uid=`). Export the SyncStatus type. Extend the Zustand store with `deleteDialogOpen: boolean`, `deleteDialogUid: string|null`, `lastSyncedUid: string|null` plus setters `setDeleteDialog(open, uid?)` and `setLastSyncedUid(uid)`. Defaults closed/null.
cd /home/luc/Projects/familysync && grep -q "fetchSyncStatus" apps/pwa/src/api/client.ts && grep -q "deleteDialogOpen" apps/pwa/src/store/calendarStore.ts && pnpm --filter @familysync/pwa exec tsc --noEmit && pnpm --filter @familysync/pwa test
- `grep -Eq "deleteEvent|fetchSyncStatus" apps/pwa/src/api/client.ts`.
- `grep -q "lastSyncedUid" apps/pwa/src/store/calendarStore.ts`.
- PWA tsc --noEmit passes; existing tests green.
deleteEvent/fetchSyncStatus and delete/sync Zustand keys exist and type-check.
Task 2: SyncStateToast with polled sync-status (D-05/D-06/D-08/D-09)
apps/pwa/src/components/SyncStateToast.tsx, apps/pwa/src/components/CalendarShell.tsx
- .planning/phases/03-event-write-back-pwa-install/03-UI-SPEC.md (§SyncStateToast — states/icons/copy/colors/position/auto-dismiss; §Interaction Contract sync-state feedback; §Copywriting toast strings; role=status/alert)
- .planning/phases/03-event-write-back-pwa-install/03-RESEARCH.md (§Pattern 8 / §Code Examples useSyncStatus — refetchInterval 3000 while pending)
- apps/pwa/src/api/client.ts (fetchSyncStatus from Task 1)
- apps/pwa/src/components/EventDetailPopover.tsx (token usage + lucide icon import pattern)
Tests (SyncStateToast.test.tsx) with mocked fetchSyncStatus:
- status 'pending' renders "Syncing…" + spinner, role="status".
- status 'done' renders "Saved", auto-dismiss after 2s, and triggers queryClient.invalidateQueries(['events']).
- status 'failed' (generic) renders "Didn't save. Try again." role="alert", persists with a dismiss button.
- status 'failed' with a 412/conflict error renders the conflict copy and invalidates ['events'].
- status 'dead' renders "Not saved. Check your connection.".
- refetchInterval is active (3000) only while pending.
Implement `SyncStateToast.tsx`: a `useQuery(['syncStatus', uid], fetchSyncStatus, { enabled: uid!==null, refetchInterval: d => d?.status==='pending' ? 3000 : false, staleTime:0 })` keyed on `lastSyncedUid` from Zustand. Render the toast per UI-SPEC states table (Loader2/Check/AlertCircle icons, exact copy, exact colors/tokens, bottom-of-screen position, auto-dismiss done after 2s, persistent failed/dead with an X dismiss that clears lastSyncedUid). On transition to 'done' OR a 412-conflict, call `queryClient.invalidateQueries({ queryKey: ['events'] })` (D-06/D-08). Use role="status" for pending/done and role="alert" for failed/dead. Mount `` in CalendarShell (always rendered; renders nothing when lastSyncedUid is null). Set `lastSyncedUid` from the EventForm create/edit mutations (Plan 05 stored the uid; wire it via setLastSyncedUid) and from the delete flow (Task 3). No SSE (D-09).
cd /home/luc/Projects/familysync && pnpm --filter @familysync/pwa test -- SyncStateToast && grep -q "invalidateQueries" apps/pwa/src/components/SyncStateToast.tsx && pnpm --filter @familysync/pwa exec tsc --noEmit
- SyncStateToast.test.tsx GREEN for all five states + conflict + refetchInterval-while-pending.
- `grep -q "refetchInterval" apps/pwa/src/components/SyncStateToast.tsx`.
- No SSE / EventSource reference in the toast: `grep -c "EventSource" apps/pwa/src/components/SyncStateToast.tsx` returns 0.
SyncStateToast polls sync-status, renders all contract states, invalidates events on done/conflict, mounted in the shell.
Task 3: EventDetailPopover Edit/Delete footer + DeleteConfirmationDialog
apps/pwa/src/components/EventDetailPopover.tsx, apps/pwa/src/components/DeleteConfirmationDialog.tsx, apps/pwa/src/components/CalendarShell.tsx
- apps/pwa/src/components/EventDetailPopover.tsx (lines ~380-388 reserved footer; button style ~235-251; Zustand+TanStack usage ~109-137)
- .planning/phases/03-event-write-back-pwa-install/03-UI-SPEC.md (§EventDetailPopover extended footer; §DeleteConfirmationDialog layout/copy/colors; §Interaction Contract delete interaction 1-6; §Copywriting delete strings)
- .planning/phases/03-event-write-back-pwa-install/03-PATTERNS.md (§EventDetailPopover.tsx — replace reserved footer, button style, design tokens, XSS guard)
- apps/pwa/src/store/calendarStore.ts (setEventForm, setDeleteDialog, setLastSyncedUid)
- apps/pwa/src/api/client.ts (deleteEvent)
Tests: EventDetailPopover footer renders an "Edit" button (opens EventForm in edit mode with the event's uid, closes popover) and a "Delete" button (`--color-destructive`, opens DeleteConfirmationDialog). DeleteConfirmationDialog renders heading "Delete event?" + body, a Cancel that closes without deleting, and a "Delete" (red, 48px) that calls deleteEvent, sets lastSyncedUid, closes both surfaces. Escape closes the dialog without deleting; focus trapped.
Replace the EventDetailPopover reserved footer (`aria-hidden` placeholder) with a flex space-between row: a left "Edit" ghost button (Edit2 icon, `--color-text-primary`, opens `setEventForm(true,'edit', occurrence.uid)` and closes the popover) and a right "Delete" ghost button (Trash2 icon, `--color-destructive`, calls `setDeleteDialog(true, occurrence.uid)`). Remove `aria-hidden`. Implement `DeleteConfirmationDialog.tsx` as a centered modal (max-width 320px, backdrop `--color-overlay`, focus trap, Escape-to-cancel) per UI-SPEC: heading "Delete event?", body "This will be removed from your Fastmail calendar.", Cancel (ghost) and Delete (filled `--color-destructive`, white label, Trash2, 48px). On Delete: call `deleteEvent(deleteDialogUid)` via a TanStack mutation, `setLastSyncedUid(uid)` so the toast tracks it, close the dialog and popover; on the calendar, optimistic removal is acceptable but server state wins on refetch (no silent loss). Mount `` in CalendarShell (rendered while deleteDialogOpen). All tokens/touch-targets/plain-text-children per the contract.
cd /home/luc/Projects/familysync && pnpm --filter @familysync/pwa test && grep -q "deleteDialogOpen" apps/pwa/src/components/EventDetailPopover.tsx && grep -q "Delete event?" apps/pwa/src/components/DeleteConfirmationDialog.tsx && pnpm --filter @familysync/pwa exec tsc --noEmit
- Footer Edit opens EventForm edit mode; Delete opens the confirmation dialog (tests GREEN).
- DeleteConfirmationDialog requires explicit confirm; Cancel/Escape do not delete.
- `grep -c 'aria-hidden="true"' apps/pwa/src/components/EventDetailPopover.tsx` does not count the old footer placeholder (it is replaced).
- Full PWA suite green; tsc --noEmit passes.
Edit/Delete reachable from the popover; two-tap delete confirmation fires DELETE and feeds the sync toast.
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| delete action → API | A destructive operation crosses to the write API |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-17 | Tampering | accidental/irreversible delete | mitigate | Mandatory two-tap DeleteConfirmationDialog; no inline single-tap delete; no "don't ask again" (UI-SPEC) |
| T-03-18 | Repudiation | silent data loss on failed delete sync | mitigate | failed/dead toast persists until dismissed; server-authoritative refetch restores the event; no silent loss (D-08) |
| T-03-19 | Information Disclosure | sync-status of another member surfaced in toast | mitigate | sync-status is member-scoped server-side (Plan 03 T-03-07); toast only queries the current member's uid |
- `pnpm --filter @familysync/pwa test` green (SyncStateToast, DeleteConfirmationDialog, popover footer + existing).
- `pnpm --filter @familysync/pwa exec tsc --noEmit` passes.
- No SSE/EventSource in any Phase 3 sync-feedback component (D-09).
- CAL-05 edit and CAL-06 delete are user-reachable from the popover.
- Every write surfaces non-blocking polled sync feedback; 412 conflict shows the warning + re-fetch (D-08); done invalidates events (D-06).