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:
+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'; });
|
||||
|
||||
Reference in New Issue
Block a user