docs(03-06): complete edit/delete + sync toast plan
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
---
|
||||
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)
|
||||
Reference in New Issue
Block a user