- Wire publishListEvent fan-out in lists.ts after every write mutation (item:added/updated/deleted, list:updated/deleted)
- Add GET /api/sse/lists scoped endpoint in sse.ts: resolveUserId → 401 on null; getAccessibleListIds → subscribe only to accessible channels; 30s heartbeat; cleanup on disconnect (D-04/T-04-01/T-04-02)
- Create useListSSE.ts: bounded-backoff EventSource wrapper (250ms→500ms→1s→2s→4s→cap 8s); MAX_ATTEMPTS=6; withCredentials:true; close-before-retry prevents reconnect storm (Pitfall 3); invalidates ['list', listId] on open (D-10) and on each event; onStateChange('disconnected') after exhaustion (D-11)
- Create LiveSyncIndicator.tsx: connected=green dot; reconnecting=pulsing muted dot + label; disconnected=red dot + 'Updates paused' (role=alert); correct ARIA per UI-SPEC
- Wire useListSSE + LiveSyncIndicator into ListDetail header; retain refetchInterval:30000 polling fallback (D-12)
- All 8 useListSSE tests pass; all 54 API tests pass; both typechecks pass
- playwright-cli: live update confirmed (eggs item added via API appeared in browser without manual refresh)
120 lines
3.2 KiB
TypeScript
120 lines
3.2 KiB
TypeScript
/**
|
|
* LiveSyncIndicator — visual SSE connection status indicator (LIST-04, D-11).
|
|
*
|
|
* States per UI-SPEC §"LiveSyncIndicator":
|
|
* connected: 8px filled circle in --color-member-1 (#50C878), no label
|
|
* reconnecting: 8px pulsing circle in --color-text-muted + "Reconnecting…" label
|
|
* disconnected: 8px filled circle in --color-destructive + "Updates paused" label
|
|
*
|
|
* Accessibility:
|
|
* - role="status" for connected/reconnecting (polite announcements)
|
|
* - role="alert" for disconnected state (assertive announcement)
|
|
* - aria-label per UI-SPEC copywriting contract
|
|
*
|
|
* Positioned at the right end of the ListDetail header row.
|
|
* Visible only inside ListDetail (not on ListsIndex).
|
|
*/
|
|
|
|
import type { SyncState } from '../hooks/useListSSE.js'
|
|
|
|
interface LiveSyncIndicatorProps {
|
|
state: SyncState
|
|
}
|
|
|
|
export function LiveSyncIndicator({ state }: LiveSyncIndicatorProps) {
|
|
if (state === 'connected') {
|
|
return (
|
|
<div
|
|
role="status"
|
|
aria-label="Live sync connected"
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 'var(--space-1)',
|
|
}}
|
|
>
|
|
{/* 8px filled green dot — --color-member-1 (#50C878) per UI-SPEC */}
|
|
<div
|
|
style={{
|
|
width: '8px',
|
|
height: '8px',
|
|
borderRadius: '50%',
|
|
backgroundColor: 'var(--color-member-1, #50C878)',
|
|
flexShrink: 0,
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (state === 'reconnecting') {
|
|
return (
|
|
<div
|
|
role="status"
|
|
aria-label="Reconnecting"
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 'var(--space-1)',
|
|
}}
|
|
>
|
|
{/* 8px pulsing muted dot — animation via keyframes in CSS */}
|
|
<div
|
|
style={{
|
|
width: '8px',
|
|
height: '8px',
|
|
borderRadius: '50%',
|
|
backgroundColor: 'var(--color-text-muted, #9CA3AF)',
|
|
flexShrink: 0,
|
|
animation: 'pulse 1.4s ease-in-out infinite',
|
|
}}
|
|
/>
|
|
<span
|
|
style={{
|
|
fontSize: 'var(--text-label-size, 13px)',
|
|
color: 'var(--color-text-muted, #9CA3AF)',
|
|
fontFamily: 'var(--font-family-base)',
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
>
|
|
Reconnecting…
|
|
</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// disconnected — role="alert" for assertive announcement
|
|
return (
|
|
<div
|
|
role="alert"
|
|
aria-label="Updates paused — tap to retry"
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 'var(--space-1)',
|
|
}}
|
|
>
|
|
{/* 8px filled red dot — --color-destructive (#DC2626) */}
|
|
<div
|
|
style={{
|
|
width: '8px',
|
|
height: '8px',
|
|
borderRadius: '50%',
|
|
backgroundColor: 'var(--color-destructive, #DC2626)',
|
|
flexShrink: 0,
|
|
}}
|
|
/>
|
|
<span
|
|
style={{
|
|
fontSize: 'var(--text-label-size, 13px)',
|
|
color: 'var(--color-destructive, #DC2626)',
|
|
fontFamily: 'var(--font-family-base)',
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
>
|
|
Updates paused
|
|
</span>
|
|
</div>
|
|
)
|
|
}
|