'use client' // PBI-74 / Story 9 / T-880: Client hook for the sprint workspace SSE stream. // Mounts in SprintHydrationWrapper so it survives Server Action refreshes. // Dispatches sprint/story/task change events into useSprintWorkspaceStore. // // Mirrors use-backlog-realtime.ts: // - Stream blijft open op tab hidden — gemiste events worden opgehaald via // resyncActiveScopes('visible') uit useSprintWorkspaceResync. // - Latere 'ready'-events (post-reconnect) triggeren // resyncActiveScopes('reconnect') zodat events tijdens disconnect alsnog // binnenkomen. import { useEffect, useRef } from 'react' import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store' const BACKOFF_START_MS = 1_000 const BACKOFF_MAX_MS = 30_000 export function useSprintRealtime(productId: string | null) { const sourceRef = useRef(null) const backoffRef = useRef(BACKOFF_START_MS) const reconnectTimerRef = useRef | null>(null) const readyCountRef = useRef(0) useEffect(() => { if (!productId) return const close = () => { if (sourceRef.current) { sourceRef.current.close() sourceRef.current = null } if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current) reconnectTimerRef.current = null } } const connect = () => { close() const source = new EventSource( `/api/realtime/sprint?product_id=${encodeURIComponent(productId)}`, ) sourceRef.current = source useSprintWorkspaceStore.getState().setRealtimeStatus('connecting') source.addEventListener('ready', () => { backoffRef.current = BACKOFF_START_MS readyCountRef.current += 1 useSprintWorkspaceStore.getState().setRealtimeStatus('open') if (readyCountRef.current > 1) { void useSprintWorkspaceStore.getState().resyncActiveScopes('reconnect') } }) source.onmessage = (e) => { if (!e.data) return try { const payload = JSON.parse(e.data) as Record useSprintWorkspaceStore.getState().applyRealtimeEvent(payload) } catch (err) { if (process.env.NODE_ENV !== 'production') { console.error('[realtime/sprint] failed to parse event', err, e.data) } } } source.onerror = () => { if (sourceRef.current !== source) return close() useSprintWorkspaceStore.getState().setRealtimeStatus('disconnected') if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return const delay = backoffRef.current backoffRef.current = Math.min(backoffRef.current * 2, BACKOFF_MAX_MS) reconnectTimerRef.current = setTimeout(connect, delay) } } const onVisibility = () => { if (document.visibilityState === 'visible' && sourceRef.current === null) { backoffRef.current = BACKOFF_START_MS connect() } } connect() document.addEventListener('visibilitychange', onVisibility) return () => { document.removeEventListener('visibilitychange', onVisibility) close() readyCountRef.current = 0 } }, [productId]) }