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:
cottongin
2026-05-10 20:33:00 -04:00
parent a1078e0cc7
commit 59db8f6ed7
7 changed files with 596 additions and 68 deletions

View File

@@ -89,6 +89,23 @@ function initializeDatabase() {
} catch (err) { } catch (err) {
// Column already exists, ignore error // 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 // Session games table
db.exec(` db.exec(`
@@ -132,6 +149,13 @@ function initializeDatabase() {
// Column already exists, ignore error // 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 // Add favor_bias column to games if it doesn't exist
try { try {
db.exec(`ALTER TABLE games ADD COLUMN favor_bias INTEGER DEFAULT 0`); db.exec(`ALTER TABLE games ADD COLUMN favor_bias INTEGER DEFAULT 0`);

View File

@@ -10,6 +10,9 @@ const { optionalAuthenticateToken } = require('../middleware/optional-auth');
const router = express.Router(); const router = express.Router();
// Active poll-ending timers keyed by sessionId
const pollEndTimers = new Map();
// Helper function to create a hash of a message // Helper function to create a hash of a message
function createMessageHash(username, message, timestamp) { function createMessageHash(username, message, timestamp) {
return crypto return crypto
@@ -341,13 +344,23 @@ router.post('/:id/voting/start', authenticateToken, (req, res) => {
return res.status(400).json({ error: 'Session is not active' }); 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( 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); ).run(new Date().toISOString(), sessionId);
const wsManager = getWebSocketManager(); const wsManager = getWebSocketManager();
if (wsManager) { if (wsManager) {
wsManager.broadcastEvent('poll.start', { sessionId }, sessionId); wsManager.broadcastEvent('poll.start', {
sessionId,
pollStartedAt: new Date().toISOString()
}, sessionId);
} }
res.json({ success: true }); 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) // 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) => { router.post('/:id/voting/end', authenticateToken, (req, res) => {
try { try {
const sessionId = parseInt(req.params.id); 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' }); 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( 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); ).run(sessionId);
const wsManager = getWebSocketManager(); const wsManager = getWebSocketManager();
if (wsManager) { 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 }); res.json({ success: true });
@@ -553,7 +714,9 @@ router.get('/:id/votes', (req, res) => {
// Add game to session (admin only) // Add game to session (admin only)
router.post('/:id/games', authenticateToken, (req, res) => { router.post('/:id/games', authenticateToken, (req, res) => {
try { 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) { if (!game_id) {
return res.status(400).json({ error: 'game_id is required' }); 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 // Add game to session with 'playing' status
const stmt = db.prepare(` const stmt = db.prepare(`
INSERT INTO session_games (session_id, game_id, manually_added, status, room_code) INSERT INTO session_games (session_id, game_id, manually_added, status, room_code, source)
VALUES (?, ?, ?, 'playing', ?) 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 // Increment play count for the game
db.prepare('UPDATE games SET play_count = play_count + 1 WHERE id = ?').run(game_id); 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, min_players: game.min_players,
max_players: game.max_players, max_players: game.max_players,
manually_added: manually_added || false, 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, type: game.game_type,
played_at: game.played_at, played_at: game.played_at,
manually_added: game.manually_added === 1, manually_added: game.manually_added === 1,
status: game.status status: game.status,
source: game.source || 'dice'
})), })),
chat_logs: chatLogs.map(log => ({ chat_logs: chatLogs.map(log => ({
username: log.chatter_name, username: log.chatter_name,
@@ -1087,7 +1257,9 @@ router.get('/:id/export', authenticateToken, (req, res) => {
} }
text += ` Played: ${game.played_at}\n`; text += ` Played: ${game.played_at}\n`;
text += ` Status: ${game.status}\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`; 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;

View File

@@ -22,6 +22,7 @@ app.get('/health', (req, res) => {
const authRoutes = require('./routes/auth'); const authRoutes = require('./routes/auth');
const gamesRoutes = require('./routes/games'); const gamesRoutes = require('./routes/games');
const sessionsRoutes = require('./routes/sessions'); const sessionsRoutes = require('./routes/sessions');
const { rescheduleEndingPolls } = require('./routes/sessions');
const statsRoutes = require('./routes/stats'); const statsRoutes = require('./routes/stats');
const pickerRoutes = require('./routes/picker'); const pickerRoutes = require('./routes/picker');
const votesRoutes = require('./routes/votes'); const votesRoutes = require('./routes/votes');
@@ -54,6 +55,7 @@ if (require.main === module) {
server.listen(PORT, '0.0.0.0', () => { server.listen(PORT, '0.0.0.0', () => {
console.log(`Server is running on port ${PORT}`); console.log(`Server is running on port ${PORT}`);
console.log(`WebSocket server available at ws://localhost:${PORT}/api/sessions/live`); console.log(`WebSocket server available at ws://localhost:${PORT}/api/sessions/live`);
rescheduleEndingPolls();
}); });
const shutdown = async () => { const shutdown = async () => {

View File

@@ -143,6 +143,8 @@ Must be authenticated.
| `voting.ended` | Host ended the voting/polling period (broadcast to subscribers) | | `voting.ended` | Host ended the voting/polling period (broadcast to subscribers) |
| `poll.start` | Host started a new poll (broadcast to subscribers) | | `poll.start` | Host started a new poll (broadcast to subscribers) |
| `poll.leading` | Current poll leader updated (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 ### voting.ended
- **Broadcast to:** Clients subscribed to the session - **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:** **Data:**
```json ```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 ## 7. Error Handling

View File

@@ -161,10 +161,15 @@ function Home() {
</div> </div>
<div className="text-xs sm:text-sm text-gray-500 dark:text-gray-400 mt-1"> <div className="text-xs sm:text-sm text-gray-500 dark:text-gray-400 mt-1">
({game.pack_name}) ({game.pack_name})
{game.manually_added === 1 && ( {game.source === 'manual' || (game.manually_added === 1 && game.source !== 'poll') ? (
<span className="ml-2 text-xs bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 px-2 py-1 rounded"> <span className="ml-2 text-xs bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 px-2 py-1 rounded">
Manual Manual
</span> </span>
) : null}
{game.source === 'poll' && (
<span className="ml-2 text-xs bg-indigo-100 dark:bg-indigo-900 text-indigo-800 dark:text-indigo-200 px-2 py-1 rounded">
Poll
</span>
)} )}
</div> </div>
</div> </div>

View File

@@ -22,6 +22,11 @@ function Picker() {
const [pollElapsed, setPollElapsed] = useState(0); const [pollElapsed, setPollElapsed] = useState(0);
const pollTimerRef = useRef(null); const pollTimerRef = useRef(null);
const pollStartedAtRef = 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 [loading, setLoading] = useState(true);
const [picking, setPicking] = useState(false); const [picking, setPicking] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -112,6 +117,18 @@ function Picker() {
votes: session.poll_leading_votes, 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 { try {
@@ -184,6 +201,23 @@ function Picker() {
return () => clearInterval(pollTimerRef.current); return () => clearInterval(pollTimerRef.current);
}, [pollActive]); }, [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 formatElapsed = (ms) => {
const minutes = Math.floor(ms / 60000); const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000); 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')}`; 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 () => { const handleCreateSession = async () => {
try { try {
const newSession = await api.post('/sessions', {}); const newSession = await api.post('/sessions', {});
@@ -205,37 +245,75 @@ function Picker() {
const leadingGameRef = useRef(leadingGame); const leadingGameRef = useRef(leadingGame);
leadingGameRef.current = leadingGame; leadingGameRef.current = leadingGame;
const pollActiveRef = useRef(pollActive);
pollActiveRef.current = pollActive;
const handleStartPolling = async () => { const handleStartPolling = async () => {
pollStartedAtRef.current = new Date().toISOString();
setPollActive(true);
setPollResult(null);
try { try {
await api.post(`/sessions/${activeSession.id}/voting/start`); await api.post(`/sessions/${activeSession.id}/voting/start`);
pollStartedAtRef.current = new Date().toISOString();
setPollActive(true);
setPollResult(null);
} catch (err) { } catch (err) {
console.error('Failed to start polling', err); console.error('Failed to start polling', err);
setPollActive(false);
pollStartedAtRef.current = null;
} }
}; };
const handleEndPolling = async () => { const handleEndPolling = async (delay = 0) => {
try { setShowEndPollOptions(false);
await api.post(`/sessions/${activeSession.id}/voting/end`); setCustomDelay('');
if (delay === 0) {
const winner = leadingGameRef.current;
setPollActive(false); setPollActive(false);
setTimeout(() => { setLeadingGame(null);
if (leadingGameRef.current) { setPollEndingAt(null);
setPollResult(leadingGameRef.current); if (winner) {
} setPollResult(winner);
setLeadingGame(null); }
}, 1500); try {
} catch (err) { await api.post(`/sessions/${activeSession.id}/voting/end`, { delay: 0 });
console.error('Failed to end polling', err); } 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 = () => { const handleUsePollResult = () => {
if (pollResult) { if (pollResult) {
const game = allGames.find(g => g.id === pollResult.gameId); const game = allGames.find(g => g.id === pollResult.gameId);
if (game) { if (game) {
setSelectedGame(game); setSelectedGame(game);
setGameSource('poll');
if (activeSession) {
api.post(`/sessions/${activeSession.id}/game-selection`, {
game_id: game.id,
source: 'poll'
}).catch(() => {});
}
} }
} }
setPollResult(null); setPollResult(null);
@@ -245,6 +323,14 @@ function Picker() {
setPollResult(null); setPollResult(null);
}; };
const handleDismissGame = () => {
setSelectedGame(null);
setGameSource('dice');
if (activeSession) {
api.delete(`/sessions/${activeSession.id}/game-selection`).catch(() => {});
}
};
const loadEligibleGames = async () => { const loadEligibleGames = async () => {
try { try {
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -306,9 +392,15 @@ function Picker() {
}); });
setSelectedGame(response.data.game); setSelectedGame(response.data.game);
setGameSource('dice');
api.post(`/sessions/${activeSession.id}/game-selection`, {
game_id: response.data.game.id,
source: 'dice'
}).catch(() => {});
} catch (err) { } catch (err) {
setError(err.response?.data?.error || 'Failed to pick a game'); setError(err.response?.data?.error || 'Failed to pick a game');
setSelectedGame(null); setSelectedGame(null);
setGameSource('dice');
} finally { } finally {
setPicking(false); setPicking(false);
} }
@@ -320,7 +412,8 @@ function Picker() {
// Show room code modal // Show room code modal
setPendingGameAction({ setPendingGameAction({
type: 'accept', type: 'accept',
game: selectedGame game: selectedGame,
source: gameSource
}); });
setShowRoomCodeModal(true); setShowRoomCodeModal(true);
}; };
@@ -329,13 +422,14 @@ function Picker() {
if (!pendingGameAction || !activeSession) return; if (!pendingGameAction || !activeSession) return;
try { try {
const { type, game, gameId } = pendingGameAction; const { type, game, gameId, source } = pendingGameAction;
if (type === 'accept' || type === 'version') { if (type === 'accept' || type === 'version') {
const response = await api.post(`/sessions/${activeSession.id}/games`, { const response = await api.post(`/sessions/${activeSession.id}/games`, {
game_id: gameId || game.id, game_id: gameId || game.id,
manually_added: false, manually_added: false,
room_code: roomCode room_code: roomCode,
source: source || 'dice'
}); });
// Set the newly added game as playing // Set the newly added game as playing
setPlayingGame(response.data); setPlayingGame(response.data);
@@ -343,7 +437,8 @@ function Picker() {
const response = await api.post(`/sessions/${activeSession.id}/games`, { const response = await api.post(`/sessions/${activeSession.id}/games`, {
game_id: gameId, game_id: gameId,
manually_added: true, manually_added: true,
room_code: roomCode room_code: roomCode,
source: 'manual'
}); });
setManualGameId(''); setManualGameId('');
setShowManualSelect(false); setShowManualSelect(false);
@@ -353,6 +448,7 @@ function Picker() {
// Close all modals and clear selected game after adding to session // Close all modals and clear selected game after adding to session
setSelectedGame(null); setSelectedGame(null);
setGameSource('dice');
setShowGamePool(false); setShowGamePool(false);
// Trigger games list refresh // Trigger games list refresh
@@ -878,6 +974,33 @@ function Picker() {
</div> </div>
</div> </div>
</div> </div>
) : pollActive && pollEndingAt ? (
<div className="bg-red-50 dark:bg-red-900/20 border-2 border-red-400 dark:border-red-700 rounded-lg shadow-lg p-4 sm:p-6 mb-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-red-800 dark:text-red-200">
Poll Ending
</h3>
<p className="text-sm text-red-600 dark:text-red-400 mt-1">
Voting will close automatically.
</p>
</div>
<div className="flex items-center gap-3">
<div className="flex flex-col items-center">
<span className="text-sm text-red-700 dark:text-red-300 font-semibold">Ending in</span>
<span className="font-mono text-2xl tracking-wider text-red-800 dark:text-red-100 bg-red-200/60 dark:bg-red-800/40 px-4 py-2 rounded" style={{ fontFamily: "'Courier New', monospace" }}>
{pollCountdown !== null ? formatCountdown(pollCountdown) : '--:--'}
</span>
</div>
<button
onClick={handleCancelPollEnd}
className="bg-gray-500 dark:bg-gray-600 text-white px-4 py-3 rounded-lg hover:bg-gray-600 dark:hover:bg-gray-700 transition font-semibold text-sm whitespace-nowrap"
>
Cancel
</button>
</div>
</div>
</div>
) : pollActive ? ( ) : pollActive ? (
<div className="bg-orange-50 dark:bg-orange-900/20 border-2 border-orange-400 dark:border-orange-700 rounded-lg shadow-lg p-4 sm:p-6 mb-6"> <div className="bg-orange-50 dark:bg-orange-900/20 border-2 border-orange-400 dark:border-orange-700 rounded-lg shadow-lg p-4 sm:p-6 mb-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -889,15 +1012,66 @@ function Picker() {
End the current poll when ready to pick the next game. End the current poll when ready to pick the next game.
</p> </p>
</div> </div>
<button <div className="relative">
onClick={handleEndPolling} <button
className="bg-orange-600 dark:bg-orange-700 text-white px-6 py-4 rounded-lg hover:bg-orange-700 dark:hover:bg-orange-800 transition font-semibold whitespace-nowrap flex flex-col items-center gap-1" onClick={() => setShowEndPollOptions(prev => !prev)}
> className="bg-orange-600 dark:bg-orange-700 text-white px-6 py-4 rounded-lg hover:bg-orange-700 dark:hover:bg-orange-800 transition font-semibold whitespace-nowrap flex flex-col items-center gap-1"
<span className="text-sm">End Poll</span> >
<span className="font-mono text-lg tracking-wider bg-black/20 px-3 py-1 rounded" style={{ fontFamily: "'Courier New', monospace" }}> <span className="text-sm">End Poll</span>
{formatElapsed(pollElapsed)} <span className="font-mono text-lg tracking-wider bg-black/20 px-3 py-1 rounded" style={{ fontFamily: "'Courier New', monospace" }}>
</span> {formatElapsed(pollElapsed)}
</button> </span>
</button>
{showEndPollOptions && (
<div className="absolute right-0 top-full mt-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl z-50 w-56 py-1">
<button
onClick={() => handleEndPolling(0)}
className="w-full text-left px-4 py-2.5 text-sm font-semibold text-red-700 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 transition"
>
End Now
</button>
<hr className="border-gray-200 dark:border-gray-700 my-1" />
{[5, 10, 30].map(s => (
<button
key={s}
onClick={() => handleEndPolling(s)}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition"
>
{s} seconds
</button>
))}
<hr className="border-gray-200 dark:border-gray-700 my-1" />
<div className="px-4 py-2 flex items-center gap-2">
<input
type="number"
min="1"
max="300"
placeholder="sec"
value={customDelay}
onChange={e => 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"
/>
<button
onClick={() => {
const val = parseInt(customDelay);
if (val >= 1 && val <= 300) handleEndPolling(val);
}}
disabled={!customDelay || parseInt(customDelay) < 1 || parseInt(customDelay) > 300}
className="px-3 py-1.5 text-sm bg-orange-600 dark:bg-orange-700 text-white rounded hover:bg-orange-700 dark:hover:bg-orange-800 transition disabled:opacity-40 disabled:cursor-not-allowed font-semibold"
>
Go
</button>
<span className="text-xs text-gray-400">max 5m</span>
</div>
</div>
)}
</div>
</div> </div>
</div> </div>
) : ( ) : (
@@ -926,7 +1100,7 @@ function Picker() {
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 sm:p-8 mb-6 relative"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 sm:p-8 mb-6 relative">
{/* Close/Dismiss Button */} {/* Close/Dismiss Button */}
<button <button
onClick={() => setSelectedGame(null)} onClick={handleDismissGame}
className="absolute top-2 right-2 sm:top-4 sm:right-4 w-8 h-8 flex items-center justify-center text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full transition" className="absolute top-2 right-2 sm:top-4 sm:right-4 w-8 h-8 flex items-center justify-center text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full transition"
title="Dismiss" title="Dismiss"
> >
@@ -999,7 +1173,7 @@ function Picker() {
🎲 Re-roll 🎲 Re-roll
</button> </button>
<button <button
onClick={() => setSelectedGame(null)} onClick={handleDismissGame}
className="bg-gray-500 dark:bg-gray-600 text-white px-4 py-3 rounded-lg hover:bg-gray-600 dark:hover:bg-gray-700 transition font-semibold" className="bg-gray-500 dark:bg-gray-600 text-white px-4 py-3 rounded-lg hover:bg-gray-600 dark:hover:bg-gray-700 transition font-semibold"
title="Cancel" title="Cancel"
> >
@@ -1111,6 +1285,11 @@ function Picker() {
setHasPlayedGames={setHasPlayedGames} setHasPlayedGames={setHasPlayedGames}
setLeadingGame={setLeadingGame} setLeadingGame={setLeadingGame}
setPollActive={setPollActive} setPollActive={setPollActive}
pollActiveRef={pollActiveRef}
setPollResult={setPollResult}
pollStartedAtRef={pollStartedAtRef}
setSelectedGame={setSelectedGame}
setGameSource={setGameSource}
/> />
</div> </div>
</div> </div>
@@ -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 { isAuthenticated, token } = useAuth();
const [games, setGames] = useState([]); const [games, setGames] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -1175,28 +1354,40 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame, se
return () => clearInterval(interval); return () => clearInterval(interval);
}, [loadGames]); }, [loadGames]);
// Setup WebSocket connection for real-time session updates // Setup WebSocket connection for real-time session updates (with ping + auto-reconnect)
useEffect(() => { const wsRef = useRef(null);
const pingIntervalRef = useRef(null);
const reconnectTimeoutRef = useRef(null);
const connectWs = useCallback(() => {
if (!token) return; if (!token) return;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; 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`; const wsUrl = `${protocol}//${window.location.hostname}:${window.location.port || (window.location.protocol === 'https:' ? 443 : 80)}/api/sessions/live`;
try { try {
const ws = new WebSocket(wsUrl); const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => { ws.onopen = () => {
console.log('[WebSocket] Connected, authenticating...'); console.log('[WebSocket] Connected, authenticating...');
ws.send(JSON.stringify({ type: 'auth', token })); ws.send(JSON.stringify({ type: 'auth', token }));
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
try { try {
const message = JSON.parse(event.data); const message = JSON.parse(event.data);
if (message.type === 'auth_success') { if (message.type === 'auth_success') {
console.log('[WebSocket] Authenticated, subscribing to session', sessionId); console.log('[WebSocket] Authenticated, subscribing to session', sessionId);
ws.send(JSON.stringify({ type: 'subscribe', sessionId: parseInt(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; return;
} }
@@ -1212,45 +1403,108 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame, se
'game.status', 'game.status',
]; ];
if (message.type === 'poll.leading') { if (message.type === 'poll.start') {
setLeadingGame(message.data); pollStartedAtRef.current = message.data.pollStartedAt || new Date().toISOString();
setPollActive(true); setPollActive(true);
setPollResult(null);
setLeadingGame(null);
setPollEndingAt(null);
setShowEndPollOptions(false);
return; 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); setLeadingGame(null);
setPollActive(false); 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)) { if (reloadEvents.includes(message.type)) {
console.log(`[WebSocket] ${message.type}:`, message.data); console.log(`[WebSocket] ${message.type}:`, message.data);
if (message.type === 'game.added') {
setSelectedGame(null);
setGameSource('dice');
}
loadGames(); loadGames();
} }
} catch (error) { } catch (error) {
console.error('[WebSocket] Error parsing message:', error); console.error('[WebSocket] Error parsing message:', error);
} }
}; };
ws.onerror = (error) => { ws.onerror = (error) => {
console.error('[WebSocket] Error:', error); console.error('[WebSocket] Error:', error);
}; };
ws.onclose = () => { ws.onclose = () => {
console.log('[WebSocket] Disconnected'); console.log('[WebSocket] Disconnected, reconnecting in 3s...');
clearInterval(pingIntervalRef.current);
reconnectTimeoutRef.current = setTimeout(connectWs, 3000);
}; };
setWsConnection(ws); setWsConnection(ws);
return () => {
if (ws.readyState === WebSocket.OPEN) {
ws.close();
}
};
} catch (error) { } catch (error) {
console.error('[WebSocket] Failed to connect:', 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) => { const handleUpdateStatus = async (gameId, newStatus) => {
try { try {
@@ -1476,10 +1730,15 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame, se
{displayNumber}. {game.title} {displayNumber}. {game.title}
</span> </span>
{getStatusBadge(game.status)} {getStatusBadge(game.status)}
{game.manually_added === 1 && ( {game.source === 'manual' || (game.manually_added === 1 && game.source !== 'poll') ? (
<span className="text-xs bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 px-2 py-1 rounded"> <span className="text-xs bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 px-2 py-1 rounded">
Manual Manual
</span> </span>
) : null}
{game.source === 'poll' && (
<span className="text-xs bg-indigo-100 dark:bg-indigo-900 text-indigo-800 dark:text-indigo-200 px-2 py-1 rounded">
Poll
</span>
)} )}
{game.room_code && ( {game.room_code && (
<div className="flex items-center gap-1 flex-wrap"> <div className="flex items-center gap-1 flex-wrap">

View File

@@ -320,10 +320,15 @@ function SessionDetail() {
<div className="text-sm text-gray-500 dark:text-gray-400"> <div className="text-sm text-gray-500 dark:text-gray-400">
{formatLocalTime(game.played_at)} {formatLocalTime(game.played_at)}
</div> </div>
{game.manually_added === 1 && ( {game.source === 'manual' || (game.manually_added === 1 && game.source !== 'poll') ? (
<span className="inline-block mt-1 text-xs bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 px-2 py-1 rounded"> <span className="inline-block mt-1 text-xs bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 px-2 py-1 rounded">
Manual Manual
</span> </span>
) : null}
{game.source === 'poll' && (
<span className="inline-block mt-1 text-xs bg-indigo-100 dark:bg-indigo-900 text-indigo-800 dark:text-indigo-200 px-2 py-1 rounded">
Poll
</span>
)} )}
</div> </div>
</div> </div>