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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user