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:
@@ -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() {
|
||||
</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 */}
|
||||
{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">
|
||||
@@ -744,6 +850,77 @@ function Picker() {
|
||||
</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) */}
|
||||
{selectedGame && (
|
||||
<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}
|
||||
playingGame={playingGame}
|
||||
setPlayingGame={setPlayingGame}
|
||||
setHasPlayedGames={setHasPlayedGames}
|
||||
setLeadingGame={setLeadingGame}
|
||||
setPollActive={setPollActive}
|
||||
/>
|
||||
</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 [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();
|
||||
|
||||
Reference in New Issue
Block a user