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:
@@ -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() {
|
||||
</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 ? (
|
||||
<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">
|
||||
@@ -889,15 +1012,66 @@ function Picker() {
|
||||
End the current poll when ready to pick the next game.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleEndPolling}
|
||||
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" }}>
|
||||
{formatElapsed(pollElapsed)}
|
||||
</span>
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button
|
||||
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" }}>
|
||||
{formatElapsed(pollElapsed)}
|
||||
</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>
|
||||
) : (
|
||||
@@ -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">
|
||||
{/* Close/Dismiss 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"
|
||||
title="Dismiss"
|
||||
>
|
||||
@@ -999,7 +1173,7 @@ function Picker() {
|
||||
🎲 Re-roll
|
||||
</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"
|
||||
title="Cancel"
|
||||
>
|
||||
@@ -1111,6 +1285,11 @@ function Picker() {
|
||||
setHasPlayedGames={setHasPlayedGames}
|
||||
setLeadingGame={setLeadingGame}
|
||||
setPollActive={setPollActive}
|
||||
pollActiveRef={pollActiveRef}
|
||||
setPollResult={setPollResult}
|
||||
pollStartedAtRef={pollStartedAtRef}
|
||||
setSelectedGame={setSelectedGame}
|
||||
setGameSource={setGameSource}
|
||||
/>
|
||||
</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 [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}
|
||||
</span>
|
||||
{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">
|
||||
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 && (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
|
||||
Reference in New Issue
Block a user