- Penalty shootout panel: shootout-flagged events get a kicker-by-kicker dot strip on the Summary tab (and stay OFF the timeline/running score). Parsing is defensive — validate against the first real shootout June 28. - Road to the final on team profiles: the model's projected knockout run (teamPath in whatif.ts forces the team through the model bracket) with per-round reach probabilities and a title-chance chip. Teams the model doesn't project through still get a hypothetical runner-up road. - Match videos: ESPN summaries carry per-match clips (SUMMARY_V=5 keeps headline/duration/thumbnail/link) — a Videos card on the Summary tab, clips open on ESPN. Tokenless; replaces the parked ScoreBat idea. - ICS calendar export: /api/calendar.ics[?team=X] (RFC 5545, escaped, folded, CRLF) + an Add-to-calendar chip on team pages. - Injuries plumbing (dormant until API_FOOTBALL_KEY is set): twice-daily API-Football sweep into the injuries table, surfaced on team profiles and the match availability banner next to suspensions. 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.