Phase 4: polish + deploy-ready — mobile nav, README, Docker validation
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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**.
|
||||||
+5
-5
@@ -14,11 +14,11 @@
|
|||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"build:server": "node server/build.mjs",
|
"build:server": "node server/build.mjs",
|
||||||
"dev:server": "tsx watch server/src/index.ts",
|
"dev:server": "tsx watch server/src/index.ts",
|
||||||
"data:fixtures": "tsx scripts/buildFixtures.ts",
|
"data:fixtures": "bun scripts/buildFixtures.ts",
|
||||||
"data:ratings": "tsx scripts/buildRatings.ts",
|
"data:ratings": "bun scripts/buildRatings.ts",
|
||||||
"data:viz": "tsx scripts/buildStatsbombViz.ts",
|
"data:viz": "bun scripts/buildStatsbombViz.ts",
|
||||||
"data:icons": "node scripts/make-icons.mjs",
|
"data:icons": "bun scripts/make-icons.mjs",
|
||||||
"data:build": "npm run data:icons && npm run data:fixtures && npm run data:ratings"
|
"data:build": "bun run data:icons && bun run data:fixtures && bun run data:ratings"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-router": "^1.95.0",
|
"@tanstack/react-router": "^1.95.0",
|
||||||
|
|||||||
+30
-11
@@ -1,17 +1,18 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Link, Outlet } from '@tanstack/react-router';
|
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 { useUiStore } from '@/stores/uiStore';
|
||||||
import { useTournamentStore } from '@/stores/tournamentStore';
|
import { useTournamentStore } from '@/stores/tournamentStore';
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
const NAV = [
|
const NAV: { to: string; label: string; exact: boolean; icon: LucideIcon }[] = [
|
||||||
{ to: '/', label: 'Live', exact: true },
|
{ to: '/', label: 'Live', exact: true, icon: Radio },
|
||||||
{ to: '/groups', label: 'Groups', exact: false },
|
{ to: '/groups', label: 'Groups', exact: false, icon: Table },
|
||||||
{ to: '/bracket', label: 'Bracket', exact: false },
|
{ to: '/bracket', label: 'Bracket', exact: false, icon: GitMerge },
|
||||||
{ to: '/predict', label: 'Predict', exact: false },
|
{ to: '/predict', label: 'Predict', exact: false, icon: TrendingUp },
|
||||||
{ to: '/story', label: 'Story', exact: false },
|
{ to: '/story', label: 'Story', exact: false, icon: Sparkles },
|
||||||
] as const;
|
];
|
||||||
|
|
||||||
function ConnectionStatus() {
|
function ConnectionStatus() {
|
||||||
const status = useTournamentStore((s) => s.status);
|
const status = useTournamentStore((s) => s.status);
|
||||||
@@ -68,7 +69,7 @@ export function RootLayout() {
|
|||||||
</span>
|
</span>
|
||||||
Cup<span className="text-accent">26</span>
|
Cup<span className="text-accent">26</span>
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="flex items-center gap-0.5 overflow-x-auto">
|
<nav className="hidden items-center gap-0.5 sm:flex">
|
||||||
{NAV.map((item) => (
|
{NAV.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.to}
|
key={item.to}
|
||||||
@@ -90,11 +91,29 @@ export function RootLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="mx-auto w-full max-w-6xl flex-1 px-4 py-6">
|
<main className="mx-auto w-full max-w-6xl flex-1 px-4 py-6 pb-24 sm:pb-6">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="border-t border-line py-6 text-center text-xs text-faint">
|
{/* Mobile bottom tab bar */}
|
||||||
|
<nav className="fixed inset-x-0 bottom-0 z-30 flex border-t border-line bg-surface/95 backdrop-blur sm:hidden">
|
||||||
|
{NAV.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
{...(item.exact ? { activeOptions: { exact: true } } : {})}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-1 flex-col items-center gap-0.5 py-2 text-[10px] font-medium text-muted',
|
||||||
|
'[&.active]:text-accent',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon size={18} />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<footer className="border-t border-line py-6 pb-24 text-center text-xs text-faint sm:pb-6">
|
||||||
<p>
|
<p>
|
||||||
Cup26 · World Cup 2026 · data from openfootball, football-data.org & StatsBomb open data ·
|
Cup26 · World Cup 2026 · data from openfootball, football-data.org & StatsBomb open data ·
|
||||||
model-driven odds, not betting advice
|
model-driven odds, not betting advice
|
||||||
|
|||||||
Reference in New Issue
Block a user