- SoloBoard roept useSoloRealtime(productId) aan, zo komt elke task/
story-mutatie uit web/REST/MCP binnen via SSE en wordt door de
store-dispatcher (ST-804) verwerkt
- markPending/clearPending rond de drag-drop optimistic write zodat
de echo van de eigen Server Action de store niet dubbel beweegt
- RealtimeIndicator: kleine status-dot in de header
- groen ('Live') wanneer SSE-stream open OF tijdens de eerste 2s
grace-period (animatie B in de hook — voorkomt micro-flikker)
- grijs ('Verbinden…') na 2s in connecting-state
- rood ('Verbroken') na 2s in disconnected-state
- Animatie A (kolom-move): bij task UPDATE-events wikkelt de hook de
store-dispatch in document.startViewTransition + flushSync. SoloTask-
cards krijgen view-transition-name `solo-task-<id>` (alleen wanneer
niet aan het draggen) zodat de browser de positie-shift soepel
animeert van bezig naar klaar (en omgekeerd)
Bestaande 89 tests blijven groen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
162 lines
5.7 KiB
TypeScript
162 lines
5.7 KiB
TypeScript
// ST-803: client-side hook voor de Solo Paneel realtime stream.
|
|
//
|
|
// - Opent EventSource('/api/realtime/solo?product_id=...')
|
|
// - Reconnect met exponential backoff (1s → 30s, reset bij ready)
|
|
// - Pauseert bij document.visibilityState === 'hidden', resumes bij 'visible'
|
|
// - Cleanup op unmount
|
|
// - Dispatcht events naar de solo-store via handleRealtimeEvent
|
|
//
|
|
// State exposed:
|
|
// status: 'connecting' | 'open' | 'disconnected'
|
|
// showConnectingIndicator: true zodra status !== 'open' langer dan 2s duurt
|
|
// (UI gebruikt dit zodat micro-disconnects geen flikker veroorzaken)
|
|
|
|
'use client'
|
|
|
|
import { useEffect, useState, useRef } from 'react'
|
|
import { flushSync } from 'react-dom'
|
|
import { useSoloStore } from '@/stores/solo-store'
|
|
import type { RealtimeEvent } from '@/stores/solo-store'
|
|
|
|
export type RealtimeStatus = 'connecting' | 'open' | 'disconnected'
|
|
|
|
const BACKOFF_START_MS = 1_000
|
|
const BACKOFF_MAX_MS = 30_000
|
|
const CONNECTING_INDICATOR_DELAY_MS = 2_000
|
|
|
|
export function useSoloRealtime(productId: string) {
|
|
const [status, setStatus] = useState<RealtimeStatus>('connecting')
|
|
const [showConnectingIndicator, setShowConnectingIndicator] = useState(false)
|
|
|
|
// Refs voor lifecycle die ge-survival moeten zijn over re-renders
|
|
const sourceRef = useRef<EventSource | null>(null)
|
|
const backoffRef = useRef<number>(BACKOFF_START_MS)
|
|
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
const indicatorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
|
|
useEffect(() => {
|
|
const handleEvent = useSoloStore.getState().handleRealtimeEvent
|
|
|
|
const close = () => {
|
|
if (sourceRef.current) {
|
|
sourceRef.current.close()
|
|
sourceRef.current = null
|
|
}
|
|
if (reconnectTimerRef.current) {
|
|
clearTimeout(reconnectTimerRef.current)
|
|
reconnectTimerRef.current = null
|
|
}
|
|
}
|
|
|
|
const scheduleIndicator = (next: RealtimeStatus) => {
|
|
if (indicatorTimerRef.current) {
|
|
clearTimeout(indicatorTimerRef.current)
|
|
indicatorTimerRef.current = null
|
|
}
|
|
if (next === 'open') {
|
|
setShowConnectingIndicator(false)
|
|
} else {
|
|
indicatorTimerRef.current = setTimeout(() => {
|
|
setShowConnectingIndicator(true)
|
|
}, CONNECTING_INDICATOR_DELAY_MS)
|
|
}
|
|
}
|
|
|
|
const connect = () => {
|
|
close()
|
|
setStatus('connecting')
|
|
scheduleIndicator('connecting')
|
|
|
|
const source = new EventSource(
|
|
`/api/realtime/solo?product_id=${encodeURIComponent(productId)}`,
|
|
)
|
|
sourceRef.current = source
|
|
|
|
source.addEventListener('ready', () => {
|
|
backoffRef.current = BACKOFF_START_MS
|
|
setStatus('open')
|
|
scheduleIndicator('open')
|
|
})
|
|
|
|
source.onmessage = (e) => {
|
|
if (!e.data) return
|
|
try {
|
|
const payload = JSON.parse(e.data) as RealtimeEvent
|
|
// ST-805 animatie A: kanban-move animeren via View Transitions API.
|
|
// Voor task UPDATE-events wrap'en we de store-update in een view
|
|
// transition. De browser snapshot't de DOM voor en na, en animeert
|
|
// het verschil — vereist `view-transition-name` op de cards.
|
|
// Andere events (task INSERT/DELETE, story-events) krijgen geen
|
|
// animatie; die zijn niet zichtbaar als positie-shift in de
|
|
// kanban-kolommen.
|
|
const animate =
|
|
payload.entity === 'task' &&
|
|
payload.op === 'U' &&
|
|
typeof document !== 'undefined' &&
|
|
typeof (document as Document & { startViewTransition?: unknown }).startViewTransition ===
|
|
'function'
|
|
if (animate) {
|
|
// flushSync forceert React om de re-render synchroon te doen
|
|
// tijdens de view-transition callback, zodat de nieuwe DOM-state
|
|
// wordt gesnapshot voor de animatie. Zonder flushSync rendert
|
|
// React asynchroon en captured de browser nog de oude state.
|
|
;(
|
|
document as Document & {
|
|
startViewTransition: (cb: () => void) => unknown
|
|
}
|
|
).startViewTransition(() => {
|
|
flushSync(() => handleEvent(payload))
|
|
})
|
|
} else {
|
|
handleEvent(payload)
|
|
}
|
|
} catch (err) {
|
|
if (process.env.NODE_ENV !== 'production') {
|
|
console.error('[realtime] failed to parse event', err, e.data)
|
|
}
|
|
}
|
|
}
|
|
|
|
source.onerror = () => {
|
|
// EventSource probeert standaard zelf te reconnecten, maar we willen
|
|
// controle over backoff + skip-on-hidden. Dus close + plan zelf.
|
|
if (sourceRef.current !== source) return
|
|
close()
|
|
setStatus('disconnected')
|
|
scheduleIndicator('disconnected')
|
|
|
|
if (document.visibilityState === 'hidden') {
|
|
// Niet retryen tot tab weer zichtbaar wordt
|
|
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 === 'hidden') {
|
|
close()
|
|
setStatus('disconnected')
|
|
scheduleIndicator('disconnected')
|
|
} else if (sourceRef.current === null) {
|
|
backoffRef.current = BACKOFF_START_MS
|
|
connect()
|
|
}
|
|
}
|
|
|
|
if (document.visibilityState === 'visible') {
|
|
connect()
|
|
}
|
|
document.addEventListener('visibilitychange', onVisibility)
|
|
|
|
return () => {
|
|
document.removeEventListener('visibilitychange', onVisibility)
|
|
if (indicatorTimerRef.current) clearTimeout(indicatorTimerRef.current)
|
|
close()
|
|
}
|
|
}, [productId])
|
|
|
|
return { status, showConnectingIndicator }
|
|
}
|