diff --git a/lib/realtime/use-solo-realtime.ts b/lib/realtime/use-solo-realtime.ts new file mode 100644 index 0000000..cd48993 --- /dev/null +++ b/lib/realtime/use-solo-realtime.ts @@ -0,0 +1,134 @@ +// 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 { 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('connecting') + const [showConnectingIndicator, setShowConnectingIndicator] = useState(false) + + // Refs voor lifecycle die ge-survival moeten zijn over re-renders + const sourceRef = useRef(null) + const backoffRef = useRef(BACKOFF_START_MS) + const reconnectTimerRef = useRef | null>(null) + const indicatorTimerRef = useRef | 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 + 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 } +} diff --git a/stores/solo-store.ts b/stores/solo-store.ts index fa39f0e..d4ff93c 100644 --- a/stores/solo-store.ts +++ b/stores/solo-store.ts @@ -3,12 +3,28 @@ import type { SoloTask } from '@/components/solo/solo-board' type TaskStatus = SoloTask['status'] +// Payload-shape gepubliceerd door de Postgres-trigger via pg_notify (ST-801). +// Komt het Solo Paneel binnen via de SSE-stream uit /api/realtime/solo (ST-802). +export interface RealtimeEvent { + op: 'I' | 'U' | 'D' + entity: 'task' | 'story' + id: string + story_id?: string + product_id: string + sprint_id: string | null + assignee_id: string | null + changed_fields?: string[] +} + interface SoloStore { tasks: Record initTasks: (tasks: SoloTask[]) => void optimisticMove: (taskId: string, toStatus: TaskStatus) => TaskStatus | null rollback: (taskId: string, prevStatus: TaskStatus) => void updatePlan: (taskId: string, plan: string | null) => void + // ST-803 stub. Echte implementatie in ST-804 met pendingOps en + // gedifferentieerde apply{Task,Story}{Update,Create,Delete}. + handleRealtimeEvent: (event: RealtimeEvent) => void } export const useSoloStore = create((set, get) => ({ @@ -29,4 +45,8 @@ export const useSoloStore = create((set, get) => ({ updatePlan: (taskId, plan) => set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], implementation_plan: plan } } })), + + handleRealtimeEvent: (_event) => { + // ST-803 stub — vol invullen in ST-804. + }, }))