feat(ST-803): useSoloRealtime hook with EventSource lifecycle
Client-only hook die de SSE-stream van /api/realtime/solo opent en
voor de UI twee statussen exposed:
- status: 'connecting' | 'open' | 'disconnected'
- showConnectingIndicator: pas true als status !== 'open' >2s duurt
(ST-805 animatie B — voorkomt flikker bij microscopische
disconnects)
Lifecycle-gedrag:
- Reconnect met exponential backoff start 1s, plafond 30s, reset op
'ready'-event
- Page Visibility API: bij hidden sluit de hook de connectie en stopt
reconnect-pogingen; bij visible reopent direct
- onerror van EventSource wordt onderschept zodat we eigen backoff
kunnen voeren ipv de browser-default
- Volledige cleanup op unmount
Voert events naar useSoloStore.getState().handleRealtimeEvent — de
echte dispatch-logica met pendingOps en gedifferentieerde
apply{Task,Story}{Update,Create,Delete} landt in ST-804. Hier is dat
nog een stub zodat dit zelf-staand kan compileren.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e6be578c28
commit
1e548da9bf
2 changed files with 154 additions and 0 deletions
134
lib/realtime/use-solo-realtime.ts
Normal file
134
lib/realtime/use-solo-realtime.ts
Normal file
|
|
@ -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<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
|
||||
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 }
|
||||
}
|
||||
|
|
@ -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<string, SoloTask>
|
||||
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<SoloStore>((set, get) => ({
|
||||
|
|
@ -29,4 +45,8 @@ export const useSoloStore = create<SoloStore>((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.
|
||||
},
|
||||
}))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue