Compare commits

..

11 Commits

Author SHA1 Message Date
cottongin
c24223be5c feat: add TMP3 support and fix shard probe lifecycle bug
Add Trivia Murder Party 3 (Playtest) ticker and seed data. Bind-mount
tickers.json in docker-compose so ticker changes take effect on restart
without image rebuilds.

Fix shard monitor probe poisoning game lifecycle state — the player-count
probe no longer sets gameFinished, which was preventing the main WS from
processing room/lock and room/exit for games like TMP3 that don't send
entity updates.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 12:45:59 -04:00
cottongin
a2164c8242 fix: pass missing poll state setters to SessionInfo WebSocket handler
setPollEndingAt and setShowEndPollOptions were referenced in
SessionInfo's WS handler but never passed as props, causing a
ReferenceError that prevented setPollResult from executing on
delayed poll ends. Bumps version to 0.7.12.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 02:03:57 -04:00
cottongin
1d7395e121 fix: clear stale pollEndingAt state when starting a new poll
After a countdown-based poll end, pollEndingAt could remain set as a
stale value during the optimistic update in handleStartPolling, causing
the new poll to briefly render in the "Poll Ending" state with 0:00.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 21:05:59 -04:00
cottongin
26ce643351 chore: bump version to 0.7.11
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 20:40:00 -04:00
cottongin
59db8f6ed7 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>
2026-05-10 20:33:00 -04:00
cottongin
a1078e0cc7 feat: add alarm sounds and sound controls to countdown timer widget
- Add four synthesized alarm sounds (Digital Beep, Gentle Chime, Urgent
  Alarm, Bell) using Web Audio API oscillators
- Add sound enable/mute toggle button and sound selector dropdown
- Add preview button to test selected alarm before countdown
- Play selected alarm sound when countdown expires (if enabled)
- Default widget mode to 'timer' instead of 'stopwatch'
- Extract shared inputClass constant for timer input styling
- Hide native number input spinners via Tailwind utility classes

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 14:37:00 -04:00
cottongin
4bbc1856f5 chore: bump version to 0.7.7 - Pokémon-Go-To-The-Polls Edition
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 20:44:30 -04:00
cottongin
4be520476c feat: add draggable stopwatch widget with lap counter and countdown timer
New general-purpose floating stopwatch accessible from a clock icon in
the nav bar. Supports two modes: stopwatch (count up with laps) and
countdown timer (presets + custom input, red flash on expiry). Desktop
renders as a compact draggable card; mobile docks to the bottom as a
fixed sheet. Timer persists while hidden.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 20:44:18 -04:00
cottongin
10c34557c5 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>
2026-05-07 20:44:04 -04:00
cottongin
9cd601bab2 feat: add poll start/end endpoints, poll.leading WS handler, and poll state persistence
Adds POST /:id/voting/start and POST /:id/voting/end endpoints that
broadcast poll lifecycle events and persist poll state to the sessions
table. The poll.leading WebSocket message is now handled server-side
(rebroadcast + DB persist) with self-healing for polls started before
the persistence columns existed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 20:43:39 -04:00
cottongin
1c9f0ef280 docs: add game-status-by-session guide and external downstream clients reference
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 20:43:10 -04:00
18 changed files with 1715 additions and 43 deletions

View File

@@ -8,6 +8,7 @@
"FBG4": "Fibbage 4",
"TMP1": "Trivia Murder Party",
"TMP2": "Trivia Murder Party 2",
"TMP3": "Trivia Murder Party 3 (Playtest)",
"DRWF": "Drawful",
"DRWA": "Drawful Animate",
"DD": "Dirty Drawful",

View File

@@ -63,6 +63,50 @@ function initializeDatabase() {
// Column already exists, ignore error
}
// Poll state columns on sessions
try {
db.exec(`ALTER TABLE sessions ADD COLUMN poll_active INTEGER DEFAULT 0`);
} catch (err) {
// Column already exists, ignore error
}
try {
db.exec(`ALTER TABLE sessions ADD COLUMN poll_started_at TEXT`);
} catch (err) {
// Column already exists, ignore error
}
try {
db.exec(`ALTER TABLE sessions ADD COLUMN poll_leading_game_id INTEGER`);
} catch (err) {
// Column already exists, ignore error
}
try {
db.exec(`ALTER TABLE sessions ADD COLUMN poll_leading_label TEXT`);
} catch (err) {
// Column already exists, ignore error
}
try {
db.exec(`ALTER TABLE sessions ADD COLUMN poll_leading_votes INTEGER`);
} catch (err) {
// 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
db.exec(`
CREATE TABLE IF NOT EXISTS session_games (
@@ -105,6 +149,13 @@ function initializeDatabase() {
// 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
try {
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();
// Active poll-ending timers keyed by sessionId
const pollEndTimers = new Map();
// Helper function to create a hash of a message
function createMessageHash(username, message, timestamp) {
return crypto
@@ -327,6 +330,222 @@ router.post('/:id/close', authenticateToken, (req, res) => {
}
});
// Start voting/polling for a session (broadcasts poll.start to subscribers)
router.post('/:id/voting/start', 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' });
}
if (session.is_active === 0) {
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(
'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);
const wsManager = getWebSocketManager();
if (wsManager) {
wsManager.broadcastEvent('poll.start', {
sessionId,
pollStartedAt: new Date().toISOString()
}, sessionId);
}
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 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)
// Accepts optional { delay } in body (seconds, 0-300). 0 or omitted = immediate.
router.post('/:id/voting/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' });
}
if (session.is_active === 0) {
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(
'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);
const wsManager = getWebSocketManager();
if (wsManager) {
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 });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Delete session (admin only)
router.delete('/:id', authenticateToken, (req, res) => {
try {
@@ -495,7 +714,9 @@ router.get('/:id/votes', (req, res) => {
// Add game to session (admin only)
router.post('/:id/games', authenticateToken, (req, res) => {
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) {
return res.status(400).json({ error: 'game_id is required' });
@@ -539,11 +760,16 @@ router.post('/:id/games', authenticateToken, (req, res) => {
// Add game to session with 'playing' status
const stmt = db.prepare(`
INSERT INTO session_games (session_id, game_id, manually_added, status, room_code)
VALUES (?, ?, ?, 'playing', ?)
INSERT INTO session_games (session_id, game_id, manually_added, status, room_code, source)
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
db.prepare('UPDATE games SET play_count = play_count + 1 WHERE id = ?').run(game_id);
@@ -586,7 +812,8 @@ router.post('/:id/games', authenticateToken, (req, res) => {
min_players: game.min_players,
max_players: game.max_players,
manually_added: manually_added || false,
room_code: room_code || null
room_code: room_code || null,
source: gameSource
}
};
@@ -988,7 +1215,8 @@ router.get('/:id/export', authenticateToken, (req, res) => {
type: game.game_type,
played_at: game.played_at,
manually_added: game.manually_added === 1,
status: game.status
status: game.status,
source: game.source || 'dice'
})),
chat_logs: chatLogs.map(log => ({
username: log.chatter_name,
@@ -1029,7 +1257,9 @@ router.get('/:id/export', authenticateToken, (req, res) => {
}
text += ` Played: ${game.played_at}\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`;
}
});
@@ -1205,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 gamesRoutes = require('./routes/games');
const sessionsRoutes = require('./routes/sessions');
const { rescheduleEndingPolls } = require('./routes/sessions');
const statsRoutes = require('./routes/stats');
const pickerRoutes = require('./routes/picker');
const votesRoutes = require('./routes/votes');
@@ -54,6 +55,7 @@ if (require.main === module) {
server.listen(PORT, '0.0.0.0', () => {
console.log(`Server is running on port ${PORT}`);
console.log(`WebSocket server available at ws://localhost:${PORT}/api/sessions/live`);
rescheduleEndingPolls();
});
const shutdown = async () => {

View File

@@ -172,8 +172,9 @@ class EcastShardClient {
this.playerCount = playerCount;
this.playerNames = playerNames;
}
} else if (msg.opcode === 'error' && msg.result?.code === 2027) {
this.gameFinished = true;
} else if (msg.opcode === 'error') {
// Probe errors are transient — lifecycle is managed
// by the main connection and reconnect logic
}
} catch (_) {}
clearTimeout(timeout);
@@ -562,6 +563,8 @@ class EcastShardClient {
finalPlayerCount: this.playerCount,
});
activeShards.delete(`${this.sessionId}-${this.gameId}`);
this.gameFinished = true;
this.disconnect();
return false;
} finally {
this.reconnecting = false;

View File

@@ -1,6 +1,7 @@
const { WebSocketServer } = require('ws');
const jwt = require('jsonwebtoken');
const { JWT_SECRET } = require('../middleware/auth');
const db = require('../database');
/**
* WebSocket Manager for handling real-time session events
@@ -111,6 +112,35 @@ class WebSocketManager {
clientInfo.currentPage = message.page || null;
this.broadcastPresence();
break;
case 'poll.leading':
if (!clientInfo.authenticated) {
this.sendError(ws, 'Not authenticated');
return;
}
if (message.sessionId) {
this.broadcastEvent('poll.leading', {
sessionId: message.sessionId,
gameId: message.gameId,
label: message.label,
votes: message.votes
}, message.sessionId);
try {
const result = db.prepare(
'UPDATE sessions SET poll_leading_game_id = ?, poll_leading_label = ?, poll_leading_votes = ? WHERE id = ? AND poll_active = 1'
).run(message.gameId, message.label, message.votes, message.sessionId);
if (result.changes === 0) {
// Self-heal: poll.leading arrived but poll_active is 0 (poll started before persistence fix)
db.prepare(
'UPDATE sessions SET poll_active = 1, poll_started_at = ?, poll_leading_game_id = ?, poll_leading_label = ?, poll_leading_votes = ? WHERE id = ? AND is_active = 1'
).run(new Date().toISOString(), message.gameId, message.label, message.votes, message.sessionId);
console.log(`[WebSocket] Self-healed poll state for session ${message.sessionId}`);
}
} catch (err) {
console.error('[WebSocket] Failed to persist poll.leading:', err.message);
}
}
break;
default:
this.sendError(ws, `Unknown message type: ${message.type}`);

View File

@@ -17,6 +17,7 @@ services:
- jackbox-data:/app/data
- ./games-list.csv:/app/games-list.csv:ro,z
- ./backend/config/admins.json:/app/config/admins.json:ro,z
- ./backend/config/tickers.json:/app/config/tickers.json:ro,z
ports:
- "5000:5000"
networks:

View File

@@ -179,4 +179,4 @@ Most list endpoints return full result sets. The exception is `GET /api/votes`,
- [OpenAPI Spec](openapi.yaml)
- **Endpoint docs**: [Auth](endpoints/auth.md), [Games](endpoints/games.md), [Sessions](endpoints/sessions.md), [Picker](endpoints/picker.md), [Stats](endpoints/stats.md), [Votes](endpoints/votes.md), [Webhooks](endpoints/webhooks.md)
- [WebSocket Protocol](websocket.md)
- **Guides**: [Getting Started](guides/getting-started.md), [Session Lifecycle](guides/session-lifecycle.md), [Voting & Popularity](guides/voting-and-popularity.md), [Webhooks & Events](guides/webhooks-and-events.md)
- **Guides**: [Getting Started](guides/getting-started.md), [Session Lifecycle](guides/session-lifecycle.md), [Game Status by Session](guides/game-status-by-session.md), [Voting & Popularity](guides/voting-and-popularity.md), [Webhooks & Events](guides/webhooks-and-events.md)

View File

@@ -0,0 +1,187 @@
# Game Status by Session
How to build a complete view of all enabled games—with popularity data and whether each game has been played in the current session. No single endpoint returns all three pieces today; this guide shows how to combine two public endpoints and join the results client-side.
---
## 1. Fetch Enabled Games
`GET /api/games?enabled=true` returns every enabled game in the catalog. Each row includes `popularity_score`, `upvotes`, `downvotes`, `play_count`, and `favor_bias`.
**Why:** This gives you the full list of games that are available for play, along with their cumulative popularity data across all sessions.
See [Games endpoints](../endpoints/games.md) for all available filters (`playerCount`, `drawing`, `length`, `familyFriendly`, `pack`).
```bash
curl "http://localhost:5000/api/games?enabled=true"
```
**Sample response (200 OK):**
```json
[
{
"id": 1,
"pack_name": "Jackbox Party Pack 7",
"title": "Quiplash 3",
"min_players": 3,
"max_players": 8,
"length_minutes": 15,
"has_audience": 1,
"family_friendly": 0,
"game_type": "Writing",
"secondary_type": null,
"play_count": 4,
"popularity_score": 7,
"upvotes": 10,
"downvotes": 3,
"enabled": 1,
"favor_bias": 0,
"created_at": "2024-01-15T12:00:00.000Z"
},
{
"id": 5,
"pack_name": "Jackbox Party Pack 9",
"title": "Fibbage 4",
"min_players": 2,
"max_players": 8,
"length_minutes": 15,
"has_audience": 1,
"family_friendly": 1,
"game_type": "Trivia",
"secondary_type": null,
"play_count": 2,
"popularity_score": 3,
"upvotes": 5,
"downvotes": 2,
"enabled": 1,
"favor_bias": 1,
"created_at": "2024-01-15T12:00:00.000Z"
}
]
```
---
## 2. Fetch Session Games
`GET /api/sessions/{id}/games` returns games that have been added to a specific session. Each row includes `status` (`playing`, `played`, or `skipped`), `played_at`, and joined popularity fields from the game catalog.
**Why:** This tells you which games have already been played (or are currently playing) in the session, so you can mark them in the catalog.
See [Sessions endpoints](../endpoints/sessions.md) for full details on session game fields.
```bash
curl "http://localhost:5000/api/sessions/5/games"
```
**Sample response (200 OK):**
```json
[
{
"id": 14,
"session_id": 5,
"game_id": 1,
"played_at": "2026-03-15T20:30:00.000Z",
"manually_added": 0,
"status": "played",
"room_code": "LSBN",
"player_count": 6,
"player_count_check_status": "completed",
"pack_name": "Jackbox Party Pack 7",
"title": "Quiplash 3",
"game_type": "Writing",
"min_players": 3,
"max_players": 8,
"popularity_score": 7,
"upvotes": 10,
"downvotes": 3
}
]
```
Key fields for the join:
| Field | Purpose |
|-------|---------|
| `game_id` | Matches `id` in the games catalog |
| `status` | `playing`, `played`, or `skipped` |
| `played_at` | When the game was added to the session |
---
## 3. Combine Client-Side
Join the two responses by matching `game_id` (from session games) to `id` (from the catalog). This produces a single list of enabled games annotated with their session play status.
```javascript
const gamesRes = await fetch('/api/games?enabled=true');
const games = await gamesRes.json();
const sessionGamesRes = await fetch('/api/sessions/5/games');
const sessionGames = await sessionGamesRes.json();
const sessionGameMap = new Map(
sessionGames.map(sg => [sg.game_id, sg])
);
const combined = games.map(game => {
const sessionEntry = sessionGameMap.get(game.id);
return {
...game,
playedInSession: !!sessionEntry,
sessionStatus: sessionEntry?.status ?? null,
sessionPlayedAt: sessionEntry?.played_at ?? null,
};
});
```
**Sample merged output (one entry):**
```json
{
"id": 1,
"pack_name": "Jackbox Party Pack 7",
"title": "Quiplash 3",
"min_players": 3,
"max_players": 8,
"length_minutes": 15,
"has_audience": 1,
"family_friendly": 0,
"game_type": "Writing",
"play_count": 4,
"popularity_score": 7,
"upvotes": 10,
"downvotes": 3,
"enabled": 1,
"favor_bias": 0,
"playedInSession": true,
"sessionStatus": "played",
"sessionPlayedAt": "2026-03-15T20:30:00.000Z"
}
```
Games that have not been played in the session will have `playedInSession: false`, `sessionStatus: null`, and `sessionPlayedAt: null`.
---
## Quick Reference
| Data point | `GET /api/games` | `GET /api/sessions/{id}/games` |
|------------|------------------|-------------------------------|
| Enabled status | `enabled` field + `?enabled=true` filter | Not included |
| Popularity score | `popularity_score`, `upvotes`, `downvotes` | `popularity_score`, `upvotes`, `downvotes` (joined) |
| All-time play count | `play_count` | Not included |
| Played in session | Not included | `status` field (`playing`/`played`/`skipped`) |
| Auth required | No | No |
---
## Related Documentation
- [Games endpoints](../endpoints/games.md) — full catalog CRUD, filters, favor bias
- [Sessions endpoints](../endpoints/sessions.md) — session games, status updates, room codes
- [Picker endpoint](../endpoints/picker.md) — weighted random selection with session exclusion
- [Stats endpoint](../endpoints/stats.md) — `mostPlayedGames`, `topRatedGames`
- [Voting & Popularity guide](voting-and-popularity.md) — how votes and popularity scores work

View File

@@ -90,6 +90,7 @@ Obtain a JWT by calling `POST /api/auth/login` with your admin key.
| `subscribe` | `sessionId` | Subscribe to a session's events |
| `unsubscribe`| `sessionId` | Unsubscribe from a session |
| `ping` | — | Heartbeat; server responds with `pong` |
| `poll.leading` | `sessionId`, `gameId`, `label`, `votes` | Report current poll leader (rebroadcast to subscribers) |
### auth
```json
@@ -139,6 +140,11 @@ Must be authenticated.
| `game.status` | Periodic game state heartbeat every 20s (broadcast to subscribers) |
| `player-count.updated` | Manual player count override (broadcast to subscribers) |
| `vote.received` | Live vote recorded (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.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) |
---
@@ -384,6 +390,84 @@ Possible `reason` values: `room_closed`, `room_not_found`, `connection_failed`,
}
```
### voting.ended
- **Broadcast to:** Clients subscribed to the session
- **Triggered by:** `POST /api/sessions/:id/voting/end` (host ends the voting/polling period, either immediately or when a countdown reaches zero)
**Data:**
```json
{
"sessionId": 3,
"winnerGameId": 42,
"winnerLabel": "Quiplash 3",
"winnerVotes": 7
}
```
### poll.start
- **Broadcast to:** Clients subscribed to the session
- **Triggered by:** `POST /api/sessions/:id/voting/start` (host starts a new poll)
**Data:**
```json
{
"sessionId": 3
}
```
### poll.leading
- **Broadcast to:** Clients subscribed to the session
- **Triggered by:** Downstream voting client sends `poll.leading` message (rebroadcast to all session subscribers)
**Data:**
```json
{
"sessionId": 3,
"gameId": 42,
"label": "Quiplash 3",
"votes": 7
}
```
### 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

View File

@@ -0,0 +1,84 @@
# Manual Poll Start — Upstream Integration Guide
## Overview
The vote-app now supports a `poll.start` WebSocket message that explicitly triggers poll generation. This replaces the previous behavior where polls were always auto-generated on `game.ended`.
## Poll Modes
The vote-app has two poll modes:
| Mode | `game.ended` behavior | `poll.start` behavior |
|------|----------------------|----------------------|
| **manual** (default) | Logged but no poll generated | Generates a new poll |
| **auto** | Generates a new poll (legacy behavior) | Generates a new poll |
The mode is persisted in the vote-app database and survives restarts. It can be toggled from the debug panel (Game Control section).
## Message Format
Send this JSON message over the existing upstream WebSocket connection:
```json
{
"type": "poll.start",
"sessionId": 3
}
```
### Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `type` | string | yes | Must be `"poll.start"` |
| `sessionId` | number | no | Included for consistency; the vote-app uses its internally tracked session ID |
The `sessionId` field is optional in practice — the vote-app tracks the active session from `session.started` / subscription events and uses that to determine which games to exclude from the poll. Including it is recommended for protocol consistency.
## When to Send
- **After `game.ended`** — if the vote-app is in `manual` mode, `game.ended` alone will not create a poll. Send `poll.start` when you're ready for viewers to vote on the next game.
- **At any time** — you can send `poll.start` to force a new poll regardless of mode. Any existing active poll is deactivated and replaced.
## What Happens on Receipt
1. The vote-app fetches enabled games from the upstream API (`GET /api/games?enabled=true`)
2. It fetches the current session's games (`GET /api/sessions/{id}/games`) to exclude recently played titles
3. It picks 3 random games (weighted by favor bias) plus an "Other" option
4. The previous active poll (if any) is deactivated
5. The new poll is created and broadcast to all connected browser clients via WebSocket
## Example (Node.js)
```javascript
// Assuming `ws` is your existing WebSocket connection to the vote-app upstream
// and you've already authenticated and subscribed to the session
function startPoll(sessionId) {
ws.send(JSON.stringify({
type: 'poll.start',
sessionId: sessionId
}));
}
// Typical flow: game ends, wait for the right moment, then start the poll
ws.on('message', (raw) => {
const msg = JSON.parse(raw);
if (msg.type === 'game.ended') {
// Game just ended — start poll when ready
// (in manual mode, vote-app won't auto-generate one)
startPoll(msg.data.sessionId);
}
});
```
## Existing Messages (Unchanged)
These upstream messages continue to work as before:
- `game.ended` — in `auto` mode, still triggers poll generation; in `manual` mode, logged but no poll created
- `voting.ended` — deactivates the active poll and broadcasts the winner
- `game.started` — deactivates the active poll and hides the overlay
- `room.connected` — deactivates the active poll and broadcasts room info
- `poll.leading` — still sent by the vote-app to upstream when the leading vote changes

View File

@@ -5,6 +5,7 @@ import { ToastProvider } from './components/Toast';
import { branding } from './config/branding';
import Logo from './components/Logo';
import ThemeToggle from './components/ThemeToggle';
import StopwatchWidget from './components/StopwatchWidget';
import InstallPrompt from './components/InstallPrompt';
import SafariInstallPrompt from './components/SafariInstallPrompt';
import PresenceBar from './components/PresenceBar';
@@ -18,6 +19,7 @@ import SessionDetail from './pages/SessionDetail';
function App() {
const { isAuthenticated, logout } = useAuth();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [stopwatchVisible, setStopwatchVisible] = useState(false);
const closeMobileMenu = () => setMobileMenuOpen(false);
@@ -72,12 +74,37 @@ function App() {
</Link>
)}
{/* Stopwatch Toggle */}
<button
onClick={() => setStopwatchVisible(v => !v)}
title="Stopwatch"
className="p-1.5 hover:bg-indigo-700 dark:hover:bg-indigo-900 rounded transition"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<circle cx="12" cy="13" r="8" />
<path strokeLinecap="round" d="M12 9v4l2 2" />
<path strokeLinecap="round" d="M12 5V3" />
<path strokeLinecap="round" d="M10 3h4" />
</svg>
</button>
{/* Theme Toggle */}
<ThemeToggle />
</div>
{/* Mobile: Theme Toggle and Hamburger */}
{/* Mobile: Theme Toggle, Stopwatch, and Hamburger */}
<div className="flex sm:hidden items-center gap-2">
<button
onClick={() => setStopwatchVisible(v => !v)}
title="Stopwatch"
className="p-1.5 hover:bg-indigo-700 dark:hover:bg-indigo-900 rounded transition"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<circle cx="12" cy="13" r="8" />
<path strokeLinecap="round" d="M12 9v4l2 2" />
<path strokeLinecap="round" d="M12 5V3" />
<path strokeLinecap="round" d="M10 3h4" />
</svg>
</button>
<ThemeToggle />
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
@@ -189,6 +216,9 @@ function App() {
{/* PWA Install Prompts */}
<InstallPrompt />
<SafariInstallPrompt />
{/* Stopwatch Widget */}
<StopwatchWidget visible={stopwatchVisible} onHide={() => setStopwatchVisible(false)} />
</div>
</ToastProvider>
);

View File

@@ -0,0 +1,482 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
function useMediaQuery(query) {
const [matches, setMatches] = useState(() => window.matchMedia(query).matches);
useEffect(() => {
const mql = window.matchMedia(query);
const handler = (e) => setMatches(e.matches);
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, [query]);
return matches;
}
function formatTime(ms, showCentiseconds = true) {
const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
if (!showCentiseconds) {
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}
const cs = Math.floor((ms % 1000) / 10);
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}:${String(cs).padStart(2, '0')}`;
}
const COUNTDOWN_PRESETS = [
{ label: '1:00', ms: 60000 },
{ label: '2:00', ms: 120000 },
{ label: '3:00', ms: 180000 },
{ label: '5:00', ms: 300000 },
];
const ALARM_SOUNDS = [
{
id: 'digital',
label: 'Digital Beep',
play: () => {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const beep = (startTime) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = 830;
osc.type = 'square';
gain.gain.value = 0.3;
osc.start(startTime);
osc.stop(startTime + 0.15);
};
beep(ctx.currentTime);
beep(ctx.currentTime + 0.25);
beep(ctx.currentTime + 0.5);
},
},
{
id: 'chime',
label: 'Gentle Chime',
play: () => {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
[523, 659, 784].forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = freq;
osc.type = 'sine';
gain.gain.setValueAtTime(0.25, ctx.currentTime + i * 0.3);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + i * 0.3 + 0.4);
osc.start(ctx.currentTime + i * 0.3);
osc.stop(ctx.currentTime + i * 0.3 + 0.4);
});
},
},
{
id: 'urgent',
label: 'Urgent Alarm',
play: () => {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
for (let i = 0; i < 5; i++) {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = 1000;
osc.type = 'sawtooth';
gain.gain.value = 0.2;
osc.start(ctx.currentTime + i * 0.12);
osc.stop(ctx.currentTime + i * 0.12 + 0.08);
}
},
},
{
id: 'bell',
label: 'Bell',
play: () => {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = 660;
osc.type = 'sine';
gain.gain.setValueAtTime(0.4, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 1.5);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + 1.5);
},
},
];
const inputClass = 'w-10 bg-gray-800 border border-gray-600 rounded px-1 py-0.5 text-center text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none';
export default function StopwatchWidget({ visible, onHide }) {
const isDesktop = useMediaQuery('(min-width: 640px)');
const [mode, setMode] = useState('timer');
// Stopwatch state
const [swRunning, setSwRunning] = useState(false);
const [swElapsed, setSwElapsed] = useState(0);
const [laps, setLaps] = useState([]);
const swIntervalRef = useRef(null);
const swStartRef = useRef(0);
// Countdown state
const [cdTarget, setCdTarget] = useState(60000);
const [cdRemaining, setCdRemaining] = useState(60000);
const [cdRunning, setCdRunning] = useState(false);
const [cdExpired, setCdExpired] = useState(false);
const [cdInputMin, setCdInputMin] = useState('1');
const [cdInputSec, setCdInputSec] = useState('00');
const cdIntervalRef = useRef(null);
const cdStartRef = useRef(0);
const cdRemainingAtStartRef = useRef(0);
// Sound state
const [soundEnabled, setSoundEnabled] = useState(true);
const [selectedSound, setSelectedSound] = useState('digital');
// Drag state (desktop only)
const [position, setPosition] = useState({ x: window.innerWidth - 300, y: 80 });
const [isDragging, setIsDragging] = useState(false);
const dragOffset = useRef({ x: 0, y: 0 });
const cardRef = useRef(null);
// Stopwatch logic
useEffect(() => {
if (swRunning) {
swStartRef.current = Date.now() - swElapsed;
swIntervalRef.current = setInterval(() => {
setSwElapsed(Date.now() - swStartRef.current);
}, 10);
} else {
clearInterval(swIntervalRef.current);
}
return () => clearInterval(swIntervalRef.current);
}, [swRunning]);
const handleSwStartStop = () => setSwRunning(r => !r);
const handleSwReset = () => {
setSwRunning(false);
setSwElapsed(0);
setLaps([]);
};
const handleSwLap = () => {
setLaps(prev => [...prev, swElapsed]);
};
// Countdown logic
useEffect(() => {
if (cdRunning) {
cdStartRef.current = Date.now();
cdRemainingAtStartRef.current = cdRemaining;
cdIntervalRef.current = setInterval(() => {
const elapsed = Date.now() - cdStartRef.current;
const remaining = cdRemainingAtStartRef.current - elapsed;
if (remaining <= 0) {
setCdRemaining(0);
setCdRunning(false);
setCdExpired(true);
clearInterval(cdIntervalRef.current);
} else {
setCdRemaining(remaining);
}
}, 10);
} else {
clearInterval(cdIntervalRef.current);
}
return () => clearInterval(cdIntervalRef.current);
}, [cdRunning]);
const handleCdStartPause = () => {
if (cdExpired) return;
setCdRunning(r => !r);
};
const handleCdReset = () => {
setCdRunning(false);
setCdRemaining(cdTarget);
setCdExpired(false);
};
const handleCdPreset = (ms) => {
setCdRunning(false);
setCdTarget(ms);
setCdRemaining(ms);
setCdExpired(false);
setCdInputMin(String(Math.floor(ms / 60000)));
setCdInputSec(String(Math.floor((ms % 60000) / 1000)).padStart(2, '0'));
};
const handleCdCustomSet = () => {
const min = parseInt(cdInputMin) || 0;
const sec = parseInt(cdInputSec) || 0;
const ms = (min * 60 + sec) * 1000;
if (ms > 0) {
setCdTarget(ms);
setCdRemaining(ms);
setCdRunning(false);
setCdExpired(false);
}
};
// Expired flash + sound
useEffect(() => {
if (cdExpired) {
if (soundEnabled) {
const sound = ALARM_SOUNDS.find(s => s.id === selectedSound);
if (sound) sound.play();
}
const timeout = setTimeout(() => setCdExpired(false), 3000);
return () => clearTimeout(timeout);
}
}, [cdExpired, soundEnabled, selectedSound]);
const handlePreviewSound = () => {
const sound = ALARM_SOUNDS.find(s => s.id === selectedSound);
if (sound) sound.play();
};
// Drag handlers (desktop)
const handleDragStart = useCallback((e) => {
if (!isDesktop) return;
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
dragOffset.current = { x: clientX - position.x, y: clientY - position.y };
setIsDragging(true);
}, [isDesktop, position]);
useEffect(() => {
if (!isDragging) return;
const handleMove = (e) => {
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
setPosition({
x: Math.max(0, Math.min(window.innerWidth - 280, clientX - dragOffset.current.x)),
y: Math.max(0, Math.min(window.innerHeight - 100, clientY - dragOffset.current.y)),
});
};
const handleUp = () => setIsDragging(false);
document.addEventListener('mousemove', handleMove);
document.addEventListener('mouseup', handleUp);
document.addEventListener('touchmove', handleMove);
document.addEventListener('touchend', handleUp);
return () => {
document.removeEventListener('mousemove', handleMove);
document.removeEventListener('mouseup', handleUp);
document.removeEventListener('touchmove', handleMove);
document.removeEventListener('touchend', handleUp);
};
}, [isDragging]);
if (!visible) return null;
const cardClasses = isDesktop
? 'fixed z-50 w-[280px] rounded-xl shadow-2xl'
: 'fixed z-50 bottom-0 left-0 right-0 rounded-t-xl shadow-2xl';
const cardStyle = isDesktop ? { left: position.x, top: position.y } : {};
const borderColor = cdExpired
? 'border-red-500 animate-pulse'
: 'border-gray-700 dark:border-gray-600';
const content = (
<div
ref={cardRef}
className={`${cardClasses} bg-gray-900 dark:bg-gray-950 border-2 ${borderColor} text-white select-none`}
style={cardStyle}
>
{/* Drag handle / header */}
<div
className={`flex items-center justify-between px-3 py-2 ${isDesktop ? 'cursor-grab active:cursor-grabbing' : ''}`}
onMouseDown={handleDragStart}
onTouchStart={handleDragStart}
>
<div className="flex gap-1">
<button
onClick={() => setMode('stopwatch')}
className={`px-2 py-1 text-xs rounded transition ${mode === 'stopwatch' ? 'bg-indigo-600 text-white' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'}`}
>
Stopwatch
</button>
<button
onClick={() => setMode('timer')}
className={`px-2 py-1 text-xs rounded transition ${mode === 'timer' ? 'bg-indigo-600 text-white' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'}`}
>
Timer
</button>
</div>
<button
onClick={onHide}
className="w-7 h-7 sm:w-6 sm:h-6 flex items-center justify-center text-gray-400 hover:text-white hover:bg-gray-700 rounded transition"
title="Hide"
>
</button>
</div>
{/* Display */}
<div className="px-3 pb-3">
<div className="bg-black/40 rounded-lg px-4 py-3 sm:py-2 text-center mb-3">
<span
className={`font-mono tracking-wider ${isDesktop ? 'text-2xl' : 'text-3xl'} ${mode === 'timer' && cdRemaining < 10000 && cdRunning ? 'text-red-400' : 'text-green-400'}`}
style={{ fontFamily: "'Courier New', monospace" }}
>
{mode === 'stopwatch'
? formatTime(swElapsed)
: formatTime(cdRemaining, false)
}
</span>
</div>
{/* Controls */}
{mode === 'stopwatch' ? (
<div className="flex gap-2 mb-2">
<button
onClick={handleSwStartStop}
className={`flex-1 py-2 sm:py-1.5 rounded text-sm font-semibold transition ${swRunning ? 'bg-red-600 hover:bg-red-700' : 'bg-green-600 hover:bg-green-700'}`}
>
{swRunning ? 'Stop' : 'Start'}
</button>
{swRunning && (
<button
onClick={handleSwLap}
className="flex-1 py-2 sm:py-1.5 rounded text-sm font-semibold bg-blue-600 hover:bg-blue-700 transition"
>
Lap
</button>
)}
<button
onClick={handleSwReset}
className="flex-1 py-2 sm:py-1.5 rounded text-sm font-semibold bg-gray-600 hover:bg-gray-700 transition"
>
Reset
</button>
</div>
) : (
<>
<div className="flex gap-2 mb-2">
<button
onClick={handleCdStartPause}
disabled={cdExpired || cdRemaining <= 0}
className={`flex-1 py-2 sm:py-1.5 rounded text-sm font-semibold transition disabled:opacity-50 ${cdRunning ? 'bg-yellow-600 hover:bg-yellow-700' : 'bg-green-600 hover:bg-green-700'}`}
>
{cdRunning ? 'Pause' : 'Start'}
</button>
<button
onClick={handleCdReset}
className="flex-1 py-2 sm:py-1.5 rounded text-sm font-semibold bg-gray-600 hover:bg-gray-700 transition"
>
Reset
</button>
</div>
{!cdRunning && (
<>
<div className="flex gap-1 mb-2">
{COUNTDOWN_PRESETS.map(p => (
<button
key={p.ms}
onClick={() => handleCdPreset(p.ms)}
className={`flex-1 py-1 rounded text-xs font-medium transition ${cdTarget === p.ms ? 'bg-indigo-600' : 'bg-gray-700 hover:bg-gray-600'}`}
>
{p.label}
</button>
))}
</div>
<div className="flex items-center gap-1 text-xs">
<input
type="number"
min="0"
max="99"
value={cdInputMin}
onChange={(e) => setCdInputMin(e.target.value)}
className={inputClass}
/>
<span className="text-gray-400">m</span>
<input
type="number"
min="0"
max="59"
value={cdInputSec}
onChange={(e) => setCdInputSec(e.target.value)}
className={inputClass}
/>
<span className="text-gray-400">s</span>
<button
onClick={handleCdCustomSet}
className="ml-1 px-2 py-0.5 bg-indigo-600 hover:bg-indigo-700 rounded text-xs font-medium transition"
>
Set
</button>
</div>
</>
)}
{/* Sound controls */}
<div className="flex items-center gap-2 mt-3 pt-2 border-t border-gray-700/50">
<button
onClick={() => setSoundEnabled(s => !s)}
className={`p-1 rounded transition ${soundEnabled ? 'text-indigo-400 hover:text-indigo-300' : 'text-gray-500 hover:text-gray-400'}`}
title={soundEnabled ? 'Mute alarm' : 'Enable alarm sound'}
>
{soundEnabled ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.536 8.464a5 5 0 010 7.072M17.95 6.05a8 8 0 010 11.9M6.5 8H4a1 1 0 00-1 1v6a1 1 0 001 1h2.5l4.5 4V4l-4.5 4z" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707A1 1 0 0112 5v14a1 1 0 01-1.707.707L5.586 15z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2" />
</svg>
)}
</button>
<div className="relative flex-1">
<select
value={selectedSound}
onChange={(e) => setSelectedSound(e.target.value)}
disabled={!soundEnabled}
className="w-full appearance-none bg-gray-800 border border-gray-600 rounded px-2 py-1 pr-6 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500 disabled:opacity-40 disabled:cursor-not-allowed transition"
>
{ALARM_SOUNDS.map(s => (
<option key={s.id} value={s.id}>{s.label}</option>
))}
</select>
<svg className="absolute right-1.5 top-1/2 -translate-y-1/2 w-3 h-3 text-gray-400 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</div>
<button
onClick={handlePreviewSound}
disabled={!soundEnabled}
className="p-1 rounded text-gray-400 hover:text-white transition disabled:opacity-40 disabled:cursor-not-allowed"
title="Preview sound"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</button>
</div>
</>
)}
{/* Lap list (stopwatch mode) */}
{mode === 'stopwatch' && laps.length > 0 && (
<div className={`mt-2 border-t border-gray-700 pt-2 overflow-y-auto ${isDesktop ? 'max-h-32' : 'max-h-24'}`}>
{laps.map((lapTime, i) => {
const prev = i === 0 ? 0 : laps[i - 1];
const delta = lapTime - prev;
return (
<div key={i} className="flex justify-between text-xs text-gray-300 py-0.5">
<span className="text-gray-500">#{i + 1}</span>
<span className="font-mono">{formatTime(delta)}</span>
<span className="font-mono text-gray-500">{formatTime(lapTime)}</span>
</div>
);
})}
</div>
)}
</div>
</div>
);
return createPortal(content, document.body);
}

View File

@@ -2,7 +2,7 @@ export const branding = {
app: {
name: 'HSO Jackbox Game Picker',
shortName: 'Jackbox Game Picker',
version: '0.7.0 - Fixed For Real Edition',
version: '0.7.13 - Pokémon-Go-To-The-Polls Edition',
description: 'Spicing up Hyper Spaceout game nights!',
},
meta: {

View File

@@ -161,10 +161,15 @@ function Home() {
</div>
<div className="text-xs sm:text-sm text-gray-500 dark:text-gray-400 mt-1">
({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">
Manual
</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>

View File

@@ -14,7 +14,19 @@ 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 [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('');
@@ -92,8 +104,33 @@ 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,
});
}
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 {
const sessionGamesResponse = await api.get(`/sessions/${session.id}/games`);
const playingGameEntry = sessionGamesResponse.data.find(g => g.status === 'playing');
@@ -102,6 +139,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 +185,52 @@ 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]);
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);
const centiseconds = Math.floor((ms % 1000) / 10);
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', {});
@@ -158,6 +242,97 @@ 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);
setPollEndingAt(null);
setShowEndPollOptions(false);
try {
await api.post(`/sessions/${activeSession.id}/voting/start`);
} catch (err) {
console.error('Failed to start polling', err);
setPollActive(false);
pollStartedAtRef.current = null;
}
};
const handleEndPolling = async (delay = 0) => {
setShowEndPollOptions(false);
setCustomDelay('');
if (delay === 0) {
const winner = leadingGameRef.current;
setPollActive(false);
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);
};
const handleIgnorePollResult = () => {
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();
@@ -219,9 +394,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);
}
@@ -233,7 +414,8 @@ function Picker() {
// Show room code modal
setPendingGameAction({
type: 'accept',
game: selectedGame
game: selectedGame,
source: gameSource
});
setShowRoomCodeModal(true);
};
@@ -242,13 +424,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);
@@ -256,7 +439,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);
@@ -266,6 +450,7 @@ function Picker() {
// Close all modals and clear selected game after adding to session
setSelectedGame(null);
setGameSource('dice');
setShowGamePool(false);
// Trigger games list refresh
@@ -687,6 +872,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,12 +948,161 @@ 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 && 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">
<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>
<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>
) : (
<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">
{/* 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"
>
@@ -822,7 +1175,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"
>
@@ -931,6 +1284,16 @@ function Picker() {
onGamesUpdate={gamesUpdateTrigger}
playingGame={playingGame}
setPlayingGame={setPlayingGame}
setHasPlayedGames={setHasPlayedGames}
setLeadingGame={setLeadingGame}
setPollActive={setPollActive}
pollActiveRef={pollActiveRef}
setPollResult={setPollResult}
setPollEndingAt={setPollEndingAt}
setShowEndPollOptions={setShowEndPollOptions}
pollStartedAtRef={pollStartedAtRef}
setSelectedGame={setSelectedGame}
setGameSource={setGameSource}
/>
</div>
</div>
@@ -938,7 +1301,7 @@ function Picker() {
);
}
function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame }) {
function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame, setHasPlayedGames, setLeadingGame, setPollActive, pollActiveRef, setPollResult, setPollEndingAt, setShowEndPollOptions, pollStartedAtRef, setSelectedGame, setGameSource }) {
const { isAuthenticated, token } = useAuth();
const [games, setGames] = useState([]);
const [loading, setLoading] = useState(true);
@@ -973,6 +1336,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 {
@@ -993,28 +1358,40 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame })
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;
}
@@ -1030,34 +1407,108 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame })
'game.status',
];
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 === '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, setPollEndingAt, setShowEndPollOptions, 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 {
@@ -1283,10 +1734,15 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame })
{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">

View File

@@ -320,10 +320,15 @@ function SessionDetail() {
<div className="text-sm text-gray-500 dark:text-gray-400">
{formatLocalTime(game.played_at)}
</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">
Manual
</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>

View File

@@ -58,3 +58,4 @@ Jackbox Naughty Pack,Fakin It All Night Long,3,8,????,?,No,?,?
Jackbox Naughty Pack,Dirty Drawful,3,8,????,?,No,?,?
Jackbox Naughty Pack,Let Me Finish,3,8,????,?,No,?,?
Jackbox Party Pack,Survey Scramble,2,10,????,Yes,Yes,?,?
Jackbox Party Pack Playtests,Trivia Murder Party 3 (Playtest),1,8,????,?,No,Trivia,
1 Game Pack Game Title Min. Players Max. Players Length Audience Family Friendly? Game Type Secondary Type
58 Jackbox Naughty Pack Dirty Drawful 3 8 ???? ? No ? ?
59 Jackbox Naughty Pack Let Me Finish 3 8 ???? ? No ? ?
60 Jackbox Party Pack Survey Scramble 2 10 ???? Yes Yes ? ?
61 Jackbox Party Pack Playtests Trivia Murder Party 3 (Playtest) 1 8 ???? ? No Trivia