From 10c34557c59a730998f32e5128089b05d7ddb7f2 Mon Sep 17 00:00:00 2001 From: cottongin Date: Thu, 7 May 2026 20:44:04 -0400 Subject: [PATCH] feat: add poll control UI to Picker with start/end toggle, leading game indicator, and timer Adds a three-state poll control card (Start Poll / End Poll / Poll Result) with an LED-style stopwatch on the End Poll button. Shows the current poll leader from downstream poll.leading WebSocket messages. On poll end, prompts the admin to use the winner as the next game choice or ignore it. Restores poll state from the session on page load for continuity across reloads. Co-authored-by: Cursor --- frontend/src/pages/Picker.jsx | 199 +++++++++++++++++++++++++++++++++- 1 file changed, 196 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/Picker.jsx b/frontend/src/pages/Picker.jsx index 5b82647..72c6625 100644 --- a/frontend/src/pages/Picker.jsx +++ b/frontend/src/pages/Picker.jsx @@ -14,7 +14,14 @@ function Picker() { const [activeSession, setActiveSession] = useState(null); const [allGames, setAllGames] = useState([]); const [selectedGame, setSelectedGame] = useState(null); - const [playingGame, setPlayingGame] = useState(null); // Currently playing game + const [playingGame, setPlayingGame] = useState(null); + const [hasPlayedGames, setHasPlayedGames] = useState(false); + const [leadingGame, setLeadingGame] = useState(null); + const [pollActive, setPollActive] = useState(false); + const [pollResult, setPollResult] = useState(null); + const [pollElapsed, setPollElapsed] = useState(0); + const pollTimerRef = useRef(null); + const pollStartedAtRef = useRef(null); const [loading, setLoading] = useState(true); const [picking, setPicking] = useState(false); const [error, setError] = useState(''); @@ -92,8 +99,21 @@ function Picker() { const gamesResponse = await api.get('/games?enabled=true'); setAllGames(gamesResponse.data); - // Load currently playing game if session exists + // Load currently playing game and restore poll state if session exists if (session && session.id) { + // Restore poll state from persisted session data + if (session.poll_active) { + pollStartedAtRef.current = session.poll_started_at || null; + setPollActive(true); + if (session.poll_leading_game_id) { + setLeadingGame({ + gameId: session.poll_leading_game_id, + label: session.poll_leading_label, + votes: session.poll_leading_votes, + }); + } + } + try { const sessionGamesResponse = await api.get(`/sessions/${session.id}/games`); const playingGameEntry = sessionGamesResponse.data.find(g => g.status === 'playing'); @@ -102,6 +122,7 @@ function Picker() { } else { setPlayingGame(null); } + setHasPlayedGames(sessionGamesResponse.data.some(g => g.status === 'played')); } catch (err) { console.error('Failed to load playing game', err); } @@ -147,6 +168,29 @@ function Picker() { return () => document.removeEventListener('mousedown', handleClickOutside); }, [showManualDropdown]); + useEffect(() => { + if (pollActive) { + const start = pollStartedAtRef.current + ? new Date(pollStartedAtRef.current).getTime() + : Date.now(); + pollTimerRef.current = setInterval(() => { + setPollElapsed(Date.now() - start); + }, 10); + } else { + clearInterval(pollTimerRef.current); + setPollElapsed(0); + pollStartedAtRef.current = null; + } + return () => clearInterval(pollTimerRef.current); + }, [pollActive]); + + const formatElapsed = (ms) => { + const minutes = Math.floor(ms / 60000); + const seconds = Math.floor((ms % 60000) / 1000); + const centiseconds = Math.floor((ms % 1000) / 10); + return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}:${String(centiseconds).padStart(2, '0')}`; + }; + const handleCreateSession = async () => { try { const newSession = await api.post('/sessions', {}); @@ -158,6 +202,49 @@ function Picker() { } }; + const leadingGameRef = useRef(leadingGame); + leadingGameRef.current = leadingGame; + + const handleStartPolling = async () => { + 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); + } + }; + + const handleEndPolling = async () => { + try { + await api.post(`/sessions/${activeSession.id}/voting/end`); + setPollActive(false); + setTimeout(() => { + if (leadingGameRef.current) { + setPollResult(leadingGameRef.current); + } + setLeadingGame(null); + }, 1500); + } catch (err) { + console.error('Failed to end polling', err); + } + }; + + const handleUsePollResult = () => { + if (pollResult) { + const game = allGames.find(g => g.id === pollResult.gameId); + if (game) { + setSelectedGame(game); + } + } + setPollResult(null); + }; + + const handleIgnorePollResult = () => { + setPollResult(null); + }; + const loadEligibleGames = async () => { try { const params = new URLSearchParams(); @@ -687,6 +774,25 @@ function Picker() { )} + {/* Poll Leader Indicator */} + {leadingGame && ( +
+
+
+ + Poll Leader + + + {leadingGame.label} + +
+ + {leadingGame.votes} votes + +
+
+ )} + {/* Currently Playing Game Card */} {playingGame && (
@@ -744,6 +850,77 @@ function Picker() {
)} + {/* Poll Control Card */} + {pollResult ? ( +
+
+
+

+ Poll Winner: {pollResult.label} +

+

+ {pollResult.votes} votes — Use as the next game? +

+
+
+ + +
+
+
+ ) : pollActive ? ( +
+
+
+

+ Voting In Progress +

+

+ End the current poll when ready to pick the next game. +

+
+ +
+
+ ) : ( +
+
+
+

+ Ready to Vote +

+

+ Start a new poll for the next game. +

+
+ +
+
+ )} + {/* Selected Game Card (from dice roll) */} {selectedGame && (
@@ -931,6 +1108,9 @@ function Picker() { onGamesUpdate={gamesUpdateTrigger} playingGame={playingGame} setPlayingGame={setPlayingGame} + setHasPlayedGames={setHasPlayedGames} + setLeadingGame={setLeadingGame} + setPollActive={setPollActive} />
@@ -938,7 +1118,7 @@ function Picker() { ); } -function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame }) { +function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame, setHasPlayedGames, setLeadingGame, setPollActive }) { const { isAuthenticated, token } = useAuth(); const [games, setGames] = useState([]); const [loading, setLoading] = useState(true); @@ -973,6 +1153,8 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame }) setPlayingGame(null); } } + + setHasPlayedGames(freshGames.some(g => g.status === 'played')); } catch (err) { console.error('Failed to load session games'); } finally { @@ -1030,6 +1212,17 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame }) 'game.status', ]; + if (message.type === 'poll.leading') { + setLeadingGame(message.data); + setPollActive(true); + return; + } + + if (message.type === 'voting.ended' || message.type === 'game.started') { + setLeadingGame(null); + setPollActive(false); + } + if (reloadEvents.includes(message.type)) { console.log(`[WebSocket] ${message.type}:`, message.data); loadGames();