Files
courtside/server/routes.js
T
bkvargyasandClaude Fable 5.1 1956d25061 Team-name links to team pages, court timing and next-up per court, desk link in footer
Every team name on the board links to /t/<slug>/teams/<id>. Court cards show
average game time, minutes played and the next match predicted for that court
with its expected ready time; the queue shows clock times too. Team schedules
show scores from the team's own perspective. Release 0.3.1.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MB7nCCAscYsb3zzkT6LHZi
2026-09-04 01:48:54 +00:00

167 lines
11 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.
import QRCode from 'qrcode';
import { Router, HttpError, readBody, send, esc } from './http.js';
import { publicState } from './public-state.js';
import { VERSION } from './version.js';
import * as pub from './views/public.js';
import * as adm from './views/admin.js';
export function buildRouter({ registry, auth, store }) {
const r = new Router();
const origin = req => `${(req.headers['x-forwarded-proto'] ?? 'http').split(',')[0]}://${req.headers['x-forwarded-host'] ?? req.headers.host}`;
const item = slug => { const it = registry.get(slug); if (!it) throw new HttpError(404, 'No such tournament'); return it; };
const q = req => new URL(req.url, 'http://x').searchParams;
// ---------- public ----------
r.get('/', (req, res) => send.html(res, pub.homePage(registry.list())));
r.get('/healthz', (req, res) => send.json(res, { ok: true, version: VERSION, tournaments: registry.items.size, uptime: process.uptime() }));
r.get('/t/:slug', (req, res, { slug }) => {
const it = item(slug);
const state = publicState(it);
if (state.phase === 'checkin') return send.html(res, pub.registerPage(state, { error: q(req).get('error') }));
send.html(res, pub.boardPage(state));
});
r.get('/t/:slug/state.json', (req, res, { slug }) => send.json(res, publicState(item(slug))));
r.get('/t/:slug/display', (req, res, { slug }) => send.html(res, pub.boardPage(publicState(item(slug)), { display: true })));
r.get('/t/:slug/team/:code', (req, res, { slug, code }) => {
const it = item(slug);
const team = it.t.teamByCode(code);
if (!team) throw new HttpError(404, 'No team with that code');
const state = publicState(it);
if (state.phase === 'checkin' && q(req).get('new')) return send.html(res, pub.registeredPage(state, team, origin(req)));
send.html(res, pub.boardPage(state, { teamId: team.id }));
});
r.get('/t/:slug/teams/:id', (req, res, { slug, id }) => {
const it = item(slug);
if (!it.t.teams.has(id)) throw new HttpError(404, 'No such team');
send.html(res, pub.boardPage(publicState(it), { teamId: id }));
});
r.post('/t/:slug/register', async (req, res, { slug }) => {
const it = item(slug);
const body = await readBody(req);
try {
const team = registry.mutate(slug, t => t.registerTeam({
name: body.name, captain: String(body.captain ?? '').trim().slice(0, 60), phone: String(body.phone ?? '').trim().slice(0, 30),
players: body.players, playerNames: String(body.playerNames ?? '').split('\n').map(s => s.trim()).filter(Boolean).slice(0, 20),
}));
send.redirect(res, `/t/${slug}/team/${team.code}?new=1`);
} catch (e) {
send.html(res, pub.registerPage(publicState(it), { error: e.message, values: body }), 400);
}
});
// ---------- organizer ----------
const requireAdmin = (req, res) => {
const who = auth.identify(req);
if (!who) { send.redirect(res, `/admin/login?next=${encodeURIComponent(req.url)}`); return null; }
return who;
};
r.get('/admin/login', (req, res) => send.html(res, adm.loginPage(q(req).get('error'), q(req).get('next') ?? '/admin')));
r.post('/admin/login', async (req, res) => {
const body = await readBody(req);
if (!auth.checkPassword(body.password)) return send.redirect(res, '/admin/login?error=' + encodeURIComponent('Wrong password'));
const secure = (req.headers['x-forwarded-proto'] ?? '').includes('https');
send.redirect(res, safeNext(body.next), { 'set-cookie': auth.cookie('organizer', secure) });
});
r.get('/admin/logout', (req, res) => send.redirect(res, '/', { 'set-cookie': auth.clearCookie() }));
r.get('/admin', (req, res) => { const who = requireAdmin(req, res); if (who) send.html(res, adm.adminHome(registry.list(), who, q(req).get('msg'))); });
r.post('/admin/new', async (req, res) => {
const who = requireAdmin(req, res); if (!who) return;
const b = await readBody(req);
const rules = { teamSize: +b.teamSize || 2, pointsTo: +b.pointsTo || 21, winBy: +b.winBy || 2, cap: +b.cap || 0, bestOf: +b.bestOf || 1 };
const it = registry.create({ name: b.name, date: b.date, courtCount: +b.courtCount || 2, rules, stages: String(b.stages ?? 'pool,bracket').split(','), notes: b.notes ?? '' });
send.redirect(res, `/admin/t/${it.t.slug}?msg=${encodeURIComponent('Created. Print the QR code and open registration when ready.')}`);
});
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)));
});
r.get('/admin/t/:slug/qr', async (req, res, { slug }) => {
const who = requireAdmin(req, res); if (!who) return;
const it = item(slug);
const dataUrl = await QRCode.toDataURL(`${origin(req)}/t/${slug}`, { width: 720, margin: 2, errorCorrectionLevel: 'M' });
send.html(res, adm.qrPage(it.t, origin(req), dataUrl, who));
});
r.get('/admin/t/:slug/qr.png', async (req, res, { slug }) => {
const who = requireAdmin(req, res); if (!who) return;
item(slug);
const buf = await QRCode.toBuffer(`${origin(req)}/t/${slug}`, { width: 1200, margin: 2 });
res.writeHead(200, { 'content-type': 'image/png' }); res.end(buf);
});
// Every organizer action: POST, mutate, redirect back with a message (or JSON for fetch callers).
const action = (path, fn) => r.post(`/admin/t/:slug${path}`, async (req, res, params) => {
const who = requireAdmin(req, res); if (!who) return;
const body = await readBody(req);
const wantsJson = (req.headers['content-type'] ?? '').includes('json');
try {
const msg = registry.mutate(params.slug, (t, engine) => fn({ t, engine, body, who, params })) ?? 'Done';
if (wantsJson) send.json(res, { ok: true, msg }); else send.redirect(res, `/admin/t/${params.slug}?msg=${encodeURIComponent(msg)}`);
} catch (e) {
if (e instanceof HttpError) throw e;
if (wantsJson) send.json(res, { ok: false, error: e.message }, 400); else send.redirect(res, `/admin/t/${params.slug}?error=${encodeURIComponent(e.message)}`);
}
});
action('/phase', ({ t, engine, body }) => {
const p = body.phase;
if (p === 'checkin') { t.reopenRegistration(); return 'Registration reopened'; }
if (p === 'closed') { if (t.phase === 'live') throw new Error('Already live; use Final or reopen is not possible'); t.closeRegistration(); return 'Registration closed'; }
if (p === 'live') { if (!t.matches.length) throw new Error('Generate pools or a bracket first'); engine.start(); return 'Live. Courts assigned.'; }
if (p === 'final') { t.phase = 'final'; t.emit({ type: 'phase', phase: 'final', champion: t.champion() }); return 'Marked final'; }
throw new Error('Unknown phase');
});
action('/banner', ({ t, body }) => { t.banner = String(body.banner ?? '').trim().slice(0, 200) || null; t.emit({ type: 'banner', banner: t.banner }); return t.banner ? 'Banner set' : 'Banner cleared'; });
action('/broadcast', ({ t, engine, body }) => { const text = String(body.text ?? '').trim().slice(0, 300); if (!text) throw new Error('Nothing to send'); engine.broadcast(text); return 'Broadcast sent'; });
action('/generate', ({ t, body }) => {
if (body.what === 'pools') { t.generatePools(+body.poolSize || 4); return `Pools generated: ${t.pools.map(p => `${p.id} (${p.teams.length})`).join(', ')}`; }
const seeded = t.pools.length ? t.advanceFromPools({ perPool: +body.perPool || 2, wildcards: +body.wildcards || 0 }) : t.activeTeams().map(x => x.id);
if (seeded.length < 2) throw new Error('Need at least two teams');
const res = t.generateBracket(seeded, { type: body.type === 'double' ? 'double' : 'single', thirdPlace: !!body.thirdPlace });
return `${body.type === 'double' ? 'Double' : 'Single'} elimination bracket of ${res.size} generated with ${seeded.length} teams`;
});
action('/regenerate', ({ t }) => { if (t.matches.some(m => ['final', 'forfeit'].includes(m.status))) throw new Error('Scores already entered'); t.matches = []; t.pools = []; for (const x of t.teams.values()) x.poolId = null; if (t.phase === 'live') t.phase = 'closed'; t.emit({ type: 'play_cleared' }); return 'Generated play cleared'; });
action('/score', ({ t, engine, body, who }) => {
const sets = parseSets(body.sets);
const m = t.match(body.match); if (!m) throw new Error('No such match');
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;
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'; });
action('/forfeit', ({ t, engine, body, who }) => { const m = t.match(body.match); if (!m) throw new Error('No such match'); if (m.status === 'live') engine.recordResult(m.id, [], { actor: who, forfeit: body.team }); else t.recordResult(m.id, [], { actor: who, forfeit: body.team }); return `${t.teamName(body.team)} forfeits`; });
action('/swap', ({ engine, body }) => { engine.swapToCourt(body.match, +body.court); return `Moved to court ${body.court}`; });
action('/pushback', ({ engine, body }) => { engine.pushBack(body.match, 2); return 'Pushed back'; });
action('/court', ({ engine, body }) => { if (body.op === 'pause') engine.pauseCourt(+body.court, String(body.reason ?? '').slice(0, 60)); else engine.resumeCourt(+body.court); return `Court ${body.court} ${body.op}d`; });
action('/team', ({ t, body }) => {
if (body.op === 'add') { const team = t.registerTeam({ name: body.name, captain: body.captain, players: body.players }, { force: true }); return `Added ${team.name} (code ${team.code})`; }
if (body.op === 'remove') { t.removeTeam(body.id); return 'Team removed'; }
if (body.op === 'seed') { const list = t.activeTeams().filter(x => x.id !== body.id); list.splice(Math.max(0, +body.seed - 1), 0, t.teams.get(body.id)); t.setSeeds(list.map(x => x.id)); return 'Seeds updated'; }
t.updateTeam(body.id, { name: body.name, captain: body.captain, players: body.players }); return 'Team saved';
});
action('/withdraw', ({ t, engine, body, who }) => { const team = t.teams.get(body.id); if (!team) throw new Error('No such team'); if (t.phase === 'live') engine.withdrawTeam(body.id, { mode: body.mode, actor: who }); else t.withdrawTeam(body.id, { mode: body.mode, actor: who }); return `${team.name} withdrawn (${body.mode})`; });
r.post('/admin/t/:slug/delete', async (req, res, { slug }) => {
const who = requireAdmin(req, res); if (!who) return;
const body = await readBody(req);
if (body.confirm !== 'yes') return send.redirect(res, `/admin/t/${slug}?error=Not+confirmed`);
item(slug); registry.remove(slug);
send.redirect(res, '/admin?msg=' + encodeURIComponent('Tournament deleted'));
});
return r;
}
function parseSets(str) {
const sets = String(str ?? '').split(/[,;]/).map(s => s.trim()).filter(Boolean).map(s => {
const m = s.match(/^(\d+)\s*[-:]\s*(\d+)$/); if (!m) throw new Error(`Can't read set "${s}" — use 21-18`);
return [+m[1], +m[2]];
});
if (!sets.length) throw new Error('Enter at least one set, like 21-18');
return sets;
}
const safeNext = n => (typeof n === 'string' && n.startsWith('/') && !n.startsWith('//')) ? n : '/admin';