Files
courtside/server/views/admin.js
T

209 lines
23 KiB
JavaScript
Raw Permalink 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';
import { tzOf, fmtTime } from '../public-state.js';
const TZS = ['America/New_York', 'America/Chicago', 'America/Denver', 'America/Phoenix', 'America/Los_Angeles', 'America/Anchorage', 'Pacific/Honolulu', 'Europe/London', 'Europe/Riga', 'UTC'];
const tzLabel = z => ({ 'America/New_York': 'Eastern', 'America/Chicago': 'Central', 'America/Denver': 'Mountain', 'America/Phoenix': 'Arizona', 'America/Los_Angeles': 'Pacific', 'America/Anchorage': 'Alaska', 'Pacific/Honolulu': 'Hawaii', 'Europe/London': 'UK', 'Europe/Riga': 'Latvia', UTC: 'UTC' }[z] ?? z);
export const tzSelect = (cur) => `<select name="timezone">${TZS.map(z => `<option value="${z}" ${z === cur ? 'selected' : ''}>${tzLabel(z)} (${z})</option>`).join('')}</select>`;
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>Time zone ${tzSelect('America/Chicago')}</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)}/settings" class="inline"><label>Date <input name="date" type="date" value="${esc(t.date ?? '')}"></label><label>Time zone ${tzSelect(tzOf(t))}</label><input name="notes" placeholder="Notes shown on the registration page" value="${esc(t.notes ?? '')}" style="flex:1;min-width:200px"><button class="small" type="submit">Save settings</button></form>
${!t.matches.some(m => ['final', 'forfeit'].includes(m.status)) ? `<form method="post" action="/admin/t/${esc(t.slug)}/settings" class="inline"><span class="muted small">Scoring</span><label>Points to <input name="pointsTo" type="number" value="${t.rules.pointsTo}" min="5" max="50" style="width:64px"></label><label>Win by <input name="winBy" type="number" value="${t.rules.winBy}" min="1" max="5" style="width:56px"></label><label>Cap <input name="cap" type="number" value="${t.rules.cap || 0}" min="0" max="60" style="width:64px" title="0 = no cap"></label><label>Best of <select name="bestOf">${[1, 3, 5].map(n => `<option ${t.rules.bestOf === n ? 'selected' : ''}>${n}</option>`).join('')}</select></label><button class="small" type="submit">Save scoring</button><span class="muted small">Locked once the first score is entered.</span></form>` : ''}
<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><label title="Sorts teams by the average age given at registration and pairs neighbours in round 1; teams without an age are placed at random; byes are spread so round-2 opponents are close in age too. Untick to use seed order instead."><input type="checkbox" name="byAge" value="1" checked> Match round 1 by similar age${t.hasAges() ? '' : ' (no ages yet: fully random)'}</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>` : ''}
${!hasBracket && t.stages.includes('bracket') && (t.pools.length || !t.stages.includes('pool')) ? manualBuilder(t) : ''}
${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>Avg age</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"></td>
<td><input name="avgAge" type="number" value="${x.avgAge ?? ''}" min="5" max="99" style="width:56px" placeholder="—"> <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"><input name="avgAge" type="number" placeholder="avg age" min="5" max="99" style="width:84px"><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">${fmtTime(e.ts, tzOf(t))}</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' });
}
/** Hand-built bracket: one row per first-round match with a team picker on each side. */
function manualBuilder(t) {
const teams = t.activeTeams();
const seeded = t.pools.length ? t.advanceFromPools({ perPool: 2, wildcards: Math.max(0, (1 << Math.ceil(Math.log2(Math.max(2, t.pools.length * 2)))) - t.pools.length * 2) }) : teams.map(x => x.id);
const pairs = !t.pools.length ? t.pairsByAge() : t.defaultPairs(seeded);
const opt = sel => `<option value="">— bye —</option>` + teams.map(x => `<option value="${esc(x.id)}" ${x.id === sel ? 'selected' : ''}>${esc(x.name)}${x.avgAge != null ? ` · age ${x.avgAge}` : ''}${x.seed ? ` (${x.seed})` : ''}</option>`).join('');
return `<details class="manual"><summary>Build the bracket by hand — choose who plays whom in round 1</summary>
<form method="post" action="/admin/t/${esc(t.slug)}/generate" class="manualform">
<input type="hidden" name="what" value="manual">
<div class="inline"><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>
<span class="muted small">${teams.length} teams in a bracket of ${pairs.length * 2}; ${pairs.length * 2 - teams.length} bye${pairs.length * 2 - teams.length === 1 ? '' : 's'}. Every team must appear exactly once; a side left as "bye" gives the other team a walkover.</span></div>
<table class="adm-table pairs"><tr><th>Match</th><th>Side A</th><th></th><th>Side B</th></tr>
${pairs.map(([a, b], i) => `<tr><td>${i + 1}</td><td><select name="a${i + 1}">${opt(a)}</select></td><td class="muted">vs</td><td><select name="b${i + 1}">${opt(b)}</select></td></tr>`).join('')}
</table>
<p class="muted small">${!t.pools.length ? (t.hasAges() ? 'Pre-filled by similar average age (youngest at the top; teams without an age placed at random).' : 'Pre-filled at random (no ages were given).') : `Pre-filled with standard seeding (1 v ${pairs.length * 2}, 2 v ${pairs.length * 2 - 1}, …).`} Winners of matches 1 and 2 meet, 3 and 4 meet, and so on down the sheet.</p>
<button class="primary small" type="submit">Build bracket from these pairings</button>
</form></details>`;
}
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>` });
}