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:
@@ -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>` });
|
||||
}
|
||||
Reference in New Issue
Block a user