import { esc } from '../http.js';
import { layout, flash } from './layout.js';
import { phaseLabel } from './public.js';
import { tzOf, fmtTime } from '../public-state.js';
const TZS = ['America/New_York', 'America/Chicago', 'America/Denver', 'America/Phoenix', 'America/Los_Angeles', 'America/Anchorage', 'Pacific/Honolulu', 'Europe/London', 'Europe/Riga', 'UTC'];
const tzLabel = z => ({ 'America/New_York': 'Eastern', 'America/Chicago': 'Central', 'America/Denver': 'Mountain', 'America/Phoenix': 'Arizona', 'America/Los_Angeles': 'Pacific', 'America/Anchorage': 'Alaska', 'Pacific/Honolulu': 'Hawaii', 'Europe/London': 'UK', 'Europe/Riga': 'Latvia', UTC: 'UTC' }[z] ?? z);
export const tzSelect = (cur) => `${TZS.map(z => `${tzLabel(z)} (${z}) `).join('')} `;
export function loginPage(error = null, next = '/admin') {
return layout({ title: 'Organizer sign in', body: `
` });
}
export function adminHome(list, who, msg) {
return layout({ title: 'Desk', admin: who, body: `
Organizer desk ${flash(msg)}
Tournaments ${list.length ? `${list.map(x => `${esc(x.name)} ${esc(phaseLabel(x.phase))} ${esc(x.date ?? '')} · ${x.teams} teams `).join('')} ` : 'None yet.
'}
` });
}
export function adminTournament({ t, engine }, who, origin, msg, err, events, keeper = { url: () => '#', qr: {} }) {
const s = t.phase;
const teams = [...t.teams.values()].sort((a, b) => a.seed - b.seed);
const live = engine.courts.map(c => ({ c, m: c.matchId ? t.match(c.matchId) : null }));
const queue = s === 'live' ? engine.queue().slice(0, 8) : [];
const generated = t.matches.length > 0;
const poolsDone = t.pools.length && t.matches.filter(m => m.stage === 'pool').every(m => ['final', 'forfeit', 'void'].includes(m.status));
const hasBracket = t.matches.some(m => m.stage !== 'pool');
const act = (path, label, cls = '', extra = '') => `${extra}${label} `;
const body = `
Desk · ${esc(t.date ?? '')}
${esc(t.name)} ${esc(phaseLabel(s))}
${t.rules.teamSize}s · to ${t.rules.pointsTo}, win by ${t.rules.winBy}${t.rules.cap ? `, cap ${t.rules.cap}` : ''}, best of ${t.rules.bestOf} · ${t.courtCount} court${t.courtCount > 1 ? 's' : ''} · public page · TV display · QR code ${s === 'live' ? ` · Score keeper QR codes ` : ''}
${flash(msg)}${flash(err, 'error')}
${s === 'live' ? `
Courts
${live.map(({ c, m }) => `
Court ${c.number} ${esc(c.status)}${c.pauseReason ? ` · ${esc(c.pauseReason)}` : ''}
${c.status === 'open' ? act('court', 'Pause', '', ` `) : act('court', 'Resume', 'primary', ` `)}
${keeper.qr[c.number] ? `
` : ''}
Score keeper for Court ${c.number} Whoever is scoring this court scans this. The page follows whatever match is on the court and can finalize it; corrections stay here on the desk. Open on this device
${m ? scorePad(t, m) : '
No match assigned.
'}
`).join('')}
Queue
${queue.length ? `# Match Predicted ${queue.map((q, i) => `${i + 1} ${q.tentative ? 'pending ' : ''}${esc(t.teamName(q.match.teamA))} vs ${esc(t.teamName(q.match.teamB))} ${q.match.stage === 'pool' ? `Pool ${esc(q.match.poolId)} R${q.match.round}` : esc(q.match.label ?? `Bracket R${q.match.round}`)} C${q.court} ~${Math.round(q.eta / 60000)}m ${q.tentative ? '' : act('pushback', 'Push back', '', ` `)} `).join('')}
` : 'Nothing waiting.
'}
` : ''}
Phase
${act('phase', 'Check-in (registration open)', s === 'checkin' ? 'cur' : '', ' ')}
${act('phase', 'Close registration', s === 'closed' ? 'cur' : '', ' ')}
${act('phase', 'Go live', s === 'live' ? 'cur' : '', ' ')}
${act('phase', 'Final', s === 'final' ? 'cur' : '', ' ')}
The public QR page follows this. Going live starts assigning courts immediately.
Date Time zone ${tzSelect(tzOf(t))} Save settings
Set banner
Broadcast
Recent activity ${events.slice(0, 40).map(e => `${fmtTime(e.ts, tzOf(t))} ${esc(describe(e, t))} `).join('')}
`;
return layout({ title: `${t.name} desk`, admin: who, body, script: '/static/admin.js' });
}
/** Hand-built bracket: one row per first-round match with a team picker on each side. */
function manualBuilder(t) {
const teams = t.activeTeams();
const seeded = t.pools.length ? t.advanceFromPools({ perPool: 2, wildcards: Math.max(0, (1 << Math.ceil(Math.log2(Math.max(2, t.pools.length * 2)))) - t.pools.length * 2) }) : teams.map(x => x.id);
const pairs = !t.pools.length ? t.pairsByAge() : t.defaultPairs(seeded);
const opt = sel => `— bye — ` + teams.map(x => `${esc(x.name)}${x.avgAge != null ? ` · age ${x.avgAge}` : ''}${x.seed ? ` (${x.seed})` : ''} `).join('');
return `Build the bracket by hand — choose who plays whom in round 1
Bracket Single elimination Double elimination
3rd place
${teams.length} teams in a bracket of ${pairs.length * 2}; ${pairs.length * 2 - teams.length} bye${pairs.length * 2 - teams.length === 1 ? '' : 's'}. Every team must appear exactly once; a side left as "bye" gives the other team a walkover.
Match Side A Side B
${pairs.map(([a, b], i) => `${i + 1} ${opt(a)} vs ${opt(b)} `).join('')}
${!t.pools.length ? (t.hasAges() ? 'Pre-filled by similar average age (youngest at the top; teams without an age placed at random).' : 'Pre-filled at random (no ages were given).') : `Pre-filled with standard seeding (1 v ${pairs.length * 2}, 2 v ${pairs.length * 2 - 1}, …).`} Winners of matches 1 and 2 meet, 3 and 4 meet, and so on down the sheet.
Build bracket from these pairings
`;
}
function scorePad(t, m) {
const live = m.live ?? [0, 0];
return `
${esc(t.teamName(m.teamA))}
${m.stage === 'pool' ? `Pool ${esc(m.poolId)} R${m.round}` : esc(m.label ?? `Bracket R${m.round}`)}
${esc(t.teamName(m.teamB))}
${live[0]}
to ${t.rules.pointsTo}
${live[1]}
− +
− +
Mark final
Save sets
${esc(t.teamName(m.teamA))} ${esc(t.teamName(m.teamB))} Forfeits
${t.courtCount > 1 ? `${Array.from({ length: t.courtCount }, (_, i) => i + 1).filter(n => n !== m.court).map(n => `${n} `).join('')} Move to court ` : ''}
`;
}
function describe(e, t) {
const n = id => t.teamName(id);
switch (e.type) {
case 'team_registered': return `Registered: ${e.name}`;
case 'team_updated': return `Edited team: ${e.name}`;
case 'team_removed': return `Removed a team`;
case 'team_withdrawn': return `Withdrawn: ${e.name} (${e.mode})`;
case 'phase': return `Phase → ${e.phase}${e.champion ? ` · champion ${e.champion}` : ''}`;
case 'pools_generated': return `Pools generated: ${e.pools.map(p => `${p.id}(${p.teams.length})`).join(' ')}`;
case 'bracket_generated': return `${e.bracket} bracket of ${e.size} generated`;
case 'match_started': return `Court ${e.court}: ${e.a} vs ${e.b}`;
case 'result': { const m = t.match(e.match); return `${m ? `${n(m.teamA)} vs ${n(m.teamB)}` : e.match} → ${n(e.winner)} ${e.score}${e.actor ? ` (${e.actor})` : ''}`; }
case 'alert': return `Alert: ${e.text}`;
case 'court_paused': return `Court ${e.court} paused${e.reason ? `: ${e.reason}` : ''}`;
case 'court_resumed': return `Court ${e.court} resumed`;
case 'court_swapped': return `Match moved from court ${e.from} to ${e.to}`;
case 'match_pushed_back': return `Match pushed back`;
default: return e.type;
}
}
export function qrPage(t, origin, dataUrl, who) {
const url = `${origin}/t/${t.slug}`;
return layout({ title: `${t.name} QR`, admin: who, body: `
${esc(t.name)} Scan to register, then to follow the tournament live.
${esc(url)}
Print Back to desk
` });
}
/** Courtside Manager: the score keeper page for one court. Everything is rendered client-side from state. */
export function keeperPage(t, court, k, state) {
const body = ``;
return layout({ title: `Court ${court} score keeper`, body, state, script: '/static/keeper.js', bodyClass: 'keeper-body' });
}
/** Printable page: one score keeper QR per court, to tape to the scorer's table. */
export function keeperQrPage(t, cards, who) {
return layout({ title: `${t.name} score keeper codes`, admin: who, body: `
${esc(t.name)} · score keeper codes
Print this and tape each code at its court. Scanning opens Courtside Manager for that court only. Codes stop working if the server's SESSION_SECRET changes.
${cards.map(c => `
Court ${c.court} Score keeper · ${esc(t.name)}
`).join('')}
Print Back to desk
` });
}