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
+73
View File
@@ -0,0 +1,73 @@
// In-memory registry of tournaments. Every mutation goes through `mutate()`, which
// persists the new state and notifies subscribers (the WebSocket layer) with the public view.
import { Tournament } from '../engine/tournament.js';
import { CourtEngine } from '../engine/courts.js';
import { publicState } from './public-state.js';
const slugify = s => String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'tournament';
export class Registry {
constructor(store) {
this.store = store;
this.items = new Map(); // slug -> { t, engine }
this.subscribers = new Map(); // slug -> Set<fn(state)>
for (const row of store.loadAll()) {
const t = Tournament.fromJSON(row.tournament);
t.slug = row.slug;
const engine = row.engine ? CourtEngine.fromJSON(t, row.engine) : new CourtEngine(t, { courtCount: t.courtCount });
this._attach(row.slug, t, engine);
}
}
list() { return [...this.items.entries()].map(([slug, { t }]) => ({ slug, name: t.name, date: t.date, phase: t.phase, teams: t.activeTeams().length, createdAt: t.createdAt })); }
get(slug) { return this.items.get(slug) ?? null; }
create({ name, date, courtCount = 2, rules = {}, stages = ['pool', 'bracket'], notes = '', slug: wanted = null }) {
const base = slugify(wanted ?? name);
let slug = base, n = 2;
while (this.items.has(slug)) slug = `${base}-${n++}`;
const t = new Tournament({ name, courtCount: Number(courtCount), rules, stages });
t.slug = slug; t.date = date || null; t.notes = notes; t.createdAt = Date.now();
const engine = new CourtEngine(t, { courtCount: t.courtCount });
this._attach(slug, t, engine);
this.persist(slug);
return this.items.get(slug);
}
remove(slug) { this.items.delete(slug); this.subscribers.delete(slug); this.store.remove(slug); }
_attach(slug, t, engine) {
this.items.set(slug, { t, engine });
t.on(evt => { try { this.store.logEvent(slug, evt); } catch { /* audit log is best-effort */ } });
}
/** Run fn against a tournament, then persist and broadcast. Throws propagate to the caller. */
mutate(slug, fn) {
const item = this.get(slug);
if (!item) throw new Error('No such tournament');
const result = fn(item.t, item.engine);
// keep the queue/on-deck fresh after any change during live play
if (item.t.phase === 'live') item.engine.tick();
this.persist(slug);
this.notify(slug);
return result;
}
persist(slug) {
const { t, engine } = this.get(slug);
this.store.save(slug, t.toJSON(), engine.toJSON());
}
subscribe(slug, fn) {
if (!this.subscribers.has(slug)) this.subscribers.set(slug, new Set());
this.subscribers.get(slug).add(fn);
return () => this.subscribers.get(slug)?.delete(fn);
}
notify(slug) {
const subs = this.subscribers.get(slug);
if (!subs?.size) return;
const state = publicState(this.get(slug));
for (const fn of subs) { try { fn(state); } catch { /* a dead socket must not break the others */ } }
}
}