feat(04-06): implement live-sync SSE vertical slice (LIST-04, D-04/D-10/D-11/D-12)

- 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)
This commit is contained in:
Lucas Berger
2026-06-09 13:33:04 -04:00
parent 5a8d1efe1c
commit 1652a68c51
6 changed files with 360 additions and 27 deletions
@@ -0,0 +1,119 @@
/**
* 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>
)
}