/* 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 = `
Court ${court}${esc(state.name)}
`; if (!m) { const next = state.upNext.find(u => u.court === court); root.innerHTML = `${head}

${c?.status === 'paused' ? 'Court paused' : 'No match on this court'}

${next ? `Next up: ${esc(next.a?.name)} vs ${esc(next.b?.name)} (~${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.'}

${note ? `

${esc(note)}

` : ''}
`; 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}
${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}` : ''}
${esc(m.a?.name)}
${cur.a}
${esc(m.b?.name)}
${cur.b}
${cur.sets.length ? `
Sets: ${cur.sets.map(s => `${s[0]}–${s[1]}`).join(', ')}
` : ''}
${r.bestOf > 1 && over && !matchDone ? `` : ''} ${over && matchDone && !confirming ? `` : ''} ${confirming ? `

Final score ${[...cur.sets, [cur.a, cur.b]].map(s => `${s[0]}–${s[1]}`).join(', ')}. Once finalized, only the organizer desk can change it.

` : ''} ${!over ? `

Finalize appears when a side reaches ${r.pointsTo} with a ${r.winBy}-point lead${r.cap ? ` or hits the cap of ${r.cap}` : ''}.

` : ''}
${note ? `

${esc(note)}

` : ''}`; } 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(() => {}); }); })();