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
+
)}