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
+160
View File
@@ -0,0 +1,160 @@
import QRCode from 'qrcode';
import { Router, HttpError, readBody, send, esc } from './http.js';
import { publicState } from './public-state.js';
import * as pub from './views/public.js';
import * as adm from './views/admin.js';
export function buildRouter({ registry, auth, store }) {
const r = new Router();
const origin = req => `${(req.headers['x-forwarded-proto'] ?? 'http').split(',')[0]}://${req.headers['x-forwarded-host'] ?? req.headers.host}`;
const item = slug => { const it = registry.get(slug); if (!it) throw new HttpError(404, 'No such tournament'); return it; };
const q = req => new URL(req.url, 'http://x').searchParams;
// ---------- public ----------
r.get('/', (req, res) => send.html(res, pub.homePage(registry.list())));
r.get('/healthz', (req, res) => send.json(res, { ok: true, tournaments: registry.items.size, uptime: process.uptime() }));
r.get('/t/:slug', (req, res, { slug }) => {
const it = item(slug);
const state = publicState(it);
if (state.phase === 'checkin') return send.html(res, pub.registerPage(state, { error: q(req).get('error') }));
send.html(res, pub.boardPage(state));
});
r.get('/t/:slug/state.json', (req, res, { slug }) => send.json(res, publicState(item(slug))));
r.get('/t/:slug/display', (req, res, { slug }) => send.html(res, pub.boardPage(publicState(item(slug)), { display: true })));
r.get('/t/:slug/team/:code', (req, res, { slug, code }) => {
const it = item(slug);
const team = it.t.teamByCode(code);
if (!team) throw new HttpError(404, 'No team with that code');
const state = publicState(it);
if (state.phase === 'checkin' && q(req).get('new')) return send.html(res, pub.registeredPage(state, team, origin(req)));
send.html(res, pub.boardPage(state, { teamId: team.id }));
});
r.post('/t/:slug/register', async (req, res, { slug }) => {
const it = item(slug);
const body = await readBody(req);
try {
const team = registry.mutate(slug, t => t.registerTeam({
name: body.name, captain: String(body.captain ?? '').trim().slice(0, 60), phone: String(body.phone ?? '').trim().slice(0, 30),
players: body.players, playerNames: String(body.playerNames ?? '').split('\n').map(s => s.trim()).filter(Boolean).slice(0, 20),
}));
send.redirect(res, `/t/${slug}/team/${team.code}?new=1`);
} catch (e) {
send.html(res, pub.registerPage(publicState(it), { error: e.message, values: body }), 400);
}
});
// ---------- organizer ----------
const requireAdmin = (req, res) => {
const who = auth.identify(req);
if (!who) { send.redirect(res, `/admin/login?next=${encodeURIComponent(req.url)}`); return null; }
return who;
};
r.get('/admin/login', (req, res) => send.html(res, adm.loginPage(q(req).get('error'), q(req).get('next') ?? '/admin')));
r.post('/admin/login', async (req, res) => {
const body = await readBody(req);
if (!auth.checkPassword(body.password)) return send.redirect(res, '/admin/login?error=' + encodeURIComponent('Wrong password'));
const secure = (req.headers['x-forwarded-proto'] ?? '').includes('https');
send.redirect(res, safeNext(body.next), { 'set-cookie': auth.cookie('organizer', secure) });
});
r.get('/admin/logout', (req, res) => send.redirect(res, '/', { 'set-cookie': auth.clearCookie() }));
r.get('/admin', (req, res) => { const who = requireAdmin(req, res); if (who) send.html(res, adm.adminHome(registry.list(), who, q(req).get('msg'))); });
r.post('/admin/new', async (req, res) => {
const who = requireAdmin(req, res); if (!who) return;
const b = await readBody(req);
const rules = { teamSize: +b.teamSize || 2, pointsTo: +b.pointsTo || 21, winBy: +b.winBy || 2, cap: +b.cap || 0, bestOf: +b.bestOf || 1 };
const it = registry.create({ name: b.name, date: b.date, courtCount: +b.courtCount || 2, rules, stages: String(b.stages ?? 'pool,bracket').split(','), notes: b.notes ?? '' });
send.redirect(res, `/admin/t/${it.t.slug}?msg=${encodeURIComponent('Created. Print the QR code and open registration when ready.')}`);
});
r.get('/admin/t/:slug', (req, res, { slug }) => {
const who = requireAdmin(req, res); if (!who) return;
send.html(res, adm.adminTournament(item(slug), who, origin(req), q(req).get('msg'), q(req).get('error'), store.events(slug, 40)));
});
r.get('/admin/t/:slug/qr', async (req, res, { slug }) => {
const who = requireAdmin(req, res); if (!who) return;
const it = item(slug);
const dataUrl = await QRCode.toDataURL(`${origin(req)}/t/${slug}`, { width: 720, margin: 2, errorCorrectionLevel: 'M' });
send.html(res, adm.qrPage(it.t, origin(req), dataUrl, who));
});
r.get('/admin/t/:slug/qr.png', async (req, res, { slug }) => {
const who = requireAdmin(req, res); if (!who) return;
item(slug);
const buf = await QRCode.toBuffer(`${origin(req)}/t/${slug}`, { width: 1200, margin: 2 });
res.writeHead(200, { 'content-type': 'image/png' }); res.end(buf);
});
// Every organizer action: POST, mutate, redirect back with a message (or JSON for fetch callers).
const action = (path, fn) => r.post(`/admin/t/:slug${path}`, async (req, res, params) => {
const who = requireAdmin(req, res); if (!who) return;
const body = await readBody(req);
const wantsJson = (req.headers['content-type'] ?? '').includes('json');
try {
const msg = registry.mutate(params.slug, (t, engine) => fn({ t, engine, body, who, params })) ?? 'Done';
if (wantsJson) send.json(res, { ok: true, msg }); else send.redirect(res, `/admin/t/${params.slug}?msg=${encodeURIComponent(msg)}`);
} catch (e) {
if (e instanceof HttpError) throw e;
if (wantsJson) send.json(res, { ok: false, error: e.message }, 400); else send.redirect(res, `/admin/t/${params.slug}?error=${encodeURIComponent(e.message)}`);
}
});
action('/phase', ({ t, engine, body }) => {
const p = body.phase;
if (p === 'checkin') { t.reopenRegistration(); return 'Registration reopened'; }
if (p === 'closed') { if (t.phase === 'live') throw new Error('Already live; use Final or reopen is not possible'); t.closeRegistration(); return 'Registration closed'; }
if (p === 'live') { if (!t.matches.length) throw new Error('Generate pools or a bracket first'); engine.start(); return 'Live. Courts assigned.'; }
if (p === 'final') { t.phase = 'final'; t.emit({ type: 'phase', phase: 'final', champion: t.champion() }); return 'Marked final'; }
throw new Error('Unknown phase');
});
action('/banner', ({ t, body }) => { t.banner = String(body.banner ?? '').trim().slice(0, 200) || null; t.emit({ type: 'banner', banner: t.banner }); return t.banner ? 'Banner set' : 'Banner cleared'; });
action('/broadcast', ({ t, engine, body }) => { const text = String(body.text ?? '').trim().slice(0, 300); if (!text) throw new Error('Nothing to send'); engine.broadcast(text); return 'Broadcast sent'; });
action('/generate', ({ t, body }) => {
if (body.what === 'pools') { t.generatePools(+body.poolSize || 4); return `Pools generated: ${t.pools.map(p => `${p.id} (${p.teams.length})`).join(', ')}`; }
const seeded = t.pools.length ? t.advanceFromPools({ perPool: +body.perPool || 2, wildcards: +body.wildcards || 0 }) : t.activeTeams().map(x => x.id);
if (seeded.length < 2) throw new Error('Need at least two teams');
const res = t.generateBracket(seeded, { type: body.type === 'double' ? 'double' : 'single', thirdPlace: !!body.thirdPlace });
return `${body.type === 'double' ? 'Double' : 'Single'} elimination bracket of ${res.size} generated with ${seeded.length} teams`;
});
action('/regenerate', ({ t }) => { if (t.matches.some(m => ['final', 'forfeit'].includes(m.status))) throw new Error('Scores already entered'); t.matches = []; t.pools = []; for (const x of t.teams.values()) x.poolId = null; if (t.phase === 'live') t.phase = 'closed'; t.emit({ type: 'play_cleared' }); return 'Generated play cleared'; });
action('/score', ({ t, engine, body, who }) => {
const sets = parseSets(body.sets);
const m = t.match(body.match); if (!m) throw new Error('No such match');
const wasLive = m.status === 'live';
if (wasLive) engine.recordResult(m.id, sets, { actor: who });
else { t.recordResult(m.id, sets, { actor: who }); m.court = null; }
delete m.live;
return `Saved ${t.teamName(m.teamA)} vs ${t.teamName(m.teamB)}: ${sets.map(s => s.join('-')).join(', ')}`;
});
action('/live', ({ t, body }) => { const m = t.match(body.match); if (!m || m.status !== 'live') throw new Error('Match is not live'); m.live = [Math.max(0, +body.a | 0), Math.max(0, +body.b | 0)]; return 'ok'; });
action('/forfeit', ({ t, engine, body, who }) => { const m = t.match(body.match); if (!m) throw new Error('No such match'); if (m.status === 'live') engine.recordResult(m.id, [], { actor: who, forfeit: body.team }); else t.recordResult(m.id, [], { actor: who, forfeit: body.team }); return `${t.teamName(body.team)} forfeits`; });
action('/swap', ({ engine, body }) => { engine.swapToCourt(body.match, +body.court); return `Moved to court ${body.court}`; });
action('/pushback', ({ engine, body }) => { engine.pushBack(body.match, 2); return 'Pushed back'; });
action('/court', ({ engine, body }) => { if (body.op === 'pause') engine.pauseCourt(+body.court, String(body.reason ?? '').slice(0, 60)); else engine.resumeCourt(+body.court); return `Court ${body.court} ${body.op}d`; });
action('/team', ({ t, body }) => {
if (body.op === 'add') { const team = t.registerTeam({ name: body.name, captain: body.captain, players: body.players }, { force: true }); return `Added ${team.name} (code ${team.code})`; }
if (body.op === 'remove') { t.removeTeam(body.id); return 'Team removed'; }
if (body.op === 'seed') { const list = t.activeTeams().filter(x => x.id !== body.id); list.splice(Math.max(0, +body.seed - 1), 0, t.teams.get(body.id)); t.setSeeds(list.map(x => x.id)); return 'Seeds updated'; }
t.updateTeam(body.id, { name: body.name, captain: body.captain, players: body.players }); return 'Team saved';
});
action('/withdraw', ({ t, engine, body, who }) => { const team = t.teams.get(body.id); if (!team) throw new Error('No such team'); if (t.phase === 'live') engine.withdrawTeam(body.id, { mode: body.mode, actor: who }); else t.withdrawTeam(body.id, { mode: body.mode, actor: who }); return `${team.name} withdrawn (${body.mode})`; });
r.post('/admin/t/:slug/delete', async (req, res, { slug }) => {
const who = requireAdmin(req, res); if (!who) return;
const body = await readBody(req);
if (body.confirm !== 'yes') return send.redirect(res, `/admin/t/${slug}?error=Not+confirmed`);
item(slug); registry.remove(slug);
send.redirect(res, '/admin?msg=' + encodeURIComponent('Tournament deleted'));
});
return r;
}
function parseSets(str) {
const sets = String(str ?? '').split(/[,;]/).map(s => s.trim()).filter(Boolean).map(s => {
const m = s.match(/^(\d+)\s*[-:]\s*(\d+)$/); if (!m) throw new Error(`Can't read set "${s}" — use 21-18`);
return [+m[1], +m[2]];
});
if (!sets.length) throw new Error('Enter at least one set, like 21-18');
return sets;
}
const safeNext = n => (typeof n === 'string' && n.startsWith('/') && !n.startsWith('//')) ? n : '/admin';