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
This commit is contained in:
Lucas Berger
2026-06-05 18:43:36 -04:00
parent 6874e1a074
commit aa7c4c37d4
5 changed files with 286 additions and 50 deletions
+210
View File
@@ -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<string | null>(null)
const { data, isLoading } = useQuery<SyncStatus>({
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 = (
<Check
size={14}
style={{ color: '#50C878', flexShrink: 0 }}
aria-hidden="true"
/>
)
} else if (isConflict) {
copy = 'This event changed elsewhere — review the latest version'
icon = (
<AlertCircle
size={14}
style={{ color: 'var(--color-destructive)', flexShrink: 0 }}
aria-hidden="true"
/>
)
} else if (status === 'failed') {
copy = "Didn't save. Try again."
icon = (
<AlertCircle
size={14}
style={{ color: 'var(--color-destructive)', flexShrink: 0 }}
aria-hidden="true"
/>
)
} else if (status === 'dead') {
copy = 'Not saved. Check your connection.'
icon = (
<AlertCircle
size={14}
style={{ color: 'var(--color-destructive)', flexShrink: 0 }}
aria-hidden="true"
/>
)
} else {
// pending (or loading)
copy = 'Syncing…'
icon = (
<Loader2
size={14}
style={{
color: 'var(--color-text-secondary)',
flexShrink: 0,
animation: 'spin 1s linear infinite',
}}
aria-hidden="true"
/>
)
}
return (
<div
role={ariaRole}
aria-live={ariaRole === 'alert' ? 'assertive' : 'polite'}
style={{
position: 'fixed',
bottom: 'var(--space-12)',
left: '50%',
transform: 'translateX(-50%)',
zIndex: 300,
background: 'var(--color-surface-raised)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--space-1)',
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
padding: 'var(--space-2) var(--space-3)',
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2)',
fontSize: 'var(--text-label-size)',
fontWeight: 'var(--text-label-weight)',
lineHeight: 'var(--text-label-line-height)',
fontFamily: 'var(--font-family-base)',
color:
status === 'done'
? '#50C878'
: status === 'failed' || status === 'dead'
? 'var(--color-destructive)'
: 'var(--color-text-secondary)',
whiteSpace: 'nowrap',
maxWidth: '90vw',
}}
>
{icon}
{/* Plain text — XSS guard */}
<span>{copy}</span>
{/* Dismiss button — only for persistent states (failed/dead) */}
{isPersistent && (
<button
aria-label="Dismiss sync notification"
onClick={dismiss}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
minWidth: '44px',
minHeight: '44px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--color-text-secondary)',
borderRadius: 'var(--space-1)',
padding: 0,
marginLeft: 'var(--space-1)',
}}
>
<X size={16} aria-hidden="true" />
</button>
)}
</div>
)
}