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
+35
View File
@@ -0,0 +1,35 @@
/* Organizer desk: live score pad. Each tap posts the running score so the public board
shows it; "Mark final" submits the set as the result. Plain fetch, no framework. */
(() => {
const slug = location.pathname.split('/')[3];
const post = (path, body) => fetch(`/admin/t/${slug}/${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }).then(async r => { const j = await r.json().catch(() => ({})); if (!r.ok) throw new Error(j.error || r.statusText); return j; });
document.querySelectorAll('.scorepad').forEach(pad => {
const id = pad.dataset.match;
const a = pad.querySelector('[data-side=a]'), b = pad.querySelector('[data-side=b]');
let timer = null;
const push = () => { clearTimeout(timer); timer = setTimeout(() => post('live', { match: id, a: +a.textContent, b: +b.textContent }).catch(e => alert(e.message)), 150); };
pad.querySelectorAll('[data-op]').forEach(btn => btn.addEventListener('click', () => {
const [side, op] = btn.dataset.op;
const el = side === 'a' ? a : b;
el.textContent = Math.max(0, +el.textContent + (op === '+' ? 1 : -1));
push();
}));
const finalBtn = pad.nextElementSibling.querySelector('[data-final]');
finalBtn.addEventListener('click', () => {
const sa = +a.textContent, sb = +b.textContent;
if (!confirm(`Final: ${sa}${sb}?`)) return;
post('score', { match: id, sets: `${sa}-${sb}` }).then(() => location.reload()).catch(e => alert(e.message));
});
});
// keep the desk fresh when another organizer enters a score
const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/${slug}`);
let last = null;
ws.onmessage = ev => {
const s = JSON.parse(ev.data);
const sig = JSON.stringify([s.phase, s.courts.map(c => [c.match?.id, c.match?.status, c.status]), s.upNext.map(u => u.id)]);
if (last && sig !== last && !document.querySelector('.scorepad:hover')) location.reload();
last = sig;
};
})();