Round system redesign: Phases 1-7 complete
Full pipeline/track/stage architecture replacing the legacy round system. Schema: 11 new models (Pipeline, Track, Stage, StageTransition, ProjectStageState, RoutingRule, Cohort, CohortProject, LiveProgressCursor, OverrideAction, AudienceVoter) + 8 new enums. Backend: 9 new routers (pipeline, stage, routing, stageFiltering, stageAssignment, cohort, live, decision, award) + 6 new services (stage-engine, routing-engine, stage-filtering, stage-assignment, stage-notifications, live-control). Frontend: Pipeline wizard (17 components), jury stage pages (7), applicant pipeline pages (3), public stage pages (2), admin pipeline pages (5), shared stage components (3), SSE route, live hook. Phase 6 refit: 23 routers/services migrated from roundId to stageId, all frontend components refitted. Deleted round.ts (985 lines), roundTemplate.ts, round-helpers.ts, round-settings.ts, round-type-settings.tsx, 10 legacy admin pages, 7 legacy jury pages, 3 legacy dialogs. Phase 7 validation: 36 tests (10 unit + 8 integration files) all passing, TypeScript 0 errors, Next.js build succeeds, 13 integrity checks, legacy symbol sweep clean, auto-seed on first Docker startup. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
153
src/hooks/use-stage-live-sse.ts
Normal file
153
src/hooks/use-stage-live-sse.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
|
||||
interface StageliveSseState {
|
||||
isConnected: boolean
|
||||
activeProject: {
|
||||
id: string
|
||||
title: string
|
||||
teamName: string | null
|
||||
description: string | null
|
||||
} | null
|
||||
openCohorts: Array<{
|
||||
id: string
|
||||
name: string
|
||||
isOpen: boolean
|
||||
projectIds: string[]
|
||||
}>
|
||||
isPaused: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
interface UseStageliveSseOptions {
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export function useStageliveSse(
|
||||
sessionId: string | null,
|
||||
options: UseStageliveSseOptions = {}
|
||||
) {
|
||||
const { enabled = true } = options
|
||||
const [state, setState] = useState<StageliveSseState>({
|
||||
isConnected: false,
|
||||
activeProject: null,
|
||||
openCohorts: [],
|
||||
isPaused: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const eventSourceRef = useRef<EventSource | null>(null)
|
||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const retryCountRef = useRef(0)
|
||||
const maxRetries = 10
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!sessionId || !enabled) return
|
||||
|
||||
// Clean up existing connection
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close()
|
||||
}
|
||||
|
||||
const url = `/api/sse/stage-live/${sessionId}`
|
||||
const es = new EventSource(url)
|
||||
eventSourceRef.current = es
|
||||
|
||||
es.onopen = () => {
|
||||
setState((prev) => ({ ...prev, isConnected: true, error: null }))
|
||||
retryCountRef.current = 0
|
||||
}
|
||||
|
||||
es.addEventListener('cursor.updated', (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
activeProject: data.activeProject ?? prev.activeProject,
|
||||
isPaused: data.isPaused ?? prev.isPaused,
|
||||
}))
|
||||
} catch {
|
||||
// Ignore malformed events
|
||||
}
|
||||
})
|
||||
|
||||
es.addEventListener('cohort.window.changed', (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
openCohorts: data.cohorts ?? prev.openCohorts,
|
||||
}))
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
})
|
||||
|
||||
es.addEventListener('vote.received', () => {
|
||||
// Used for UI feedback (e.g. flash animation)
|
||||
// No state change needed
|
||||
})
|
||||
|
||||
es.addEventListener('session.paused', () => {
|
||||
setState((prev) => ({ ...prev, isPaused: true }))
|
||||
})
|
||||
|
||||
es.addEventListener('session.resumed', () => {
|
||||
setState((prev) => ({ ...prev, isPaused: false }))
|
||||
})
|
||||
|
||||
es.addEventListener('init', (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
setState({
|
||||
isConnected: true,
|
||||
activeProject: data.activeProject ?? null,
|
||||
openCohorts: data.openCohorts ?? [],
|
||||
isPaused: data.isPaused ?? false,
|
||||
error: null,
|
||||
})
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
})
|
||||
|
||||
es.onerror = () => {
|
||||
es.close()
|
||||
setState((prev) => ({ ...prev, isConnected: false }))
|
||||
|
||||
if (retryCountRef.current < maxRetries) {
|
||||
const delay = Math.min(1000 * Math.pow(2, retryCountRef.current), 30000)
|
||||
retryCountRef.current++
|
||||
reconnectTimeoutRef.current = setTimeout(connect, delay)
|
||||
} else {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
error: 'Connection lost. Please refresh the page.',
|
||||
}))
|
||||
}
|
||||
}
|
||||
}, [sessionId, enabled])
|
||||
|
||||
useEffect(() => {
|
||||
connect()
|
||||
|
||||
return () => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close()
|
||||
eventSourceRef.current = null
|
||||
}
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
reconnectTimeoutRef.current = null
|
||||
}
|
||||
}
|
||||
}, [connect])
|
||||
|
||||
const reconnect = useCallback(() => {
|
||||
retryCountRef.current = 0
|
||||
connect()
|
||||
}, [connect])
|
||||
|
||||
return { ...state, reconnect }
|
||||
}
|
||||
Reference in New Issue
Block a user