Courtside MVP: engine, web app, Docker + Cloudflare Tunnel deploy, docs

Tournament engine (pools, single/double elimination, standings with proper
tiebreaks, withdrawals, two-court queue with ETAs and alerts), a single-process
Node server with SQLite via node:sqlite and a WebSocket live board, organizer
desk, QR landing page that follows the tournament phase, Dockerfile and
compose with cloudflared, and documentation for deploying and running a day.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MB7nCCAscYsb3zzkT6LHZi
This commit is contained in:
2026-09-03 20:31:46 +00:00
co-authored by Claude Fable 5.1
commit 5ade592384
39 changed files with 3103 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
// Creates a demo tournament in the database so a fresh install has something to look at.
// Run with the server STOPPED (the server keeps state in memory and writes it back):
// DB_PATH=./data/courtside.sqlite node scripts/seed-demo.js
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { Store } from '../server/store.js';
import { Registry } from '../server/state.js';
const here = dirname(fileURLToPath(import.meta.url));
const DB_PATH = process.env.DB_PATH ?? join(here, '..', 'data', 'courtside.sqlite');
const store = new Store(DB_PATH);
const registry = new Registry(store);
if (registry.get('demo-labor-day-2s')) { console.log('Demo tournament already exists at /t/demo-labor-day-2s'); process.exit(0); }
const { t, engine } = registry.create({ slug: 'demo-labor-day-2s', name: 'Demo Labor Day 2s', date: '2026-09-07', courtCount: 2, rules: { teamSize: 2, pointsTo: 21, winBy: 2, cap: 25 }, notes: 'Demo data. Scores are made up.' });
const names = ['Net Gains', 'Block Party', 'Sunburnt', 'Dig It', 'Kiss My Ace', 'Setting Ducks', 'The Spikers', 'Sandbaggers', 'Serves You Right', 'Bump Chumps', 'Ace Holes', 'Beach Please'];
for (const n of names) t.registerTeam({ name: n, players: 2, captain: 'Captain' });
t.closeRegistration();
t.generatePools(4);
let clock = Date.now() - 90 * 60_000;
engine.now = () => clock; t.now = engine.now;
engine.start();
// play the first seven matches so there are standings, then leave two live with a running score
let seed = 11;
const rnd = () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 2 ** 32);
for (let i = 0; i < 7; i++) {
const c = engine.courts.find(c => c.matchId);
clock += (12 + rnd() * 6) * 60_000;
const hi = 21, lo = Math.floor(8 + rnd() * 12);
engine.recordResult(c.matchId, [rnd() < 0.5 ? [hi, lo] : [lo, hi]]);
}
const live = engine.courts.filter(c => c.matchId).map(c => t.match(c.matchId));
if (live[0]) live[0].live = [14, 11];
if (live[1]) live[1].live = [7, 9];
t.banner = 'Demo tournament — scores are simulated';
engine.now = () => Date.now(); t.now = engine.now;
registry.persist(t.slug);
store.close();
console.log(`Seeded demo at /t/${t.slug}. Team codes: ${[...t.teams.values()].slice(0, 3).map(x => `${x.name}=${x.code}`).join(', ')}`);