Game Features
This commit is contained in:
+2
-12
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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(); });
|
||||
}
|
||||
@@ -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); }
|
||||
@@ -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); });
|
||||
}
|
||||
@@ -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; } }
|
||||
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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();
|
||||
@@ -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
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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();
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export function escape(value: unknown) { return String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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,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,
|
||||
|
||||
Reference in New Issue
Block a user