diff --git a/.gitignore b/.gitignore
index 7b8a2e9..a5b4f4c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,6 +32,10 @@ Thumbs.db
# Docker
.dockerignore
+# Local development
+.local/
+.old-chrome-extension/
+
# Cursor
.cursor/
chat-summaries/
diff --git a/backend/Dockerfile b/backend/Dockerfile
index 50979c2..9c4e435 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -2,11 +2,14 @@ FROM node:18-alpine
WORKDIR /app
+# Install wget for healthcheck
+RUN apk add --no-cache wget
+
# Copy package files
COPY package*.json ./
# Install dependencies
-RUN npm ci --only=production
+RUN npm install --omit=dev
# Copy application files
COPY . .
diff --git a/backend/database.js b/backend/database.js
index ee070ca..db711f2 100644
--- a/backend/database.js
+++ b/backend/database.js
@@ -5,9 +5,17 @@ const fs = require('fs');
const dbPath = process.env.DB_PATH || path.join(__dirname, 'data', 'jackbox.db');
const dbDir = path.dirname(dbPath);
-// Ensure data directory exists
-if (!fs.existsSync(dbDir)) {
- fs.mkdirSync(dbDir, { recursive: true });
+// Ensure data directory exists with proper permissions
+try {
+ if (!fs.existsSync(dbDir)) {
+ fs.mkdirSync(dbDir, { recursive: true, mode: 0o777 });
+ }
+ // Also ensure the directory is writable
+ fs.accessSync(dbDir, fs.constants.W_OK);
+} catch (err) {
+ console.error(`Error with database directory ${dbDir}:`, err.message);
+ console.error('Please ensure the directory exists and is writable');
+ process.exit(1);
}
const db = new Database(dbPath);
@@ -56,10 +64,41 @@ function initializeDatabase() {
game_id INTEGER NOT NULL,
played_at DATETIME DEFAULT CURRENT_TIMESTAMP,
manually_added INTEGER DEFAULT 0,
+ status TEXT DEFAULT 'played',
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE,
FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE
)
`);
+
+ // Add status column if it doesn't exist (for existing databases)
+ try {
+ db.exec(`ALTER TABLE session_games ADD COLUMN status TEXT DEFAULT 'played'`);
+ } 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`);
+ } catch (err) {
+ // Column already exists, ignore error
+ }
+
+ // Packs table for pack-level favoriting
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS packs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL UNIQUE,
+ favor_bias INTEGER DEFAULT 0,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )
+ `);
+
+ // Populate packs table with unique pack names from games
+ db.exec(`
+ INSERT OR IGNORE INTO packs (name)
+ SELECT DISTINCT pack_name FROM games
+ `);
// Chat logs table
db.exec(`
diff --git a/backend/routes/games.js b/backend/routes/games.js
index b4b73fc..0922895 100644
--- a/backend/routes/games.js
+++ b/backend/routes/games.js
@@ -73,6 +73,91 @@ router.get('/', (req, res) => {
}
});
+// Get all packs with their favor bias (MUST be before /:id)
+router.get('/packs', (req, res) => {
+ try {
+ const packs = db.prepare('SELECT * FROM packs ORDER BY name').all();
+ res.json(packs);
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+// Get pack metadata (MUST be before /:id)
+router.get('/meta/packs', (req, res) => {
+ try {
+ const packs = db.prepare(`
+ SELECT
+ pack_name as name,
+ COUNT(*) as total_count,
+ SUM(CASE WHEN enabled = 1 THEN 1 ELSE 0 END) as enabled_count,
+ SUM(play_count) as total_plays
+ FROM games
+ GROUP BY pack_name
+ ORDER BY pack_name
+ `).all();
+ res.json(packs);
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+// Export games as CSV (MUST be before /:id)
+router.get('/export/csv', authenticateToken, (req, res) => {
+ try {
+ const games = db.prepare('SELECT * FROM games ORDER BY pack_name, title').all();
+
+ const records = games.map(game => ({
+ 'Pack Name': game.pack_name,
+ 'Title': game.title,
+ 'Min Players': game.min_players,
+ 'Max Players': game.max_players,
+ 'Length (minutes)': game.length_minutes || '',
+ 'Audience': game.has_audience ? 'Yes' : 'No',
+ 'Family Friendly': game.family_friendly ? 'Yes' : 'No',
+ 'Game Type': game.game_type || '',
+ 'Secondary Type': game.secondary_type || ''
+ }));
+
+ const csv = stringify(records, { header: true });
+
+ res.setHeader('Content-Type', 'text/csv');
+ res.setHeader('Content-Disposition', 'attachment; filename="jackbox-games.csv"');
+ res.send(csv);
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+// Set favor bias for a pack (MUST be before /:id)
+router.patch('/packs/:name/favor', authenticateToken, (req, res) => {
+ try {
+ const { favor_bias } = req.body;
+
+ // Validate favor_bias value
+ if (![1, -1, 0].includes(favor_bias)) {
+ return res.status(400).json({ error: 'favor_bias must be 1 (favor), -1 (disfavor), or 0 (neutral)' });
+ }
+
+ // Update pack favor bias
+ const packStmt = db.prepare('UPDATE packs SET favor_bias = ? WHERE name = ?');
+ const packResult = packStmt.run(favor_bias, req.params.name);
+
+ if (packResult.changes === 0) {
+ // Pack doesn't exist, create it
+ const insertStmt = db.prepare('INSERT INTO packs (name, favor_bias) VALUES (?, ?)');
+ insertStmt.run(req.params.name, favor_bias);
+ }
+
+ res.json({
+ message: 'Pack favor bias updated successfully',
+ favor_bias
+ });
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
// Get single game by ID
router.get('/:id', (req, res) => {
try {
@@ -224,25 +309,6 @@ router.patch('/:id/toggle', authenticateToken, (req, res) => {
}
});
-// Get list of unique pack names
-router.get('/meta/packs', (req, res) => {
- try {
- const packs = db.prepare(`
- SELECT
- pack_name,
- COUNT(*) as game_count,
- SUM(enabled) as enabled_count
- FROM games
- GROUP BY pack_name
- ORDER BY pack_name
- `).all();
-
- res.json(packs);
- } catch (error) {
- res.status(500).json({ error: error.message });
- }
-});
-
// Toggle entire pack (admin only)
router.patch('/packs/:name/toggle', authenticateToken, (req, res) => {
try {
@@ -264,33 +330,6 @@ router.patch('/packs/:name/toggle', authenticateToken, (req, res) => {
}
});
-// Export games to CSV (admin only)
-router.get('/export/csv', authenticateToken, (req, res) => {
- try {
- const games = db.prepare('SELECT * FROM games ORDER BY pack_name, title').all();
-
- const csvData = games.map(game => ({
- 'Game Pack': game.pack_name,
- 'Game Title': game.title,
- 'Min. Players': game.min_players,
- 'Max. Players': game.max_players,
- 'Length': game.length_minutes ? `${game.length_minutes} minutes` : '????',
- 'Audience': game.has_audience ? 'Yes' : 'No',
- 'Family Friendly?': game.family_friendly ? 'Yes' : 'No',
- 'Game Type': game.game_type || '',
- 'Secondary Type': game.secondary_type || ''
- }));
-
- const csv = stringify(csvData, { header: true });
-
- res.setHeader('Content-Type', 'text/csv');
- res.setHeader('Content-Disposition', 'attachment; filename=games-export.csv');
- res.send(csv);
- } catch (error) {
- res.status(500).json({ error: error.message });
- }
-});
-
// Import games from CSV (admin only)
router.post('/import/csv', authenticateToken, (req, res) => {
try {
@@ -360,5 +399,31 @@ function parseBoolean(value) {
return value.toLowerCase() === 'yes' ? 1 : 0;
}
+// Set favor bias for a game (1 = favor, -1 = disfavor, 0 = neutral)
+router.patch('/:id/favor', authenticateToken, (req, res) => {
+ try {
+ const { favor_bias } = req.body;
+
+ // Validate favor_bias value
+ if (![1, -1, 0].includes(favor_bias)) {
+ return res.status(400).json({ error: 'favor_bias must be 1 (favor), -1 (disfavor), or 0 (neutral)' });
+ }
+
+ const stmt = db.prepare('UPDATE games SET favor_bias = ? WHERE id = ?');
+ const result = stmt.run(favor_bias, req.params.id);
+
+ if (result.changes === 0) {
+ return res.status(404).json({ error: 'Game not found' });
+ }
+
+ res.json({
+ message: 'Favor bias updated successfully',
+ favor_bias
+ });
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
module.exports = router;
diff --git a/backend/routes/picker.js b/backend/routes/picker.js
index 5c87a7f..c13c6b2 100644
--- a/backend/routes/picker.js
+++ b/backend/routes/picker.js
@@ -3,6 +3,68 @@ const db = require('../database');
const router = express.Router();
+/**
+ * Select a game using weighted random selection based on favor bias
+ *
+ * Bias system:
+ * - favor_bias = 1 (favored): 3x weight
+ * - favor_bias = 0 (neutral): 1x weight
+ * - favor_bias = -1 (disfavored): 0.2x weight (still possible but less likely)
+ *
+ * Also considers pack-level bias
+ */
+function selectGameWithBias(games) {
+ if (games.length === 0) return null;
+ if (games.length === 1) return games[0];
+
+ // Get pack biases
+ const packs = db.prepare('SELECT name, favor_bias FROM packs').all();
+ const packBiasMap = {};
+ packs.forEach(pack => {
+ packBiasMap[pack.name] = pack.favor_bias || 0;
+ });
+
+ // Calculate weights for each game
+ const weights = games.map(game => {
+ let weight = 1.0; // Base weight
+
+ // Apply game-level bias
+ const gameBias = game.favor_bias || 0;
+ if (gameBias === 1) {
+ weight *= 3.0; // Favored games are 3x more likely
+ } else if (gameBias === -1) {
+ weight *= 0.2; // Disfavored games are 5x less likely
+ }
+
+ // Apply pack-level bias
+ const packBias = packBiasMap[game.pack_name] || 0;
+ if (packBias === 1) {
+ weight *= 2.0; // Favored packs are 2x more likely
+ } else if (packBias === -1) {
+ weight *= 0.3; // Disfavored packs are ~3x less likely
+ }
+
+ return weight;
+ });
+
+ // Calculate total weight
+ const totalWeight = weights.reduce((sum, w) => sum + w, 0);
+
+ // Pick a random number between 0 and totalWeight
+ let random = Math.random() * totalWeight;
+
+ // Select game based on weighted random
+ for (let i = 0; i < games.length; i++) {
+ random -= weights[i];
+ if (random <= 0) {
+ return games[i];
+ }
+ }
+
+ // Fallback (shouldn't reach here)
+ return games[games.length - 1];
+}
+
// Pick a random game with filters and repeat avoidance
router.post('/pick', (req, res) => {
try {
@@ -11,7 +73,8 @@ router.post('/pick', (req, res) => {
drawing,
length,
familyFriendly,
- sessionId
+ sessionId,
+ excludePlayed
} = req.body;
// Build query for eligible games
@@ -60,14 +123,25 @@ router.post('/pick', (req, res) => {
// Apply repeat avoidance if session provided
if (sessionId) {
- const lastGames = db.prepare(`
- SELECT game_id FROM session_games
- WHERE session_id = ?
- ORDER BY played_at DESC
- LIMIT 2
- `).all(sessionId);
+ let lastGamesQuery;
+
+ if (excludePlayed) {
+ // Exclude ALL previously played games in this session
+ lastGamesQuery = db.prepare(`
+ SELECT DISTINCT game_id FROM session_games
+ WHERE session_id = ?
+ `).all(sessionId);
+ } else {
+ // Default: only exclude last 2 games
+ lastGamesQuery = db.prepare(`
+ SELECT game_id FROM session_games
+ WHERE session_id = ?
+ ORDER BY played_at DESC
+ LIMIT 2
+ `).all(sessionId);
+ }
- const excludeIds = lastGames.map(g => g.game_id);
+ const excludeIds = lastGamesQuery.map(g => g.game_id);
if (excludeIds.length > 0) {
eligibleGames = eligibleGames.filter(game => !excludeIds.includes(game.id));
@@ -75,16 +149,17 @@ router.post('/pick', (req, res) => {
if (eligibleGames.length === 0) {
return res.status(404).json({
- error: 'All eligible games have been played recently',
+ error: excludePlayed
+ ? 'All eligible games have been played in this session'
+ : 'All eligible games have been played recently',
suggestion: 'Enable more games or adjust your filters',
recentlyPlayed: excludeIds
});
}
}
- // Pick random game from eligible pool
- const randomIndex = Math.floor(Math.random() * eligibleGames.length);
- const selectedGame = eligibleGames[randomIndex];
+ // Apply favor biasing to selection
+ const selectedGame = selectGameWithBias(eligibleGames);
res.json({
game: selectedGame,
diff --git a/backend/routes/sessions.js b/backend/routes/sessions.js
index 8dbf5f9..d803270 100644
--- a/backend/routes/sessions.js
+++ b/backend/routes/sessions.js
@@ -37,8 +37,9 @@ router.get('/active', (req, res) => {
LIMIT 1
`).get();
+ // Return null instead of 404 when no active session
if (!session) {
- return res.status(404).json({ error: 'No active session found' });
+ return res.json({ session: null, message: 'No active session' });
}
res.json(session);
@@ -114,6 +115,13 @@ router.post('/:id/close', authenticateToken, (req, res) => {
return res.status(400).json({ error: 'Session is already closed' });
}
+ // Set all 'playing' games to 'played' before closing
+ db.prepare(`
+ UPDATE session_games
+ SET status = 'played'
+ WHERE session_id = ? AND status = 'playing'
+ `).run(req.params.id);
+
const stmt = db.prepare(`
UPDATE sessions
SET is_active = 0, closed_at = CURRENT_TIMESTAMP, notes = COALESCE(?, notes)
@@ -129,6 +137,33 @@ router.post('/:id/close', authenticateToken, (req, res) => {
}
});
+// Delete session (admin only)
+router.delete('/:id', authenticateToken, (req, res) => {
+ try {
+ const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(req.params.id);
+
+ if (!session) {
+ return res.status(404).json({ error: 'Session not found' });
+ }
+
+ // Prevent deletion of active sessions
+ if (session.is_active === 1) {
+ return res.status(400).json({ error: 'Cannot delete an active session. Please close it first.' });
+ }
+
+ // Delete related data first (cascade)
+ db.prepare('DELETE FROM chat_logs WHERE session_id = ?').run(req.params.id);
+ db.prepare('DELETE FROM session_games WHERE session_id = ?').run(req.params.id);
+
+ // Delete the session
+ db.prepare('DELETE FROM sessions WHERE id = ?').run(req.params.id);
+
+ res.json({ message: 'Session deleted successfully', sessionId: parseInt(req.params.id) });
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
// Get games played in a session
router.get('/:id/games', (req, res) => {
try {
@@ -180,10 +215,20 @@ router.post('/:id/games', authenticateToken, (req, res) => {
return res.status(404).json({ error: 'Game not found' });
}
- // Add game to session
+ // Set all current 'playing' games to 'played' (except skipped ones)
+ db.prepare(`
+ UPDATE session_games
+ SET status = CASE
+ WHEN status = 'skipped' THEN 'skipped'
+ ELSE 'played'
+ END
+ WHERE session_id = ? AND status = 'playing'
+ `).run(req.params.id);
+
+ // Add game to session with 'playing' status
const stmt = db.prepare(`
- INSERT INTO session_games (session_id, game_id, manually_added)
- VALUES (?, ?, ?)
+ INSERT INTO session_games (session_id, game_id, manually_added, status)
+ VALUES (?, ?, ?, 'playing')
`);
const result = stmt.run(req.params.id, game_id, manually_added ? 1 : 0);
@@ -336,5 +381,64 @@ router.post('/:id/chat-import', authenticateToken, (req, res) => {
}
});
+// Update session game status (admin only)
+router.patch('/:sessionId/games/:gameId/status', authenticateToken, (req, res) => {
+ try {
+ const { status } = req.body;
+ const { sessionId, gameId } = req.params;
+
+ if (!status || !['playing', 'played', 'skipped'].includes(status)) {
+ return res.status(400).json({ error: 'Invalid status. Must be playing, played, or skipped' });
+ }
+
+ // If setting to 'playing', first set all other games in session to 'played' or keep as 'skipped'
+ if (status === 'playing') {
+ db.prepare(`
+ UPDATE session_games
+ SET status = CASE
+ WHEN status = 'skipped' THEN 'skipped'
+ ELSE 'played'
+ END
+ WHERE session_id = ? AND status = 'playing'
+ `).run(sessionId);
+ }
+
+ // Update the specific game
+ const result = db.prepare(`
+ UPDATE session_games
+ SET status = ?
+ WHERE session_id = ? AND id = ?
+ `).run(status, sessionId, gameId);
+
+ if (result.changes === 0) {
+ return res.status(404).json({ error: 'Session game not found' });
+ }
+
+ res.json({ message: 'Status updated successfully', status });
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+// Delete session game (admin only)
+router.delete('/:sessionId/games/:gameId', authenticateToken, (req, res) => {
+ try {
+ const { sessionId, gameId } = req.params;
+
+ const result = db.prepare(`
+ DELETE FROM session_games
+ WHERE session_id = ? AND id = ?
+ `).run(sessionId, gameId);
+
+ if (result.changes === 0) {
+ return res.status(404).json({ error: 'Session game not found' });
+ }
+
+ res.json({ message: 'Game removed from session successfully' });
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
module.exports = router;
diff --git a/docker-compose.yml b/docker-compose.yml
index befee79..7696cf0 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,5 +1,3 @@
-version: '3.8'
-
services:
backend:
build:
@@ -14,12 +12,18 @@ services:
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
- ADMIN_KEY=${ADMIN_KEY:-admin123}
volumes:
- - ./backend/data:/app/data
+ - jackbox-data:/app/data
- ./games-list.csv:/app/games-list.csv:ro
ports:
- "5000:5000"
networks:
- jackbox-network
+ healthcheck:
+ test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:5000/health"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ start_period: 10s
frontend:
build:
@@ -30,7 +34,8 @@ services:
ports:
- "3000:80"
depends_on:
- - backend
+ backend:
+ condition: service_healthy
networks:
- jackbox-network
@@ -39,5 +44,6 @@ networks:
driver: bridge
volumes:
- backend-data:
+ jackbox-data:
+ driver: local
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index 97b4370..b1ee06f 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -7,7 +7,7 @@ WORKDIR /app
COPY package*.json ./
# Install dependencies
-RUN npm ci
+RUN npm install
# Copy application files
COPY . .
diff --git a/frontend/index.html b/frontend/index.html
index d3b7f4e..b434497 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -2,11 +2,34 @@
-
+
+
Jackbox Game Picker
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js
index b4a6220..2ce518b 100644
--- a/frontend/postcss.config.js
+++ b/frontend/postcss.config.js
@@ -1,4 +1,4 @@
-export default {
+module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg
new file mode 100644
index 0000000..00afeda
--- /dev/null
+++ b/frontend/public/favicon.svg
@@ -0,0 +1,25 @@
+
+
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index ca11de2..04dbd1c 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,6 +1,10 @@
-import React from 'react';
+import React, { useState } from 'react';
import { Routes, Route, Link } from 'react-router-dom';
import { useAuth } from './context/AuthContext';
+import { ToastProvider } from './components/Toast';
+import { branding } from './config/branding';
+import Logo from './components/Logo';
+import ThemeToggle from './components/ThemeToggle';
import Home from './pages/Home';
import Login from './pages/Login';
import Picker from './pages/Picker';
@@ -9,36 +13,46 @@ import History from './pages/History';
function App() {
const { isAuthenticated, logout } = useAuth();
+ const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
+
+ const closeMobileMenu = () => setMobileMenuOpen(false);
return (
-
+
+
{/* Navigation */}
-