Game Features
ci / frontend (push) Successful in 9m13s
ci / backend (push) Failing after 6m40s

This commit is contained in:
khodowany
2026-10-04 23:20:06 -05:00
parent 2759337d16
commit 78b2c383f1
65 changed files with 12650 additions and 563 deletions
+2 -12
View File
@@ -8,18 +8,8 @@
<title>Fantasy Politics</title>
</head>
<body>
<header class="site-header">
<a class="wordmark" href="/" aria-label="Fantasy Politics home">
<span class="monogram" aria-hidden="true">fp.</span>
<span>Fantasy Politics</span>
</a>
<span class="edition">The 2026 edition</span>
</header>
<main id="app"><p class="loading" role="status">Loading the field…</p></main>
<noscript>This prototype needs JavaScript to browse candidates and calculate scores.</noscript>
<footer><span>A game among friends.</span><span>2026 US midterm elections</span></footer>
<div id="app"><p role="status">Loading Fantasy Politics…</p></div>
<noscript>Enable JavaScript to play with your league.</noscript>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>FPOL · Development lab</title></head><body><div id="app"></div><script type="module" src="/src/lab.ts"></script></body></html>
+25
View File
@@ -0,0 +1,25 @@
import { api } from './api';
import { element, escape, time } from './ui';
import type { Catalogs } from './types';
interface Source {
id: string;
kind: string;
enabled: number;
verified: number;
last_checked: number | null;
last_success: number | null;
last_error: string | null;
}
export async function adminPage(signal: AbortSignal) {
const [data, catalogs] = await Promise.all([api<{
sources: Source[];
quarantine: {
source_id: string;
reason: string;
retrieved_at: number;
}[];
}>('admin/sources', undefined, signal), api<Catalogs>('catalogs', undefined, signal)]);
if (signal.aborted)
return;
element('#page').innerHTML = `<h1>Source diagnostics</h1><p class="muted">Operator tools. Catalog imports, source configuration, and password recovery run through the local C++ command-line tools.</p>${catalogs.allow_demo ? '<p><a href="/lab.html">Open the development simulator ↗</a></p>' : ''}<section class="panel"><h2>Feeds</h2>${data.sources.map(s => `<article class="candidate"><strong>${escape(s.id)}</strong><p>${escape(s.kind)} · ${s.verified ? 'verified' : 'unverified'} · ${s.enabled ? 'enabled' : 'disabled'}</p><p>Checked ${time(s.last_checked)} · succeeded ${time(s.last_success)}</p>${s.last_error ? `<p class="error">${escape(s.last_error)}</p>` : ''}</article>`).join('') || '<p>No feeds are configured. Live coverage is not yet verified.</p>'}</section><details class="panel"><summary>Quarantined responses (${data.quarantine.length} recent)</summary>${data.quarantine.map(q => `<p>${escape(q.source_id)} · ${time(q.retrieved_at)}<br>${escape(q.reason)}</p>`).join('') || '<p>No quarantined responses.</p>'}</details>`;
}
+28
View File
@@ -0,0 +1,28 @@
export interface User {
id: string;
username: string;
admin: number;
csrf: string;
}
export let user: User | null = null;
export function setUser(value: User | null) { user = value; }
export class ApiError extends Error {
constructor(message: string, public status: number) { super(message); }
}
let controller = new AbortController();
export function beginPage() { controller.abort(); controller = new AbortController(); return controller.signal; }
export async function api<T>(action: string, input?: object, signal?: AbortSignal): Promise<T> {
const response = await fetch(`/api/${action}`, { method: input ? 'POST' : 'GET', credentials: 'same-origin', signal,
headers: input ? { 'Content-Type': 'application/json', 'X-Fpol-Request': '1', 'X-CSRF-Token': user?.csrf || '' } : {}, body: input ? JSON.stringify(input) : undefined });
const body = await response.json();
if (!response.ok || body.ok === false)
throw new ApiError(body.message || 'The request could not be completed.', response.status);
return body.payload as T;
}
export function query(action: string, values: Record<string, string | number | null | undefined>) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(values))
if (value != null && value !== '')
params.set(key, String(value));
return `${action}?${params}`;
}
+25
View File
@@ -0,0 +1,25 @@
import { api, setUser, user } from './api';
import type { User } from './api';
import { element, escape, field, go, submit } from './ui';
export function authPage(path: string[], refresh: () => void) {
const invite = path[0] === 'join' ? path[1] || '' : '';
const reset = path[0] === 'reset' ? path[1] || '' : '';
element('#page').innerHTML = `<section class="auth"><p class="eyebrow">A little friendly competition</p><h1>${reset ? 'Reset your password' : invite ? 'You’re invited.' : 'Election night, together.'}</h1><p>Draft a fantasy political party. Follow the races. Play with your friends.</p><div class="panel"><h2>${reset ? 'Choose a new password' : 'Sign in'}</h2><form id="signin">${reset ? '' : field('Username', 'username', '', 'autocomplete="username" pattern="[a-zA-Z0-9_]{3,32}"')}${field('Password', 'password', '', 'type="password" autocomplete="' + (reset ? 'new-password' : 'current-password') + '" minlength="12" maxlength="256"')}<button type="submit">${reset ? 'Reset password' : 'Sign in'}</button></form><p class="muted">${reset ? 'Your reset link can be used once.' : 'Need a password reset? Ask the person hosting your league for a reset link.'}</p></div>${invite ? `<details class="panel" open><summary>New here? Create an account</summary><form id="register">${field('Username', 'username', '', 'autocomplete="username" pattern="[a-zA-Z0-9_]{3,32}" minlength="3" maxlength="32"')}${field('Password · at least 12 characters', 'password', '', 'type="password" autocomplete="new-password" minlength="12" maxlength="256"')}${field('Your team name', 'team_name', '', 'maxlength="60"')}<button type="submit">Join your friends</button></form></details>` : !reset ? '<p class="muted">New players join through a league invitation.</p>' : ''}</section>`;
submit('#signin', async (data) => {
if (reset) {
await api('auth/reset', { reset_token: reset, password: data.get('password') });
setUser(null);
go('leagues');
refresh();
return;
}
setUser(await api<User>('auth/login', Object.fromEntries(data)));
refresh();
});
if (invite)
submit('#register', async (data) => { setUser(await api<User>('auth/register', { ...Object.fromEntries(data), invite })); go('leagues'); refresh(); });
}
export function accountPage(refresh: () => void) {
element('#page').innerHTML = `<h1>Your account</h1><p>Signed in as ${escape(user?.username)}</p><section class="panel narrow"><h2>Change password</h2><form id="password">${field('Current password', 'current_password', '', 'type="password" autocomplete="current-password"')}${field('New password', 'password', '', 'type="password" autocomplete="new-password" minlength="12" maxlength="256"')}<button type="submit">Update password</button></form></section>`;
submit('#password', async (data) => { setUser(await api<User>('auth/password', Object.fromEntries(data))); go('leagues'); refresh(); });
}
+45
View File
@@ -0,0 +1,45 @@
import { api, query } from './api';
import { click, element, escape, message, sourceLink } from './ui';
import type { Candidate, CandidatePage } from './types';
export interface CandidateActions {
legal: () => Set<string>;
queued: () => Set<string>;
pick: (candidate: Candidate) => Promise<void>;
queue: (candidate: Candidate) => Promise<void>;
}
export function candidateBrowser(root: HTMLElement, signal: AbortSignal, league?: string, actions?: CandidateActions) {
let offset = 0, sequence = 0;
const pageSize = 30;
root.innerHTML = `<form class="search" role="search"><label class="sr-only" for="candidate-search">Search candidates</label><input id="candidate-search" name="search" type="search" placeholder="Search by name, state, or district" autocomplete="off"><details><summary>Filters</summary><div class="filters"><label>Office<select name="office"><option value="">All offices</option><option value="house">House</option><option value="senate">Senate</option><option value="governor">Governor</option></select></label><label>Party slot<select name="affiliation"><option value="">Any party</option><option value="democrat">Democrat</option><option value="republican">Republican</option><option value="other">Other</option></select></label><label>State<input name="state" maxlength="2" placeholder="e.g. MI"></label>${league ? '<label class="checkbox"><input type="checkbox" name="available" value="true"> Available only</label>' : ''}</div></details></form><p class="muted catalog-note"></p><div class="candidate-list" aria-live="polite"></div><div class="pagination"></div>`;
const form = element<HTMLFormElement>('form', root);
let timer: number | undefined;
async function refresh() {
const ticket = ++sequence;
const values = Object.fromEntries(new FormData(form)) as Record<string, string>;
values.state = values.state?.toUpperCase();
try {
const page = await api<CandidatePage>(query('candidates', { ...values, league_id: league, offset, limit: pageSize }), undefined, signal);
if (signal.aborted || ticket !== sequence)
return;
element('.catalog-note', root).textContent = page.catalog ? `${page.total} candidates · ${page.catalog.mode === 'demo' ? 'Fictional practice pool' : page.catalog.complete ? 'Complete catalog' : 'Partial official catalog · more states to come'}` : 'Candidate data is being prepared.';
element('.candidate-list', root).innerHTML = page.items.length ? page.items.map(c => `<article class="candidate"><div class="candidate-main"><div><strong>${escape(c.name)}</strong><span class="candidate-meta">${escape(c.state)} · ${escape(c.office)} · ${escape(c.affiliation)}${c.owner ? ' · ' + escape(c.owner) : ''}</span></div>${actions ? `<div class="actions"><button class="quiet queue-candidate" data-id="${escape(c.id)}" ${c.owner || actions.queued().has(c.id) ? 'disabled' : ''}>${actions.queued().has(c.id) ? 'Queued' : '+ Queue'}</button><button class="pick-candidate" data-id="${escape(c.id)}" ${!actions.legal().has(c.id) ? 'disabled' : ''}>Draft</button></div>` : `<span class="badge">${c.rating ? escape(c.rating.replace('_', ' ')) : 'Unrated'}</span>`}</div><details><summary>Race & source</summary><p>${escape(c.race_id)} · ${escape(c.party || c.affiliation)}</p><p>Rating: ${c.rating ? escape(c.rating.replace('_', ' ')) : 'Awaiting a sourced rating'}. November first-round share plus a 50-point winner bonus, multiplied by the frozen race rating.</p><p>${sourceLink(c.source.url, c.source.name)} · retrieved ${escape(c.source.retrieved_at)}</p></details></article>`).join('') : '<p class="empty">No candidates match this search.</p>';
element('.pagination', root).innerHTML = page.total ? `<button class="secondary previous" ${offset === 0 ? 'disabled' : ''}>Previous</button><span>${offset + 1}–${Math.min(offset + pageSize, page.total)} of ${page.total}</span><button class="secondary next" ${offset + pageSize >= page.total ? 'disabled' : ''}>Next</button>` : '';
click('.previous', async () => { offset = Math.max(0, offset - pageSize); await refresh(); }, root);
click('.next', async () => { offset += pageSize; await refresh(); }, root);
for (const candidate of page.items)
if (actions) {
click(`.pick-candidate[data-id="${candidate.id}"]`, async () => { await actions.pick(candidate); await refresh(); }, root);
click(`.queue-candidate[data-id="${candidate.id}"]`, async () => { await actions.queue(candidate); await refresh(); }, root);
}
}
catch (error) {
message(error);
}
}
form.addEventListener('submit', event => { event.preventDefault(); offset = 0; void refresh(); });
form.addEventListener('input', () => { offset = 0; clearTimeout(timer); timer = window.setTimeout(() => void refresh(), 250); });
signal.addEventListener('abort', () => clearTimeout(timer));
void refresh();
return refresh;
}
export function candidatesPage(signal: AbortSignal, league?: string) { element('#page').innerHTML = '<p class="eyebrow">Meet the field</p><h1>Candidates</h1><div id="browser"></div>'; candidateBrowser(element('#browser'), signal, league); }
+58
View File
@@ -0,0 +1,58 @@
import { api, query, user } from './api';
import { candidateBrowser } from './candidates';
import { click, element, escape, message } from './ui';
import type { LeagueView } from './types';
export async function draftPage(id: string, signal: AbortSignal) {
let view = await api<LeagueView>(query('league', { league_id: id }), undefined, signal);
if (signal.aborted)
return;
element('#page').innerHTML = `<a class="back" href="#league/${id}">← ${escape(view.league.name)}</a><h1>Draft room</h1>${view.league.catalog_mode === 'demo' ? '<p class="badge">Practice · fictional candidates</p>' : ''}<section class="turn panel" aria-live="polite"><div id="turn-label"></div><div class="clock" id="clock"></div><div id="draft-controls"></div></section><div class="draft-layout"><section><div id="browser"></div></section><aside><section class="panel"><h2>Your private queue</h2><p class="muted">On timeout, the first legal candidate here is picked. Otherwise, a legal candidate is chosen at random.</p><ol id="queue"></ol></section><section class="panel"><h2>Recent picks</h2><ol id="recent"></ol></section><details class="panel"><summary>All rosters</summary><div id="rosters"></div></details></aside></div>`;
let received = Date.now(), poll: number | undefined;
let revision = '';
let drawing = '';
const refreshCandidates = candidateBrowser(element('#browser'), signal, id, { legal: () => new Set(view.league.status === 'drafting' ? view.legal_candidate_ids : []), queued: () => new Set(view.my_team.queue), pick: async (c) => { await api('draft/pick', { league_id: id, candidate_id: c.id, pick_number: view.draft.pick_number, request_id: crypto.randomUUID() }); await refresh(); }, queue: async (c) => { await api('draft/queue', { league_id: id, candidates: [...view.my_team.queue, c.id] }); await refresh(); } });
function draw() {
const status = view.league.status;
const commissioner = view.league.commissioner_id === user?.id;
const picker = view.picker;
element('#turn-label').innerHTML = status === 'forming' ? `<h2>Before the first pick</h2><p>${view.readiness.ready ? 'Everyone is ready. Your commissioner can start the draft.' : view.readiness.problems.map(escape).join(' · ')}</p>` : status === 'drafted' ? '<h2>Draft complete</h2><p>Your party is ready for election night.</p>' : `<p class="eyebrow">Round ${Math.floor(view.draft.pick_number / view.teams.length) + 1} of 12 · Pick ${view.draft.pick_number + 1}</p><h2>${picker?.user_id === user?.id ? 'You’re on the clock' : escape(picker?.name) + ' is picking'}</h2>${status === 'paused' ? '<p>Draft paused. Your commissioner can resume it.</p>' : ''}`;
element('#draft-controls').innerHTML = commissioner ? (status === 'forming' ? `<button id="start" ${view.readiness.ready ? '' : 'disabled'}>Start draft</button>` : status === 'drafting' ? '<button class="secondary" id="pause">Pause draft</button>' : status === 'paused' ? '<button id="resume">Resume draft</button>' : '') : '';
for (const action of ['start', 'pause', 'resume'])
click(`#${action}`, async () => { await api(`draft/${action}`, { league_id: id }); await refresh(); });
element('#queue').innerHTML = view.my_team.queue.length ? view.my_team.queue.map((candidate, index) => `<li><span>${escape(view.candidate_names[candidate] || candidate)}</span><div><button class="quiet up" data-index="${index}" ${index === 0 ? 'disabled' : ''} aria-label="Move ${escape(view.candidate_names[candidate] || candidate)} earlier">↑</button><button class="quiet unqueue" data-index="${index}" aria-label="Remove ${escape(view.candidate_names[candidate] || candidate)} from queue">×</button></div></li>`).join('') : '<li class="muted">Add candidates from the list.</li>';
view.my_team.queue.forEach((_, index) => { click(`.up[data-index="${index}"]`, async () => { const queue = [...view.my_team.queue]; [queue[index - 1], queue[index]] = [queue[index], queue[index - 1]]; await api('draft/queue', { league_id: id, candidates: queue }); await refresh(); }); click(`.unqueue[data-index="${index}"]`, async () => { await api('draft/queue', { league_id: id, candidates: view.my_team.queue.filter((_, i) => i !== index) }); await refresh(); }); });
element('#recent').innerHTML = view.picks.slice(-6).reverse().map(p => `<li><strong>${escape(view.candidate_names[p.candidate_id] || p.candidate_id)}</strong><small>${escape(view.teams.find(t => t.id === p.team_id)?.name)} · ${escape(p.slot)}${p.automatic ? ' · auto' : ''}</small></li>`).join('') || '<li class="muted">The first pick is still ahead.</li>';
element('#rosters').innerHTML = view.teams.map(t => `<details><summary>${escape(t.name)}</summary><ul>${view.picks.filter(p => p.team_id === t.id).map(p => `<li>${escape(view.candidate_names[p.candidate_id] || p.candidate_id)}<small>${escape(p.slot)}</small></li>`).join('') || '<li>No picks yet.</li>'}</ul></details>`).join('');
}
async function refresh() {
const previousQueue = JSON.stringify(view.my_team.queue);
const next = await api<LeagueView>(query('league', { league_id: id }), undefined, signal);
if (signal.aborted)
return;
view = next;
received = Date.now();
const state = JSON.stringify({ ...view, server_time: 0 });
if (state !== drawing) {
drawing = state;
draw();
}
if (revision !== view.league.status + view.picks.length || previousQueue !== JSON.stringify(view.my_team.queue)) {
revision = view.league.status + view.picks.length;
await refreshCandidates();
}
}
function clock() { if (signal.aborted)
return; const remaining = view.league.status === 'drafting' && view.draft.deadline ? Math.max(0, view.draft.deadline - view.server_time - Math.floor((Date.now() - received) / 1000)) : view.league.status === 'paused' ? view.draft.remaining : null; element('#clock').textContent = remaining == null ? '' : `${Math.floor(remaining / 60)}:${String(remaining % 60).padStart(2, '0')}`; }
async function loop() { try {
await refresh();
}
catch (error) {
message(error);
} if (!signal.aborted)
poll = window.setTimeout(() => void loop(), 2000); }
draw();
clock();
const ticker = window.setInterval(clock, 500);
poll = window.setTimeout(() => void loop(), 2000);
signal.addEventListener('abort', () => { clearTimeout(poll); clearInterval(ticker); });
}
+73
View File
@@ -0,0 +1,73 @@
:root { font-family: 'Avenir Next', Avenir, 'Segoe UI', sans-serif; color: #213632; background: #f6f4ee; font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; --muted: #596760; --line: #d6dbd2; }
* { box-sizing: border-box; }
body { margin: 0; }
a { color: inherit; text-underline-offset: 3px; }
:focus-visible { outline: 3px solid #98602c; outline-offset: 3px; }
.site-header, main, footer { width: min(1200px, calc(100% - 64px)); margin-inline: auto; }
.site-header { min-height: 94px; display: flex; align-items: center; justify-content: space-between; gap: 24px; border-bottom: 1px solid var(--line); }
.wordmark { display: inline-flex; align-items: center; gap: 14px; text-decoration: none; font-size: 17px; font-weight: 650; letter-spacing: -.03em; }
.monogram { display: grid; place-items: center; width: 43px; height: 43px; border: 1px solid #213632; border-radius: 50%; font-family: Georgia, serif; font-size: 25px; font-weight: 400; padding-bottom: 4px; }
.edition, .eyebrow { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .13em; }
.intro { padding: 56px 0 36px; }
.eyebrow { margin: 0 0 14px; font-weight: 650; }
h1 { margin: 0; font-family: Georgia, serif; font-size: clamp(38px, 5.5vw, 70px); font-weight: 400; line-height: 1.08; letter-spacing: -.05em; }
h1 em { font-weight: 400; color: #67765e; }
.description { max-width: 600px; margin: 22px 0 0; font-size: 17px; line-height: 1.7; color: var(--muted); }
h2 { font: 400 32px Georgia, serif; margin: 0; letter-spacing: -.02em; }
p { line-height: 1.65; }
.small, .muted, small { font-size: 12px; color: var(--muted); }
.workspace { padding: 24px; background: #e9ece3; border: 1px solid var(--line); }
.workspace > label { max-width: 510px; }
label { display: flex; flex-direction: column; gap: 7px; font-size: 12px; font-weight: 600; }
select, input { font: inherit; font-size: 14px; background: #fffefa; border: 1px solid #b3bdb0; border-radius: 4px; color: #213632; padding: 10px 12px; min-width: 0; max-width: 100%; min-height: 42px; }
select { width: 100%; }
button { font: inherit; font-size: 13px; font-weight: 600; color: #fffefa; background: #294d41; padding: 11px 17px; border: 1px solid #294d41; border-radius: 4px; cursor: pointer; min-height: 42px; }
button:hover { background: #3f6150; }
button.secondary { background: transparent; color: #294d41; }
button:disabled { opacity: .5; cursor: default; }
.notice { border-left: 3px solid #8d784f; padding: 10px 14px; background: #f6f1e4; font-size: 13px; }
.notice.real { border-color: #9b5d2d; }
.notice strong { margin-right: 6px; }
.section-nav { display: flex; flex-wrap: wrap; gap: 30px; padding: 25px 0; border-bottom: 1px solid var(--line); font-size: 13px; }
.section-nav a { text-decoration: none; }
.panel { padding: 38px 0; border-bottom: 1px solid var(--line); scroll-margin-top: 20px; }
.section-heading { display: flex; justify-content: space-between; align-items: center; gap: 20px; margin-bottom: 24px; }
.filters { display: grid; grid-template-columns: 2fr repeat(4, 1fr); gap: 12px; margin-bottom: 20px; }
.table-scroll { overflow: auto; max-height: 440px; border: 1px solid var(--line); }
table { width: 100%; border-collapse: collapse; font-size: 13px; text-align: left; }
th { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); background: #e9ece3; position: sticky; top: 0; z-index: 1; white-space: nowrap; }
th, td { padding: 14px 16px; border-bottom: 1px solid var(--line); }
tbody tr:last-child td { border: 0; }
td strong { font-weight: 600; }
.cell-note { display: block; font-size: 11px; color: var(--muted); margin-top: 5px; }
.tag { display: inline-block; border: 1px solid var(--line); padding: 4px 7px; font-size: 11px; white-space: nowrap; border-radius: 3px; background: #eeefe7; }
details { margin-top: 22px; }
summary { cursor: pointer; font-size: 14px; padding: 8px 0; }
.capacity-controls { display: flex; align-items: end; gap: 22px; margin-top: 15px; }
.capacity-controls label { min-width: 145px; }
.warning { color: #914518; font-weight: 600; }
.actions { display: flex; gap: 10px; }
.roster-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; margin-top: 25px; }
fieldset { margin: 0; padding: 22px; border: 1px solid var(--line); background: #fdfcf7; min-width: 0; }
legend { font: 23px Georgia, serif; padding: 0 8px; }
fieldset label + label { margin-top: 15px; }
#roster-status { font-size: 13px; }
.formula { font: 23px Georgia, serif; line-height: 1.6; }
.rating-key { display: flex; gap: 24px; flex-wrap: wrap; font-size: 13px; }
.rating-key strong { margin-left: 6px; }
.replay-controls { display: grid; grid-template-columns: 1fr 1.5fr auto; align-items: start; gap: 24px; margin-top: 25px; }
.replay-controls button { margin-top: 21px; }
input[type=range] { width: 100%; accent-color: #294d41; padding: 0; border: 0; background: transparent; }
.metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: var(--line); border: 1px solid var(--line); margin: 25px 0; }
.metrics > div { background: #e9ece3; padding: 22px; display: flex; flex-direction: column; gap: 10px; }
.metrics span { font-size: 12px; }
.metrics strong { font: 42px Georgia, serif; font-variant-numeric: tabular-nums; }
.metrics small { line-height: 1.5; }
.comparison { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin: 20px 0; font-size: 12px; }
.comparison i { display: block; height: 8px; min-width: 2px; background: #8a987b; margin-top: 10px; transition: width .3s; }
.comparison > div:last-child i { background: #294d41; }
footer { display: flex; justify-content: space-between; gap: 20px; padding: 24px 0 30px; font-size: 11px; color: var(--muted); }
.loading { padding: 60px 0; }
@media (max-width: 800px) { .filters { grid-template-columns: 1fr 1fr; } .search-label { grid-column: 1 / -1; } .roster-grid { grid-template-columns: 1fr; } .replay-controls { grid-template-columns: 1fr; gap: 16px; } .replay-controls button { margin: 0; } .section-heading { align-items: start; flex-direction: column; } .metrics { grid-template-columns: 1fr; } }
@media (max-width: 500px) { .site-header, main, footer { width: calc(100% - 32px); } .edition { max-width: 90px; text-align: right; line-height: 1.6; } .wordmark { gap: 9px; font-size: 14px; } .workspace { padding: 16px; } .section-nav { gap: 15px; font-size: 12px; } .capacity-controls { align-items: stretch; flex-direction: column; gap: 0; } footer { flex-direction: column; gap: 8px; } }
@media (prefers-reduced-motion: reduce) { .comparison i { transition: none; } }
+172
View File
@@ -0,0 +1,172 @@
import './lab.css';
type Source = { name: string; url: string; retrieved_at: string };
type Race = { id: string; office: string; state: string; name: string; round: string };
type Candidate = { id: string; race_id: string; name: string; affiliation: string; ballot_lines: string[]; source: Source };
type Document = { id: string; kind: string; mode: 'demo' | 'real'; label: string; as_of: string; source: Source; catalog_id?: string };
type Catalog = Document & { complete: boolean; races: Race[]; candidates: Candidate[] };
type Ratings = Document & { entries: { race_id: string; rating: string }[] };
type Slot = { id: string; office: string; affiliation: string };
type Rules = { slots: Slot[]; formula: string; rounding: string; multipliers: Record<string, number> };
type Capacity = { offices: { office: string; requirements: { group: string; available: number; required: number; shortfall: number }[] }[]; sufficient_candidates: boolean; catalog_complete: boolean };
type Score = { slot: string; candidate_id: string; points: number | null; reason?: string; vote_share?: number; win_bonus?: number; multiplier?: number; baseline_points?: number; leader_preview_points?: number | null; status?: string };
type Preview = { valid: boolean; complete_roster: boolean; errors: string[]; scores: Score[]; missing_scores?: number; unavailable_leader_previews?: number; known_subtotal?: number; baseline_subtotal?: number; leader_preview_subtotal?: number; total: number | null };
const root = document.querySelector<HTMLElement>('#app')!;
let documents: Document[] = [];
let rules: Rules;
let catalog: Catalog;
let ratingId = '';
let roster: Record<string, string> = {};
let step = 0;
let playing: ReturnType<typeof setInterval> | undefined;
let previewVersion = 0;
let capacityVersion = 0;
const labels: Record<string, string> = { house: 'House', senate: 'Senate', governor: 'Governor', democrat: 'Democrat', republican: 'Republican', other: 'Other', flex: 'Flex', safe: 'Safe', likely: 'Likely', lean: 'Lean', toss_up: 'Toss-up', total: 'All candidates' };
const escape = (value: unknown) => String(value).replace(/[&<>"']/g, character => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character]!);
const label = (value: string) => labels[value] ?? value;
const money = (value: number | null | undefined) => value == null ? '—' : value.toFixed(2);
const element = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
const ratings = () => documents.filter(document => document.kind === 'ratings' && document.catalog_id === catalog.id) as Ratings[];
const results = () => documents.filter(document => document.kind === 'results' && document.catalog_id === catalog.id);
const selectedRatings = () => ratings().find(rating => rating.id === ratingId);
const raceFor = (candidate: Candidate) => catalog.races.find(race => race.id === candidate.race_id)!;
const fits = (candidate: Candidate, slot: Slot) => raceFor(candidate).office === slot.office && (slot.affiliation === 'flex' || candidate.affiliation === slot.affiliation);
const options = (items: { id: string; name: string }[], selected = '') => items.map(item => `<option value="${escape(item.id)}" ${item.id === selected ? 'selected' : ''}>${escape(item.name)}</option>`).join('');
function sourceLink(source: Source): string {
if (!/^https?:\/\//.test(source.url)) return '<span class="muted">Fictional example</span>';
return `<a href="${escape(source.url)}" target="_blank" rel="noopener noreferrer">Source ↗</a> <span class="muted">${escape(source.retrieved_at.slice(0, 10))}</span>`;
}
async function api<T>(path: string, body?: unknown): Promise<T> {
const response = await fetch(`/api/${path}`, body === undefined ? {} : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const envelope = await response.json();
if (!response.ok || !envelope.ok) throw new Error(envelope.message ?? `Request failed (${response.status})`);
return envelope.payload as T;
}
function stopReplay() {
clearInterval(playing);
playing = undefined;
const button = document.getElementById('play');
if (button) button.textContent = 'Play replay';
}
function render() {
stopReplay();
++previewVersion;
++capacityVersion;
const snapshots = results();
root.innerHTML = `
<section class="intro"><p class="eyebrow">The draft starts with a little homework.</p><h1>Build your party.<br><em>Make every race count.</em></h1><p class="description">Try your 12 picks, explore the field, and replay a fictional election night. A game among friends, taking shape for 2026.</p></section>
<section class="workspace" aria-label="Data selection"><label>Candidate catalog<select id="dataset">${options(documents.filter(item => item.kind === 'catalog').map(item => ({ id: item.id, name: item.label })), catalog.id)}</select></label><p class="notice ${catalog.mode}"><strong>${catalog.mode === 'demo' ? 'SYNTHETIC DEMO' : catalog.complete ? 'REAL CATALOG' : 'PARTIAL REAL CATALOG'}</strong> ${catalog.mode === 'demo' ? 'Every candidate, rating, vote and call in this dataset is fictional.' : catalog.complete ? 'Candidate coverage is marked complete by its curator. Ratings and live coverage still need verification before a draft.' : 'Candidate coverage is incomplete. Verify the remaining candidates, real ratings and live results before a draft.'}</p><p class="small">Catalog dated ${escape(catalog.as_of)} · ${sourceLink(catalog.source)}</p></section>
<nav class="section-nav" aria-label="Prototype sections"><a href="#field">01 / The field</a><a href="#party">02 / Your party</a><a href="#night">03 / Election night</a></nav>
<section id="field" class="panel"><div class="section-heading"><div><p class="eyebrow">01 / Scout your picks</p><h2>The field</h2></div><span id="candidate-count" class="small"></span></div>
<div class="filters"><label class="search-label">Search candidates<input id="search" type="search" placeholder="Name or race"></label>${['office', 'affiliation', 'state', 'rating'].map(filter => `<label>${filter === 'affiliation' ? 'Party' : label(filter)}<select id="filter-${filter}"><option value="">All ${filter === 'affiliation' ? 'parties' : filter + 's'}</option>${filter === 'state' ? options([...new Set(catalog.races.map(race => race.state))].sort().map(state => ({ id: state, name: state }))) : options((filter === 'office' ? ['house', 'senate', 'governor'] : filter === 'affiliation' ? ['democrat', 'republican', 'other'] : ['safe', 'likely', 'lean', 'toss_up', 'unrated']).map(id => ({ id, name: label(id) })))}</select></label>`).join('')}</div>
<div class="table-scroll"><table><thead><tr><th>Candidate</th><th>Race</th><th>Party / ballot lines</th><th>Frozen rating</th><th>Provenance</th></tr></thead><tbody id="candidates"></tbody></table></div>
<details><summary>Can everyone fill a roster?</summary><div class="capacity-controls"><label>League size<select id="players">${options(Array.from({ length: 11 }, (_, index) => ({ id: String(index + 2), name: `${index + 2} players` })), '12')}</select></label><p class="small">Each office needs one D, one R, one Other and one Flex per player. Counts include all race ratings.</p></div><div id="capacity" aria-live="polite"></div></details>
</section>
<section id="party" class="panel"><div class="section-heading"><div><p class="eyebrow">02 / Twelve picks. Three offices.</p><h2>Your party</h2></div><div class="actions">${catalog.mode === 'demo' ? '<button id="fill">Try an example roster</button>' : ''}<button id="clear" class="secondary">Clear picks</button></div></div><p class="small">One Democrat, one Republican, one Other and one Flex in each office. Other includes independent and third-party candidates. Flex accepts any party in that office. These picks stay in this page only.</p><div class="roster-grid">${['house', 'senate', 'governor'].map(office => `<fieldset><legend>${label(office)}</legend>${rules.slots.filter(slot => slot.office === office).map(slot => `<label>${label(slot.affiliation)}<select data-slot="${escape(slot.id)}" aria-label="${label(office)} ${label(slot.affiliation)}"></select></label>`).join('')}</fieldset>`).join('')}</div><p id="roster-status" role="status"></p></section>
<section id="night" class="panel"><div class="section-heading"><div><p class="eyebrow">03 / Follow the swings</p><h2>Election night, on replay.</h2></div><span class="tag">${catalog.mode === 'demo' ? 'Fictional results' : 'No verified live feed'}</span></div><p class="formula">${escape(rules.formula)}</p><div class="rating-key">${Object.entries(rules.multipliers).map(([rating, factor]) => `<span>${label(rating)} <strong>×${factor.toFixed(2)}</strong></span>`).join('')}</div><p class="small">${escape(rules.rounding)} Ratings are selected as a single snapshot here; a real league will freeze one before the draft. A lead is not a win. Corrections can reverse awarded points.</p><div class="replay-controls"><label>Rating snapshot<select id="rating-snapshot" ${ratings().length ? '' : 'disabled'}>${ratings().length ? options(ratings().map(item => ({ id: item.id, name: item.label })), ratingId) : '<option>No ratings imported</option>'}</select></label><label class="timeline">Result snapshot<input id="timeline" type="range" min="0" max="${Math.max(0, snapshots.length - 1)}" value="${step}" ${snapshots.length ? '' : 'disabled'}><span id="snapshot-label" class="small"></span></label><button id="play" ${snapshots.length > 1 ? '' : 'disabled'}>Play replay</button></div><p id="snapshot-source" class="small"></p><div id="preview" aria-live="polite"></div></section>`;
element<HTMLSelectElement>('dataset').onchange = event => {
catalog = documents.find(document => document.id === (event.target as HTMLSelectElement).value) as Catalog;
roster = {};
ratingId = ratings()[0]?.id ?? '';
step = 0;
render();
};
for (const id of ['search', 'filter-office', 'filter-affiliation', 'filter-state', 'filter-rating']) element(id).oninput = renderCandidates;
element('players').onchange = () => void renderCapacity();
element('clear').onclick = () => { roster = {}; renderRoster(); void renderPreview(); };
const fill = document.getElementById('fill');
if (fill) fill.onclick = () => {
roster = {};
for (const slot of rules.slots) {
const eligible = catalog.candidates.filter(candidate => fits(candidate, slot) && !Object.values(roster).includes(candidate.id));
const preferredRatings: Record<string, string> = { democrat: 'toss_up', republican: 'safe', other: 'likely', flex: 'lean' };
const preferredRating = preferredRatings[slot.affiliation];
const match = eligible.find(candidate => selectedRatings()?.entries.some(entry => entry.race_id === candidate.race_id && entry.rating === preferredRating)) ?? eligible[0];
if (match) roster[slot.id] = match.id;
}
renderRoster();
void renderPreview();
};
element('rating-snapshot').onchange = event => { ratingId = (event.target as HTMLSelectElement).value; renderCandidates(); void renderPreview(); };
element('timeline').oninput = event => { stopReplay(); step = Number((event.target as HTMLInputElement).value); void renderPreview(); };
element('play').onclick = () => {
if (playing) { stopReplay(); return; }
if (step === snapshots.length - 1) step = 0;
element('play').textContent = 'Pause replay';
void renderPreview();
playing = setInterval(() => {
step += 1;
void renderPreview();
if (step === snapshots.length - 1) stopReplay();
}, 1800);
};
renderCandidates();
renderRoster();
void renderCapacity();
void renderPreview();
}
function renderCandidates() {
const search = element<HTMLInputElement>('search').value.toLowerCase();
const filters = ['office', 'affiliation', 'state', 'rating'].map(filter => element<HTMLSelectElement>(`filter-${filter}`).value);
const ratingEntries = selectedRatings()?.entries ?? [];
const candidates = catalog.candidates.filter(candidate => {
const race = raceFor(candidate);
const rating = ratingEntries.find(entry => entry.race_id === race.id)?.rating ?? 'unrated';
return `${candidate.name} ${race.name}`.toLowerCase().includes(search) && [race.office, candidate.affiliation, race.state, rating].every((value, index) => !filters[index] || filters[index] === value);
});
element('candidate-count').textContent = `${candidates.length} of ${catalog.candidates.length} candidates`;
element('candidates').innerHTML = candidates.map(candidate => {
const race = raceFor(candidate);
const rating = ratingEntries.find(entry => entry.race_id === race.id)?.rating;
return `<tr><td><strong>${escape(candidate.name)}</strong></td><td>${escape(race.name)}<span class="cell-note">${escape(race.state)} · ${escape(label(race.office))}</span></td><td>${escape(label(candidate.affiliation))}<span class="cell-note">${escape(candidate.ballot_lines.join(' / '))}</span></td><td><span class="tag">${rating ? `${escape(label(rating))} ×${rules.multipliers[rating].toFixed(2)}` : 'Missing rating'}</span></td><td class="small">${sourceLink(candidate.source)}</td></tr>`;
}).join('') || '<tr><td colspan="5">No candidates match these filters.</td></tr>';
}
function renderRoster() {
for (const slot of rules.slots) {
const select = root.querySelector<HTMLSelectElement>(`[data-slot="${slot.id}"]`)!;
select.innerHTML = '<option value="">Choose a candidate</option>' + catalog.candidates.filter(candidate => fits(candidate, slot)).map(candidate => `<option value="${escape(candidate.id)}" ${roster[slot.id] === candidate.id ? 'selected' : ''} ${Object.entries(roster).some(([id, pick]) => id !== slot.id && pick === candidate.id) ? 'disabled' : ''}>${escape(candidate.name)} · ${escape(raceFor(candidate).state)}</option>`).join('');
select.onchange = () => { roster[slot.id] = select.value; renderRoster(); void renderPreview(); };
}
const filled = Object.values(roster).filter(Boolean).length;
element('roster-status').textContent = `${filled} / 12 slots filled${filled === 12 ? ' · Ready to simulate.' : ' · Fill each slot to calculate a complete total.'}`;
}
async function renderCapacity() {
const version = ++capacityVersion;
try {
const result = await api<Capacity>('capacity', { catalog_id: catalog.id, players: Number(element<HTMLSelectElement>('players').value) });
if (version !== capacityVersion) return;
element('capacity').innerHTML = `<p class="notice"><strong>${result.sufficient_candidates ? 'Enough candidates in this catalog.' : 'Not enough candidates in this catalog.'}</strong> ${!result.catalog_complete ? 'This is a partial catalog; these counts do not establish nationwide feasibility.' : catalog.mode === 'demo' ? 'Synthetic capacity only; this does not establish real-world feasibility.' : 'Ratings and results coverage still require separate verification.'}</p><div class="table-scroll"><table><thead><tr><th>Office</th><th>Group</th><th>Available</th><th>Needed</th><th>Shortfall</th></tr></thead><tbody>${result.offices.flatMap(office => office.requirements.map(group => `<tr><td>${label(office.office)}</td><td>${label(group.group)}</td><td>${group.available}</td><td>${group.required}</td><td class="${group.shortfall ? 'warning' : ''}">${group.shortfall}</td></tr>`)).join('')}</tbody></table></div>`;
} catch (error) { if (version === capacityVersion) element('capacity').textContent = String(error); }
}
async function renderPreview() {
const version = ++previewVersion;
const snapshot = results()[step];
element<HTMLInputElement>('timeline').value = String(step);
element('snapshot-label').textContent = snapshot ? `${snapshot.label} · ${snapshot.as_of}` : 'No result snapshots imported';
element('snapshot-source').innerHTML = snapshot ? `Results: ${sourceLink(snapshot.source)}${selectedRatings() ? ` · Ratings: ${sourceLink(selectedRatings()!.source)}` : ''}` : '';
if (!snapshot || !ratingId) { element('preview').innerHTML = '<p class="notice">Scoring is unavailable until both ratings and results are imported for this catalog. Missing data is not treated as zero.</p>'; return; }
element('preview').innerHTML = '<p role="status">Calculating…</p>';
try {
const preview = await api<Preview>('score-preview', { catalog_id: catalog.id, ratings_id: ratingId, results_id: snapshot.id, roster: Object.entries(roster).filter(([, id]) => id).map(([slot, candidate_id]) => ({ slot, candidate_id })) });
if (version !== previewVersion) return;
if (!preview.valid) { element('preview').innerHTML = `<p class="notice warning">${preview.errors.map(escape).join('<br>')}</p>`; return; }
const max = Math.max(preview.known_subtotal ?? 0, preview.baseline_subtotal ?? 0, 1);
element('preview').innerHTML = `<div class="metrics"><div><span>Complete roster total</span><strong>${money(preview.total)}</strong><small>${preview.total === null ? 'Needs 12 picks with available scores' : 'Counted shares + awarded bonuses'}</small></div><div><span>Known subtotal</span><strong>${money(preview.known_subtotal)}</strong><small>${preview.missing_scores ?? 0} selected scores unavailable</small></div><div><span>If current leaders win</span><strong>${money(preview.leader_preview_subtotal)}</strong><small>${preview.unavailable_leader_previews ?? 0} previews unavailable · not awarded points</small></div></div><div class="comparison" aria-label="Scoring comparison"><div><span>No multipliers ${money(preview.baseline_subtotal)}</span><i style="width:${(preview.baseline_subtotal ?? 0) / max * 100}%"></i></div><div><span>Race-weighted ${money(preview.known_subtotal)}</span><i style="width:${(preview.known_subtotal ?? 0) / max * 100}%"></i></div></div><div class="table-scroll"><table><thead><tr><th>Pick</th><th>Vote share</th><th>Win bonus</th><th>Multiplier</th><th>Points</th><th>If leader wins</th></tr></thead><tbody>${preview.scores.map(score => `<tr><td><strong>${escape(catalog.candidates.find(candidate => candidate.id === score.candidate_id)?.name)}</strong><span class="cell-note">${escape(score.slot.split(':').map(label).join(' / '))} · ${escape(score.reason ?? score.status)}</span></td><td>${score.vote_share === undefined ? '—' : `${score.vote_share.toFixed(2)}%`}</td><td>${score.win_bonus ?? '—'}</td><td>${score.multiplier === undefined ? '—' : `×${score.multiplier.toFixed(2)}`}</td><td><strong>${money(score.points)}</strong></td><td>${money(score.leader_preview_points)}</td></tr>`).join('') || '<tr><td colspan="6">Choose candidates above to see their points.</td></tr>'}</tbody></table></div>`;
} catch (error) { if (version === previewVersion) element('preview').textContent = String(error); }
}
async function start() {
try {
const [ruleData, catalogData] = await Promise.all([api<Rules>('rules'), api<{ documents: Document[] }>('catalog')]);
rules = ruleData;
documents = catalogData.documents;
catalog = documents.find(document => document.kind === 'catalog' && document.mode === 'demo') as Catalog ?? documents.find(document => document.kind === 'catalog') as Catalog;
if (!catalog) { root.innerHTML = '<section class="intro"><h1>The field is empty.</h1><p>Import a catalog with the local admin tool to start exploring. Setup instructions are in the repository README.</p></section>'; return; }
ratingId = ratings()[0]?.id ?? '';
render();
} catch (error) {
root.innerHTML = `<section class="intro"><h1>Unable to load the league.</h1><p role="alert">${escape(error)}</p><p>Check that the C++ backend is running, then try again.</p><button id="retry">Retry</button></section>`;
element('retry').onclick = () => void start();
}
}
void start();
+55
View File
@@ -0,0 +1,55 @@
import { api, query, user } from './api';
import { click, element, escape, field, go, submit } from './ui';
import type { Catalogs, League, LeagueView } from './types';
export async function leaguesPage(signal: AbortSignal) {
const leagues = await api<League[]>('leagues', undefined, signal);
if (signal.aborted)
return;
element('#page').innerHTML = `<div class="page-heading"><div><p class="eyebrow">2026 midterms</p><h1>My leagues</h1></div><div class="actions"><a class="button secondary" href="#join">Join a league</a><a class="button" href="#create">Create a league</a></div></div>${leagues.length ? `<div class="league-grid">${leagues.map(l => `<a class="panel league-card" href="#league/${l.id}"><span class="badge">${l.catalog_mode === 'demo' ? 'Practice · ' : ''}${escape(l.status)}</span><h2>${escape(l.name)}</h2><p>${escape(l.team_name)}</p><span class="muted">${l.members} / ${l.max_teams} teams</span><span class="arrow" aria-hidden="true">↗</span></a>`).join('')}</div>` : '<section class="empty"><h2>Start with your people.</h2><p>Create a private league, then invite your friends. You can get everyone together while election data is being prepared.</p></section>'}`;
}
export async function createPage(signal: AbortSignal) {
const catalogs = await api<Catalogs>('catalogs', undefined, signal);
if (signal.aborted)
return;
element('#page').innerHTML = `<a href="#leagues" class="back">← My leagues</a><h1>Create a league</h1><section class="panel narrow"><form id="create">${field('League name', 'name', '', 'maxlength="80"')}${field('Your team name', 'team_name', '', 'maxlength="60"')}<label>Maximum teams<select name="max_teams">${Array.from({ length: 11 }, (_, i) => `<option ${i === 6 ? 'selected' : ''}>${i + 2}</option>`).join('')}</select></label>${catalogs.allow_demo ? '<label>Candidate pool<select name="catalog_id"><option value="">Latest real candidates</option>' + catalogs.catalogs.filter(c => c.mode === 'demo').map(c => `<option value="${escape(c.id)}">Practice · ${escape(c.label)}</option>`).join('') + '</select></label>' : ''}<p class="muted">Private by default. Twelve picks per team. The draft opens when the candidate pool, ratings, and result sources are ready.</p><button type="submit">Create league</button></form></section>`;
submit('#create', async (data) => { const result = await api<{
league_id: string;
}>('league/create', { name: data.get('name'), team_name: data.get('team_name'), max_teams: Number(data.get('max_teams')), catalog_id: data.get('catalog_id') || null }); go(`league/${result.league_id}`); });
}
export function joinPage(invite = '') {
element('#page').innerHTML = `<a href="#leagues" class="back">← My leagues</a><h1>Join a league</h1><section class="panel narrow"><form id="join">${field('Invitation link or code', 'invite', invite, 'autocomplete="off"')}${field('Your team name', 'team_name', '', 'maxlength="60"')}<button type="submit">Join league</button></form></section>`;
submit('#join', async (data) => { const code = String(data.get('invite')).split('#join/').pop()!.trim(); const result = await api<{
league_id: string;
}>('league/join', { invite: code, team_name: data.get('team_name') }); go(`league/${result.league_id}`); });
}
export async function leaguePage(id: string, signal: AbortSignal) {
const view = await api<LeagueView>(query('league', { league_id: id }), undefined, signal);
if (signal.aborted)
return;
const l = view.league, commissioner = l.commissioner_id === user?.id;
element('#page').innerHTML = `<a href="#leagues" class="back">← My leagues</a><div class="page-heading"><div><p class="eyebrow">${l.catalog_mode === 'demo' ? 'Practice · fictional candidates · ' : ''}${escape(l.status)}</p><h1>${escape(l.name)}</h1></div><a class="button" href="#draft/${id}">${l.status === 'forming' ? 'Draft room' : l.status === 'drafted' ? 'View draft' : 'Enter draft'}</a></div><nav class="subnav"><a href="#roster/${id}">My roster</a><a href="#standings/${id}">Standings</a><a href="#results/${id}">Race results</a></nav>${l.status === 'forming' && !view.readiness.ready ? `<aside class="notice"><strong>Getting ready to draft</strong><ul>${view.readiness.problems.map(p => `<li>${escape(p)}</li>`).join('')}</ul></aside>` : ''}<section class="panel"><div class="page-heading"><h2>Teams <span class="muted">${view.teams.length} / ${l.max_teams}</span></h2>${commissioner && l.status === 'forming' ? '<button id="invite">Invite friends</button>' : ''}</div><div id="invite-link"></div><ul class="team-list">${view.teams.map(t => `<li><span><strong>${escape(t.name)}</strong><small>${escape(t.username)}${t.user_id === l.commissioner_id ? ' · commissioner' : ''}</small></span>${commissioner && t.user_id !== user?.id && l.status === 'forming' ? `<button class="quiet remove" data-team="${t.id}">Remove</button>` : ''}</li>`).join('')}</ul></section><details class="panel"><summary>Team settings</summary><form id="rename">${field('Team name', 'name', view.my_team.name, 'maxlength="60"')}<button type="submit">Save team name</button></form></details>${commissioner ? `<details class="panel"><summary>League name</summary><form id="league-name">${field('League name','name',l.name,'maxlength="80"')}<button type="submit">Save league name</button></form></details>` : ''}${commissioner && l.status === 'forming' ? '<details class="panel" id="commissioner"><summary>Commissioner settings</summary><div id="settings">Loading settings…</div><form id="ratings-import"><label>Import sourced ratings (JSON)<input name="ratings" type="file" accept=".json,application/json" required></label><p class="muted">Use the ratings schema in the data contract. Include a source URL, retrieval date, and a new snapshot ID.</p><button type="submit">Import ratings</button></form></details>' : ''}`;
const reload = () => leaguePage(id, signal);
if (commissioner) submit('#league-name', async data => { await api('league/name', {league_id:id, name:data.get('name')}); await reload(); });
submit('#rename', async (data) => { await api('team/name', { league_id: id, name: data.get('name') }); await reload(); });
click('#invite', async () => { const result = await api<{
invite: string;
}>('league/invite', { league_id: id }); const url = `${location.origin}${location.pathname}#join/${result.invite}`; element('#invite-link').innerHTML = `<label>Share this private link · expires in 7 days<input readonly value="${escape(url)}" aria-label="Invitation link"></label><p class="muted">Creating another invitation replaces this one.</p>`; });
document.querySelectorAll<HTMLButtonElement>('.remove').forEach(button => click(`[data-team="${button.dataset.team}"]`, async () => { await api('league/remove', { league_id: id, team_id: button.dataset.team }); await reload(); }));
if (commissioner && l.status === 'forming') {
submit('#ratings-import', async (form) => { const file = form.get('ratings'); if (!(file instanceof File) || file.size > 60000)
throw new Error('Choose a ratings JSON file under 60 KB.'); const document = JSON.parse(await file.text()); await api('league/ratings', { league_id: id, document }); await reload(); });
const data = await api<Catalogs>('catalogs', undefined, signal);
if (signal.aborted)
return;
element('#settings').innerHTML = `<form id="settings-form">${field('League name', 'name', l.name, 'maxlength="80"')}<label>Candidate catalog<select name="catalog_id" required>${data.catalogs.map(c => `<option value="${escape(c.id)}" ${c.id === l.catalog_id ? 'selected' : ''}>${escape(c.label)}</option>`).join('')}</select></label><label>Race ratings<select name="ratings_id"><option value="">Awaiting sourced ratings</option>${data.ratings.map(r => `<option value="${escape(r.id)}" data-catalog="${escape(r.catalog_id)}" ${r.id === l.ratings_id ? 'selected' : ''}>${escape(r.label)}</option>`).join('')}</select></label><p class="muted">Catalog and ratings freeze when the draft starts. Changing these settings clears player queues.</p><button type="submit">Save league settings</button></form><button class="quiet" id="revoke">Revoke invitations</button>`;
const filterRatings = () => { const catalog = element<HTMLSelectElement>('[name=catalog_id]').value; const ratings = element<HTMLSelectElement>('[name=ratings_id]'); for (const option of ratings.options) {
option.hidden = !!option.dataset.catalog && option.dataset.catalog !== catalog;
if (option.selected && option.hidden)
ratings.value = '';
} };
element('[name=catalog_id]').addEventListener('change', filterRatings);
filterRatings();
submit('#settings-form', async (form) => { await api('league/settings', { league_id: id, ...Object.fromEntries(form), ratings_id: form.get('ratings_id') || null }); await reload(); });
click('#revoke', async () => { await api('league/revoke-invites', { league_id: id }); element('#invite-link').textContent = 'Invitations revoked.'; });
}
}
+52 -167
View File
@@ -1,172 +1,57 @@
import './style.css';
type Source = { name: string; url: string; retrieved_at: string };
type Race = { id: string; office: string; state: string; name: string; round: string };
type Candidate = { id: string; race_id: string; name: string; affiliation: string; ballot_lines: string[]; source: Source };
type Document = { id: string; kind: string; mode: 'demo' | 'real'; label: string; as_of: string; source: Source; catalog_id?: string };
type Catalog = Document & { complete: boolean; races: Race[]; candidates: Candidate[] };
type Ratings = Document & { entries: { race_id: string; rating: string }[] };
type Slot = { id: string; office: string; affiliation: string };
type Rules = { slots: Slot[]; formula: string; rounding: string; multipliers: Record<string, number> };
type Capacity = { offices: { office: string; requirements: { group: string; available: number; required: number; shortfall: number }[] }[]; sufficient_candidates: boolean; catalog_complete: boolean };
type Score = { slot: string; candidate_id: string; points: number | null; reason?: string; vote_share?: number; win_bonus?: number; multiplier?: number; baseline_points?: number; leader_preview_points?: number | null; status?: string };
type Preview = { valid: boolean; complete_roster: boolean; errors: string[]; scores: Score[]; missing_scores?: number; unavailable_leader_previews?: number; known_subtotal?: number; baseline_subtotal?: number; leader_preview_subtotal?: number; total: number | null };
const root = document.querySelector<HTMLElement>('#app')!;
let documents: Document[] = [];
let rules: Rules;
let catalog: Catalog;
let ratingId = '';
let roster: Record<string, string> = {};
let step = 0;
let playing: ReturnType<typeof setInterval> | undefined;
let previewVersion = 0;
let capacityVersion = 0;
const labels: Record<string, string> = { house: 'House', senate: 'Senate', governor: 'Governor', democrat: 'Democrat', republican: 'Republican', other: 'Other', flex: 'Flex', safe: 'Safe', likely: 'Likely', lean: 'Lean', toss_up: 'Toss-up', total: 'All candidates' };
const escape = (value: unknown) => String(value).replace(/[&<>"']/g, character => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character]!);
const label = (value: string) => labels[value] ?? value;
const money = (value: number | null | undefined) => value == null ? '—' : value.toFixed(2);
const element = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
const ratings = () => documents.filter(document => document.kind === 'ratings' && document.catalog_id === catalog.id) as Ratings[];
const results = () => documents.filter(document => document.kind === 'results' && document.catalog_id === catalog.id);
const selectedRatings = () => ratings().find(rating => rating.id === ratingId);
const raceFor = (candidate: Candidate) => catalog.races.find(race => race.id === candidate.race_id)!;
const fits = (candidate: Candidate, slot: Slot) => raceFor(candidate).office === slot.office && (slot.affiliation === 'flex' || candidate.affiliation === slot.affiliation);
const options = (items: { id: string; name: string }[], selected = '') => items.map(item => `<option value="${escape(item.id)}" ${item.id === selected ? 'selected' : ''}>${escape(item.name)}</option>`).join('');
function sourceLink(source: Source): string {
if (!/^https?:\/\//.test(source.url)) return '<span class="muted">Fictional example</span>';
return `<a href="${escape(source.url)}" target="_blank" rel="noopener noreferrer">Source ↗</a> <span class="muted">${escape(source.retrieved_at.slice(0, 10))}</span>`;
}
async function api<T>(path: string, body?: unknown): Promise<T> {
const response = await fetch(`/api/${path}`, body === undefined ? {} : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const envelope = await response.json();
if (!response.ok || !envelope.ok) throw new Error(envelope.message ?? `Request failed (${response.status})`);
return envelope.payload as T;
}
function stopReplay() {
clearInterval(playing);
playing = undefined;
const button = document.getElementById('play');
if (button) button.textContent = 'Play replay';
}
function render() {
stopReplay();
++previewVersion;
++capacityVersion;
const snapshots = results();
root.innerHTML = `
<section class="intro"><p class="eyebrow">The draft starts with a little homework.</p><h1>Build your party.<br><em>Make every race count.</em></h1><p class="description">Try your 12 picks, explore the field, and replay a fictional election night. A game among friends, taking shape for 2026.</p></section>
<section class="workspace" aria-label="Data selection"><label>Candidate catalog<select id="dataset">${options(documents.filter(item => item.kind === 'catalog').map(item => ({ id: item.id, name: item.label })), catalog.id)}</select></label><p class="notice ${catalog.mode}"><strong>${catalog.mode === 'demo' ? 'SYNTHETIC DEMO' : catalog.complete ? 'REAL CATALOG' : 'PARTIAL REAL CATALOG'}</strong> ${catalog.mode === 'demo' ? 'Every candidate, rating, vote and call in this dataset is fictional.' : catalog.complete ? 'Candidate coverage is marked complete by its curator. Ratings and live coverage still need verification before a draft.' : 'Candidate coverage is incomplete. Verify the remaining candidates, real ratings and live results before a draft.'}</p><p class="small">Catalog dated ${escape(catalog.as_of)} · ${sourceLink(catalog.source)}</p></section>
<nav class="section-nav" aria-label="Prototype sections"><a href="#field">01 / The field</a><a href="#party">02 / Your party</a><a href="#night">03 / Election night</a></nav>
<section id="field" class="panel"><div class="section-heading"><div><p class="eyebrow">01 / Scout your picks</p><h2>The field</h2></div><span id="candidate-count" class="small"></span></div>
<div class="filters"><label class="search-label">Search candidates<input id="search" type="search" placeholder="Name or race"></label>${['office', 'affiliation', 'state', 'rating'].map(filter => `<label>${filter === 'affiliation' ? 'Party' : label(filter)}<select id="filter-${filter}"><option value="">All ${filter === 'affiliation' ? 'parties' : filter + 's'}</option>${filter === 'state' ? options([...new Set(catalog.races.map(race => race.state))].sort().map(state => ({ id: state, name: state }))) : options((filter === 'office' ? ['house', 'senate', 'governor'] : filter === 'affiliation' ? ['democrat', 'republican', 'other'] : ['safe', 'likely', 'lean', 'toss_up', 'unrated']).map(id => ({ id, name: label(id) })))}</select></label>`).join('')}</div>
<div class="table-scroll"><table><thead><tr><th>Candidate</th><th>Race</th><th>Party / ballot lines</th><th>Frozen rating</th><th>Provenance</th></tr></thead><tbody id="candidates"></tbody></table></div>
<details><summary>Can everyone fill a roster?</summary><div class="capacity-controls"><label>League size<select id="players">${options(Array.from({ length: 11 }, (_, index) => ({ id: String(index + 2), name: `${index + 2} players` })), '12')}</select></label><p class="small">Each office needs one D, one R, one Other and one Flex per player. Counts include all race ratings.</p></div><div id="capacity" aria-live="polite"></div></details>
</section>
<section id="party" class="panel"><div class="section-heading"><div><p class="eyebrow">02 / Twelve picks. Three offices.</p><h2>Your party</h2></div><div class="actions">${catalog.mode === 'demo' ? '<button id="fill">Try an example roster</button>' : ''}<button id="clear" class="secondary">Clear picks</button></div></div><p class="small">One Democrat, one Republican, one Other and one Flex in each office. Other includes independent and third-party candidates. Flex accepts any party in that office. These picks stay in this page only.</p><div class="roster-grid">${['house', 'senate', 'governor'].map(office => `<fieldset><legend>${label(office)}</legend>${rules.slots.filter(slot => slot.office === office).map(slot => `<label>${label(slot.affiliation)}<select data-slot="${escape(slot.id)}" aria-label="${label(office)} ${label(slot.affiliation)}"></select></label>`).join('')}</fieldset>`).join('')}</div><p id="roster-status" role="status"></p></section>
<section id="night" class="panel"><div class="section-heading"><div><p class="eyebrow">03 / Follow the swings</p><h2>Election night, on replay.</h2></div><span class="tag">${catalog.mode === 'demo' ? 'Fictional results' : 'No verified live feed'}</span></div><p class="formula">${escape(rules.formula)}</p><div class="rating-key">${Object.entries(rules.multipliers).map(([rating, factor]) => `<span>${label(rating)} <strong>×${factor.toFixed(2)}</strong></span>`).join('')}</div><p class="small">${escape(rules.rounding)} Ratings are selected as a single snapshot here; a real league will freeze one before the draft. A lead is not a win. Corrections can reverse awarded points.</p><div class="replay-controls"><label>Rating snapshot<select id="rating-snapshot" ${ratings().length ? '' : 'disabled'}>${ratings().length ? options(ratings().map(item => ({ id: item.id, name: item.label })), ratingId) : '<option>No ratings imported</option>'}</select></label><label class="timeline">Result snapshot<input id="timeline" type="range" min="0" max="${Math.max(0, snapshots.length - 1)}" value="${step}" ${snapshots.length ? '' : 'disabled'}><span id="snapshot-label" class="small"></span></label><button id="play" ${snapshots.length > 1 ? '' : 'disabled'}>Play replay</button></div><p id="snapshot-source" class="small"></p><div id="preview" aria-live="polite"></div></section>`;
element<HTMLSelectElement>('dataset').onchange = event => {
catalog = documents.find(document => document.id === (event.target as HTMLSelectElement).value) as Catalog;
roster = {};
ratingId = ratings()[0]?.id ?? '';
step = 0;
render();
};
for (const id of ['search', 'filter-office', 'filter-affiliation', 'filter-state', 'filter-rating']) element(id).oninput = renderCandidates;
element('players').onchange = () => void renderCapacity();
element('clear').onclick = () => { roster = {}; renderRoster(); void renderPreview(); };
const fill = document.getElementById('fill');
if (fill) fill.onclick = () => {
roster = {};
for (const slot of rules.slots) {
const eligible = catalog.candidates.filter(candidate => fits(candidate, slot) && !Object.values(roster).includes(candidate.id));
const preferredRatings: Record<string, string> = { democrat: 'toss_up', republican: 'safe', other: 'likely', flex: 'lean' };
const preferredRating = preferredRatings[slot.affiliation];
const match = eligible.find(candidate => selectedRatings()?.entries.some(entry => entry.race_id === candidate.race_id && entry.rating === preferredRating)) ?? eligible[0];
if (match) roster[slot.id] = match.id;
import { api, beginPage, setUser, user } from './api';
import type { User } from './api';
import { authPage, accountPage } from './auth';
import { createPage, joinPage, leaguePage, leaguesPage } from './leagues';
import { candidatesPage } from './candidates';
import { draftPage } from './draft';
import { resultsPage, standingsPage } from './results';
import { adminPage } from './admin';
import { click, element, escape, message } from './ui';
function shell(active: string) { element('#app').innerHTML = `<header class="site-header"><a class="wordmark" href="#leagues"><span class="monogram">fp.</span><span>Fantasy Politics</span></a>${user ? `<nav aria-label="Main navigation">${['leagues', 'candidates', 'results'].map(p => `<a href="#${p}" ${active === p ? 'aria-current="page"' : ''}>${p[0].toUpperCase() + p.slice(1)}</a>`).join('')}</nav><details class="account-menu"><summary>${escape(user.username)}</summary><div><a href="#account">Account</a>${user.admin ? '<a href="#admin">Source diagnostics</a>' : ''}<button class="quiet" id="logout">Sign out</button></div></details>` : '<span class="edition">The 2026 edition</span>'}</header><main><div id="notice" role="alert" class="notice error" hidden></div><div id="page"><p class="loading" role="status">Loading…</p></div></main><footer><span>A game among friends.</span><span>2026 US midterms</span></footer>`; click('#logout', async () => { await api('auth/logout', {}); setUser(null); await render(); }); }
async function render() {
const signal = beginPage();
const path = location.hash.slice(1).split('/');
const page = path[0] || 'leagues';
shell(page);
try {
if (!user || page === 'reset') {
authPage(path, () => void render());
return;
}
if (page === 'leagues')
await leaguesPage(signal);
else if (page === 'create')
await createPage(signal);
else if (page === 'join')
joinPage(path[1]);
else if (page === 'league' && path[1])
await leaguePage(path[1], signal);
else if (page === 'draft' && path[1])
await draftPage(path[1], signal);
else if (page === 'candidates')
candidatesPage(signal, path[1]);
else if (page === 'results')
await resultsPage(signal, path[1]);
else if ((page === 'standings' || page === 'roster') && path[1])
await standingsPage(path[1], signal, page === 'roster');
else if (page === 'account')
accountPage(() => void render());
else if (page === 'admin')
await adminPage(signal);
else
element('#page').innerHTML = '<h1>Page not found</h1><a href="#leagues">Back to my leagues</a>';
}
catch (error) {
if (!signal.aborted)
message(error);
}
renderRoster();
void renderPreview();
};
element('rating-snapshot').onchange = event => { ratingId = (event.target as HTMLSelectElement).value; renderCandidates(); void renderPreview(); };
element('timeline').oninput = event => { stopReplay(); step = Number((event.target as HTMLInputElement).value); void renderPreview(); };
element('play').onclick = () => {
if (playing) { stopReplay(); return; }
if (step === snapshots.length - 1) step = 0;
element('play').textContent = 'Pause replay';
void renderPreview();
playing = setInterval(() => {
step += 1;
void renderPreview();
if (step === snapshots.length - 1) stopReplay();
}, 1800);
};
renderCandidates();
renderRoster();
void renderCapacity();
void renderPreview();
}
function renderCandidates() {
const search = element<HTMLInputElement>('search').value.toLowerCase();
const filters = ['office', 'affiliation', 'state', 'rating'].map(filter => element<HTMLSelectElement>(`filter-${filter}`).value);
const ratingEntries = selectedRatings()?.entries ?? [];
const candidates = catalog.candidates.filter(candidate => {
const race = raceFor(candidate);
const rating = ratingEntries.find(entry => entry.race_id === race.id)?.rating ?? 'unrated';
return `${candidate.name} ${race.name}`.toLowerCase().includes(search) && [race.office, candidate.affiliation, race.state, rating].every((value, index) => !filters[index] || filters[index] === value);
});
element('candidate-count').textContent = `${candidates.length} of ${catalog.candidates.length} candidates`;
element('candidates').innerHTML = candidates.map(candidate => {
const race = raceFor(candidate);
const rating = ratingEntries.find(entry => entry.race_id === race.id)?.rating;
return `<tr><td><strong>${escape(candidate.name)}</strong></td><td>${escape(race.name)}<span class="cell-note">${escape(race.state)} · ${escape(label(race.office))}</span></td><td>${escape(label(candidate.affiliation))}<span class="cell-note">${escape(candidate.ballot_lines.join(' / '))}</span></td><td><span class="tag">${rating ? `${escape(label(rating))} ×${rules.multipliers[rating].toFixed(2)}` : 'Missing rating'}</span></td><td class="small">${sourceLink(candidate.source)}</td></tr>`;
}).join('') || '<tr><td colspan="5">No candidates match these filters.</td></tr>';
}
function renderRoster() {
for (const slot of rules.slots) {
const select = root.querySelector<HTMLSelectElement>(`[data-slot="${slot.id}"]`)!;
select.innerHTML = '<option value="">Choose a candidate</option>' + catalog.candidates.filter(candidate => fits(candidate, slot)).map(candidate => `<option value="${escape(candidate.id)}" ${roster[slot.id] === candidate.id ? 'selected' : ''} ${Object.entries(roster).some(([id, pick]) => id !== slot.id && pick === candidate.id) ? 'disabled' : ''}>${escape(candidate.name)} · ${escape(raceFor(candidate).state)}</option>`).join('');
select.onchange = () => { roster[slot.id] = select.value; renderRoster(); void renderPreview(); };
}
const filled = Object.values(roster).filter(Boolean).length;
element('roster-status').textContent = `${filled} / 12 slots filled${filled === 12 ? ' · Ready to simulate.' : ' · Fill each slot to calculate a complete total.'}`;
}
async function renderCapacity() {
const version = ++capacityVersion;
try {
const result = await api<Capacity>('capacity', { catalog_id: catalog.id, players: Number(element<HTMLSelectElement>('players').value) });
if (version !== capacityVersion) return;
element('capacity').innerHTML = `<p class="notice"><strong>${result.sufficient_candidates ? 'Enough candidates in this catalog.' : 'Not enough candidates in this catalog.'}</strong> ${!result.catalog_complete ? 'This is a partial catalog; these counts do not establish nationwide feasibility.' : catalog.mode === 'demo' ? 'Synthetic capacity only; this does not establish real-world feasibility.' : 'Ratings and results coverage still require separate verification.'}</p><div class="table-scroll"><table><thead><tr><th>Office</th><th>Group</th><th>Available</th><th>Needed</th><th>Shortfall</th></tr></thead><tbody>${result.offices.flatMap(office => office.requirements.map(group => `<tr><td>${label(office.office)}</td><td>${label(group.group)}</td><td>${group.available}</td><td>${group.required}</td><td class="${group.shortfall ? 'warning' : ''}">${group.shortfall}</td></tr>`)).join('')}</tbody></table></div>`;
} catch (error) { if (version === capacityVersion) element('capacity').textContent = String(error); }
}
async function renderPreview() {
const version = ++previewVersion;
const snapshot = results()[step];
element<HTMLInputElement>('timeline').value = String(step);
element('snapshot-label').textContent = snapshot ? `${snapshot.label} · ${snapshot.as_of}` : 'No result snapshots imported';
element('snapshot-source').innerHTML = snapshot ? `Results: ${sourceLink(snapshot.source)}${selectedRatings() ? ` · Ratings: ${sourceLink(selectedRatings()!.source)}` : ''}` : '';
if (!snapshot || !ratingId) { element('preview').innerHTML = '<p class="notice">Scoring is unavailable until both ratings and results are imported for this catalog. Missing data is not treated as zero.</p>'; return; }
element('preview').innerHTML = '<p role="status">Calculating…</p>';
try {
const preview = await api<Preview>('score-preview', { catalog_id: catalog.id, ratings_id: ratingId, results_id: snapshot.id, roster: Object.entries(roster).filter(([, id]) => id).map(([slot, candidate_id]) => ({ slot, candidate_id })) });
if (version !== previewVersion) return;
if (!preview.valid) { element('preview').innerHTML = `<p class="notice warning">${preview.errors.map(escape).join('<br>')}</p>`; return; }
const max = Math.max(preview.known_subtotal ?? 0, preview.baseline_subtotal ?? 0, 1);
element('preview').innerHTML = `<div class="metrics"><div><span>Complete roster total</span><strong>${money(preview.total)}</strong><small>${preview.total === null ? 'Needs 12 picks with available scores' : 'Counted shares + awarded bonuses'}</small></div><div><span>Known subtotal</span><strong>${money(preview.known_subtotal)}</strong><small>${preview.missing_scores ?? 0} selected scores unavailable</small></div><div><span>If current leaders win</span><strong>${money(preview.leader_preview_subtotal)}</strong><small>${preview.unavailable_leader_previews ?? 0} previews unavailable · not awarded points</small></div></div><div class="comparison" aria-label="Scoring comparison"><div><span>No multipliers ${money(preview.baseline_subtotal)}</span><i style="width:${(preview.baseline_subtotal ?? 0) / max * 100}%"></i></div><div><span>Race-weighted ${money(preview.known_subtotal)}</span><i style="width:${(preview.known_subtotal ?? 0) / max * 100}%"></i></div></div><div class="table-scroll"><table><thead><tr><th>Pick</th><th>Vote share</th><th>Win bonus</th><th>Multiplier</th><th>Points</th><th>If leader wins</th></tr></thead><tbody>${preview.scores.map(score => `<tr><td><strong>${escape(catalog.candidates.find(candidate => candidate.id === score.candidate_id)?.name)}</strong><span class="cell-note">${escape(score.slot.split(':').map(label).join(' / '))} · ${escape(score.reason ?? score.status)}</span></td><td>${score.vote_share === undefined ? '—' : `${score.vote_share.toFixed(2)}%`}</td><td>${score.win_bonus ?? '—'}</td><td>${score.multiplier === undefined ? '—' : `×${score.multiplier.toFixed(2)}`}</td><td><strong>${money(score.points)}</strong></td><td>${money(score.leader_preview_points)}</td></tr>`).join('') || '<tr><td colspan="6">Choose candidates above to see their points.</td></tr>'}</tbody></table></div>`;
} catch (error) { if (version === previewVersion) element('preview').textContent = String(error); }
}
async function start() {
try {
const [ruleData, catalogData] = await Promise.all([api<Rules>('rules'), api<{ documents: Document[] }>('catalog')]);
rules = ruleData;
documents = catalogData.documents;
catalog = documents.find(document => document.kind === 'catalog' && document.mode === 'demo') as Catalog ?? documents.find(document => document.kind === 'catalog') as Catalog;
if (!catalog) { root.innerHTML = '<section class="intro"><h1>The field is empty.</h1><p>Import a catalog with the local admin tool to start exploring. Setup instructions are in the repository README.</p></section>'; return; }
ratingId = ratings()[0]?.id ?? '';
render();
} catch (error) {
root.innerHTML = `<section class="intro"><h1>Unable to load the league.</h1><p role="alert">${escape(error)}</p><p>Check that the C++ backend is running, then try again.</p><button id="retry">Retry</button></section>`;
element('retry').onclick = () => void start();
}
window.addEventListener('hashchange', () => void render());
async function start() { try {
setUser(await api<User>('auth/me'));
}
catch {
setUser(null);
} await render(); }
void start();
+56
View File
@@ -0,0 +1,56 @@
import { api, query, user } from './api';
import { element, escape, message, points, sourceLink, time } from './ui';
import type { LeagueView, Provenance, RaceResult, Standing } from './types';
function provenance(source: Provenance) { return `${sourceLink(source.url, source.source_name || source.source_id)}${source.stale ? ' · stale, showing last good data' : ''}<br>Fetched ${time(source.received_at)} · source updated ${escape(source.source_time || 'not supplied')} · checked ${time(source.last_checked)}`; }
export async function resultsPage(signal: AbortSignal, league?: string) {
element('#page').innerHTML = `${league ? `<a class="back" href="#league/${league}">← League</a>` : ''}<p class="eyebrow">Election night</p><h1>Race results</h1><p class="muted">Updates every 15 seconds. Missing data stays unknown. Race calls can change.</p><label class="search-label">Find a race<input id="race-search" type="search" placeholder="State, district, or office"></label><div id="race-results"></div>`;
let races: RaceResult[] = [], timer: number | undefined;
function draw() { const search = element<HTMLInputElement>('#race-search').value.toLowerCase(); const filtered = races.filter(r => (r.name + ' ' + r.state + ' ' + r.office).toLowerCase().includes(search)); element('#race-results').innerHTML = filtered.map(r => `<details class="panel race"><summary><span>${escape(r.name)}</span><span class="muted">${r.counts ? r.counts.total_votes.toLocaleString() + ' votes' : 'Awaiting counts'} · ${r.outcome ? escape(r.outcome.status.replace('_', ' ')) : 'No call'}</span></summary>${r.counts ? `<p>${provenance(r.counts.provenance)}</p><p>November first-round counts</p><table><thead><tr><th>Candidate</th><th>Votes</th><th>Share</th></tr></thead><tbody>${r.candidates.map(c => { const votes = r.counts!.votes.find(v => v.candidate_id === c.id)?.votes; return `<tr><td>${escape(c.name)}<small>${escape(c.affiliation)}</small></td><td>${votes == null ? '—' : votes.toLocaleString()}</td><td>${votes == null || !r.counts!.total_votes ? '—' : (votes * 100 / r.counts!.total_votes).toFixed(2) + '%'}</td></tr>`; }).join('')}</tbody></table>` : '<p>No verified vote counts have arrived.</p>'}${r.outcome ? `<p>${provenance(r.outcome.provenance)}</p><p>Outcome round: ${escape(r.outcome.round)}${r.outcome.winner_id ? ' · Winner: ' + escape(r.candidates.find(c => c.id === r.outcome!.winner_id)?.name || r.outcome.winner_id) : ''}</p>` : '<p>No verified outcome has arrived.</p>'}</details>`).join('') || '<section class="empty"><h2>Waiting for the field.</h2><p>Results will appear here as verified sources become available.</p></section>'; }
async function update() { try {
const result = await api<{
races: RaceResult[];
}>(query('results', { league_id: league }), undefined, signal);
if (signal.aborted)
return;
const changed = JSON.stringify(result.races) !== JSON.stringify(races);
races = result.races;
if (changed || !element('#race-results').innerHTML)
draw();
}
catch (error) {
message(error);
} if (!signal.aborted)
timer = window.setTimeout(() => void update(), 15000); }
element('#race-search').addEventListener('input', draw);
signal.addEventListener('abort', () => clearTimeout(timer));
await update();
}
export async function standingsPage(id: string, signal: AbortSignal, rosterOnly = false) {
const view = await api<LeagueView>(query('league', { league_id: id }), undefined, signal);
if (signal.aborted)
return;
element('#page').innerHTML = `<a class="back" href="#league/${id}">← ${escape(view.league.name)}</a><h1>${rosterOnly ? 'My roster' : 'Standings'}</h1><nav class="subnav"><a href="#roster/${id}">My roster</a><a href="#standings/${id}">Standings</a><a href="#results/${id}">Race results</a></nav><div id="scores"></div><details class="panel"><summary>How points work</summary><p>(November first-round vote share + 50 for the eventual called or certified winner) × race rating.</p><p>Safe ×1 · Likely ×1.1 · Lean ×1.25 · Toss-up ×1.5. Each candidate is rounded to two decimals before adding. A later runoff changes the winner bonus, while November vote share stays in place.</p><p>Provisional positions compare known subtotals. Check reporting coverage before comparing teams with missing scores.</p></details>`;
let timer: number | undefined, previous = '';
function roster(row: Standing) { return `<div class="roster">${row.roster.map(score => `<details class="candidate"><summary><span><strong>${escape(score.candidate.name)}</strong><small>${escape(score.slot)}</small></span><strong>${points(score.points)} pts</strong></summary><p>November share ${score.share == null ? 'unknown' : score.share.toFixed(2) + '%'} · win bonus ${score.win_bonus} · rating ${score.rating == null ? 'unknown' : '×' + score.rating}</p>${score.counts ? `<p>${provenance(score.counts.provenance)}</p>` : '<p>Awaiting counts.</p>'}${score.outcome ? `<p>${escape(score.outcome.status)} · ${provenance(score.outcome.provenance)}</p>` : ''}</details>`).join('') || '<p class="empty">Your roster starts with the first pick.</p>'}</div>`; }
async function update() { try {
const data = await api<{
teams: Standing[];
provisional: boolean;
}>(query('standings', { league_id: id }), undefined, signal);
if (signal.aborted)
return;
const serialized = JSON.stringify(data);
if (serialized !== previous) {
previous = serialized;
const mine = view.teams.find(t => t.user_id === user?.id)?.id;
const rows = rosterOnly ? data.teams.filter(r => r.team.id === mine) : data.teams;
element('#scores').innerHTML = `${data.provisional ? '<p class="notice">Provisional · totals include only reported scores.</p>' : ''}${rows.map(row => rosterOnly ? roster(row) : `<details class="panel standing"><summary><span class="rank">${row.rank ?? '—'}</span><span class="grow"><strong>${escape(row.team.name)}</strong><small>${row.known_scores} / 12 scores available</small></span><strong>${points(row.known_subtotal)} <small>${row.total == null ? 'known subtotal' : 'points'}</small></strong></summary>${roster(row)}</details>`).join('')}`;
}
}
catch (error) {
message(error);
} if (!signal.aborted)
timer = window.setTimeout(() => void update(), 15000); }
signal.addEventListener('abort', () => clearTimeout(timer));
await update();
}
+691 -73
View File
@@ -1,73 +1,691 @@
:root { font-family: 'Avenir Next', Avenir, 'Segoe UI', sans-serif; color: #213632; background: #f6f4ee; font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; --muted: #596760; --line: #d6dbd2; }
* { box-sizing: border-box; }
body { margin: 0; }
a { color: inherit; text-underline-offset: 3px; }
:focus-visible { outline: 3px solid #98602c; outline-offset: 3px; }
.site-header, main, footer { width: min(1200px, calc(100% - 64px)); margin-inline: auto; }
.site-header { min-height: 94px; display: flex; align-items: center; justify-content: space-between; gap: 24px; border-bottom: 1px solid var(--line); }
.wordmark { display: inline-flex; align-items: center; gap: 14px; text-decoration: none; font-size: 17px; font-weight: 650; letter-spacing: -.03em; }
.monogram { display: grid; place-items: center; width: 43px; height: 43px; border: 1px solid #213632; border-radius: 50%; font-family: Georgia, serif; font-size: 25px; font-weight: 400; padding-bottom: 4px; }
.edition, .eyebrow { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .13em; }
.intro { padding: 56px 0 36px; }
.eyebrow { margin: 0 0 14px; font-weight: 650; }
h1 { margin: 0; font-family: Georgia, serif; font-size: clamp(38px, 5.5vw, 70px); font-weight: 400; line-height: 1.08; letter-spacing: -.05em; }
h1 em { font-weight: 400; color: #67765e; }
.description { max-width: 600px; margin: 22px 0 0; font-size: 17px; line-height: 1.7; color: var(--muted); }
h2 { font: 400 32px Georgia, serif; margin: 0; letter-spacing: -.02em; }
p { line-height: 1.65; }
.small, .muted, small { font-size: 12px; color: var(--muted); }
.workspace { padding: 24px; background: #e9ece3; border: 1px solid var(--line); }
.workspace > label { max-width: 510px; }
label { display: flex; flex-direction: column; gap: 7px; font-size: 12px; font-weight: 600; }
select, input { font: inherit; font-size: 14px; background: #fffefa; border: 1px solid #b3bdb0; border-radius: 4px; color: #213632; padding: 10px 12px; min-width: 0; max-width: 100%; min-height: 42px; }
select { width: 100%; }
button { font: inherit; font-size: 13px; font-weight: 600; color: #fffefa; background: #294d41; padding: 11px 17px; border: 1px solid #294d41; border-radius: 4px; cursor: pointer; min-height: 42px; }
button:hover { background: #3f6150; }
button.secondary { background: transparent; color: #294d41; }
button:disabled { opacity: .5; cursor: default; }
.notice { border-left: 3px solid #8d784f; padding: 10px 14px; background: #f6f1e4; font-size: 13px; }
.notice.real { border-color: #9b5d2d; }
.notice strong { margin-right: 6px; }
.section-nav { display: flex; flex-wrap: wrap; gap: 30px; padding: 25px 0; border-bottom: 1px solid var(--line); font-size: 13px; }
.section-nav a { text-decoration: none; }
.panel { padding: 38px 0; border-bottom: 1px solid var(--line); scroll-margin-top: 20px; }
.section-heading { display: flex; justify-content: space-between; align-items: center; gap: 20px; margin-bottom: 24px; }
.filters { display: grid; grid-template-columns: 2fr repeat(4, 1fr); gap: 12px; margin-bottom: 20px; }
.table-scroll { overflow: auto; max-height: 440px; border: 1px solid var(--line); }
table { width: 100%; border-collapse: collapse; font-size: 13px; text-align: left; }
th { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); background: #e9ece3; position: sticky; top: 0; z-index: 1; white-space: nowrap; }
th, td { padding: 14px 16px; border-bottom: 1px solid var(--line); }
tbody tr:last-child td { border: 0; }
td strong { font-weight: 600; }
.cell-note { display: block; font-size: 11px; color: var(--muted); margin-top: 5px; }
.tag { display: inline-block; border: 1px solid var(--line); padding: 4px 7px; font-size: 11px; white-space: nowrap; border-radius: 3px; background: #eeefe7; }
details { margin-top: 22px; }
summary { cursor: pointer; font-size: 14px; padding: 8px 0; }
.capacity-controls { display: flex; align-items: end; gap: 22px; margin-top: 15px; }
.capacity-controls label { min-width: 145px; }
.warning { color: #914518; font-weight: 600; }
.actions { display: flex; gap: 10px; }
.roster-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; margin-top: 25px; }
fieldset { margin: 0; padding: 22px; border: 1px solid var(--line); background: #fdfcf7; min-width: 0; }
legend { font: 23px Georgia, serif; padding: 0 8px; }
fieldset label + label { margin-top: 15px; }
#roster-status { font-size: 13px; }
.formula { font: 23px Georgia, serif; line-height: 1.6; }
.rating-key { display: flex; gap: 24px; flex-wrap: wrap; font-size: 13px; }
.rating-key strong { margin-left: 6px; }
.replay-controls { display: grid; grid-template-columns: 1fr 1.5fr auto; align-items: start; gap: 24px; margin-top: 25px; }
.replay-controls button { margin-top: 21px; }
input[type=range] { width: 100%; accent-color: #294d41; padding: 0; border: 0; background: transparent; }
.metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: var(--line); border: 1px solid var(--line); margin: 25px 0; }
.metrics > div { background: #e9ece3; padding: 22px; display: flex; flex-direction: column; gap: 10px; }
.metrics span { font-size: 12px; }
.metrics strong { font: 42px Georgia, serif; font-variant-numeric: tabular-nums; }
.metrics small { line-height: 1.5; }
.comparison { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin: 20px 0; font-size: 12px; }
.comparison i { display: block; height: 8px; min-width: 2px; background: #8a987b; margin-top: 10px; transition: width .3s; }
.comparison > div:last-child i { background: #294d41; }
footer { display: flex; justify-content: space-between; gap: 20px; padding: 24px 0 30px; font-size: 11px; color: var(--muted); }
.loading { padding: 60px 0; }
@media (max-width: 800px) { .filters { grid-template-columns: 1fr 1fr; } .search-label { grid-column: 1 / -1; } .roster-grid { grid-template-columns: 1fr; } .replay-controls { grid-template-columns: 1fr; gap: 16px; } .replay-controls button { margin: 0; } .section-heading { align-items: start; flex-direction: column; } .metrics { grid-template-columns: 1fr; } }
@media (max-width: 500px) { .site-header, main, footer { width: calc(100% - 32px); } .edition { max-width: 90px; text-align: right; line-height: 1.6; } .wordmark { gap: 9px; font-size: 14px; } .workspace { padding: 16px; } .section-nav { gap: 15px; font-size: 12px; } .capacity-controls { align-items: stretch; flex-direction: column; gap: 0; } footer { flex-direction: column; gap: 8px; } }
@media (prefers-reduced-motion: reduce) { .comparison i { transition: none; } }
:root {
font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
color:#233b31;
background:#f6f4ee;
font-synthesis:none;
--green:#254f3b;
--line:#d9ded4;
--muted:#66736a;
--paper:#fffefa;
}
* {
box-sizing:border-box;
}
body {
margin:0;
}
button,input,select {
font:inherit;
}
a {
color:inherit;
text-underline-offset:4px;
}
button,.button {
display:inline-flex;
align-items:center;
justify-content:center;
gap:.4rem;
background:var(--green);
color:white;
border:1px solid var(--green);
border-radius:7px;
padding:.65rem 1rem;
font-size:.9rem;
font-weight:600;
cursor:pointer;
text-decoration:none;
min-height:42px;
}
button:hover,.button:hover {
filter:brightness(1.12);
}
button:disabled {
opacity:.45;
cursor:default;
}
button.secondary,.button.secondary {
background:transparent;
color:var(--green);
border-color:#a6b5a6;
}
.quiet {
background:transparent;
color:var(--green);
border-color:transparent;
padding:.35rem .55rem;
min-height:36px;
}
input,select {
display:block;
width:100%;
border:1px solid #b9c4b9;
border-radius:6px;
background:var(--paper);
color:inherit;
padding:.7rem .8rem;
min-height:43px;
}
input:focus,select:focus,button:focus-visible,a:focus-visible,summary:focus-visible {
outline:3px solid #94bca0;
outline-offset:3px;
}
label {
display:block;
font-size:.88rem;
font-weight:550;
line-height:1.5;
}
label input,label select {
margin-top:.4rem;
}
form {
display:grid;
gap:1rem;
}
form button[type=submit] {
justify-self:start;
}
p {
line-height:1.65;
margin:.6rem 0 1rem;
}
h1,h2,h3 {
font-weight:550;
letter-spacing:-.025em;
line-height:1.2;
}
h1 {
font-family:Georgia,serif;
font-size:clamp(2.2rem,5vw,3.5rem);
font-weight:400;
margin:.25rem 0 1.6rem;
}
h2 {
font-size:1.2rem;
margin:.2rem 0 1rem;
}
small {
display:block;
font-size:.78rem;
font-weight:400;
color:var(--muted);
margin-top:.3rem;
}
.site-header {
max-width:1280px;
margin:auto;
min-height:104px;
padding:1.5rem 2rem;
display:flex;
align-items:center;
gap:2.4rem;
border-bottom:1px solid var(--line);
}
.wordmark {
display:flex;
align-items:center;
gap:.8rem;
font-size:.95rem;
text-decoration:none;
font-weight:600;
white-space:nowrap;
}
.monogram {
font-family:Georgia,serif;
font-size:2.8rem;
letter-spacing:-.07em;
font-weight:400;
}
.site-header nav {
display:flex;
gap:1.8rem;
margin:auto;
}
.site-header nav a {
text-decoration:none;
color:var(--muted);
padding:.6rem 0;
font-size:.92rem;
}
.site-header nav a[aria-current] {
border-bottom:2px solid var(--green);
color:var(--green);
}
.edition {
margin-left:auto;
font-size:.8rem;
color:var(--muted);
}
main {
max-width:1120px;
margin:3.5rem auto 5rem;
padding:0 2rem;
min-height:60vh;
}
footer {
max-width:1280px;
margin:auto;
border-top:1px solid var(--line);
padding:1.5rem 2rem;
display:flex;
justify-content:space-between;
gap:1rem;
color:var(--muted);
font-size:.78rem;
}
.page-heading {
display:flex;
align-items:center;
justify-content:space-between;
gap:1rem;
margin-bottom:1.4rem;
}
.page-heading h1,.page-heading h2 {
margin-bottom:0;
}
.eyebrow {
text-transform:uppercase;
letter-spacing:.13em;
font-size:.7rem;
font-weight:700;
color:var(--muted);
margin-bottom:.65rem;
}
.actions {
display:flex;
align-items:center;
gap:.5rem;
flex-shrink:0;
}
.panel {
background:var(--paper);
border:1px solid var(--line);
border-radius:12px;
padding:1.5rem;
margin-bottom:1.2rem;
}
.narrow {
max-width:580px;
}
.muted {
color:var(--muted);
font-size:.88rem;
}
.empty {
text-align:center;
padding:5rem 1rem;
max-width:620px;
margin:auto;
}
.empty h2 {
font-family:Georgia,serif;
font-size:1.8rem;
font-weight:400;
}
.empty p {
color:var(--muted);
}
.league-grid {
display:grid;
grid-template-columns:repeat(auto-fit,minmax(270px,1fr));
gap:1.2rem;
}
.league-card {
text-decoration:none;
position:relative;
min-height:205px;
}
.league-card h2 {
font-size:1.5rem;
margin-top:1.2rem;
}
.league-card:hover {
border-color:#7c9983;
}
.arrow {
position:absolute;
bottom:1.5rem;
right:1.5rem;
font-size:1.5rem;
}
.badge {
display:inline-block;
background:#eaf0e5;
color:#446449;
border-radius:4px;
padding:.25rem .5rem;
font-size:.7rem;
text-transform:capitalize;
}
.back {
display:inline-block;
font-size:.87rem;
color:var(--muted);
margin-bottom:1.5rem;
text-decoration:none;
}
.subnav {
display:flex;
gap:1.5rem;
margin:0 0 2rem;
font-size:.9rem;
}
.notice {
background:#eef1df;
border:1px solid #d5ddbd;
border-radius:8px;
padding:1rem 1.2rem;
margin-bottom:1.5rem;
font-size:.9rem;
}
.notice ul {
margin-bottom:0;
padding-left:1.1rem;
line-height:1.8;
}
.error {
color:#893626;
}
.notice.error {
background:#faeee6;
border-color:#dec5b2;
}
.auth {
max-width:560px;
margin:5rem auto;
}
.auth>p {
color:var(--muted);
}
.auth .panel {
margin-top:1.5rem;
}
.auth h1 {
font-size:3rem;
}
.team-list {
list-style:none;
padding:0;
margin:0;
}
.team-list li {
display:flex;
align-items:center;
justify-content:space-between;
padding:1rem 0;
border-bottom:1px solid var(--line);
}
.team-list li:last-child {
border:0;
}
details>summary {
cursor:pointer;
font-size:.88rem;
font-weight:550;
line-height:1.5;
}
details[open]>summary {
margin-bottom:1.2rem;
}
details p {
font-size:.84rem;
color:var(--muted);
overflow-wrap:anywhere;
}
.search {
margin:0 0 1.5rem;
}
.search input[type=search] {
padding:1rem;
background:var(--paper);
}
.filters {
display:flex;
gap:1rem;
flex-wrap:wrap;
margin-top:1rem;
}
.filters label {
flex:1;
min-width:100px;
}
.filters .checkbox {
display:flex;
align-items:center;
gap:.5rem;
}
.checkbox input {
width:auto;
margin:0;
}
.candidate-list {
border-top:1px solid var(--line);
}
.candidate {
padding:1.15rem 0;
border-bottom:1px solid var(--line);
}
.candidate-main {
display:flex;
align-items:center;
justify-content:space-between;
gap:1rem;
}
.candidate-meta {
display:block;
color:var(--muted);
font-size:.78rem;
margin-top:.4rem;
}
.candidate>details {
margin-top:.7rem;
}
.candidate>details>summary {
font-size:.74rem;
color:var(--muted);
}
.candidate>details[open]>summary {
margin-bottom:.4rem;
}
.pagination {
display:flex;
align-items:center;
justify-content:center;
gap:1rem;
margin-top:1.5rem;
font-size:.82rem;
}
.draft-layout {
display:grid;
grid-template-columns:minmax(0,1fr) 290px;
gap:2rem;
}
.draft-layout aside .panel {
padding:1.1rem;
}
.draft-layout aside h2 {
font-size:1rem;
}
.draft-layout ol {
padding-left:1.2rem;
font-size:.85rem;
}
.draft-layout li {
padding:.6rem 0;
}
.draft-layout #queue {
list-style:none;
padding:0;
}
.draft-layout #queue li {
display:flex;
align-items:center;
justify-content:space-between;
gap:.4rem;
}
.draft-layout #queue li>div {
white-space:nowrap;
}
.turn {
display:flex;
align-items:center;
gap:1.5rem;
background:#e9eee1;
}
.turn>div:first-child {
flex:1;
}
.turn p {
margin-bottom:0;
font-size:.85rem;
}
.clock {
font-size:2.2rem;
font-variant-numeric:tabular-nums;
}
.rank {
font-family:Georgia,serif;
font-size:1.8rem;
min-width:2rem;
}
.standing>summary,.roster summary,.race>summary {
display:flex;
align-items:center;
gap:1.2rem;
justify-content:space-between;
}
.grow {
flex:1;
}
.standing summary strong {
font-weight:600;
}
.search-label {
max-width:580px;
margin-bottom:2rem;
}
.account-menu {
position:relative;
font-size:.8rem;
}
.account-menu>div {
position:absolute;
right:0;
top:2rem;
background:var(--paper);
padding:1rem;
border:1px solid var(--line);
border-radius:8px;
z-index:5;
min-width:180px;
box-shadow:0 8px 20px #233b3110;
}
.account-menu a {
display:block;
margin-bottom:1rem;
}
.account-menu[open]>summary {
margin-bottom:0;
}
.sr-only {
position:absolute;
width:1px;
height:1px;
padding:0;
overflow:hidden;
clip:rect(0,0,0,0);
white-space:nowrap;
border:0;
}
[hidden] {
display:none!important;
}
@media(max-width:760px) {
.site-header {
padding:1rem;
gap:.8rem;
flex-wrap:wrap;
}
.site-header nav {
order:3;
width:100%;
justify-content:center;
gap:2.5rem;
}
.account-menu {
margin-left:auto;
}
.wordmark {
font-size:.85rem;
}
main {
padding:0 1rem;
margin-top:2rem;
}
.page-heading {
align-items:flex-start;
flex-direction:column;
}
.page-heading .actions {
width:100%;
flex-wrap:wrap;
}
.draft-layout {
grid-template-columns:1fr;
}
.draft-layout aside {
display:flex;
flex-direction:column;
}
.turn {
flex-wrap:wrap;
}
.candidate-main {
align-items:flex-start;
flex-wrap:wrap;
}
.candidate-main .actions {
margin-left:auto;
}
.auth {
margin:2rem auto;
}
.auth h1 {
font-size:2.6rem;
}
.race>summary {
align-items:flex-start;
flex-direction:column;
gap:.4rem;
}
.panel {
padding:1.1rem;
}
footer {
padding:1.3rem 1rem;
}
.edition {
font-size:.7rem;
}
}
table {
width:100%;
border-collapse:collapse;
font-size:.85rem;
}
th,td {
text-align:left;
padding:.7rem .3rem;
border-bottom:1px solid var(--line);
}
th {
color:var(--muted);
font-weight:500;
}
td:not(:first-child) {
font-variant-numeric:tabular-nums;
}
+138
View File
@@ -0,0 +1,138 @@
export interface League {
id: string;
name: string;
commissioner_id: string;
max_teams: number;
status: string;
catalog_mode: string | null;
catalog_id: string | null;
ratings_id: string | null;
members: number;
team_name: string;
}
export interface Team {
id: string;
user_id: string;
name: string;
username: string;
draft_order: number | null;
queue?: string[];
}
export interface Pick {
number: number;
team_id: string;
candidate_id: string;
slot: string;
automatic: number;
}
export interface LeagueView {
league: League;
teams: Team[];
my_team: Team & {
queue: string[];
};
picks: Pick[];
draft: {
pick_number: number;
deadline: number | null;
remaining: number;
};
picker: Team | null;
legal_candidate_ids: string[];
candidate_names: Record<string, string>;
server_time: number;
readiness: {
ready: boolean;
problems: string[];
};
}
export interface Snapshot {
id: string;
label: string;
mode: string;
catalog_id?: string;
complete?: boolean;
}
export interface Catalogs {
catalogs: Snapshot[];
ratings: Snapshot[];
allow_demo: boolean;
}
export interface Candidate {
id: string;
race_id: string;
name: string;
affiliation: string;
party: string;
office: string;
state: string;
rating: string | null;
owner: string | null;
source: {
url: string;
name: string;
retrieved_at: string;
};
}
export interface CandidatePage {
items: Candidate[];
total: number;
catalog: Snapshot | null;
}
export interface Provenance {
source_id: string;
source_name: string;
url: string;
source_time: string | null;
received_at: number;
last_checked: number | null;
stale: boolean;
verified: number;
}
export interface Counts {
total_votes: number;
votes: {
candidate_id: string;
votes: number;
}[];
provenance: Provenance;
}
export interface Outcome {
status: string;
round: string;
winner_id: string | null;
provenance: Provenance;
}
export interface RaceResult {
id: string;
name: string;
office: string;
state: string;
candidates: {
id: string;
name: string;
affiliation: string;
}[];
counts: Counts | null;
outcome: Outcome | null;
}
export interface Score {
candidate: Candidate;
slot: string;
points: number | null;
share: number | null;
win_bonus: number;
rating: number | null;
counts: Counts | null;
outcome: Outcome | null;
}
export interface Standing {
team: Team;
roster: Score[];
known_scores: number;
missing_scores: number;
known_subtotal: number | null;
total: number | null;
rank: number | null;
provisional: boolean;
}
+43
View File
@@ -0,0 +1,43 @@
export function escape(value: unknown) { return String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]!)); }
export function element<T extends HTMLElement = HTMLElement>(selector: string, root: ParentNode = document): T { const node = root.querySelector<T>(selector); if (!node)
throw new Error(`Missing element: ${selector}`); return node; }
export function message(error: unknown) { if (error instanceof DOMException && error.name === 'AbortError')
return; const region = document.querySelector<HTMLElement>('#notice'); if (region) {
region.textContent = error instanceof Error ? error.message : String(error);
region.hidden = false;
} }
export function clearMessage() { const region = element('#notice'); region.hidden = true; region.textContent = ''; }
export function submit(selector: string, operation: (data: FormData) => Promise<void>) {
element<HTMLFormElement>(selector).addEventListener('submit', async (event) => {
event.preventDefault();
clearMessage();
const form = event.currentTarget as HTMLFormElement;
const button = form.querySelector<HTMLButtonElement>('button[type=submit]');
if (button)
button.disabled = true;
try {
await operation(new FormData(form));
}
catch (error) {
message(error);
}
finally {
if (button)
button.disabled = false;
}
});
}
export function click(selector: string, operation: () => Promise<void>, root: ParentNode = document) { const button = root.querySelector<HTMLButtonElement>(selector); button?.addEventListener('click', async () => { clearMessage(); button.disabled = true; try {
await operation();
}
catch (error) {
message(error);
}
finally {
button.disabled = false;
} }); }
export function field(label: string, name: string, value = '', extra = '') { return `<label>${escape(label)}<input name="${name}" value="${escape(value)}" ${extra} required></label>`; }
export function sourceLink(url: string, label: string) { return /^https?:\/\//.test(url) ? `<a href="${escape(url)}" target="_blank" rel="noopener noreferrer">${escape(label)}</a>` : escape(label); }
export function time(value: number | null) { return value ? new Date(value * 1000).toLocaleString() : 'Not checked'; }
export function points(value: number | null) { return value == null ? '—' : value.toFixed(2); }
export function go(path: string) { location.hash = path; }
+1
View File
@@ -1,6 +1,7 @@
import { defineConfig } from 'vite';
export default defineConfig({
build: { rollupOptions: { input: { app: "index.html", lab: "lab.html" } } },
server: {
host: '127.0.0.1',
port: 5173,