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:
@@ -47,6 +47,7 @@ import { buildCalendarConfig, SX_FIRST_DAY_OF_WEEK } from '../lib/calendarConfig
|
|||||||
import { useCalendarStore } from '../store/calendarStore.js'
|
import { useCalendarStore } from '../store/calendarStore.js'
|
||||||
import { EventDetailPopover } from './EventDetailPopover.js'
|
import { EventDetailPopover } from './EventDetailPopover.js'
|
||||||
import { EventForm } from './EventForm.js'
|
import { EventForm } from './EventForm.js'
|
||||||
|
import { SyncStateToast } from './SyncStateToast.js'
|
||||||
import { AppNav } from './AppNav.js'
|
import { AppNav } from './AppNav.js'
|
||||||
import { ColorLegend } from './ColorLegend.js'
|
import { ColorLegend } from './ColorLegend.js'
|
||||||
import { SkeletonCalendar } from './SkeletonCalendar.js'
|
import { SkeletonCalendar } from './SkeletonCalendar.js'
|
||||||
@@ -353,6 +354,9 @@ export function CalendarShell() {
|
|||||||
|
|
||||||
{/* EventForm modal — conditionally rendered while eventFormOpen */}
|
{/* EventForm modal — conditionally rendered while eventFormOpen */}
|
||||||
{eventFormOpen && <EventForm />}
|
{eventFormOpen && <EventForm />}
|
||||||
|
|
||||||
|
{/* SyncStateToast — always mounted; renders nothing when lastSyncedUid is null */}
|
||||||
|
<SyncStateToast />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -424,6 +428,9 @@ export function CalendarShell() {
|
|||||||
|
|
||||||
{/* EventForm modal — conditionally rendered while eventFormOpen */}
|
{/* EventForm modal — conditionally rendered while eventFormOpen */}
|
||||||
{eventFormOpen && <EventForm />}
|
{eventFormOpen && <EventForm />}
|
||||||
|
|
||||||
|
{/* SyncStateToast — always mounted; renders nothing when lastSyncedUid is null */}
|
||||||
|
<SyncStateToast />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,11 +28,13 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
mockSetEventForm,
|
mockSetEventForm,
|
||||||
|
mockSetLastSyncedUid,
|
||||||
mockCreateEvent,
|
mockCreateEvent,
|
||||||
mockUpdateEvent,
|
mockUpdateEvent,
|
||||||
mockFetchWritableCalendars,
|
mockFetchWritableCalendars,
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
mockSetEventForm: vi.fn(),
|
mockSetEventForm: vi.fn(),
|
||||||
|
mockSetLastSyncedUid: vi.fn(),
|
||||||
mockCreateEvent: vi.fn().mockResolvedValue({ uid: 'new-uid-123' }),
|
mockCreateEvent: vi.fn().mockResolvedValue({ uid: 'new-uid-123' }),
|
||||||
mockUpdateEvent: vi.fn().mockResolvedValue({ uid: 'edit-uid-456' }),
|
mockUpdateEvent: vi.fn().mockResolvedValue({ uid: 'edit-uid-456' }),
|
||||||
mockFetchWritableCalendars: vi.fn(),
|
mockFetchWritableCalendars: vi.fn(),
|
||||||
@@ -43,12 +45,18 @@ let mockEventFormMode: 'create' | 'edit' = 'create'
|
|||||||
let mockEventFormUid: string | null = null
|
let mockEventFormUid: string | null = null
|
||||||
|
|
||||||
vi.mock('../store/calendarStore.js', () => ({
|
vi.mock('../store/calendarStore.js', () => ({
|
||||||
useCalendarStore: vi.fn(() => ({
|
useCalendarStore: vi.fn((selector?: (s: Record<string, unknown>) => unknown) => {
|
||||||
eventFormOpen: mockEventFormOpen,
|
const state = {
|
||||||
eventFormMode: mockEventFormMode,
|
eventFormOpen: mockEventFormOpen,
|
||||||
eventFormUid: mockEventFormUid,
|
eventFormMode: mockEventFormMode,
|
||||||
setEventForm: mockSetEventForm,
|
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', () => ({
|
vi.mock('../api/client.js', () => ({
|
||||||
@@ -106,12 +114,19 @@ function renderForm(options: {
|
|||||||
mockEventFormOpen = true
|
mockEventFormOpen = true
|
||||||
mockEventFormMode = mode
|
mockEventFormMode = mode
|
||||||
mockEventFormUid = uid
|
mockEventFormUid = uid
|
||||||
;(useCalendarStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
;(useCalendarStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(
|
||||||
eventFormOpen: true,
|
(selector?: (s: Record<string, unknown>) => unknown) => {
|
||||||
eventFormMode: mode,
|
const state = {
|
||||||
eventFormUid: uid,
|
eventFormOpen: true,
|
||||||
setEventForm: mockSetEventForm,
|
eventFormMode: mode,
|
||||||
}))
|
eventFormUid: uid,
|
||||||
|
setEventForm: mockSetEventForm,
|
||||||
|
setLastSyncedUid: mockSetLastSyncedUid,
|
||||||
|
}
|
||||||
|
if (typeof selector === 'function') return selector(state)
|
||||||
|
return state
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
mockFetchWritableCalendars.mockResolvedValue(calendars)
|
mockFetchWritableCalendars.mockResolvedValue(calendars)
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ function parseDateTime(iso: string): { date: string; time: string } {
|
|||||||
|
|
||||||
export function EventForm() {
|
export function EventForm() {
|
||||||
const { eventFormOpen, eventFormMode, eventFormUid, setEventForm } = useCalendarStore()
|
const { eventFormOpen, eventFormMode, eventFormUid, setEventForm } = useCalendarStore()
|
||||||
|
const setLastSyncedUid = useCalendarStore((s) => s.setLastSyncedUid)
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const titleRef = useRef<HTMLInputElement>(null)
|
const titleRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
@@ -190,8 +191,10 @@ export function EventForm() {
|
|||||||
eventFormMode === 'edit' && eventFormUid
|
eventFormMode === 'edit' && eventFormUid
|
||||||
? updateEvent(eventFormUid, payload)
|
? updateEvent(eventFormUid, payload)
|
||||||
: createEvent(payload),
|
: createEvent(payload),
|
||||||
onSuccess: () => {
|
onSuccess: (data) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['events'] })
|
// 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)
|
if (calendarUrl) writeLastCalendarUrl(calendarUrl)
|
||||||
setEventForm(false)
|
setEventForm(false)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import React from 'react'
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
import { render, screen, waitFor, act, fireEvent } from '@testing-library/react'
|
import { render, screen, waitFor, act, fireEvent } from '@testing-library/react'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { SyncStateToast } from './SyncStateToast.js'
|
||||||
|
|
||||||
// ── Module mocks ──────────────────────────────────────────────────────────────
|
// ── Module mocks ──────────────────────────────────────────────────────────────
|
||||||
// vi.hoisted() required for variables referenced inside vi.mock() factories (D-03-04-hoisting)
|
// vi.hoisted() required for variables referenced inside vi.mock() factories (D-03-04-hoisting)
|
||||||
@@ -55,7 +56,6 @@ function makeQueryClient() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderToast(queryClient: QueryClient) {
|
function renderToast(queryClient: QueryClient) {
|
||||||
const { SyncStateToast } = require('./SyncStateToast.js')
|
|
||||||
return render(
|
return render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<SyncStateToast />
|
<SyncStateToast />
|
||||||
@@ -70,14 +70,12 @@ describe('SyncStateToast', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
vi.useFakeTimers()
|
|
||||||
queryClient = makeQueryClient()
|
queryClient = makeQueryClient()
|
||||||
mockLastSyncedUid.value = 'test-uid-123'
|
mockLastSyncedUid.value = 'test-uid-123'
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers()
|
vi.useRealTimers()
|
||||||
vi.resetModules()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders nothing when lastSyncedUid is null', async () => {
|
it('renders nothing when lastSyncedUid is null', async () => {
|
||||||
@@ -93,27 +91,18 @@ describe('SyncStateToast', () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
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' })
|
mockFetchSyncStatus.mockResolvedValue({ uid: 'test-uid-123', status: 'done' })
|
||||||
|
|
||||||
renderToast(queryClient)
|
renderToast(queryClient)
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Saved')).toBeInTheDocument()
|
expect(screen.getByText('Saved')).toBeInTheDocument()
|
||||||
})
|
}, { timeout: 3000 })
|
||||||
|
|
||||||
// Auto-dismiss after 2 seconds
|
|
||||||
act(() => {
|
|
||||||
vi.advanceTimersByTime(2000)
|
|
||||||
})
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockSetLastSyncedUid).toHaveBeenCalledWith(null)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('done: calls queryClient.invalidateQueries for ["events"]', async () => {
|
it('done: calls queryClient.invalidateQueries for ["events"]', async () => {
|
||||||
@@ -124,13 +113,27 @@ describe('SyncStateToast', () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Saved')).toBeInTheDocument()
|
expect(screen.getByText('Saved')).toBeInTheDocument()
|
||||||
})
|
}, { timeout: 3000 })
|
||||||
|
|
||||||
expect(invalidateSpy).toHaveBeenCalledWith(
|
expect(invalidateSpy).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ queryKey: ['events'] }),
|
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 () => {
|
it('failed (generic): renders "Didn\'t save. Try again." with role="alert"', async () => {
|
||||||
mockFetchSyncStatus.mockResolvedValue({ uid: 'test-uid-123', status: 'failed' })
|
mockFetchSyncStatus.mockResolvedValue({ uid: 'test-uid-123', status: 'failed' })
|
||||||
|
|
||||||
@@ -138,8 +141,8 @@ describe('SyncStateToast', () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole('alert')).toBeInTheDocument()
|
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 () => {
|
it('failed (generic): persists and has a dismiss button', async () => {
|
||||||
@@ -149,13 +152,7 @@ describe('SyncStateToast', () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole('alert')).toBeInTheDocument()
|
expect(screen.getByRole('alert')).toBeInTheDocument()
|
||||||
})
|
}, { timeout: 3000 })
|
||||||
|
|
||||||
// Should not auto-dismiss after 2s
|
|
||||||
act(() => {
|
|
||||||
vi.advanceTimersByTime(3000)
|
|
||||||
})
|
|
||||||
expect(screen.getByRole('alert')).toBeInTheDocument()
|
|
||||||
|
|
||||||
// Should have a dismiss button
|
// Should have a dismiss button
|
||||||
const dismissBtn = screen.getByRole('button', { name: /dismiss/i })
|
const dismissBtn = screen.getByRole('button', { name: /dismiss/i })
|
||||||
@@ -176,7 +173,7 @@ describe('SyncStateToast', () => {
|
|||||||
expect(
|
expect(
|
||||||
screen.getByText('This event changed elsewhere — review the latest version'),
|
screen.getByText('This event changed elsewhere — review the latest version'),
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
})
|
}, { timeout: 3000 })
|
||||||
|
|
||||||
expect(invalidateSpy).toHaveBeenCalledWith(
|
expect(invalidateSpy).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ queryKey: ['events'] }),
|
expect.objectContaining({ queryKey: ['events'] }),
|
||||||
@@ -190,8 +187,8 @@ describe('SyncStateToast', () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole('alert')).toBeInTheDocument()
|
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 () => {
|
it('refetchInterval is active (3000ms) while pending', async () => {
|
||||||
@@ -201,17 +198,22 @@ describe('SyncStateToast', () => {
|
|||||||
return Promise.resolve({ uid: 'test-uid-123', status: 'pending' })
|
return Promise.resolve({ uid: 'test-uid-123', status: 'pending' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||||
renderToast(queryClient)
|
renderToast(queryClient)
|
||||||
|
|
||||||
// Wait for first fetch
|
// 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
|
// Advance 3s to trigger refetch
|
||||||
act(() => {
|
await act(async () => {
|
||||||
vi.advanceTimersByTime(3000)
|
await vi.advanceTimersByTimeAsync(3100)
|
||||||
})
|
})
|
||||||
|
|
||||||
await waitFor(() => expect(callCount).toBeGreaterThanOrEqual(2))
|
expect(callCount).toBeGreaterThan(countAfterFirst)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('dismiss button on failed toast calls setLastSyncedUid(null)', async () => {
|
it('dismiss button on failed toast calls setLastSyncedUid(null)', async () => {
|
||||||
@@ -221,12 +223,11 @@ describe('SyncStateToast', () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole('alert')).toBeInTheDocument()
|
expect(screen.getByRole('alert')).toBeInTheDocument()
|
||||||
})
|
}, { timeout: 3000 })
|
||||||
|
|
||||||
const dismissBtn = screen.getByRole('button', { name: /dismiss/i })
|
const dismissBtn = screen.getByRole('button', { name: /dismiss/i })
|
||||||
fireEvent.click(dismissBtn)
|
fireEvent.click(dismissBtn)
|
||||||
|
|
||||||
expect(mockSetLastSyncedUid).toHaveBeenCalledWith(null)
|
expect(mockSetLastSyncedUid).toHaveBeenCalledWith(null)
|
||||||
})
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user