Apply full refactor updates plus pipeline/email UX confirmations
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s

This commit is contained in:
Matt
2026-02-14 15:26:42 +01:00
parent e56e143a40
commit b5425e705e
374 changed files with 116737 additions and 111969 deletions

View File

@@ -1,12 +1,12 @@
import { useState, useEffect } from 'react'
export function useDebounce<T>(value: T, delay: number = 300): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay)
return () => clearTimeout(timer)
}, [value, delay])
return debouncedValue
}
import { useState, useEffect } from 'react'
export function useDebounce<T>(value: T, delay: number = 300): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay)
return () => clearTimeout(timer)
}, [value, delay])
return debouncedValue
}

View File

@@ -1,130 +1,130 @@
'use client'
import { useEffect, useRef, useCallback, useState } from 'react'
export interface VoteUpdate {
projectId: string
totalVotes: number
averageScore: number | null
latestVote: { score: number; isAudienceVote: boolean; votedAt: string } | null
timestamp: string
}
export interface AudienceVoteUpdate {
projectId: string
audienceVotes: number
audienceAverage: number | null
timestamp: string
}
export interface SessionStatusUpdate {
status: string
timestamp: string
}
export interface ProjectChangeUpdate {
projectId: string | null
projectIndex: number
timestamp: string
}
interface SSECallbacks {
onVoteUpdate?: (data: VoteUpdate) => void
onAudienceVote?: (data: AudienceVoteUpdate) => void
onSessionStatus?: (data: SessionStatusUpdate) => void
onProjectChange?: (data: ProjectChangeUpdate) => void
onConnected?: () => void
onError?: (error: Event) => void
}
export function useLiveVotingSSE(
sessionId: string | null,
callbacks: SSECallbacks
) {
const [isConnected, setIsConnected] = useState(false)
const eventSourceRef = useRef<EventSource | null>(null)
const callbacksRef = useRef(callbacks)
callbacksRef.current = callbacks
const connect = useCallback(() => {
if (!sessionId) return
// Close any existing connection
if (eventSourceRef.current) {
eventSourceRef.current.close()
}
const baseUrl = typeof window !== 'undefined' ? window.location.origin : ''
const url = `${baseUrl}/api/live-voting/stream?sessionId=${sessionId}`
const es = new EventSource(url)
eventSourceRef.current = es
es.addEventListener('connected', () => {
setIsConnected(true)
callbacksRef.current.onConnected?.()
})
es.addEventListener('vote_update', (event) => {
try {
const data = JSON.parse(event.data) as VoteUpdate
callbacksRef.current.onVoteUpdate?.(data)
} catch {
// Ignore parse errors
}
})
es.addEventListener('audience_vote', (event) => {
try {
const data = JSON.parse(event.data) as AudienceVoteUpdate
callbacksRef.current.onAudienceVote?.(data)
} catch {
// Ignore parse errors
}
})
es.addEventListener('session_status', (event) => {
try {
const data = JSON.parse(event.data) as SessionStatusUpdate
callbacksRef.current.onSessionStatus?.(data)
} catch {
// Ignore parse errors
}
})
es.addEventListener('project_change', (event) => {
try {
const data = JSON.parse(event.data) as ProjectChangeUpdate
callbacksRef.current.onProjectChange?.(data)
} catch {
// Ignore parse errors
}
})
es.onerror = (event) => {
setIsConnected(false)
callbacksRef.current.onError?.(event)
// Auto-reconnect after 3 seconds
setTimeout(() => {
if (eventSourceRef.current === es) {
connect()
}
}, 3000)
}
}, [sessionId])
const disconnect = useCallback(() => {
if (eventSourceRef.current) {
eventSourceRef.current.close()
eventSourceRef.current = null
setIsConnected(false)
}
}, [])
useEffect(() => {
connect()
return () => disconnect()
}, [connect, disconnect])
return { isConnected, reconnect: connect, disconnect }
}
'use client'
import { useEffect, useRef, useCallback, useState } from 'react'
export interface VoteUpdate {
projectId: string
totalVotes: number
averageScore: number | null
latestVote: { score: number; isAudienceVote: boolean; votedAt: string } | null
timestamp: string
}
export interface AudienceVoteUpdate {
projectId: string
audienceVotes: number
audienceAverage: number | null
timestamp: string
}
export interface SessionStatusUpdate {
status: string
timestamp: string
}
export interface ProjectChangeUpdate {
projectId: string | null
projectIndex: number
timestamp: string
}
interface SSECallbacks {
onVoteUpdate?: (data: VoteUpdate) => void
onAudienceVote?: (data: AudienceVoteUpdate) => void
onSessionStatus?: (data: SessionStatusUpdate) => void
onProjectChange?: (data: ProjectChangeUpdate) => void
onConnected?: () => void
onError?: (error: Event) => void
}
export function useLiveVotingSSE(
sessionId: string | null,
callbacks: SSECallbacks
) {
const [isConnected, setIsConnected] = useState(false)
const eventSourceRef = useRef<EventSource | null>(null)
const callbacksRef = useRef(callbacks)
callbacksRef.current = callbacks
const connect = useCallback(() => {
if (!sessionId) return
// Close any existing connection
if (eventSourceRef.current) {
eventSourceRef.current.close()
}
const baseUrl = typeof window !== 'undefined' ? window.location.origin : ''
const url = `${baseUrl}/api/live-voting/stream?sessionId=${sessionId}`
const es = new EventSource(url)
eventSourceRef.current = es
es.addEventListener('connected', () => {
setIsConnected(true)
callbacksRef.current.onConnected?.()
})
es.addEventListener('vote_update', (event) => {
try {
const data = JSON.parse(event.data) as VoteUpdate
callbacksRef.current.onVoteUpdate?.(data)
} catch {
// Ignore parse errors
}
})
es.addEventListener('audience_vote', (event) => {
try {
const data = JSON.parse(event.data) as AudienceVoteUpdate
callbacksRef.current.onAudienceVote?.(data)
} catch {
// Ignore parse errors
}
})
es.addEventListener('session_status', (event) => {
try {
const data = JSON.parse(event.data) as SessionStatusUpdate
callbacksRef.current.onSessionStatus?.(data)
} catch {
// Ignore parse errors
}
})
es.addEventListener('project_change', (event) => {
try {
const data = JSON.parse(event.data) as ProjectChangeUpdate
callbacksRef.current.onProjectChange?.(data)
} catch {
// Ignore parse errors
}
})
es.onerror = (event) => {
setIsConnected(false)
callbacksRef.current.onError?.(event)
// Auto-reconnect after 3 seconds
setTimeout(() => {
if (eventSourceRef.current === es) {
connect()
}
}, 3000)
}
}, [sessionId])
const disconnect = useCallback(() => {
if (eventSourceRef.current) {
eventSourceRef.current.close()
eventSourceRef.current = null
setIsConnected(false)
}
}, [])
useEffect(() => {
connect()
return () => disconnect()
}, [connect, disconnect])
return { isConnected, reconnect: connect, disconnect }
}

View File

@@ -1,46 +1,46 @@
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
export function usePipelineInlineEdit(pipelineId: string) {
const utils = trpc.useUtils()
const updateMutation = trpc.pipeline.update.useMutation({
onSuccess: () => {
utils.pipeline.getDraft.invalidate({ id: pipelineId })
},
onError: (err) => {
toast.error(`Failed to update pipeline: ${err.message}`)
},
})
const updateConfigMutation = trpc.stage.updateConfig.useMutation({
onSuccess: () => {
utils.pipeline.getDraft.invalidate({ id: pipelineId })
},
onError: (err) => {
toast.error(`Failed to update stage: ${err.message}`)
},
})
const updatePipeline = async (
data: { name?: string; slug?: string; status?: 'DRAFT' | 'ACTIVE' | 'CLOSED' | 'ARCHIVED'; settingsJson?: Record<string, unknown> }
) => {
await updateMutation.mutateAsync({ id: pipelineId, ...data })
toast.success('Pipeline updated')
}
const updateStageConfig = async (
stageId: string,
configJson: Record<string, unknown>,
name?: string
) => {
await updateConfigMutation.mutateAsync({ id: stageId, configJson, name })
toast.success('Stage configuration updated')
}
return {
isUpdating: updateMutation.isPending || updateConfigMutation.isPending,
updatePipeline,
updateStageConfig,
}
}
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
export function usePipelineInlineEdit(pipelineId: string) {
const utils = trpc.useUtils()
const updateMutation = trpc.pipeline.update.useMutation({
onSuccess: () => {
utils.pipeline.getDraft.invalidate({ id: pipelineId })
},
onError: (err) => {
toast.error(`Failed to update pipeline: ${err.message}`)
},
})
const updateConfigMutation = trpc.stage.updateConfig.useMutation({
onSuccess: () => {
utils.pipeline.getDraft.invalidate({ id: pipelineId })
},
onError: (err) => {
toast.error(`Failed to update stage: ${err.message}`)
},
})
const updatePipeline = async (
data: { name?: string; slug?: string; status?: 'DRAFT' | 'ACTIVE' | 'CLOSED' | 'ARCHIVED'; settingsJson?: Record<string, unknown> }
) => {
await updateMutation.mutateAsync({ id: pipelineId, ...data })
toast.success('Pipeline updated')
}
const updateStageConfig = async (
stageId: string,
configJson: Record<string, unknown>,
name?: string
) => {
await updateConfigMutation.mutateAsync({ id: stageId, configJson, name })
toast.success('Stage configuration updated')
}
return {
isUpdating: updateMutation.isPending || updateConfigMutation.isPending,
updatePipeline,
updateStageConfig,
}
}

View File

@@ -1,153 +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 }
}
'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 }
}