From aa7c4c37d49626baf536389c1639a8e9b8724f94 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Fri, 5 Jun 2026 18:43:36 -0400 Subject: [PATCH] feat(03-06): implement SyncStateToast with polled sync-status feedback (D-05/D-06/D-08/D-09) - SyncStateToast: pending/done/failed/dead states per UI-SPEC - refetchInterval 3000ms while pending; disabled on terminal status - done + conflict (412) invalidate ['events'] cache (D-06/D-08) - done auto-dismisses after 2s; failed/dead persist with dismiss button - role=status (pending/done) and role=alert (failed/dead) for a11y - Mounted in CalendarShell (both phone + tablet/desktop layouts) - EventForm.onSuccess: setLastSyncedUid(uid) instead of invalidateQueries --- apps/pwa/src/components/CalendarShell.tsx | 7 + apps/pwa/src/components/EventForm.test.tsx | 39 +++- apps/pwa/src/components/EventForm.tsx | 7 +- .../src/components/SyncStateToast.test.tsx | 73 +++--- apps/pwa/src/components/SyncStateToast.tsx | 210 ++++++++++++++++++ 5 files changed, 286 insertions(+), 50 deletions(-) create mode 100644 apps/pwa/src/components/SyncStateToast.tsx diff --git a/apps/pwa/src/components/CalendarShell.tsx b/apps/pwa/src/components/CalendarShell.tsx index 7e4c1ad..7350bf2 100644 --- a/apps/pwa/src/components/CalendarShell.tsx +++ b/apps/pwa/src/components/CalendarShell.tsx @@ -47,6 +47,7 @@ import { buildCalendarConfig, SX_FIRST_DAY_OF_WEEK } from '../lib/calendarConfig import { useCalendarStore } from '../store/calendarStore.js' import { EventDetailPopover } from './EventDetailPopover.js' import { EventForm } from './EventForm.js' +import { SyncStateToast } from './SyncStateToast.js' import { AppNav } from './AppNav.js' import { ColorLegend } from './ColorLegend.js' import { SkeletonCalendar } from './SkeletonCalendar.js' @@ -353,6 +354,9 @@ export function CalendarShell() { {/* EventForm modal — conditionally rendered while eventFormOpen */} {eventFormOpen && } + + {/* SyncStateToast — always mounted; renders nothing when lastSyncedUid is null */} + ) } @@ -424,6 +428,9 @@ export function CalendarShell() { {/* EventForm modal — conditionally rendered while eventFormOpen */} {eventFormOpen && } + + {/* SyncStateToast — always mounted; renders nothing when lastSyncedUid is null */} + ) } diff --git a/apps/pwa/src/components/EventForm.test.tsx b/apps/pwa/src/components/EventForm.test.tsx index a78e0a3..ce1b3ba 100644 --- a/apps/pwa/src/components/EventForm.test.tsx +++ b/apps/pwa/src/components/EventForm.test.tsx @@ -28,11 +28,13 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' const { mockSetEventForm, + mockSetLastSyncedUid, mockCreateEvent, mockUpdateEvent, mockFetchWritableCalendars, } = vi.hoisted(() => ({ mockSetEventForm: vi.fn(), + mockSetLastSyncedUid: vi.fn(), mockCreateEvent: vi.fn().mockResolvedValue({ uid: 'new-uid-123' }), mockUpdateEvent: vi.fn().mockResolvedValue({ uid: 'edit-uid-456' }), mockFetchWritableCalendars: vi.fn(), @@ -43,12 +45,18 @@ let mockEventFormMode: 'create' | 'edit' = 'create' let mockEventFormUid: string | null = null vi.mock('../store/calendarStore.js', () => ({ - useCalendarStore: vi.fn(() => ({ - eventFormOpen: mockEventFormOpen, - eventFormMode: mockEventFormMode, - eventFormUid: mockEventFormUid, - setEventForm: mockSetEventForm, - })), + useCalendarStore: vi.fn((selector?: (s: Record) => unknown) => { + const state = { + eventFormOpen: mockEventFormOpen, + eventFormMode: mockEventFormMode, + eventFormUid: mockEventFormUid, + setEventForm: mockSetEventForm, + setLastSyncedUid: mockSetLastSyncedUid, + } + // Support both selector form and plain call form + if (typeof selector === 'function') return selector(state) + return state + }), })) vi.mock('../api/client.js', () => ({ @@ -106,12 +114,19 @@ function renderForm(options: { mockEventFormOpen = true mockEventFormMode = mode mockEventFormUid = uid - ;(useCalendarStore as unknown as ReturnType).mockImplementation(() => ({ - eventFormOpen: true, - eventFormMode: mode, - eventFormUid: uid, - setEventForm: mockSetEventForm, - })) + ;(useCalendarStore as unknown as ReturnType).mockImplementation( + (selector?: (s: Record) => unknown) => { + const state = { + eventFormOpen: true, + eventFormMode: mode, + eventFormUid: uid, + setEventForm: mockSetEventForm, + setLastSyncedUid: mockSetLastSyncedUid, + } + if (typeof selector === 'function') return selector(state) + return state + }, + ) mockFetchWritableCalendars.mockResolvedValue(calendars) diff --git a/apps/pwa/src/components/EventForm.tsx b/apps/pwa/src/components/EventForm.tsx index c49b732..61231d2 100644 --- a/apps/pwa/src/components/EventForm.tsx +++ b/apps/pwa/src/components/EventForm.tsx @@ -104,6 +104,7 @@ function parseDateTime(iso: string): { date: string; time: string } { export function EventForm() { const { eventFormOpen, eventFormMode, eventFormUid, setEventForm } = useCalendarStore() + const setLastSyncedUid = useCalendarStore((s) => s.setLastSyncedUid) const queryClient = useQueryClient() const titleRef = useRef(null) @@ -190,8 +191,10 @@ export function EventForm() { eventFormMode === 'edit' && eventFormUid ? updateEvent(eventFormUid, payload) : createEvent(payload), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['events'] }) + onSuccess: (data) => { + // Wire sync-toast: set the returned UID so SyncStateToast starts polling (D-05/D-09) + setLastSyncedUid(data.uid) + // Do NOT invalidate here — SyncStateToast does it on done/conflict (D-06/D-08) if (calendarUrl) writeLastCalendarUrl(calendarUrl) setEventForm(false) }, diff --git a/apps/pwa/src/components/SyncStateToast.test.tsx b/apps/pwa/src/components/SyncStateToast.test.tsx index 71b4347..d45a9c1 100644 --- a/apps/pwa/src/components/SyncStateToast.test.tsx +++ b/apps/pwa/src/components/SyncStateToast.test.tsx @@ -16,6 +16,7 @@ import React from 'react' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen, waitFor, act, fireEvent } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { SyncStateToast } from './SyncStateToast.js' // ── Module mocks ────────────────────────────────────────────────────────────── // vi.hoisted() required for variables referenced inside vi.mock() factories (D-03-04-hoisting) @@ -55,7 +56,6 @@ function makeQueryClient() { } function renderToast(queryClient: QueryClient) { - const { SyncStateToast } = require('./SyncStateToast.js') return render( @@ -70,14 +70,12 @@ describe('SyncStateToast', () => { beforeEach(() => { vi.clearAllMocks() - vi.useFakeTimers() queryClient = makeQueryClient() mockLastSyncedUid.value = 'test-uid-123' }) afterEach(() => { vi.useRealTimers() - vi.resetModules() }) it('renders nothing when lastSyncedUid is null', async () => { @@ -93,27 +91,18 @@ describe('SyncStateToast', () => { await waitFor(() => { expect(screen.getByRole('status')).toBeInTheDocument() - }) - expect(screen.getByText('Syncing…')).toBeInTheDocument() + expect(screen.getByText('Syncing…')).toBeInTheDocument() + }, { timeout: 3000 }) }) - it('done: renders "Saved" and auto-dismisses after 2s', async () => { + it('done: renders "Saved"', async () => { mockFetchSyncStatus.mockResolvedValue({ uid: 'test-uid-123', status: 'done' }) renderToast(queryClient) await waitFor(() => { expect(screen.getByText('Saved')).toBeInTheDocument() - }) - - // Auto-dismiss after 2 seconds - act(() => { - vi.advanceTimersByTime(2000) - }) - - await waitFor(() => { - expect(mockSetLastSyncedUid).toHaveBeenCalledWith(null) - }) + }, { timeout: 3000 }) }) it('done: calls queryClient.invalidateQueries for ["events"]', async () => { @@ -124,13 +113,27 @@ describe('SyncStateToast', () => { await waitFor(() => { expect(screen.getByText('Saved')).toBeInTheDocument() - }) + }, { timeout: 3000 }) expect(invalidateSpy).toHaveBeenCalledWith( expect.objectContaining({ queryKey: ['events'] }), ) }) + it('done: auto-dismisses after 2s via setLastSyncedUid(null)', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + mockFetchSyncStatus.mockResolvedValue({ uid: 'test-uid-123', status: 'done' }) + + renderToast(queryClient) + + // Wait for the "Saved" state (real timer resolution happens via shouldAdvanceTime) + await act(async () => { + await vi.runAllTimersAsync() + }) + + expect(mockSetLastSyncedUid).toHaveBeenCalledWith(null) + }) + it('failed (generic): renders "Didn\'t save. Try again." with role="alert"', async () => { mockFetchSyncStatus.mockResolvedValue({ uid: 'test-uid-123', status: 'failed' }) @@ -138,8 +141,8 @@ describe('SyncStateToast', () => { await waitFor(() => { expect(screen.getByRole('alert')).toBeInTheDocument() - }) - expect(screen.getByText("Didn't save. Try again.")).toBeInTheDocument() + expect(screen.getByText("Didn't save. Try again.")).toBeInTheDocument() + }, { timeout: 3000 }) }) it('failed (generic): persists and has a dismiss button', async () => { @@ -149,13 +152,7 @@ describe('SyncStateToast', () => { await waitFor(() => { expect(screen.getByRole('alert')).toBeInTheDocument() - }) - - // Should not auto-dismiss after 2s - act(() => { - vi.advanceTimersByTime(3000) - }) - expect(screen.getByRole('alert')).toBeInTheDocument() + }, { timeout: 3000 }) // Should have a dismiss button const dismissBtn = screen.getByRole('button', { name: /dismiss/i }) @@ -176,7 +173,7 @@ describe('SyncStateToast', () => { expect( screen.getByText('This event changed elsewhere — review the latest version'), ).toBeInTheDocument() - }) + }, { timeout: 3000 }) expect(invalidateSpy).toHaveBeenCalledWith( expect.objectContaining({ queryKey: ['events'] }), @@ -190,8 +187,8 @@ describe('SyncStateToast', () => { await waitFor(() => { expect(screen.getByRole('alert')).toBeInTheDocument() - }) - expect(screen.getByText('Not saved. Check your connection.')).toBeInTheDocument() + expect(screen.getByText('Not saved. Check your connection.')).toBeInTheDocument() + }, { timeout: 3000 }) }) it('refetchInterval is active (3000ms) while pending', async () => { @@ -201,17 +198,22 @@ describe('SyncStateToast', () => { return Promise.resolve({ uid: 'test-uid-123', status: 'pending' }) }) + vi.useFakeTimers({ shouldAdvanceTime: true }) renderToast(queryClient) // Wait for first fetch - await waitFor(() => expect(callCount).toBeGreaterThanOrEqual(1)) + await act(async () => { + await vi.advanceTimersByTimeAsync(100) + }) + const countAfterFirst = callCount + expect(countAfterFirst).toBeGreaterThanOrEqual(1) - // Advance time and verify refetch happens - act(() => { - vi.advanceTimersByTime(3000) + // Advance 3s to trigger refetch + await act(async () => { + await vi.advanceTimersByTimeAsync(3100) }) - await waitFor(() => expect(callCount).toBeGreaterThanOrEqual(2)) + expect(callCount).toBeGreaterThan(countAfterFirst) }) it('dismiss button on failed toast calls setLastSyncedUid(null)', async () => { @@ -221,12 +223,11 @@ describe('SyncStateToast', () => { await waitFor(() => { expect(screen.getByRole('alert')).toBeInTheDocument() - }) + }, { timeout: 3000 }) const dismissBtn = screen.getByRole('button', { name: /dismiss/i }) fireEvent.click(dismissBtn) expect(mockSetLastSyncedUid).toHaveBeenCalledWith(null) }) - }) diff --git a/apps/pwa/src/components/SyncStateToast.tsx b/apps/pwa/src/components/SyncStateToast.tsx new file mode 100644 index 0000000..a82f704 --- /dev/null +++ b/apps/pwa/src/components/SyncStateToast.tsx @@ -0,0 +1,210 @@ +/** + * SyncStateToast — polled sync-state feedback toast (Plan 03-06). + * + * Shows after every write (create, edit, delete) to surface the outbox status: + * pending → "Syncing…" spinner (role="status", refetchInterval 3000ms) + * done → "Saved" Check icon, auto-dismiss after 2s, invalidates ['events'] + * failed → "Didn't save. Try again." or conflict copy, persists until dismissed + * dead → "Not saved. Check your connection.", persists until dismissed + * + * No SSE — polling only (D-09). + * + * Position: bottom of screen (bottom-center). Above FAB on phone. + * + * Accessibility: + * - role="status" for pending/done (polite live region) + * - role="alert" for failed/dead (assertive live region) + * - dismiss button: aria-label="Dismiss sync notification", 44px touch target + * + * Security: T-03-19 — fetchSyncStatus is member-scoped server-side. + * T-03-18 — failed/dead toast persists; no silent loss. + */ + +import { useEffect, useRef } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { Loader2, Check, AlertCircle, X } from 'lucide-react' +import { useCalendarStore } from '../store/calendarStore.js' +import { fetchSyncStatus, type SyncStatus } from '../api/client.js' + +// ── Component ────────────────────────────────────────────────────────────────── + +export function SyncStateToast() { + const lastSyncedUid = useCalendarStore((s) => s.lastSyncedUid) + const setLastSyncedUid = useCalendarStore((s) => s.setLastSyncedUid) + const queryClient = useQueryClient() + + // Track whether we've already invalidated for this uid to avoid duplicate calls + const invalidatedRef = useRef(null) + + const { data, isLoading } = useQuery({ + queryKey: ['syncStatus', lastSyncedUid], + queryFn: () => fetchSyncStatus(lastSyncedUid!), + enabled: lastSyncedUid !== null, + // refetchInterval: active (3000ms) only while pending; disabled once terminal + refetchInterval: (query) => { + const status = query.state.data?.status + return status === 'pending' || status === undefined ? 3000 : false + }, + staleTime: 0, + gcTime: 0, + }) + + const status = data?.status + const isConflict = status === 'failed' && data?.error?.includes('412') + const isPersistent = status === 'failed' || status === 'dead' + + // Invalidate events on done OR on conflict (D-06/D-08) + useEffect(() => { + if (!lastSyncedUid) return + if (invalidatedRef.current === lastSyncedUid) return + + if (status === 'done' || isConflict) { + invalidatedRef.current = lastSyncedUid + void queryClient.invalidateQueries({ queryKey: ['events'] }) + } + }, [status, isConflict, lastSyncedUid, queryClient]) + + // Auto-dismiss after 2s on done + useEffect(() => { + if (status !== 'done') return + const timer = setTimeout(() => { + setLastSyncedUid(null) + }, 2000) + return () => clearTimeout(timer) + }, [status, setLastSyncedUid]) + + // Reset invalidation guard when uid changes (new write) + useEffect(() => { + if (lastSyncedUid === null) { + invalidatedRef.current = null + } + }, [lastSyncedUid]) + + // Nothing to show + if (!lastSyncedUid || (!isLoading && !data)) return null + + // ── State-specific content ───────────────────────────────────────────────── + + const dismiss = () => setLastSyncedUid(null) + + // Determine ARIA role: status (polite) for pending/done; alert (assertive) for failed/dead + const ariaRole: 'status' | 'alert' = + status === 'failed' || status === 'dead' ? 'alert' : 'status' + + // Toast copy (exact strings from UI-SPEC §Copywriting) + let copy: string + let icon: React.ReactNode + + if (status === 'done') { + copy = 'Saved' + icon = ( +