From 9c7605391c62acec46bd3ae947028c3869496e0b Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Thu, 11 Jun 2026 13:39:58 +0200 Subject: [PATCH] =?UTF-8?q?Phase=204:=20polish=20+=20deploy-ready=20?= =?UTF-8?q?=E2=80=94=20mobile=20nav,=20README,=20Docker=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mobile bottom tab bar (icon tabs) with the top nav hidden on small screens; content padding so it never overlaps - data: scripts run via bun (not tsx/node) so the oven/bun build image needs no node — fixes the Dockerfile data:build step - README: features, data sources, dev/test/build/deploy, env vars - Validated the production Docker image end-to-end: build succeeds (378MB), container serves /healthz, /api/snapshot (104 fixtures), /api/predictions (20k sims), the SPA with deep-link fallback, and the story dataset Co-Authored-By: Claude Opus 4.8 --- README.md | 80 ++++++++++++++++++++++++++++++++++++++++++ package.json | 10 +++--- src/app/RootLayout.tsx | 41 ++++++++++++++++------ 3 files changed, 115 insertions(+), 16 deletions(-) create mode 100644 README.md 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 -