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