'use client' import { useEffect, useMemo, useRef, useState } from 'react' import { flushSync } from 'react-dom' import { useDebugStore, type DebugRealtimeEvent } from './debug-store' import { StorePanel } from './store-panel' type RowType = 'ready' | 'message' | 'error' | 'open' | 'close' interface Row { receivedAt: number type: RowType raw: string parsed?: Record } interface Stats { reconnects: number totalEvents: number firstEventAt: number | null lastEventAt: number | null largestGapMs: number emitInFlight: boolean lastEmitResult: string | null } const MAX_ROWS = 500 const HEARTBEAT_WARN_MS = 30_000 export function DebugRealtimeClient() { const [rows, setRows] = useState([]) const [status, setStatus] = useState<'connecting' | 'open' | 'closed' | 'error'>('connecting') const [stats, setStats] = useState({ reconnects: 0, totalEvents: 0, firstEventAt: null, lastEventAt: null, largestGapMs: 0, emitInFlight: false, lastEmitResult: null, }) const [filterType, setFilterType] = useState<'all' | RowType>('all') const [filterEntity, setFilterEntity] = useState<'all' | 'task' | 'story' | 'debug'>('all') const [tickNow, setTickNow] = useState(() => Date.now()) // Layer-toggles — elke combinatie isoleert een potentiële bron van bugs. // - dispatchToStore: voert applyEvent uit op een mini Zustand-store // - useFlushSync: forceert React om synchroon te renderen tijdens dispatch // - useViewTransition: wrap dispatch in document.startViewTransition const [dispatchToStore, setDispatchToStore] = useState(true) const [useFlushSync, setUseFlushSync] = useState(false) const [useViewTransition, setUseViewTransition] = useState(false) const dispatchToStoreRef = useRef(dispatchToStore) const useFlushSyncRef = useRef(useFlushSync) const useViewTransitionRef = useRef(useViewTransition) useEffect(() => { dispatchToStoreRef.current = dispatchToStore }, [dispatchToStore]) useEffect(() => { useFlushSyncRef.current = useFlushSync }, [useFlushSync]) useEffect(() => { useViewTransitionRef.current = useViewTransition }, [useViewTransition]) const sourceRef = useRef(null) const lastEventTimeRef = useRef(null) // Tick every second om "since last event"-counter levend te houden useEffect(() => { const t = setInterval(() => setTickNow(Date.now()), 1000) return () => clearInterval(t) }, []) useEffect(() => { const append = (row: Row) => { setRows((prev) => [row, ...prev].slice(0, MAX_ROWS)) lastEventTimeRef.current = row.receivedAt setStats((s) => { const prevLast = s.lastEventAt const gap = prevLast ? row.receivedAt - prevLast : 0 return { ...s, totalEvents: s.totalEvents + 1, firstEventAt: s.firstEventAt ?? row.receivedAt, lastEventAt: row.receivedAt, largestGapMs: Math.max(s.largestGapMs, gap), } }) } const open = () => { const source = new EventSource('/api/debug/realtime-stream') sourceRef.current = source append({ receivedAt: Date.now(), type: 'open', raw: '(EventSource opening)' }) setStats((s) => ({ ...s, reconnects: s.reconnects + 1 })) setStatus('connecting') source.addEventListener('ready', (e) => { setStatus('open') const data = (e as MessageEvent).data ?? '' const parsed = safeParse(data) append({ receivedAt: Date.now(), type: 'ready', raw: data, parsed }) }) source.addEventListener('error', (e) => { setStatus('error') const data = (e as MessageEvent).data ?? '(no data)' append({ receivedAt: Date.now(), type: 'error', raw: data }) }) source.onmessage = (e) => { const parsed = safeParse(e.data ?? '') append({ receivedAt: Date.now(), type: 'message', raw: e.data ?? '', parsed }) // Dispatch naar de mini Zustand-store, optioneel met flushSync // en/of startViewTransition om de echte solo-flow te mimeken. if (dispatchToStoreRef.current && parsed) { const dispatch = () => useDebugStore.getState().applyEvent(parsed as DebugRealtimeEvent) const wrapWithFlush = useFlushSyncRef.current ? () => flushSync(dispatch) : dispatch if ( useViewTransitionRef.current && typeof document !== 'undefined' && typeof (document as Document & { startViewTransition?: unknown }) .startViewTransition === 'function' ) { ;( document as Document & { startViewTransition: (cb: () => void) => unknown } ).startViewTransition(wrapWithFlush) } else { wrapWithFlush() } } } source.onerror = () => { setStatus('error') append({ receivedAt: Date.now(), type: 'close', raw: '(EventSource error/close)' }) } } open() return () => { sourceRef.current?.close() sourceRef.current = null } }, []) async function emitTestEvent() { setStats((s) => ({ ...s, emitInFlight: true, lastEmitResult: null })) try { const res = await fetch('/api/debug/emit-test-notify', { method: 'POST' }) const json = (await res.json()) as { ok?: boolean; error?: string } setStats((s) => ({ ...s, emitInFlight: false, lastEmitResult: json.ok ? 'sent ✓' : `failed: ${json.error ?? 'unknown'}`, })) } catch (err) { setStats((s) => ({ ...s, emitInFlight: false, lastEmitResult: `failed: ${err instanceof Error ? err.message : String(err)}`, })) } } function reset() { setRows([]) setStats({ reconnects: 0, totalEvents: 0, firstEventAt: null, lastEventAt: null, largestGapMs: 0, emitInFlight: false, lastEmitResult: null, }) lastEventTimeRef.current = null } const filteredRows = useMemo(() => { return rows.filter((row) => { if (filterType !== 'all' && row.type !== filterType) return false if (filterEntity !== 'all') { const entity = (row.parsed?.entity as string | undefined) ?? null if (filterEntity === 'debug') { if (!row.parsed?.debug) return false } else { if (entity !== filterEntity) return false } } return true }) }, [rows, filterType, filterEntity]) const sinceLastEvent = lastEventTimeRef.current ? tickNow - lastEventTimeRef.current : null return (
{dispatchToStore && } {filteredRows.length === 0 ? ( ) : ( filteredRows.map((row, idx) => ( )) )}
received_at type entity / op payload
Wachten op events… trigger een mutatie via UI / script of klik "emit test event".
{new Date(row.receivedAt).toISOString()} {row.type} {row.parsed?.entity ? ( <> {row.parsed.entity as string} {row.parsed.op ? ` / ${row.parsed.op as string}` : ''} {row.parsed.debug ? ' [debug]' : ''} ) : ( '—' )} {row.raw}
) } function ControlBar({ status, stats, sinceLastEvent, filterType, setFilterType, filterEntity, setFilterEntity, onEmit, onReset, }: { status: 'connecting' | 'open' | 'closed' | 'error' stats: Stats sinceLastEvent: number | null filterType: 'all' | RowType setFilterType: (t: 'all' | RowType) => void filterEntity: 'all' | 'task' | 'story' | 'debug' setFilterEntity: (e: 'all' | 'task' | 'story' | 'debug') => void onEmit: () => void onReset: () => void }) { const heartbeatStale = sinceLastEvent !== null && sinceLastEvent > HEARTBEAT_WARN_MS return (
0 ? `${(stats.largestGapMs / 1000).toFixed(1)}s` : '—' } />
{stats.lastEmitResult && ( {stats.lastEmitResult} )} filter:
) } function LayerToggles({ dispatchToStore, setDispatchToStore, useFlushSync, setUseFlushSync, useViewTransition, setUseViewTransition, }: { dispatchToStore: boolean setDispatchToStore: (v: boolean) => void useFlushSync: boolean setUseFlushSync: (v: boolean) => void useViewTransition: boolean setUseViewTransition: (v: boolean) => void }) { return (
Layers:
) } function Stat({ label, value, color }: { label: string; value: string; color?: string }) { return (
{label} {value}
) } function statusColor(status: 'connecting' | 'open' | 'closed' | 'error') { switch (status) { case 'open': return 'green' case 'error': return 'red' case 'closed': return 'gray' default: return 'orange' } } function typeColor(type: RowType) { switch (type) { case 'message': return '#0070cc' case 'ready': return 'green' case 'error': return 'red' case 'open': return '#888' case 'close': return '#888' } } function safeParse(raw: string): Record | undefined { try { return JSON.parse(raw) as Record } catch { return undefined } }