Files
familysync/.planning/milestones/v1.0-phases/03-event-write-back-pwa-install/03-06-SUMMARY.md
T

9.8 KiB

phase, plan, subsystem, tags, dependency_graph, tech_stack, key_files, decisions, metrics
phase plan subsystem tags dependency_graph tech_stack key_files decisions metrics
03-event-write-back-pwa-install 06 pwa-frontend
delete
sync-feedback
toast
confirmation-dialog
tdd
zustand
tanstack-query
requires provides affects
03-03
03-05
edit/delete vertical slices
polled sync-state feedback toast
apps/pwa/src/components/CalendarShell.tsx
apps/pwa/src/components/EventDetailPopover.tsx
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
created modified
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
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
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)
duration_minutes completed tasks files_created files_modified
70 2026-06-05 3 4 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.

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)