/** * 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; // WR-06: an edit-as-move whose create hits 412 dead-ends with no retry path (the // original event is preserved, but the new uid's only outbox row is failed). The // worker tags that case with a 'move-failed:' lastError so we can show distinct copy // guiding the user to re-open and re-save, rather than the etag-conflict copy. const isMoveFailed = status === 'failed' && !!data?.error?.startsWith('move-failed'); const isConflict = status === 'failed' && !isMoveFailed && 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 =