feat: poll countdown timer, game-selection sync, source tracking, and multi-admin fixes
Work spanning May 7-10 across multiple sessions:
Poll winner detection + source column (May 7):
- Fix race condition in handleEndPolling where WS voting.ended cleared
leadingGame before the setTimeout could capture the winner
- Add pollActiveRef guard to prevent late poll.leading messages from
re-activating an ended poll
- Add 'source' column to session_games (dice/manual/poll) with backward-
compatible fallback from manually_added flag
- Show indigo "Poll" badge in game lists (Picker, Home, SessionDetail)
- Include source in session export (JSON and text formats)
Multi-admin poll state sync (May 9):
- Enrich poll.start broadcast with pollStartedAt timestamp so all admin
clients can start their timers from the correct time
- Enrich voting.ended broadcast with winnerGameId/Label/Votes so all
admins see the winner prompt, not just the one who clicked End Poll
- Add poll.start WS handler in SessionInfo so Admin B sees polls started
by Admin A without refreshing
- Make handleStartPolling optimistic with rollback on failure
WebSocket keepalive + auto-reconnect (May 9):
- Add 30s ping interval to SessionInfo WS connection (matching server's
60s timeout) to prevent silent disconnects
- Add auto-reconnect on close with 3s delay
- Proper cleanup of ping interval, reconnect timeout, and onclose handler
Sync selected game across admin clients (May 10):
- New POST/DELETE /sessions/:id/game-selection endpoints with DB
persistence (pending_game_id, pending_game_source columns)
- Broadcast game.picked/game.dismissed WS events to session subscribers
- handleDismissGame replaces inline setSelectedGame(null) calls
- Restore pending game selection on page load for late-joining admins
- Clear pending selection when game is formally added to session
Poll ending countdown timer (May 10):
- POST /:id/voting/end now accepts optional { delay } (0-300 seconds)
- New POST /:id/voting/cancel-end to abort a scheduled end
- New poll.ending and poll.ending.cancelled WS events
- poll_ending_at column on sessions table for crash recovery
- rescheduleEndingPolls() called on server startup to resume countdowns
- End Poll button opens popover with End Now / 5s / 10s / 30s / custom
- Red "Poll Ending" card with countdown display and Cancel button
- Document new WS events in docs/api/websocket.md
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user