diff --git a/backend/database.js b/backend/database.js index 9064810..2bd4b09 100644 --- a/backend/database.js +++ b/backend/database.js @@ -89,6 +89,23 @@ function initializeDatabase() { } catch (err) { // Column already exists, ignore error } + try { + db.exec(`ALTER TABLE sessions ADD COLUMN poll_ending_at TEXT`); + } catch (err) { + // Column already exists, ignore error + } + + // Pending game selection columns on sessions + try { + db.exec(`ALTER TABLE sessions ADD COLUMN pending_game_id INTEGER`); + } catch (err) { + // Column already exists, ignore error + } + try { + db.exec(`ALTER TABLE sessions ADD COLUMN pending_game_source TEXT`); + } catch (err) { + // Column already exists, ignore error + } // Session games table db.exec(` @@ -132,6 +149,13 @@ function initializeDatabase() { // Column already exists, ignore error } + // Add source column: 'dice' (random pick), 'manual' (admin search), 'poll' (poll winner) + try { + db.exec(`ALTER TABLE session_games ADD COLUMN source TEXT DEFAULT 'dice'`); + } catch (err) { + // Column already exists, ignore error + } + // Add favor_bias column to games if it doesn't exist try { db.exec(`ALTER TABLE games ADD COLUMN favor_bias INTEGER DEFAULT 0`); diff --git a/backend/routes/sessions.js b/backend/routes/sessions.js index 4425911..e3afc00 100644 --- a/backend/routes/sessions.js +++ b/backend/routes/sessions.js @@ -10,6 +10,9 @@ const { optionalAuthenticateToken } = require('../middleware/optional-auth'); const router = express.Router(); +// Active poll-ending timers keyed by sessionId +const pollEndTimers = new Map(); + // Helper function to create a hash of a message function createMessageHash(username, message, timestamp) { return crypto @@ -341,13 +344,23 @@ router.post('/:id/voting/start', authenticateToken, (req, res) => { return res.status(400).json({ error: 'Session is not active' }); } + // Cancel any pending end-poll timer from a previous poll + const existingTimer = pollEndTimers.get(sessionId); + if (existingTimer) { + clearTimeout(existingTimer); + pollEndTimers.delete(sessionId); + } + db.prepare( - 'UPDATE sessions SET poll_active = 1, poll_started_at = ?, poll_leading_game_id = NULL, poll_leading_label = NULL, poll_leading_votes = NULL WHERE id = ?' + 'UPDATE sessions SET poll_active = 1, poll_started_at = ?, poll_ending_at = NULL, poll_leading_game_id = NULL, poll_leading_label = NULL, poll_leading_votes = NULL WHERE id = ?' ).run(new Date().toISOString(), sessionId); const wsManager = getWebSocketManager(); if (wsManager) { - wsManager.broadcastEvent('poll.start', { sessionId }, sessionId); + wsManager.broadcastEvent('poll.start', { + sessionId, + pollStartedAt: new Date().toISOString() + }, sessionId); } res.json({ success: true }); @@ -356,7 +369,30 @@ router.post('/:id/voting/start', authenticateToken, (req, res) => { } }); +// Immediately end a poll for a session (internal helper, also used by the timer callback) +function endPollNow(sessionId) { + const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(sessionId); + if (!session || session.poll_active === 0) return; + + db.prepare( + 'UPDATE sessions SET poll_active = 0, poll_started_at = NULL, poll_ending_at = NULL WHERE id = ?' + ).run(sessionId); + + pollEndTimers.delete(sessionId); + + const wsManager = getWebSocketManager(); + if (wsManager) { + wsManager.broadcastEvent('voting.ended', { + sessionId, + winnerGameId: session.poll_leading_game_id, + winnerLabel: session.poll_leading_label, + winnerVotes: session.poll_leading_votes + }, sessionId); + } +} + // End voting/polling for a session (broadcasts voting.ended to subscribers) +// Accepts optional { delay } in body (seconds, 0-300). 0 or omitted = immediate. router.post('/:id/voting/end', authenticateToken, (req, res) => { try { const sessionId = parseInt(req.params.id); @@ -370,13 +406,138 @@ router.post('/:id/voting/end', authenticateToken, (req, res) => { return res.status(400).json({ error: 'Session is not active' }); } + const delay = Math.max(0, Math.min(300, parseInt(req.body.delay) || 0)); + + // Clear any existing timer first + const existingTimer = pollEndTimers.get(sessionId); + if (existingTimer) { + clearTimeout(existingTimer); + pollEndTimers.delete(sessionId); + } + + if (delay === 0) { + endPollNow(sessionId); + return res.json({ success: true }); + } + + // Schedule a delayed end + const endsAt = new Date(Date.now() + delay * 1000).toISOString(); + db.prepare( - 'UPDATE sessions SET poll_active = 0, poll_started_at = NULL WHERE id = ?' + 'UPDATE sessions SET poll_ending_at = ? WHERE id = ?' + ).run(endsAt, sessionId); + + const timerId = setTimeout(() => endPollNow(sessionId), delay * 1000); + pollEndTimers.set(sessionId, timerId); + + const wsManager = getWebSocketManager(); + if (wsManager) { + wsManager.broadcastEvent('poll.ending', { + sessionId, + endsAt, + delaySeconds: delay + }, sessionId); + } + + res.json({ success: true, endsAt, delaySeconds: delay }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Cancel a scheduled poll end (broadcasts poll.ending.cancelled to subscribers) +router.post('/:id/voting/cancel-end', authenticateToken, (req, res) => { + try { + const sessionId = parseInt(req.params.id); + const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(sessionId); + + if (!session) { + return res.status(404).json({ error: 'Session not found' }); + } + + const timerId = pollEndTimers.get(sessionId); + if (timerId) { + clearTimeout(timerId); + pollEndTimers.delete(sessionId); + } + + db.prepare( + 'UPDATE sessions SET poll_ending_at = NULL WHERE id = ?' ).run(sessionId); const wsManager = getWebSocketManager(); if (wsManager) { - wsManager.broadcastEvent('voting.ended', { sessionId }, sessionId); + wsManager.broadcastEvent('poll.ending.cancelled', { sessionId }, sessionId); + } + + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Set the pending game selection for a session (broadcasts game.picked to subscribers) +router.post('/:id/game-selection', authenticateToken, (req, res) => { + try { + const sessionId = parseInt(req.params.id); + const { game_id, source } = req.body; + + if (!game_id) { + return res.status(400).json({ error: 'game_id is required' }); + } + + const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(sessionId); + if (!session) { + return res.status(404).json({ error: 'Session not found' }); + } + if (session.is_active === 0) { + return res.status(400).json({ error: 'Session is not active' }); + } + + const game = db.prepare('SELECT * FROM games WHERE id = ?').get(game_id); + if (!game) { + return res.status(404).json({ error: 'Game not found' }); + } + + const validSources = ['dice', 'poll', 'manual']; + const gameSource = validSources.includes(source) ? source : 'dice'; + + db.prepare( + 'UPDATE sessions SET pending_game_id = ?, pending_game_source = ? WHERE id = ?' + ).run(game_id, gameSource, sessionId); + + const wsManager = getWebSocketManager(); + if (wsManager) { + wsManager.broadcastEvent('game.picked', { + sessionId, + game, + source: gameSource + }, sessionId); + } + + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Clear the pending game selection for a session (broadcasts game.dismissed to subscribers) +router.delete('/:id/game-selection', authenticateToken, (req, res) => { + try { + const sessionId = parseInt(req.params.id); + + const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(sessionId); + if (!session) { + return res.status(404).json({ error: 'Session not found' }); + } + + db.prepare( + 'UPDATE sessions SET pending_game_id = NULL, pending_game_source = NULL WHERE id = ?' + ).run(sessionId); + + const wsManager = getWebSocketManager(); + if (wsManager) { + wsManager.broadcastEvent('game.dismissed', { sessionId }, sessionId); } res.json({ success: true }); @@ -553,7 +714,9 @@ router.get('/:id/votes', (req, res) => { // Add game to session (admin only) router.post('/:id/games', authenticateToken, (req, res) => { try { - const { game_id, manually_added, room_code } = req.body; + const { game_id, manually_added, room_code, source } = req.body; + const validSources = ['dice', 'manual', 'poll']; + const gameSource = validSources.includes(source) ? source : (manually_added ? 'manual' : 'dice'); if (!game_id) { return res.status(400).json({ error: 'game_id is required' }); @@ -597,11 +760,16 @@ router.post('/:id/games', authenticateToken, (req, res) => { // Add game to session with 'playing' status const stmt = db.prepare(` - INSERT INTO session_games (session_id, game_id, manually_added, status, room_code) - VALUES (?, ?, ?, 'playing', ?) + INSERT INTO session_games (session_id, game_id, manually_added, status, room_code, source) + VALUES (?, ?, ?, 'playing', ?, ?) `); - const result = stmt.run(req.params.id, game_id, manually_added ? 1 : 0, room_code || null); + const result = stmt.run(req.params.id, game_id, manually_added ? 1 : 0, room_code || null, gameSource); + + // Clear pending game selection since the game is now formally added + db.prepare( + 'UPDATE sessions SET pending_game_id = NULL, pending_game_source = NULL WHERE id = ?' + ).run(req.params.id); // Increment play count for the game db.prepare('UPDATE games SET play_count = play_count + 1 WHERE id = ?').run(game_id); @@ -644,7 +812,8 @@ router.post('/:id/games', authenticateToken, (req, res) => { min_players: game.min_players, max_players: game.max_players, manually_added: manually_added || false, - room_code: room_code || null + room_code: room_code || null, + source: gameSource } }; @@ -1046,7 +1215,8 @@ router.get('/:id/export', authenticateToken, (req, res) => { type: game.game_type, played_at: game.played_at, manually_added: game.manually_added === 1, - status: game.status + status: game.status, + source: game.source || 'dice' })), chat_logs: chatLogs.map(log => ({ username: log.chatter_name, @@ -1087,7 +1257,9 @@ router.get('/:id/export', authenticateToken, (req, res) => { } text += ` Played: ${game.played_at}\n`; text += ` Status: ${game.status}\n`; - if (game.manually_added === 1) { + if (game.source && game.source !== 'dice') { + text += ` Source: ${game.source}\n`; + } else if (game.manually_added === 1) { text += ` (Manually Added)\n`; } }); @@ -1263,5 +1435,25 @@ router.patch('/:sessionId/games/:gameId/player-count', authenticateToken, (req, } }); -module.exports = router; +// Reschedule poll-end timers for sessions that had a pending countdown when the server stopped. +// Call once after the HTTP + WS servers are ready. +function rescheduleEndingPolls() { + const rows = db.prepare( + 'SELECT id, poll_ending_at FROM sessions WHERE poll_active = 1 AND poll_ending_at IS NOT NULL' + ).all(); + + for (const row of rows) { + const remaining = new Date(row.poll_ending_at).getTime() - Date.now(); + if (remaining <= 0) { + endPollNow(row.id); + } else { + const timerId = setTimeout(() => endPollNow(row.id), remaining); + pollEndTimers.set(row.id, timerId); + console.log(`[Sessions] Rescheduled poll end for session ${row.id} in ${Math.round(remaining / 1000)}s`); + } + } +} + +module.exports = router; +module.exports.rescheduleEndingPolls = rescheduleEndingPolls; diff --git a/backend/server.js b/backend/server.js index ec19c66..4613e8c 100644 --- a/backend/server.js +++ b/backend/server.js @@ -22,6 +22,7 @@ app.get('/health', (req, res) => { const authRoutes = require('./routes/auth'); const gamesRoutes = require('./routes/games'); const sessionsRoutes = require('./routes/sessions'); +const { rescheduleEndingPolls } = require('./routes/sessions'); const statsRoutes = require('./routes/stats'); const pickerRoutes = require('./routes/picker'); const votesRoutes = require('./routes/votes'); @@ -54,6 +55,7 @@ if (require.main === module) { server.listen(PORT, '0.0.0.0', () => { console.log(`Server is running on port ${PORT}`); console.log(`WebSocket server available at ws://localhost:${PORT}/api/sessions/live`); + rescheduleEndingPolls(); }); const shutdown = async () => { diff --git a/docs/api/websocket.md b/docs/api/websocket.md index 1035718..4138c5f 100644 --- a/docs/api/websocket.md +++ b/docs/api/websocket.md @@ -143,6 +143,8 @@ Must be authenticated. | `voting.ended` | Host ended the voting/polling period (broadcast to subscribers) | | `poll.start` | Host started a new poll (broadcast to subscribers) | | `poll.leading` | Current poll leader updated (broadcast to subscribers) | +| `poll.ending` | Poll is ending after a countdown (broadcast to subscribers) | +| `poll.ending.cancelled`| Scheduled poll end was cancelled (broadcast to subscribers) | --- @@ -391,12 +393,15 @@ Possible `reason` values: `room_closed`, `room_not_found`, `connection_failed`, ### voting.ended - **Broadcast to:** Clients subscribed to the session -- **Triggered by:** `POST /api/sessions/:id/voting/end` (host ends the voting/polling period) +- **Triggered by:** `POST /api/sessions/:id/voting/end` (host ends the voting/polling period, either immediately or when a countdown reaches zero) **Data:** ```json { - "sessionId": 3 + "sessionId": 3, + "winnerGameId": 42, + "winnerLabel": "Quiplash 3", + "winnerVotes": 7 } ``` @@ -427,6 +432,42 @@ Possible `reason` values: `room_closed`, `room_not_found`, `connection_failed`, } ``` +### poll.ending + +- **Broadcast to:** Clients subscribed to the session +- **Triggered by:** `POST /api/sessions/:id/voting/end` with `{ "delay": N }` where N > 0 + +Signals that a countdown has started and the poll will automatically end when the timer reaches zero. All connected admin clients should display the countdown. The `endsAt` timestamp is authoritative; derive the remaining time on each client by comparing against the local clock. + +**Data:** +```json +{ + "sessionId": 3, + "endsAt": "2026-05-10T20:15:30.000Z", + "delaySeconds": 30 +} +``` + +| Field | Type | Description | +|----------------|---------|--------------------------------------------------| +| `sessionId` | number | The session the poll belongs to | +| `endsAt` | string | ISO 8601 timestamp when the poll will auto-end | +| `delaySeconds` | number | Original delay requested (seconds, 1-300) | + +### poll.ending.cancelled + +- **Broadcast to:** Clients subscribed to the session +- **Triggered by:** `POST /api/sessions/:id/voting/cancel-end` + +The scheduled poll end was cancelled by an admin. Clients should stop displaying the countdown and revert to the normal "Voting In Progress" state. + +**Data:** +```json +{ + "sessionId": 3 +} +``` + --- ## 7. Error Handling diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx index bc1e14a..39e2cb5 100644 --- a/frontend/src/pages/Home.jsx +++ b/frontend/src/pages/Home.jsx @@ -161,10 +161,15 @@ function Home() {
({game.pack_name}) - {game.manually_added === 1 && ( + {game.source === 'manual' || (game.manually_added === 1 && game.source !== 'poll') ? ( Manual + ) : null} + {game.source === 'poll' && ( + + Poll + )}
diff --git a/frontend/src/pages/Picker.jsx b/frontend/src/pages/Picker.jsx index 72c6625..cc44d99 100644 --- a/frontend/src/pages/Picker.jsx +++ b/frontend/src/pages/Picker.jsx @@ -22,6 +22,11 @@ function Picker() { const [pollElapsed, setPollElapsed] = useState(0); const pollTimerRef = useRef(null); const pollStartedAtRef = useRef(null); + const [pollEndingAt, setPollEndingAt] = useState(null); + const [pollCountdown, setPollCountdown] = useState(null); + const pollCountdownRef = useRef(null); + const [showEndPollOptions, setShowEndPollOptions] = useState(false); + const [customDelay, setCustomDelay] = useState(''); const [loading, setLoading] = useState(true); const [picking, setPicking] = useState(false); const [error, setError] = useState(''); @@ -112,6 +117,18 @@ function Picker() { votes: session.poll_leading_votes, }); } + if (session.poll_ending_at && new Date(session.poll_ending_at) > new Date()) { + setPollEndingAt(session.poll_ending_at); + } + } + + // Restore pending game selection if another admin picked one + if (session.pending_game_id) { + const pendingGame = gamesResponse.data.find(g => g.id === session.pending_game_id); + if (pendingGame) { + setSelectedGame(pendingGame); + setGameSource(session.pending_game_source || 'dice'); + } } try { @@ -184,6 +201,23 @@ function Picker() { return () => clearInterval(pollTimerRef.current); }, [pollActive]); + useEffect(() => { + if (pollEndingAt) { + const tick = () => { + const remaining = Math.max(0, Math.ceil((new Date(pollEndingAt).getTime() - Date.now()) / 1000)); + setPollCountdown(remaining); + if (remaining <= 0) { + clearInterval(pollCountdownRef.current); + } + }; + tick(); + pollCountdownRef.current = setInterval(tick, 250); + return () => clearInterval(pollCountdownRef.current); + } + clearInterval(pollCountdownRef.current); + setPollCountdown(null); + }, [pollEndingAt]); + const formatElapsed = (ms) => { const minutes = Math.floor(ms / 60000); const seconds = Math.floor((ms % 60000) / 1000); @@ -191,6 +225,12 @@ function Picker() { return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}:${String(centiseconds).padStart(2, '0')}`; }; + const formatCountdown = (totalSeconds) => { + const m = Math.floor(totalSeconds / 60); + const s = totalSeconds % 60; + return `${m}:${String(s).padStart(2, '0')}`; + }; + const handleCreateSession = async () => { try { const newSession = await api.post('/sessions', {}); @@ -205,37 +245,75 @@ function Picker() { const leadingGameRef = useRef(leadingGame); leadingGameRef.current = leadingGame; + const pollActiveRef = useRef(pollActive); + pollActiveRef.current = pollActive; + const handleStartPolling = async () => { + pollStartedAtRef.current = new Date().toISOString(); + setPollActive(true); + setPollResult(null); try { await api.post(`/sessions/${activeSession.id}/voting/start`); - pollStartedAtRef.current = new Date().toISOString(); - setPollActive(true); - setPollResult(null); } catch (err) { console.error('Failed to start polling', err); + setPollActive(false); + pollStartedAtRef.current = null; } }; - const handleEndPolling = async () => { - try { - await api.post(`/sessions/${activeSession.id}/voting/end`); + const handleEndPolling = async (delay = 0) => { + setShowEndPollOptions(false); + setCustomDelay(''); + + if (delay === 0) { + const winner = leadingGameRef.current; setPollActive(false); - setTimeout(() => { - if (leadingGameRef.current) { - setPollResult(leadingGameRef.current); - } - setLeadingGame(null); - }, 1500); - } catch (err) { - console.error('Failed to end polling', err); + setLeadingGame(null); + setPollEndingAt(null); + if (winner) { + setPollResult(winner); + } + try { + await api.post(`/sessions/${activeSession.id}/voting/end`, { delay: 0 }); + } catch (err) { + console.error('Failed to end polling', err); + setPollActive(true); + setLeadingGame(winner); + setPollResult(null); + } + } else { + try { + const res = await api.post(`/sessions/${activeSession.id}/voting/end`, { delay }); + setPollEndingAt(res.data.endsAt); + } catch (err) { + console.error('Failed to schedule poll end', err); + } } }; + const handleCancelPollEnd = async () => { + setPollEndingAt(null); + try { + await api.post(`/sessions/${activeSession.id}/voting/cancel-end`); + } catch (err) { + console.error('Failed to cancel poll end', err); + } + }; + + const [gameSource, setGameSource] = useState('dice'); + const handleUsePollResult = () => { if (pollResult) { const game = allGames.find(g => g.id === pollResult.gameId); if (game) { setSelectedGame(game); + setGameSource('poll'); + if (activeSession) { + api.post(`/sessions/${activeSession.id}/game-selection`, { + game_id: game.id, + source: 'poll' + }).catch(() => {}); + } } } setPollResult(null); @@ -245,6 +323,14 @@ function Picker() { setPollResult(null); }; + const handleDismissGame = () => { + setSelectedGame(null); + setGameSource('dice'); + if (activeSession) { + api.delete(`/sessions/${activeSession.id}/game-selection`).catch(() => {}); + } + }; + const loadEligibleGames = async () => { try { const params = new URLSearchParams(); @@ -306,9 +392,15 @@ function Picker() { }); setSelectedGame(response.data.game); + setGameSource('dice'); + api.post(`/sessions/${activeSession.id}/game-selection`, { + game_id: response.data.game.id, + source: 'dice' + }).catch(() => {}); } catch (err) { setError(err.response?.data?.error || 'Failed to pick a game'); setSelectedGame(null); + setGameSource('dice'); } finally { setPicking(false); } @@ -320,7 +412,8 @@ function Picker() { // Show room code modal setPendingGameAction({ type: 'accept', - game: selectedGame + game: selectedGame, + source: gameSource }); setShowRoomCodeModal(true); }; @@ -329,13 +422,14 @@ function Picker() { if (!pendingGameAction || !activeSession) return; try { - const { type, game, gameId } = pendingGameAction; + const { type, game, gameId, source } = pendingGameAction; if (type === 'accept' || type === 'version') { const response = await api.post(`/sessions/${activeSession.id}/games`, { game_id: gameId || game.id, manually_added: false, - room_code: roomCode + room_code: roomCode, + source: source || 'dice' }); // Set the newly added game as playing setPlayingGame(response.data); @@ -343,7 +437,8 @@ function Picker() { const response = await api.post(`/sessions/${activeSession.id}/games`, { game_id: gameId, manually_added: true, - room_code: roomCode + room_code: roomCode, + source: 'manual' }); setManualGameId(''); setShowManualSelect(false); @@ -353,6 +448,7 @@ function Picker() { // Close all modals and clear selected game after adding to session setSelectedGame(null); + setGameSource('dice'); setShowGamePool(false); // Trigger games list refresh @@ -878,6 +974,33 @@ function Picker() { + ) : pollActive && pollEndingAt ? ( +
+
+
+

+ Poll Ending +

+

+ Voting will close automatically. +

+
+
+
+ Ending in + + {pollCountdown !== null ? formatCountdown(pollCountdown) : '--:--'} + +
+ +
+
+
) : pollActive ? (
@@ -889,15 +1012,66 @@ function Picker() { End the current poll when ready to pick the next game.

- +
+ + {showEndPollOptions && ( +
+ +
+ {[5, 10, 30].map(s => ( + + ))} +
+
+ setCustomDelay(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') { + const val = parseInt(customDelay); + if (val >= 1 && val <= 300) handleEndPolling(val); + } + }} + className="w-20 px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" + /> + + max 5m +
+
+ )} +
) : ( @@ -926,7 +1100,7 @@ function Picker() {
{/* Close/Dismiss Button */}
@@ -1118,7 +1297,7 @@ function Picker() { ); } -function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame, setHasPlayedGames, setLeadingGame, setPollActive }) { +function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame, setHasPlayedGames, setLeadingGame, setPollActive, pollActiveRef, setPollResult, pollStartedAtRef, setSelectedGame, setGameSource }) { const { isAuthenticated, token } = useAuth(); const [games, setGames] = useState([]); const [loading, setLoading] = useState(true); @@ -1175,28 +1354,40 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame, se return () => clearInterval(interval); }, [loadGames]); - // Setup WebSocket connection for real-time session updates - useEffect(() => { + // Setup WebSocket connection for real-time session updates (with ping + auto-reconnect) + const wsRef = useRef(null); + const pingIntervalRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + + const connectWs = useCallback(() => { if (!token) return; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.hostname}:${window.location.port || (window.location.protocol === 'https:' ? 443 : 80)}/api/sessions/live`; - + try { const ws = new WebSocket(wsUrl); - + wsRef.current = ws; + ws.onopen = () => { console.log('[WebSocket] Connected, authenticating...'); ws.send(JSON.stringify({ type: 'auth', token })); }; - + ws.onmessage = (event) => { try { const message = JSON.parse(event.data); - + if (message.type === 'auth_success') { console.log('[WebSocket] Authenticated, subscribing to session', sessionId); ws.send(JSON.stringify({ type: 'subscribe', sessionId: parseInt(sessionId) })); + + clearInterval(pingIntervalRef.current); + pingIntervalRef.current = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'ping' })); + } + }, 30000); return; } @@ -1212,45 +1403,108 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame, se 'game.status', ]; - if (message.type === 'poll.leading') { - setLeadingGame(message.data); + if (message.type === 'poll.start') { + pollStartedAtRef.current = message.data.pollStartedAt || new Date().toISOString(); setPollActive(true); + setPollResult(null); + setLeadingGame(null); + setPollEndingAt(null); + setShowEndPollOptions(false); return; } - if (message.type === 'voting.ended' || message.type === 'game.started') { + if (message.type === 'poll.ending') { + setPollEndingAt(message.data.endsAt); + setShowEndPollOptions(false); + return; + } + + if (message.type === 'poll.ending.cancelled') { + setPollEndingAt(null); + return; + } + + if (message.type === 'poll.leading') { + if (pollActiveRef.current) { + setLeadingGame(message.data); + } + return; + } + + if (message.type === 'voting.ended') { setLeadingGame(null); setPollActive(false); + setPollEndingAt(null); + setShowEndPollOptions(false); + if (message.data.winnerGameId) { + setPollResult({ + gameId: message.data.winnerGameId, + label: message.data.winnerLabel, + votes: message.data.winnerVotes + }); + } + } + + if (message.type === 'game.started') { + setLeadingGame(null); + setPollActive(false); + setPollEndingAt(null); + } + + if (message.type === 'game.picked') { + setSelectedGame(message.data.game); + setGameSource(message.data.source || 'dice'); + return; + } + + if (message.type === 'game.dismissed') { + setSelectedGame(null); + setGameSource('dice'); + return; } if (reloadEvents.includes(message.type)) { console.log(`[WebSocket] ${message.type}:`, message.data); + if (message.type === 'game.added') { + setSelectedGame(null); + setGameSource('dice'); + } loadGames(); } } catch (error) { console.error('[WebSocket] Error parsing message:', error); } }; - + ws.onerror = (error) => { console.error('[WebSocket] Error:', error); }; - + ws.onclose = () => { - console.log('[WebSocket] Disconnected'); + console.log('[WebSocket] Disconnected, reconnecting in 3s...'); + clearInterval(pingIntervalRef.current); + reconnectTimeoutRef.current = setTimeout(connectWs, 3000); }; - + setWsConnection(ws); - - return () => { - if (ws.readyState === WebSocket.OPEN) { - ws.close(); - } - }; } catch (error) { console.error('[WebSocket] Failed to connect:', error); + reconnectTimeoutRef.current = setTimeout(connectWs, 3000); } - }, [sessionId, token, loadGames]); + }, [sessionId, token, loadGames, setPollActive, setPollResult, setLeadingGame, pollActiveRef, pollStartedAtRef, setSelectedGame, setGameSource]); + + useEffect(() => { + connectWs(); + + return () => { + clearTimeout(reconnectTimeoutRef.current); + clearInterval(pingIntervalRef.current); + if (wsRef.current) { + wsRef.current.onclose = null; + wsRef.current.close(); + } + }; + }, [connectWs]); const handleUpdateStatus = async (gameId, newStatus) => { try { @@ -1476,10 +1730,15 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame, se {displayNumber}. {game.title} {getStatusBadge(game.status)} - {game.manually_added === 1 && ( + {game.source === 'manual' || (game.manually_added === 1 && game.source !== 'poll') ? ( Manual - + + ) : null} + {game.source === 'poll' && ( + + Poll + )} {game.room_code && (
diff --git a/frontend/src/pages/SessionDetail.jsx b/frontend/src/pages/SessionDetail.jsx index 4f9c4ec..1124722 100644 --- a/frontend/src/pages/SessionDetail.jsx +++ b/frontend/src/pages/SessionDetail.jsx @@ -320,10 +320,15 @@ function SessionDetail() {
{formatLocalTime(game.played_at)}
- {game.manually_added === 1 && ( + {game.source === 'manual' || (game.manually_added === 1 && game.source !== 'poll') ? ( Manual + ) : null} + {game.source === 'poll' && ( + + Poll + )}