Files
courtside/server/public/keeper.js
T
bkvargyasandClaude Fable 5.1 eb58eb9ad9 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
2026-09-04 02:10:40 +00:00

110 lines
7.6 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.
/* 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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(() => {}); });
})();