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
+161
View File
@@ -0,0 +1,161 @@
import { esc } from '../http.js';
import { layout, flash } from './layout.js';
import { phaseLabel } from './public.js';
export function loginPage(error = null, next = '/admin') {
return layout({ title: 'Organizer sign in', body: `
<section class="card form" style="max-width:420px;margin:40px auto">
<h1>Organizer desk</h1>${flash(error, 'error')}
<form method="post" action="/admin/login"><input type="hidden" name="next" value="${esc(next)}">
<label>Password <input type="password" name="password" required autofocus></label>
<button class="primary" type="submit">Sign in</button></form>
</section>` });
}
export function adminHome(list, who, msg) {
return layout({ title: 'Desk', admin: who, body: `
<h1>Organizer desk</h1>${flash(msg)}
<div class="grid2">
<section class="card"><h2>Tournaments</h2>${list.length ? `<ul class="plain">${list.map(x => `<li><a href="/admin/t/${esc(x.slug)}"><b>${esc(x.name)}</b></a> <span class="pill ${esc(x.phase)}">${esc(phaseLabel(x.phase))}</span> <span class="muted small">${esc(x.date ?? '')} · ${x.teams} teams</span></li>`).join('')}</ul>` : '<p class="muted">None yet.</p>'}</section>
<section class="card form"><h2>New tournament</h2>
<form method="post" action="/admin/new">
<label>Name <input name="name" required maxlength="60" placeholder="Labor Day 2s"></label>
<label>Date <input name="date" type="date"></label>
<div class="inline"><label>Courts <select name="courtCount"><option>1</option><option selected>2</option><option>3</option><option>4</option></select></label>
<label>Team size <select name="teamSize"><option value="2">2s</option><option value="3">3s</option><option value="4">4s</option><option value="6">6s</option></select></label></div>
<div class="inline"><label>Points to <input name="pointsTo" type="number" value="21" min="5" max="50"></label>
<label>Win by <input name="winBy" type="number" value="2" min="1" max="5"></label>
<label>Cap <input name="cap" type="number" value="25" min="0" max="60" title="0 = no cap"></label>
<label>Best of <select name="bestOf"><option>1</option><option>3</option></select></label></div>
<label>Stages <select name="stages"><option value="pool,bracket">Pools, then bracket</option><option value="pool">Pools only</option><option value="bracket">Bracket only</option></select></label>
<label>Notes for players <textarea name="notes" rows="2" placeholder="Bring water. Rec division starts at 10."></textarea></label>
<button class="primary" type="submit">Create</button>
</form></section>
</div>` });
}
export function adminTournament({ t, engine }, who, origin, msg, err, events) {
const s = t.phase;
const teams = [...t.teams.values()].sort((a, b) => a.seed - b.seed);
const live = engine.courts.map(c => ({ c, m: c.matchId ? t.match(c.matchId) : null }));
const queue = s === 'live' ? engine.queue().slice(0, 8) : [];
const generated = t.matches.length > 0;
const poolsDone = t.pools.length && t.matches.filter(m => m.stage === 'pool').every(m => ['final', 'forfeit', 'void'].includes(m.status));
const hasBracket = t.matches.some(m => m.stage !== 'pool');
const act = (path, label, cls = '', extra = '') => `<form method="post" action="/admin/t/${esc(t.slug)}/${path}">${extra}<button class="small ${cls}" type="submit">${label}</button></form>`;
const body = `
<div class="eyebrow"><a href="/admin">Desk</a> · ${esc(t.date ?? '')}</div>
<h1>${esc(t.name)} <span class="pill ${esc(s)}">${esc(phaseLabel(s))}</span></h1>
<p class="muted">${t.rules.teamSize}s · to ${t.rules.pointsTo}, win by ${t.rules.winBy}${t.rules.cap ? `, cap ${t.rules.cap}` : ''}, best of ${t.rules.bestOf} · ${t.courtCount} court${t.courtCount > 1 ? 's' : ''} · <a href="/t/${esc(t.slug)}" target="_blank">public page</a> · <a href="/t/${esc(t.slug)}/display" target="_blank">TV display</a> · <a href="/admin/t/${esc(t.slug)}/qr">QR code</a></p>
${flash(msg)}${flash(err, 'error')}
<section class="card"><h2>Phase</h2>
<div class="phases">
${act('phase', 'Check-in (registration open)', s === 'checkin' ? 'cur' : '', '<input type="hidden" name="phase" value="checkin">')}
${act('phase', 'Close registration', s === 'closed' ? 'cur' : '', '<input type="hidden" name="phase" value="closed">')}
${act('phase', 'Go live', s === 'live' ? 'cur' : '', '<input type="hidden" name="phase" value="live">')}
${act('phase', 'Final', s === 'final' ? 'cur' : '', '<input type="hidden" name="phase" value="final">')}
</div>
<p class="muted small">The public QR page follows this. Going live starts assigning courts immediately.</p>
<form method="post" action="/admin/t/${esc(t.slug)}/banner" class="inline"><input name="banner" placeholder="Banner on every public page (leave empty to clear)" value="${esc(t.banner ?? '')}" style="flex:1;min-width:240px"><button class="small" type="submit">Set banner</button></form>
<form method="post" action="/admin/t/${esc(t.slug)}/broadcast" class="inline"><input name="text" placeholder="One-time announcement to every team page" style="flex:1;min-width:240px"><button class="small" type="submit">Broadcast</button></form>
</section>
${s === 'live' ? `
<section class="card"><h2>Courts</h2>
<div class="grid2">${live.map(({ c, m }) => `
<div class="card" style="margin:0">
<div class="inline"><b>Court ${c.number}</b> <span class="pill">${esc(c.status)}${c.pauseReason ? ` · ${esc(c.pauseReason)}` : ''}</span>
${c.status === 'open' ? act('court', 'Pause', '', `<input type="hidden" name="court" value="${c.number}"><input type="hidden" name="op" value="pause"><input name="reason" placeholder="reason" style="width:110px">`) : act('court', 'Resume', 'primary', `<input type="hidden" name="court" value="${c.number}"><input type="hidden" name="op" value="resume">`)}
</div>
${m ? scorePad(t, m) : '<p class="muted">No match assigned.</p>'}
</div>`).join('')}</div>
</section>
<section class="card"><h2>Queue</h2>
${queue.length ? `<table class="adm-table"><tr><th>#</th><th>Match</th><th>Predicted</th><th></th></tr>${queue.map((q, i) => `<tr><td>${i + 1}</td><td>${esc(t.teamName(q.match.teamA))} vs ${esc(t.teamName(q.match.teamB))} <span class="muted small">${q.match.stage === 'pool' ? `Pool ${esc(q.match.poolId)} R${q.match.round}` : esc(q.match.label ?? `Bracket R${q.match.round}`)}</span></td><td class="mono">C${q.court} ~${Math.round(q.eta / 60000)}m</td><td>${act('pushback', 'Push back', '', `<input type="hidden" name="match" value="${esc(q.match.id)}">`)}</td></tr>`).join('')}</table>` : '<p class="muted">Nothing waiting.</p>'}
</section>` : ''}
<section class="card"><h2>Play</h2>
${!generated ? `
<form method="post" action="/admin/t/${esc(t.slug)}/generate" class="inline">
${t.stages.includes('pool') ? `<label>Pool size <select name="poolSize"><option>3</option><option selected>4</option><option>5</option><option>6</option></select></label><button class="primary small" type="submit" name="what" value="pools">Generate pools</button>` : ''}
${!t.stages.includes('pool') ? `<label>Bracket <select name="type"><option value="single">Single elimination</option><option value="double">Double elimination</option></select></label><label><input type="checkbox" name="thirdPlace" value="1"> 3rd place</label><button class="primary small" type="submit" name="what" value="bracket">Generate bracket</button>` : ''}
</form>
<p class="muted small">Uses current seeds (${t.activeTeams().length} active teams). Regenerating is allowed until the first score is entered.</p>` : ''}
${t.pools.length ? `<p>Pools: ${t.pools.map(p => `<b>${esc(p.id)}</b> (${p.teams.map(id => esc(t.teamName(id))).join(', ')})`).join(' · ')}</p>` : ''}
${generated && !t.matches.some(m => ['final', 'forfeit'].includes(m.status)) ? act('regenerate', 'Clear generated play', 'danger') : ''}
${t.pools.length && !hasBracket && t.stages.includes('bracket') ? `
<form method="post" action="/admin/t/${esc(t.slug)}/generate" class="inline">
<label>Advance per pool <input name="perPool" type="number" value="2" min="1" max="6" style="width:60px"></label>
<label>Wildcards <input name="wildcards" type="number" value="${Math.max(0, (1 << Math.ceil(Math.log2(Math.max(2, t.pools.length * 2)))) - t.pools.length * 2)}" min="0" max="8" style="width:60px"></label>
<label>Bracket <select name="type"><option value="single">Single elimination</option><option value="double">Double elimination</option></select></label>
<label><input type="checkbox" name="thirdPlace" value="1" checked> 3rd place</label>
<button class="primary small" type="submit" name="what" value="bracket">Generate bracket${poolsDone ? '' : ' (pools not finished)'}</button>
</form>` : ''}
${generated ? `<details><summary>All matches (${t.matches.length}) — enter or correct any score</summary><table class="adm-table"><tr><th>ID</th><th>Stage</th><th>Match</th><th>Status</th><th>Score</th><th></th></tr>
${t.matches.filter(m => !m.conditional || m.teamA).map(m => `<tr><td class="mono">${esc(m.id)}</td><td>${m.stage === 'pool' ? `Pool ${esc(m.poolId)} R${m.round}` : esc(m.label ?? `${m.stage} R${m.round}`)}</td><td>${esc(t.teamName(m.teamA))} vs ${esc(t.teamName(m.teamB))}</td><td>${esc(m.status)}${m.court ? ` C${m.court}` : ''}</td><td class="mono">${m.sets.map(x => x.join('-')).join(', ')}</td>
<td>${m.teamA && m.teamB && !['void', 'bye'].includes(m.status) ? `<form method="post" action="/admin/t/${esc(t.slug)}/score" class="inline"><input type="hidden" name="match" value="${esc(m.id)}"><input name="sets" placeholder="21-18" value="${esc(m.sets.map(x => x.join('-')).join(', '))}" style="width:110px"><button class="small" type="submit">Save</button></form>` : ''}</td></tr>`).join('')}</table></details>` : ''}
</section>
<section class="card"><h2>Teams (${t.activeTeams().length} active)</h2>
<table class="adm-table"><tr><th>Seed</th><th>Team</th><th>Captain</th><th>Players</th><th>Code</th><th></th></tr>
${teams.map(x => `<tr class="${x.status === 'withdrawn' ? 'withdrawn' : ''}">
<td><form method="post" action="/admin/t/${esc(t.slug)}/team" class="inline"><input type="hidden" name="id" value="${esc(x.id)}"><input type="hidden" name="op" value="seed"><input name="seed" type="number" value="${x.seed}" min="1" style="width:56px" ${generated ? 'disabled' : ''}>${generated ? '' : '<button class="small" type="submit">Set</button>'}</form></td>
<td><form method="post" action="/admin/t/${esc(t.slug)}/team" class="inline"><input type="hidden" name="id" value="${esc(x.id)}"><input type="hidden" name="op" value="edit"><input name="name" value="${esc(x.name)}" maxlength="40" style="width:150px"></td>
<td><input name="captain" value="${esc(x.captain ?? '')}" style="width:120px"> <span class="muted small">${esc(x.phone ?? '')}</span></td>
<td><input name="players" type="number" value="${x.players}" min="1" style="width:56px"> <button class="small" type="submit">Save</button></form></td>
<td class="mono"><a href="/t/${esc(t.slug)}/team/${esc(x.code)}" target="_blank">${esc(x.code)}</a></td>
<td>${x.status === 'withdrawn' ? `withdrawn (${esc(x.withdrawalMode)})` : generated
? `<form method="post" action="/admin/t/${esc(t.slug)}/withdraw" class="inline"><input type="hidden" name="id" value="${esc(x.id)}"><select name="mode"><option value="forfeit">forfeit remaining</option><option value="void">void all games</option></select><button class="small danger" type="submit">Withdraw</button></form>`
: act('team', 'Remove', 'danger', `<input type="hidden" name="id" value="${esc(x.id)}"><input type="hidden" name="op" value="remove">`)}</td>
</tr>`).join('')}</table>
${!generated ? `<form method="post" action="/admin/t/${esc(t.slug)}/team" class="inline"><input type="hidden" name="op" value="add"><input name="name" placeholder="Walk-up team name" required maxlength="40"><input name="captain" placeholder="captain"><input name="players" type="number" value="${t.rules.teamSize}" min="1" style="width:64px"><button class="small primary" type="submit">Add team</button></form>` : '<p class="muted small">Play is generated: use Withdraw for teams that leave. To add a team now, clear generated play first.</p>'}
</section>
<section class="card"><h2>Recent activity</h2><ul class="plain small">${events.slice(0, 40).map(e => `<li><span class="mono muted">${new Date(e.ts).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}</span> ${esc(describe(e, t))}</li>`).join('')}</ul></section>
<section class="card"><h2>Danger zone</h2>${act('delete', 'Delete this tournament', 'danger', `<label class="small"><input type="checkbox" name="confirm" value="yes" required> I understand this cannot be undone</label> `)}</section>`;
return layout({ title: `${t.name} desk`, admin: who, body, script: '/static/admin.js' });
}
function scorePad(t, m) {
const live = m.live ?? [0, 0];
return `<div class="scorepad" data-match="${esc(m.id)}">
<div class="team">${esc(t.teamName(m.teamA))}</div><div class="muted small">${m.stage === 'pool' ? `Pool ${esc(m.poolId)} R${m.round}` : esc(m.label ?? `Bracket R${m.round}`)}</div><div class="team" style="text-align:right">${esc(t.teamName(m.teamB))}</div>
<div class="pts mono" data-side="a">${live[0]}</div><div class="muted small" style="text-align:center">to ${t.rules.pointsTo}</div><div class="pts mono" data-side="b">${live[1]}</div>
<div class="btns"><button type="button" data-op="a-"></button><button type="button" data-op="a+">+</button></div><div></div><div class="btns"><button type="button" data-op="b-"></button><button type="button" data-op="b+">+</button></div>
</div>
<div class="inline"><button type="button" class="primary" data-final>Mark final</button>
<form method="post" action="/admin/t/${esc(t.slug)}/score" class="inline"><input type="hidden" name="match" value="${esc(m.id)}"><input name="sets" placeholder="21-18, 19-21, 15-9" style="width:150px"><button class="small" type="submit">Save sets</button></form>
<form method="post" action="/admin/t/${esc(t.slug)}/forfeit" class="inline"><input type="hidden" name="match" value="${esc(m.id)}"><select name="team"><option value="${esc(m.teamA)}">${esc(t.teamName(m.teamA))}</option><option value="${esc(m.teamB)}">${esc(t.teamName(m.teamB))}</option></select><button class="small danger" type="submit">Forfeits</button></form>
${t.courtCount > 1 ? `<form method="post" action="/admin/t/${esc(t.slug)}/swap" class="inline"><input type="hidden" name="match" value="${esc(m.id)}"><select name="court">${Array.from({ length: t.courtCount }, (_, i) => i + 1).filter(n => n !== m.court).map(n => `<option>${n}</option>`).join('')}</select><button class="small" type="submit">Move to court</button></form>` : ''}
</div>`;
}
function describe(e, t) {
const n = id => t.teamName(id);
switch (e.type) {
case 'team_registered': return `Registered: ${e.name}`;
case 'team_updated': return `Edited team: ${e.name}`;
case 'team_removed': return `Removed a team`;
case 'team_withdrawn': return `Withdrawn: ${e.name} (${e.mode})`;
case 'phase': return `Phase → ${e.phase}${e.champion ? ` · champion ${e.champion}` : ''}`;
case 'pools_generated': return `Pools generated: ${e.pools.map(p => `${p.id}(${p.teams.length})`).join(' ')}`;
case 'bracket_generated': return `${e.bracket} bracket of ${e.size} generated`;
case 'match_started': return `Court ${e.court}: ${e.a} vs ${e.b}`;
case 'result': { const m = t.match(e.match); return `${m ? `${n(m.teamA)} vs ${n(m.teamB)}` : e.match}${n(e.winner)} ${e.score}${e.actor ? ` (${e.actor})` : ''}`; }
case 'alert': return `Alert: ${e.text}`;
case 'court_paused': return `Court ${e.court} paused${e.reason ? `: ${e.reason}` : ''}`;
case 'court_resumed': return `Court ${e.court} resumed`;
case 'court_swapped': return `Match moved from court ${e.from} to ${e.to}`;
case 'match_pushed_back': return `Match pushed back`;
default: return e.type;
}
}
export function qrPage(t, origin, dataUrl, who) {
const url = `${origin}/t/${t.slug}`;
return layout({ title: `${t.name} QR`, admin: who, body: `
<section class="card qr"><h1>${esc(t.name)}</h1><p class="lede">Scan to register, then to follow the tournament live.</p><img src="${dataUrl}" alt="QR code for ${esc(url)}"><p class="mono">${esc(url)}</p>
<p><button onclick="print()">Print</button> <a class="button" href="/admin/t/${esc(t.slug)}">Back to desk</a></p></section>` });
}
+32
View File
@@ -0,0 +1,32 @@
import { esc } from '../http.js';
export function layout({ title, body, state = null, script = null, bodyClass = '', admin = null, nav = '' }) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0F1418">
<title>${esc(title)} · Courtside</title>
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
<link rel="manifest" href="/static/manifest.webmanifest">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@500;600;700&family=Source+Sans+3:wght@400;600&family=JetBrains+Mono:wght@500&display=swap">
<link rel="stylesheet" href="/static/app.css">
</head>
<body class="${esc(bodyClass)}">
<header class="top">
<a class="brand" href="/">Courtside</a>
<nav>${nav}${admin ? `<a href="/admin">Desk</a><a href="/admin/logout">Sign out</a>` : ''}</nav>
</header>
<main>
${body}
</main>
<footer class="foot">Courtside · self-hosted tournament desk · <a href="https://gitea.cloudfreeiot.com/bkvargyas/courtside">source</a></footer>
${state ? `<script id="state" type="application/json">${JSON.stringify(state).replace(/</g, '\\u003c')}</script>` : ''}
${script ? `<script src="${esc(script)}" defer></script>` : ''}
</body>
</html>`;
}
export const flash = (msg, kind = 'ok') => msg ? `<div class="flash ${kind}">${esc(msg)}</div>` : '';
+61
View File
@@ -0,0 +1,61 @@
import { esc } from '../http.js';
import { layout, flash } from './layout.js';
export function homePage(list) {
const rows = list.length
? list.map(x => `<a class="row" href="/t/${esc(x.slug)}"><span class="pill ${esc(x.phase)}">${esc(phaseLabel(x.phase))}</span><b>${esc(x.name)}</b><span class="muted">${esc(x.date ?? '')} · ${x.teams} teams</span></a>`).join('')
: `<p class="muted">No tournaments yet. An organizer creates one from the <a href="/admin">desk</a>.</p>`;
return layout({ title: 'Tournaments', body: `<h1>Tournaments</h1><div class="list">${rows}</div>` });
}
export const phaseLabel = p => ({ checkin: 'Registration open', closed: 'Registration closed', live: 'Live', final: 'Final' }[p] ?? p);
/** Registration form, shown while phase = checkin. */
export function registerPage(state, { error = null, values = {} } = {}) {
const r = state.rules;
const body = `
<section class="hero">
<div class="eyebrow">${esc(state.date ?? '')}</div>
<h1>${esc(state.name)}</h1>
<p class="lede">${esc(r.teamSize)}s · ${esc(r.pointsTo)} points, win by ${esc(r.winBy)}${r.cap ? `, cap ${esc(r.cap)}` : ''} · ${state.teams.length} team${state.teams.length === 1 ? '' : 's'} registered</p>
${state.notes ? `<p class="notes">${esc(state.notes)}</p>` : ''}
</section>
${flash(error, 'error')}
<form class="card form" method="post" action="/t/${esc(state.slug)}/register">
<h2>Register your team</h2>
<label>Team name <input name="name" required maxlength="40" autocomplete="off" value="${esc(values.name ?? '')}" placeholder="Net Gains"></label>
<label>Captain's name <input name="captain" required maxlength="60" value="${esc(values.captain ?? '')}"></label>
<label>Captain's mobile <input name="phone" type="tel" inputmode="tel" maxlength="30" value="${esc(values.phone ?? '')}" placeholder="optional, for organizer contact"></label>
<label>Number of players <input name="players" type="number" inputmode="numeric" min="${esc(r.teamSize)}" max="20" required value="${esc(values.players ?? r.teamSize)}"></label>
<label>Player names <textarea name="playerNames" rows="3" placeholder="one per line (optional)">${esc(values.playerNames ?? '')}</textarea></label>
<button class="primary" type="submit">Register</button>
<p class="muted small">After you register you'll get a private team link. Keep this page's QR code handy: once play starts it becomes the live board.</p>
</form>
<section class="card">
<h2>Registered so far</h2>
${state.teams.length ? `<ol class="plain">${state.teams.map(t => `<li>${esc(t.name)} <span class="muted">(${t.players})</span></li>`).join('')}</ol>` : '<p class="muted">Be the first.</p>'}
</section>`;
return layout({ title: state.name, body });
}
export function registeredPage(state, team, origin) {
const link = `${origin}/t/${state.slug}/team/${team.code}`;
const body = `
<section class="hero"><div class="eyebrow">You're in</div><h1>${esc(team.name)}</h1><p class="lede">${team.players} players · captain ${esc(team.captain ?? '')}</p></section>
<section class="card">
<h2>Your team link</h2>
<p>This page will show your next match, which court, and your record. Bookmark it or add it to your home screen.</p>
<p class="linkbox"><a href="${esc(link)}">${esc(link)}</a></p>
<p><button class="primary" type="button" data-copy="${esc(link)}">Copy link</button> <a class="button" href="sms:?&body=${encodeURIComponent(`${team.name} team page: ${link}`)}">Text it to myself</a></p>
<p class="muted small">Team code: <b class="mono">${esc(team.code)}</b>. Anyone with the code can see your team page; nobody can change scores from it.</p>
</section>
<p><a href="/t/${esc(state.slug)}">Back to the tournament</a></p>
<script>document.querySelector('[data-copy]')?.addEventListener('click',e=>{navigator.clipboard?.writeText(e.target.dataset.copy).then(()=>{e.target.textContent='Copied'})});</script>`;
return layout({ title: `${team.name} registered`, body });
}
/** Everything after check-in: closed, live and final are all rendered client-side from the state JSON. */
export function boardPage(state, { teamId = null, display = false } = {}) {
const body = `<div id="app" class="app" data-team="${esc(teamId ?? '')}" data-display="${display ? '1' : ''}"><noscript>This page needs JavaScript to show live scores.</noscript><div class="loading">Loading ${esc(state.name)}…</div></div>`;
return layout({ title: state.name, body, state, script: '/static/board.js', bodyClass: display ? 'display' : '' });
}