Courtside Manager score keeper page per court; Phase below Courts on the desk; estimated finish on court cards
A per-court signed link from the desk opens a full-screen scoring page with big +/- buttons, undo, best-of sets, and a confirmed Finalize step; it follows whatever match is on the court and can never edit a finished result. Public court cards show the estimated finish time. Release 0.4.0. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01MB7nCCAscYsb3zzkT6LHZi
This commit is contained in:
@@ -5,7 +5,7 @@ export function publicState({ t, engine }) {
|
||||
id: m.id, stage: m.stage, round: m.round, slot: m.slot, label: m.label ?? null, status: m.status, court: m.court,
|
||||
a: m.teamA ? { id: m.teamA, name: name(m.teamA) } : null,
|
||||
b: m.teamB ? { id: m.teamB, name: name(m.teamB) } : null,
|
||||
winner: m.winner, sets: m.sets, live: m.live ?? null, startedAt: m.startedAt ?? null, feeds: m.feeds, feedsSide: m.feedsSide, loserFeeds: m.loserFeeds ?? null, loserFeedsSide: m.loserFeedsSide ?? null, conditional: !!m.conditional,
|
||||
winner: m.winner, sets: m.sets, live: m.live ?? null, liveSets: m.liveSets ?? null, startedAt: m.startedAt ?? null, feeds: m.feeds, feedsSide: m.feedsSide, loserFeeds: m.loserFeeds ?? null, loserFeedsSide: m.loserFeedsSide ?? null, conditional: !!m.conditional,
|
||||
});
|
||||
const board = t.phase === 'live' ? engine.board() : null;
|
||||
const pools = t.pools.map(p => ({
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-copy]').forEach(b => b.addEventListener('click', () => navigator.clipboard?.writeText(b.dataset.copy).then(() => { b.textContent = 'Copied'; })));
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -110,3 +110,25 @@ body.display .queue{font-size:26px}body.display details{display:none}
|
||||
@media (max-width:640px){h1{font-size:34px}.cols{columns:1}.court .t{font-size:20px}.court .s{font-size:24px}}
|
||||
@media (prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}}
|
||||
@media print{.top,.foot,button{display:none}body{background:#fff;color:#000}}
|
||||
/* score keeper (Courtside Manager) */
|
||||
body.keeper-body{background:#0F1418;color:#F2F5F7}body.keeper-body .top,body.keeper-body .foot{display:none}
|
||||
body.keeper-body main{max-width:720px;padding:12px 12px 40px}
|
||||
.khead{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px}
|
||||
.kcourt{font-family:"Barlow Condensed",sans-serif;font-size:28px;font-weight:700;letter-spacing:.04em;text-transform:uppercase}
|
||||
.klbl{font-family:"Barlow Condensed",sans-serif;font-size:14px;letter-spacing:.08em;text-transform:uppercase;color:#8E9BA6;margin-bottom:10px}
|
||||
.kpad{display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
||||
.kside{background:#1A222B;border:2px solid #2B3640;border-radius:12px;padding:12px 10px 10px;display:flex;flex-direction:column;align-items:center;gap:8px}
|
||||
.kside.lead{border-color:#5AA3E6}
|
||||
.kname{font-family:"Barlow Condensed",sans-serif;font-size:22px;font-weight:600;text-align:center;line-height:1.1;min-height:2.2em;display:flex;align-items:center}
|
||||
.kpts{font-size:96px;line-height:1;font-weight:500}
|
||||
.kbtn{width:100%;border-radius:10px;border:0;font-size:44px;line-height:1;padding:22px 0;cursor:pointer;font-family:inherit;touch-action:manipulation;-webkit-tap-highlight-color:transparent}
|
||||
.kbtn.plus{background:#1D6FB5;color:#fff;padding:34px 0}.kbtn.plus:disabled{background:#2B3640;color:#5B6875;cursor:default}.kbtn.plus:active{background:#3A8AD0}
|
||||
.kbtn.minus{background:#2B3640;color:#C9D2DA;font-size:28px;padding:10px 0}.kbtn.minus:active{background:#3A4652}
|
||||
.ksets{margin:12px 0 0;color:#C9D2DA;text-align:center}
|
||||
.kactions{margin-top:14px;display:flex;flex-direction:column;gap:10px;align-items:stretch}
|
||||
.kactions button{font-size:20px;padding:14px}.kactions .big{font-size:24px;padding:18px}
|
||||
.kactions button.small{font-size:15px;padding:8px;background:transparent;color:#C9D2DA;border-color:#2B3640}
|
||||
.kactions button.primary{background:#2E8B57;border-color:#2E8B57;color:#fff}
|
||||
.kconfirm{background:#1A222B;border:2px solid #F0C46B;border-radius:12px;padding:14px;text-align:center}.kconfirm p{margin:0 0 10px}
|
||||
.kwait{text-align:center;padding:40px 10px}.kwait h1{font-size:34px}.knote{background:#3A3120;color:#F0C46B;padding:10px 14px;border-radius:8px;text-align:center}
|
||||
@media (max-width:380px){.kpts{font-size:72px}.kbtn.plus{padding:26px 0}}
|
||||
|
||||
@@ -14,14 +14,14 @@
|
||||
const isMine = m => myTeam && (m.a?.id === myTeam || m.b?.id === myTeam);
|
||||
const phaseLabel = { checkin: 'Registration open', closed: 'Registration closed', live: 'Live', final: 'Final' };
|
||||
const score = m => m.status === 'final' ? m.sets.map(s => `${s[0]}–${s[1]}`).join(', ')
|
||||
: m.status === 'forfeit' ? 'forfeit' : m.live ? `${m.live[0]}–${m.live[1]}` : m.status === 'live' ? '0–0' : '';
|
||||
: m.status === 'forfeit' ? 'forfeit' : m.live ? [...(m.liveSets ?? []), m.live].map(s => `${s[0]}–${s[1]}`).join(', ') : m.status === 'live' ? '0–0' : '';
|
||||
// score from one team's point of view (their points first)
|
||||
const scoreFor = (m, teamId) => {
|
||||
const flip = m.b?.id === teamId;
|
||||
const pair = ([a, b]) => flip ? `${b}–${a}` : `${a}–${b}`;
|
||||
if (m.status === 'final') return m.sets.map(pair).join(', ');
|
||||
if (m.status === 'forfeit') return 'forfeit';
|
||||
if (m.live) return pair(m.live);
|
||||
if (m.live) return [...(m.liveSets ?? []), m.live].map(pair).join(', ');
|
||||
return m.status === 'live' ? '0–0' : '';
|
||||
};
|
||||
const rulesLine = r => `${r.teamSize}s · to ${r.pointsTo}, win by ${r.winBy}${r.cap ? `, cap ${r.cap}` : ''}${r.bestOf > 1 ? `, best of ${r.bestOf}` : ''}`;
|
||||
@@ -74,7 +74,8 @@
|
||||
const avg = state.avgMatchMin;
|
||||
const elapsed = m.startedAt ? Math.max(0, Math.round((Date.now() - m.startedAt) / 60000)) : null;
|
||||
const left = avg != null && elapsed != null ? Math.max(0, avg - elapsed) : null;
|
||||
const timing = avg != null ? `avg game ${avg} min${elapsed != null ? ` · ${elapsed} min played${left ? `, ~${left} left` : ''}` : ''}` : '';
|
||||
const finish = avg != null && m.startedAt ? clock(Math.max(Date.now(), m.startedAt + avg * 60000)) : null;
|
||||
const timing = avg != null ? `avg game ${avg} min${elapsed != null ? ` · ${elapsed} min played` : ''}${finish ? ` · est. finish ~${finish}${left ? ` (${left} min)` : ''}` : ''}` : '';
|
||||
return `<div class="court ${isMine(m) ? 'mine' : ''}"><div class="lbl">Court ${c.court} · ${m.stage === 'pool' ? `Pool ${esc(m.a && state.teams.find(t => t.id === m.a.id)?.poolId || '')} R${m.round}` : (m.label ?? `Bracket R${m.round}`)}</div>
|
||||
<div class="teams"><div class="t">${name(m.a)}</div><div class="s mono">${score(m) || '0–0'}</div><div class="t">${name(m.b)}</div></div>
|
||||
${timing ? `<div class="timing">${timing}</div>` : ''}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/* Courtside Manager — score keeper for one court. Big buttons, one job: keep the running score of
|
||||
whatever match is on this court and finalize it. Corrections after that are the organizer's. */
|
||||
(() => {
|
||||
const root = document.getElementById('keeper');
|
||||
const court = +root.dataset.court, k = root.dataset.k;
|
||||
let state = JSON.parse(document.getElementById('state').textContent);
|
||||
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const base = `/keeper/${state.slug}/${court}`;
|
||||
const post = (path, body) => fetch(`${base}/${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ k, ...body }) }).then(async r => { const j = await r.json().catch(() => ({})); if (!r.ok || !j.ok) throw new Error(j.error || r.statusText); return j; });
|
||||
|
||||
// local scoring state for the match currently on this court
|
||||
let cur = null; // { matchId, a, b, sets: [[a,b],...], history: [] }
|
||||
let busy = false, note = '', confirming = false;
|
||||
|
||||
function courtMatch() { const c = state.courts.find(c => c.court === court); return c?.match && c.match.status === 'live' ? c.match : null; }
|
||||
|
||||
function syncFromState() {
|
||||
const m = courtMatch();
|
||||
if (!m) { cur = null; return; }
|
||||
if (!cur || cur.matchId !== m.id) {
|
||||
cur = { matchId: m.id, a: m.live?.[0] ?? 0, b: m.live?.[1] ?? 0, sets: m.liveSets ? m.liveSets.map(s => [...s]) : [], history: [] };
|
||||
confirming = false; note = '';
|
||||
}
|
||||
}
|
||||
|
||||
const rules = () => state.rules;
|
||||
function setOver(a, b) {
|
||||
const { pointsTo, winBy, cap } = rules();
|
||||
const hi = Math.max(a, b), lo = Math.min(a, b);
|
||||
if (cap && hi >= cap && hi > lo) return true;
|
||||
return hi >= pointsTo && hi - lo >= winBy;
|
||||
}
|
||||
const setsNeeded = () => Math.ceil(rules().bestOf / 2);
|
||||
const setsWon = side => cur.sets.filter(s => (side === 0 ? s[0] > s[1] : s[1] > s[0])).length;
|
||||
|
||||
function render() {
|
||||
syncFromState();
|
||||
const c = state.courts.find(c => c.court === court);
|
||||
const m = courtMatch();
|
||||
const head = `<div class="khead"><span class="kcourt">Court ${court}</span><span class="pill ${state.phase}">${esc(state.name)}</span></div>`;
|
||||
if (!m) {
|
||||
const next = state.upNext.find(u => u.court === court);
|
||||
root.innerHTML = `${head}<div class="kwait"><h1>${c?.status === 'paused' ? 'Court paused' : 'No match on this court'}</h1>
|
||||
<p class="muted">${next ? `Next up: <b>${esc(next.a?.name)} vs ${esc(next.b?.name)}</b> (~${next.etaMin} min). This page will switch to it automatically.` : state.phase === 'final' ? 'Tournament is over.' : 'Waiting for the organizer to assign the next match. This page updates on its own.'}</p>
|
||||
${note ? `<p class="knote">${esc(note)}</p>` : ''}</div>`;
|
||||
return;
|
||||
}
|
||||
const r = rules();
|
||||
const over = setOver(cur.a, cur.b);
|
||||
const matchDone = over && (setsWon(0) + (cur.a > cur.b ? 1 : 0) >= setsNeeded() || setsWon(1) + (cur.b > cur.a ? 1 : 0) >= setsNeeded());
|
||||
const setNo = cur.sets.length + 1;
|
||||
root.innerHTML = `${head}
|
||||
<div class="klbl">${m.stage === 'pool' ? `Pool play · round ${m.round}` : esc(m.label ?? `Bracket round ${m.round}`)} · to ${r.pointsTo}, win by ${r.winBy}${r.cap ? `, cap ${r.cap}` : ''}${r.bestOf > 1 ? ` · best of ${r.bestOf}, set ${setNo}` : ''}</div>
|
||||
<div class="kpad">
|
||||
<div class="kside ${cur.a > cur.b ? 'lead' : ''}"><div class="kname">${esc(m.a?.name)}</div><div class="kpts mono">${cur.a}</div><button type="button" class="kbtn plus" data-op="a+" ${over ? 'disabled' : ''} aria-label="Point ${esc(m.a?.name)}">+</button><button type="button" class="kbtn minus" data-op="a-" aria-label="Undo point ${esc(m.a?.name)}">−</button></div>
|
||||
<div class="kside ${cur.b > cur.a ? 'lead' : ''}"><div class="kname">${esc(m.b?.name)}</div><div class="kpts mono">${cur.b}</div><button type="button" class="kbtn plus" data-op="b+" ${over ? 'disabled' : ''} aria-label="Point ${esc(m.b?.name)}">+</button><button type="button" class="kbtn minus" data-op="b-" aria-label="Undo point ${esc(m.b?.name)}">−</button></div>
|
||||
</div>
|
||||
${cur.sets.length ? `<div class="ksets mono">Sets: ${cur.sets.map(s => `${s[0]}–${s[1]}`).join(', ')}</div>` : ''}
|
||||
<div class="kactions">
|
||||
${r.bestOf > 1 && over && !matchDone ? `<button type="button" class="primary" data-act="endset">End set ${setNo}</button>` : ''}
|
||||
${over && matchDone && !confirming ? `<button type="button" class="primary big" data-act="finalize">Finalize · ${esc((cur.a > cur.b ? m.a : m.b)?.name)} wins</button>` : ''}
|
||||
${confirming ? `<div class="kconfirm"><p>Final score <b class="mono">${[...cur.sets, [cur.a, cur.b]].map(s => `${s[0]}–${s[1]}`).join(', ')}</b>. Once finalized, only the organizer desk can change it.</p><button type="button" class="primary big" data-act="confirm">Yes, finalize</button> <button type="button" data-act="cancel">Not yet</button></div>` : ''}
|
||||
${!over ? `<p class="muted small">Finalize appears when a side reaches ${r.pointsTo} with a ${r.winBy}-point lead${r.cap ? ` or hits the cap of ${r.cap}` : ''}.</p>` : ''}
|
||||
<button type="button" class="small" data-act="undo" ${cur.history.length ? '' : 'disabled'}>Undo last</button>
|
||||
</div>
|
||||
${note ? `<p class="knote">${esc(note)}</p>` : ''}`;
|
||||
}
|
||||
|
||||
let pushTimer = null;
|
||||
function push() {
|
||||
clearTimeout(pushTimer);
|
||||
pushTimer = setTimeout(() => post('live', { match: cur.matchId, a: cur.a, b: cur.b, sets: cur.sets }).then(() => { note = ''; }).catch(e => { note = e.message; render(); }), 120);
|
||||
}
|
||||
function point(side, delta) {
|
||||
if (!cur) return;
|
||||
cur.history.push({ a: cur.a, b: cur.b, sets: cur.sets.map(s => [...s]) });
|
||||
if (side === 'a') cur.a = Math.max(0, cur.a + delta); else cur.b = Math.max(0, cur.b + delta);
|
||||
confirming = false; try { navigator.vibrate?.(15); } catch {}
|
||||
render(); push();
|
||||
}
|
||||
|
||||
root.addEventListener('click', e => {
|
||||
const op = e.target.closest('[data-op]')?.dataset.op; const act = e.target.closest('[data-act]')?.dataset.act;
|
||||
if (op) return point(op[0], op[1] === '+' ? 1 : -1);
|
||||
if (!act || !cur) return;
|
||||
if (act === 'undo') { const h = cur.history.pop(); if (h) { cur.a = h.a; cur.b = h.b; cur.sets = h.sets; confirming = false; render(); push(); } }
|
||||
if (act === 'endset') { cur.history.push({ a: cur.a, b: cur.b, sets: cur.sets.map(s => [...s]) }); cur.sets.push([cur.a, cur.b]); cur.a = 0; cur.b = 0; render(); push(); }
|
||||
if (act === 'finalize') { confirming = true; render(); }
|
||||
if (act === 'cancel') { confirming = false; render(); }
|
||||
if (act === 'confirm') {
|
||||
if (busy) return; busy = true;
|
||||
const sets = [...cur.sets, [cur.a, cur.b]];
|
||||
post('final', { match: cur.matchId, sets }).then(j => { note = j.msg; cur = null; confirming = false; busy = false; }).catch(e => { note = e.message; confirming = false; busy = false; render(); });
|
||||
}
|
||||
});
|
||||
|
||||
// live state: switch to the next match automatically, notice if the desk finalized this one
|
||||
let retry = 1000;
|
||||
function connect() {
|
||||
const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/${state.slug}`);
|
||||
ws.onmessage = ev => { state = JSON.parse(ev.data); retry = 1000; render(); };
|
||||
ws.onclose = () => { setTimeout(connect, retry); retry = Math.min(retry * 2, 15000); };
|
||||
ws.onerror = () => ws.close();
|
||||
}
|
||||
render(); connect();
|
||||
// keep the screen awake while scoring, where supported
|
||||
navigator.wakeLock?.request?.('screen').catch(() => {});
|
||||
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') navigator.wakeLock?.request?.('screen').catch(() => {}); });
|
||||
})();
|
||||
+46
-3
@@ -1,5 +1,5 @@
|
||||
import QRCode from 'qrcode';
|
||||
import { Router, HttpError, readBody, send, esc } from './http.js';
|
||||
import { Router, HttpError, readBody, send, esc, sign, verify } from './http.js';
|
||||
import { publicState } from './public-state.js';
|
||||
import { VERSION } from './version.js';
|
||||
import * as pub from './views/public.js';
|
||||
@@ -50,6 +50,49 @@ export function buildRouter({ registry, auth, store }) {
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- score keeper: a per-court link the organizer hands to whoever is scoring that court ----------
|
||||
// The link carries an HMAC of slug+court signed with the session secret. The page always shows
|
||||
// whatever match is live on that court, can update its running score and finalize it, and moves on
|
||||
// to the next match by itself. Corrections after a match is final need the organizer desk.
|
||||
const keeperToken = (slug, court) => sign(`${slug}:court${court}`, auth.secret).split('.').pop();
|
||||
const keeperUrl = (req, slug, court) => `${origin(req)}/keeper/${slug}/${court}?k=${keeperToken(slug, court)}`;
|
||||
const keeperCheck = (slug, court, k) => { if (!k || verify(`${slug}:court${court}.${k}`, auth.secret) === null) throw new HttpError(403, 'This score keeper link is not valid (links change when the server secret changes; get a fresh one from the desk)'); };
|
||||
const courtMatch = (it, court, matchId) => {
|
||||
const c = it.engine.courts.find(c => c.number === +court); if (!c) throw new HttpError(404, 'No such court');
|
||||
const m = c.matchId ? it.t.match(c.matchId) : null;
|
||||
if (!m || m.status !== 'live') throw new Error('No live match on this court');
|
||||
if (matchId && m.id !== matchId) throw new Error('A different match is on this court now; the page has been refreshed');
|
||||
return m;
|
||||
};
|
||||
r.get('/keeper/:slug/:court', (req, res, { slug, court }) => {
|
||||
const it = item(slug);
|
||||
keeperCheck(slug, court, q(req).get('k'));
|
||||
if (!it.engine.courts.some(c => c.number === +court)) throw new HttpError(404, 'No such court');
|
||||
send.html(res, adm.keeperPage(it.t, +court, q(req).get('k'), publicState(it)));
|
||||
});
|
||||
r.post('/keeper/:slug/:court/live', async (req, res, { slug, court }) => {
|
||||
const body = await readBody(req);
|
||||
keeperCheck(slug, court, body.k);
|
||||
try {
|
||||
registry.mutate(slug, (t, engine) => { const m = courtMatch({ t, engine }, court, body.match); m.live = [Math.max(0, +body.a | 0), Math.max(0, +body.b | 0)]; m.liveSets = Array.isArray(body.sets) ? body.sets.map(s => [+s[0] | 0, +s[1] | 0]).slice(0, 5) : []; });
|
||||
send.json(res, { ok: true });
|
||||
} catch (e) { send.json(res, { ok: false, error: e.message }, 400); }
|
||||
});
|
||||
r.post('/keeper/:slug/:court/final', async (req, res, { slug, court }) => {
|
||||
const body = await readBody(req);
|
||||
keeperCheck(slug, court, body.k);
|
||||
try {
|
||||
const msg = registry.mutate(slug, (t, engine) => {
|
||||
const m = courtMatch({ t, engine }, court, body.match);
|
||||
const sets = (Array.isArray(body.sets) ? body.sets : []).map(s => [+s[0] | 0, +s[1] | 0]);
|
||||
engine.recordResult(m.id, sets, { actor: `keeper:court${court}` });
|
||||
delete m.live; delete m.liveSets;
|
||||
return `${t.teamName(m.winner)} wins ${sets.map(s => s.join('-')).join(', ')}`;
|
||||
});
|
||||
send.json(res, { ok: true, msg });
|
||||
} catch (e) { send.json(res, { ok: false, error: e.message }, 400); }
|
||||
});
|
||||
|
||||
// ---------- organizer ----------
|
||||
const requireAdmin = (req, res) => {
|
||||
const who = auth.identify(req);
|
||||
@@ -76,7 +119,7 @@ export function buildRouter({ registry, auth, store }) {
|
||||
|
||||
r.get('/admin/t/:slug', (req, res, { slug }) => {
|
||||
const who = requireAdmin(req, res); if (!who) return;
|
||||
send.html(res, adm.adminTournament(item(slug), who, origin(req), q(req).get('msg'), q(req).get('error'), store.events(slug, 40)));
|
||||
send.html(res, adm.adminTournament(item(slug), who, origin(req), q(req).get('msg'), q(req).get('error'), store.events(slug, 40), (t, c) => keeperUrl(req, t.slug, c.number)));
|
||||
});
|
||||
r.get('/admin/t/:slug/qr', async (req, res, { slug }) => {
|
||||
const who = requireAdmin(req, res); if (!who) return;
|
||||
@@ -129,7 +172,7 @@ export function buildRouter({ registry, auth, store }) {
|
||||
const wasLive = m.status === 'live';
|
||||
if (wasLive) engine.recordResult(m.id, sets, { actor: who });
|
||||
else { t.recordResult(m.id, sets, { actor: who }); m.court = null; }
|
||||
delete m.live;
|
||||
delete m.live; delete m.liveSets;
|
||||
return `Saved ${t.teamName(m.teamA)} vs ${t.teamName(m.teamB)}: ${sets.map(s => s.join('-')).join(', ')}`;
|
||||
});
|
||||
action('/live', ({ t, body }) => { const m = t.match(body.match); if (!m || m.status !== 'live') throw new Error('Match is not live'); m.live = [Math.max(0, +body.a | 0), Math.max(0, +body.b | 0)]; return 'ok'; });
|
||||
|
||||
+23
-15
@@ -34,7 +34,7 @@ export function adminHome(list, who, msg) {
|
||||
</div>` });
|
||||
}
|
||||
|
||||
export function adminTournament({ t, engine }, who, origin, msg, err, events) {
|
||||
export function adminTournament({ t, engine }, who, origin, msg, err, events, keeperUrl = () => '#') {
|
||||
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 }));
|
||||
@@ -50,6 +50,22 @@ export function adminTournament({ t, engine }, who, origin, msg, err, events) {
|
||||
<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')}
|
||||
|
||||
|
||||
${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>
|
||||
<p class="small"><a class="button small" href="${esc(keeperUrl(t, c))}" target="_blank">Score keeper</a> <button type="button" class="small" data-copy="${esc(keeperUrl(t, c))}">Copy link</button> <span class="muted">Hand this to whoever is scoring Court ${c.number}: it scores whatever match is on this court and finalizes it. Corrections stay here on the desk.</span></p>
|
||||
${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>Phase</h2>
|
||||
<div class="phases">
|
||||
${act('phase', 'Check-in (registration open)', s === 'checkin' ? 'cur' : '', '<input type="hidden" name="phase" value="checkin">')}
|
||||
@@ -62,20 +78,6 @@ export function adminTournament({ t, engine }, who, origin, msg, err, events) {
|
||||
<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">
|
||||
@@ -159,3 +161,9 @@ export function qrPage(t, origin, dataUrl, who) {
|
||||
<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' });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user