Files
courtside/server/views/admin.js
T
bkvargyasandClaude Fable 5.1 ae44014e22 Tentative next-up for bracket matches waiting on live courts; per-court score keeper QR codes
Matches whose opponent comes from a live match now show in the queue and under
courts as 'X vs winner on Court 2 · ready ~time', which is what double
elimination needed. Score keeper access is a QR per court on the desk plus a
printable page; the copy-link button is gone. Release 0.4.1.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MB7nCCAscYsb3zzkT6LHZi
2026-09-04 02:28:04 +00:00

179 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, keeper = { url: () => '#', qr: {} }) {
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>${s === 'live' ? ` · <a href="/admin/t/${esc(t.slug)}/keeper-qr">Score keeper QR codes</a>` : ''}</p>
${flash(msg)}${flash(err, 'error')}
${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>
<div class="keeperbox">${keeper.qr[c.number] ? `<img src="${keeper.qr[c.number]}" alt="Score keeper QR for court ${c.number}" width="110" height="110">` : ''}<div><b>Score keeper for Court ${c.number}</b><br><span class="muted small">Whoever is scoring this court scans this. The page follows whatever match is on the court and can finalize it; corrections stay here on the desk.</span><br><a class="button small" href="${esc(keeper.url(c))}" target="_blank">Open on this device</a></div></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>${q.tentative ? '<span class="pill">pending</span> ' : ''}${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>${q.tentative ? '' : 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>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>
<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>` });
}
/** Courtside Manager: the score keeper page for one court. Everything is rendered client-side from state. */
export function keeperPage(t, court, k, state) {
const body = `<div id="keeper" class="keeper" data-court="${court}" data-k="${esc(k)}"><div class="loading">Loading Court ${court}…</div></div>`;
return layout({ title: `Court ${court} score keeper`, body, state, script: '/static/keeper.js', bodyClass: 'keeper-body' });
}
/** Printable page: one score keeper QR per court, to tape to the scorer's table. */
export function keeperQrPage(t, cards, who) {
return layout({ title: `${t.name} score keeper codes`, admin: who, body: `
<h1>${esc(t.name)} · score keeper codes</h1>
<p class="muted">Print this and tape each code at its court. Scanning opens Courtside Manager for that court only. Codes stop working if the server's SESSION_SECRET changes.</p>
<div class="qrgrid">${cards.map(c => `<section class="card qr"><h2>Court ${c.court}</h2><img src="${c.qr}" alt="Score keeper QR for court ${c.court}"><p class="small muted">Score keeper · ${esc(t.name)}</p></section>`).join('')}</div>
<p><button onclick="print()">Print</button> <a class="button" href="/admin/t/${esc(t.slug)}">Back to desk</a></p>` });
}