- Scheduler now captures FotMob details for fixtures inside 48h, so the projected XIs exist before kickoff; the Lineups tab shows the pitch pre-match under a 'Predicted lineups' header with an honest note (official lineups ~1h before kickoff), switching to the confirmed view at kickoff - Finished matches get their forecast back: the preview falls back to the prediction frozen <=15min before kickoff (the scoreboard's no-hindsight snapshot), with the most-likely-score chip rebuilt from the frozen expected goals; the model card now reads 'Model's pre-match expected goals: 1.60 - 1.02 · final score 2-1', and the market-movement chart returns to finished matches too - Upcoming match cards append the model's expected goals to the hint: 'Model: Brazil 64% · 1.3-0.9 goals' Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cup26 — World Cup 2026 dashboard
A local-first PWA for the 2026 FIFA World Cup: live scores, a model-driven prediction engine, and data-story visualizations — all in one TypeScript app.
- Live — fixtures, group tables and a knockout bracket that update over a WebSocket as results come in.
- Predict — an Elo + Dixon-Coles + Monte-Carlo model that simulates the whole tournament: per-match win/draw/loss, championship odds, and a title-race chart that moves with every result.
- Story — StatsBomb open-data visualizations (shot map, xG race, pass networks) of the 2022 World Cup final.
Stack
Vite + React 19 + TypeScript (strict), Tailwind 4, TanStack Router, Zustand, a Fastify WebSocket server, Recharts + custom SVG. Packaged as an installable PWA and a single Docker image. Bun for tooling.
Data sources
| Source | Used for | Notes |
|---|---|---|
| openfootball/worldcup.json | 2026 fixtures + bracket structure | public domain, no key |
| football-data.org | live scores & status | free tier, World Cup included, 10 req/min, delayed |
| SofaScore (unofficial) | near-real-time scores/events | optional, fragile, feature-flagged |
| martj42 international results | Elo / goals model training | 49k matches, 1872→now |
| StatsBomb Open Data | the Story page | 2022 WC final event data |
All external calls happen server-side, so the browser only talks to its own
origin and the client CSP stays locked to 'self'.
Develop
bun install
bun run data:build # generate icons + fixtures.json + ratings.json
bun run dev # web UI on http://localhost:5173 (proxies /api + /ws)
bun run dev:server # the Fastify live/model server on :8787 (separate shell)
Without a FOOTBALL_DATA_TOKEN the app runs in seed-only mode (full schedule,
model and story; no live scores). To exercise the live pipeline in dev:
curl -XPOST localhost:8787/api/dev/score -H content-type:application/json \
-d '{"num":1,"homeScore":2,"awayScore":1,"status":"finished"}'
Test & build
bun run test # vitest (model + standings invariants)
bun run build # tsc -b && vite build (the deploy gate)
Deploy
A self-contained Docker image serves the built PWA and the live/model server.
docker build -t cup26 .
docker run -p 8787:8787 -e FOOTBALL_DATA_TOKEN=... cup26
deploy/cup26.compose.yml is a Traefik-ready Compose project (mirrors the
sibling TTRPG deploy): rsync the repo to the host, drop it in as
docker-compose.yml, set FOOTBALL_DATA_TOKEN, then docker compose up -d --build.
Environment
| Var | Default | Purpose |
|---|---|---|
PORT |
8787 |
server port |
FOOTBALL_DATA_TOKEN |
– | football-data.org API token (enables live scores) |
ENABLE_SOFASCORE |
false |
layer the unofficial real-time source on top |
SIM_ITER |
20000 |
Monte-Carlo iterations per recompute |
ALLOWED_ORIGINS |
– | comma-separated WebSocket origin allowlist |
Odds are model estimates for fun, not betting advice.