Scrum4Me/lib/realtime/use-sprint-realtime.ts
Madhura68 307b998871 feat(PBI-74): sprint hydratie + realtime SSE (Story 9 / T-880)
- app/api/realtime/sprint/route.ts: SSE-stream LISTEN/NOTIFY op
  scrum4me_changes, filter entity ∈ {sprint, story, task} per product_id;
  ready-event, heartbeat 25s, hard-close 240s
- lib/realtime/use-sprint-realtime.ts: client-hook met backoff-reconnect;
  ready-cycle telt; geen close op hidden; setRealtimeStatus
- lib/realtime/use-sprint-workspace-resync.ts: visibility + online triggers
  resyncActiveScopes('visible' | 'reconnect')
- components/sprint/sprint-hydration-wrapper.tsx: hydrateSnapshot via
  useEffect met fingerprint-check; mount realtime + resync
- app/(app)/products/[id]/sprint/[sprintId]/page.tsx: wrap SprintBoardClient
  in SprintHydrationWrapper; bouw SprintWorkspaceTask-shape voor
  tasksByStoryWorkspace en SprintHydrationData voor de wrapper

Schaduw-fase: useSprintStore blijft parallel werken in board components
totdat T-881 die migreert en T-883 de oude store opruimt.
2026-05-10 06:37:59 +02:00

96 lines
3.2 KiB
TypeScript

'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<EventSource | null>(null)
const backoffRef = useRef<number>(BACKOFF_START_MS)
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const readyCountRef = useRef<number>(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<string, unknown>
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])
}