diff --git a/README.md b/README.md new file mode 100644 index 0000000..2ad51e4 --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +# 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](https://github.com/openfootball/worldcup.json) | 2026 fixtures + bracket structure | public domain, no key | +| [football-data.org](https://www.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](https://github.com/martj42/international_results) | Elo / goals model training | 49k matches, 1872→now | +| [StatsBomb Open Data](https://github.com/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 + +```bash +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: + +```bash +curl -XPOST localhost:8787/api/dev/score -H content-type:application/json \ + -d '{"num":1,"homeScore":2,"awayScore":1,"status":"finished"}' +``` + +## Test & build + +```bash +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. + +```bash +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**. diff --git a/package.json b/package.json index 940c9d6..7b6bbd1 100644 --- a/package.json +++ b/package.json @@ -14,11 +14,11 @@ "test:watch": "vitest", "build:server": "node server/build.mjs", "dev:server": "tsx watch server/src/index.ts", - "data:fixtures": "tsx scripts/buildFixtures.ts", - "data:ratings": "tsx scripts/buildRatings.ts", - "data:viz": "tsx scripts/buildStatsbombViz.ts", - "data:icons": "node scripts/make-icons.mjs", - "data:build": "npm run data:icons && npm run data:fixtures && npm run data:ratings" + "data:fixtures": "bun scripts/buildFixtures.ts", + "data:ratings": "bun scripts/buildRatings.ts", + "data:viz": "bun scripts/buildStatsbombViz.ts", + "data:icons": "bun scripts/make-icons.mjs", + "data:build": "bun run data:icons && bun run data:fixtures && bun run data:ratings" }, "dependencies": { "@tanstack/react-router": "^1.95.0", diff --git a/src/app/RootLayout.tsx b/src/app/RootLayout.tsx index 4cbfb8a..fc8e354 100644 --- a/src/app/RootLayout.tsx +++ b/src/app/RootLayout.tsx @@ -1,17 +1,18 @@ import { useEffect } from 'react'; import { Link, Outlet } from '@tanstack/react-router'; -import { Moon, Sun, Trophy } from 'lucide-react'; +import { GitMerge, Moon, Radio, Sparkles, Sun, Table, TrendingUp, Trophy } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; import { useUiStore } from '@/stores/uiStore'; import { useTournamentStore } from '@/stores/tournamentStore'; import { cn } from '@/lib/cn'; -const NAV = [ - { to: '/', label: 'Live', exact: true }, - { to: '/groups', label: 'Groups', exact: false }, - { to: '/bracket', label: 'Bracket', exact: false }, - { to: '/predict', label: 'Predict', exact: false }, - { to: '/story', label: 'Story', exact: false }, -] as const; +const NAV: { to: string; label: string; exact: boolean; icon: LucideIcon }[] = [ + { to: '/', label: 'Live', exact: true, icon: Radio }, + { to: '/groups', label: 'Groups', exact: false, icon: Table }, + { to: '/bracket', label: 'Bracket', exact: false, icon: GitMerge }, + { to: '/predict', label: 'Predict', exact: false, icon: TrendingUp }, + { to: '/story', label: 'Story', exact: false, icon: Sparkles }, +]; function ConnectionStatus() { const status = useTournamentStore((s) => s.status); @@ -68,7 +69,7 @@ export function RootLayout() { Cup26 -