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 <cursoragent@cursor.com>
This commit is contained in:
cottongin
2026-05-07 20:44:04 -04:00
parent 9cd601bab2
commit 10c34557c5

View File

@@ -14,7 +14,14 @@ function Picker() {
const [activeSession, setActiveSession] = useState(null); const [activeSession, setActiveSession] = useState(null);
const [allGames, setAllGames] = useState([]); const [allGames, setAllGames] = useState([]);
const [selectedGame, setSelectedGame] = useState(null); 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 [loading, setLoading] = useState(true);
const [picking, setPicking] = useState(false); const [picking, setPicking] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -92,8 +99,21 @@ function Picker() {
const gamesResponse = await api.get('/games?enabled=true'); const gamesResponse = await api.get('/games?enabled=true');
setAllGames(gamesResponse.data); 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) { 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 { try {
const sessionGamesResponse = await api.get(`/sessions/${session.id}/games`); const sessionGamesResponse = await api.get(`/sessions/${session.id}/games`);
const playingGameEntry = sessionGamesResponse.data.find(g => g.status === 'playing'); const playingGameEntry = sessionGamesResponse.data.find(g => g.status === 'playing');
@@ -102,6 +122,7 @@ function Picker() {
} else { } else {
setPlayingGame(null); setPlayingGame(null);
} }
setHasPlayedGames(sessionGamesResponse.data.some(g => g.status === 'played'));
} catch (err) { } catch (err) {
console.error('Failed to load playing game', err); console.error('Failed to load playing game', err);
} }
@@ -147,6 +168,29 @@ function Picker() {
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showManualDropdown]); }, [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 () => { const handleCreateSession = async () => {
try { try {
const newSession = await api.post('/sessions', {}); 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 () => { const loadEligibleGames = async () => {
try { try {
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -687,6 +774,25 @@ function Picker() {
</div> </div>
)} )}
{/* Poll Leader Indicator */}
{leadingGame && (
<div className="bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-200 dark:border-indigo-800 rounded-lg p-3 sm:p-4 mb-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-indigo-500 dark:text-indigo-400 uppercase tracking-wide">
Poll Leader
</span>
<span className="text-sm font-semibold text-indigo-800 dark:text-indigo-200">
{leadingGame.label}
</span>
</div>
<span className="text-xs text-indigo-500 dark:text-indigo-400">
{leadingGame.votes} votes
</span>
</div>
</div>
)}
{/* Currently Playing Game Card */} {/* Currently Playing Game Card */}
{playingGame && ( {playingGame && (
<div className="bg-green-50 dark:bg-green-900/20 border-2 border-green-500 dark:border-green-700 rounded-lg shadow-lg p-4 sm:p-8 mb-6"> <div className="bg-green-50 dark:bg-green-900/20 border-2 border-green-500 dark:border-green-700 rounded-lg shadow-lg p-4 sm:p-8 mb-6">
@@ -744,6 +850,77 @@ function Picker() {
</div> </div>
)} )}
{/* Poll Control Card */}
{pollResult ? (
<div className="bg-indigo-50 dark:bg-indigo-900/20 border-2 border-indigo-400 dark:border-indigo-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-indigo-800 dark:text-indigo-200">
Poll Winner: {pollResult.label}
</h3>
<p className="text-sm text-indigo-600 dark:text-indigo-400 mt-1">
{pollResult.votes} votes — Use as the next game?
</p>
</div>
<div className="flex gap-2">
<button
onClick={handleUsePollResult}
className="bg-green-600 dark:bg-green-700 text-white px-4 py-2 rounded-lg hover:bg-green-700 dark:hover:bg-green-800 transition font-semibold text-sm whitespace-nowrap"
>
Use as Choice
</button>
<button
onClick={handleIgnorePollResult}
className="bg-gray-500 dark:bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-600 dark:hover:bg-gray-700 transition font-semibold text-sm whitespace-nowrap"
>
Ignore
</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">
<div>
<h3 className="text-lg font-semibold text-orange-800 dark:text-orange-200">
Voting In Progress
</h3>
<p className="text-sm text-orange-600 dark:text-orange-400 mt-1">
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>
</div>
) : (
<div className="bg-green-50 dark:bg-green-900/20 border-2 border-green-400 dark:border-green-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-green-800 dark:text-green-200">
Ready to Vote
</h3>
<p className="text-sm text-green-600 dark:text-green-400 mt-1">
Start a new poll for the next game.
</p>
</div>
<button
onClick={handleStartPolling}
className="bg-green-600 dark:bg-green-700 text-white px-5 py-3 rounded-lg hover:bg-green-700 dark:hover:bg-green-800 transition font-semibold text-sm whitespace-nowrap"
>
Start Poll
</button>
</div>
</div>
)}
{/* Selected Game Card (from dice roll) */} {/* Selected Game Card (from dice roll) */}
{selectedGame && ( {selectedGame && (
<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">
@@ -931,6 +1108,9 @@ function Picker() {
onGamesUpdate={gamesUpdateTrigger} onGamesUpdate={gamesUpdateTrigger}
playingGame={playingGame} playingGame={playingGame}
setPlayingGame={setPlayingGame} setPlayingGame={setPlayingGame}
setHasPlayedGames={setHasPlayedGames}
setLeadingGame={setLeadingGame}
setPollActive={setPollActive}
/> />
</div> </div>
</div> </div>
@@ -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 { isAuthenticated, token } = useAuth();
const [games, setGames] = useState([]); const [games, setGames] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -973,6 +1153,8 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame })
setPlayingGame(null); setPlayingGame(null);
} }
} }
setHasPlayedGames(freshGames.some(g => g.status === 'played'));
} catch (err) { } catch (err) {
console.error('Failed to load session games'); console.error('Failed to load session games');
} finally { } finally {
@@ -1030,6 +1212,17 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame })
'game.status', '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)) { if (reloadEvents.includes(message.type)) {
console.log(`[WebSocket] ${message.type}:`, message.data); console.log(`[WebSocket] ${message.type}:`, message.data);
loadGames(); loadGames();