docs(03-06): complete edit/delete + sync toast plan

This commit is contained in:
Lucas Berger
2026-06-05 18:48:17 -04:00
parent 40322e11bf
commit 40dfbb48d5
3 changed files with 186 additions and 7 deletions
+3 -3
View File
@@ -112,7 +112,7 @@ Plans:
**Wave 4** *(blocked on Wave 3)* **Wave 4** *(blocked on Wave 3)*
- [ ] 03-06-PLAN.md — Delete + sync feedback: popover Edit/Delete footer + DeleteConfirmationDialog + SyncStateToast polling (D-06/08/09) (CAL-05/06) - [x] 03-06-PLAN.md — Delete + sync feedback: popover Edit/Delete footer + DeleteConfirmationDialog + SyncStateToast polling (D-06/08/09) (CAL-05/06)
**Wave 5** *(blocked on Wave 4)* **Wave 5** *(blocked on Wave 4)*
@@ -161,7 +161,7 @@ Note: Phase 4 depends only on Phase 1 and can begin as soon as Phase 1 is comple
|-------|----------------|--------|-----------| |-------|----------------|--------|-----------|
| 1. Foundation + Broker Spike | 4/4 | Complete | 2026-06-04 | | 1. Foundation + Broker Spike | 4/4 | Complete | 2026-06-04 |
| 2. Calendar Display | 5/5 | Complete | 2026-06-05 | | 2. Calendar Display | 5/5 | Complete | 2026-06-05 |
| 3. Event Write-Back + PWA Install | 6/8 | In Progress| | | 3. Event Write-Back + PWA Install | 7/8 | In Progress| |
| 4. Shared Lists + Live Sync | 0/? | Not started | - | | 4. Shared Lists + Live Sync | 0/? | Not started | - |
| 5. Web Push Notifications | 0/? | Not started | - | | 5. Web Push Notifications | 0/? | Not started | - |
@@ -171,7 +171,7 @@ Note: Phase 4 depends only on Phase 1 and can begin as soon as Phase 1 is comple
**Goal:** [Captured for future planning] Abstract the calendar backend behind a provider interface so Fastmail/CalDAV is one implementation among potentially many. Shipping with a single provider is fine, but the broker, sync, and event-expansion layers should be structured so additional providers (e.g. other CalDAV hosts, Google Calendar, generic ICS feeds) can be added without rework. Captures the "provider" seam as an explicit architectural concern. **Goal:** [Captured for future planning] Abstract the calendar backend behind a provider interface so Fastmail/CalDAV is one implementation among potentially many. Shipping with a single provider is fine, but the broker, sync, and event-expansion layers should be structured so additional providers (e.g. other CalDAV hosts, Google Calendar, generic ICS feeds) can be added without rework. Captures the "provider" seam as an explicit architectural concern.
**Requirements:** TBD **Requirements:** TBD
**Plans:** 6/8 plans executed **Plans:** 7/8 plans executed
Plans: Plans:
+4 -4
View File
@@ -4,13 +4,13 @@ milestone: v1.0
milestone_name: milestone milestone_name: milestone
status: executing status: executing
stopped_at: Completed 03-05-PLAN.md stopped_at: Completed 03-05-PLAN.md
last_updated: "2026-06-05T22:32:25.900Z" last_updated: "2026-06-05T22:48:01.593Z"
last_activity: 2026-06-05 last_activity: 2026-06-05
progress: progress:
total_phases: 6 total_phases: 6
completed_phases: 2 completed_phases: 2
total_plans: 17 total_plans: 17
completed_plans: 15 completed_plans: 16
percent: 33 percent: 33
--- ---
@@ -26,7 +26,7 @@ See: .planning/PROJECT.md (updated 2026-06-03)
## Current Position ## Current Position
Phase: 03 (event-write-back-pwa-install) — EXECUTING Phase: 03 (event-write-back-pwa-install) — EXECUTING
Plan: 7 of 8 Plan: 8 of 8
Status: Ready to execute Status: Ready to execute
Last activity: 2026-06-05 Last activity: 2026-06-05
@@ -113,6 +113,6 @@ Recent decisions affecting current work:
## Session Continuity ## Session Continuity
Last session: 2026-06-05T22:32:25.896Z Last session: 2026-06-05T22:48:01.588Z
Stopped at: Completed 03-05-PLAN.md Stopped at: Completed 03-05-PLAN.md
Resume file: None Resume file: None
@@ -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)