Phase 0: scaffold cup26 — Vite+React+Fastify TS PWA, broadcast theme, app shell

Mirrors the TTRPG stack (strict TS, Tailwind 4, vite-plugin-pwa, esbuild server
bundle, multi-stage Dockerfile, Traefik compose). Floodlit-pitch design tokens,
UI primitives (Button/Card/Badge), TanStack Router shell with Live/Groups/
Bracket/Predict/Story routes (placeholders), dependency-free PWA icon generator.
Raw data staged: openfootball 2026 fixtures + martj42 international results CSV.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 12:49:13 +02:00
commit 4e4e75a1d8
39 changed files with 53029 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "cup26-web",
"runtimeExecutable": "bun",
"runtimeArgs": ["run", "dev"],
"port": 5173
}
]
}
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
dev-dist
server/dist
server/node_modules
.git
**/*.test.ts
+14
View File
@@ -0,0 +1,14 @@
node_modules
dist
dev-dist
server/dist
server/node_modules
*.tsbuildinfo
.DS_Store
.env
.env.local
# Generated build artifacts (reproduced by `npm run data:build`)
public/data
public/pwa-192x192.png
public/pwa-512x512.png
public/favicon.svg
+22
View File
@@ -0,0 +1,22 @@
# --- build stage: generate data, bundle the PWA + the live server ---
FROM oven/bun:1 AS build
WORKDIR /app
COPY package.json bun.lock* bun.lockb* ./
RUN bun install
COPY . .
RUN bun run data:build # icons + fixtures.json + ratings.json → public/
RUN bun run build # tsc -b && vite build → /app/dist
RUN bun run build:server # esbuild → /app/server/dist/index.js
# --- runtime stage: small node image, non-root, serves dist + /ws + /api ---
FROM node:20-slim AS runtime
ENV NODE_ENV=production STATIC_DIR=/app/dist PORT=8787
WORKDIR /app/server
COPY server/package.json ./
RUN npm install --omit=dev --no-audit --no-fund
COPY --from=build /app/server/dist ./dist
COPY --from=build /app/dist /app/dist
RUN chown -R node:node /app
USER node
EXPOSE 8787
CMD ["node", "dist/index.js"]
+1729
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+728
View File
@@ -0,0 +1,728 @@
{"name": "World Cup 2026",
"matches":
[{"round": "Matchday 1",
"date": "2026-06-11",
"time": "13:00 UTC-6",
"team1": "Mexico",
"team2": "South Africa",
"group": "Group A",
"ground": "Mexico City"},
{"round": "Matchday 1",
"date": "2026-06-11",
"time": "20:00 UTC-6",
"team1": "South Korea",
"team2": "Czech Republic",
"group": "Group A",
"ground": "Guadalajara (Zapopan)"},
{"round": "Matchday 8",
"date": "2026-06-18",
"time": "12:00 UTC-4",
"team1": "Czech Republic",
"team2": "South Africa",
"group": "Group A",
"ground": "Atlanta"},
{"round": "Matchday 8",
"date": "2026-06-18",
"time": "19:00 UTC-6",
"team1": "Mexico",
"team2": "South Korea",
"group": "Group A",
"ground": "Guadalajara (Zapopan)"},
{"round": "Matchday 14",
"date": "2026-06-24",
"time": "19:00 UTC-6",
"team1": "Czech Republic",
"team2": "Mexico",
"group": "Group A",
"ground": "Mexico City"},
{"round": "Matchday 14",
"date": "2026-06-24",
"time": "19:00 UTC-6",
"team1": "South Africa",
"team2": "South Korea",
"group": "Group A",
"ground": "Monterrey (Guadalupe)"},
{"round": "Matchday 2",
"date": "2026-06-12",
"time": "15:00 UTC-4",
"team1": "Canada",
"team2": "Bosnia & Herzegovina",
"group": "Group B",
"ground": "Toronto"},
{"round": "Matchday 3",
"date": "2026-06-13",
"time": "12:00 UTC-7",
"team1": "Qatar",
"team2": "Switzerland",
"group": "Group B",
"ground": "San Francisco Bay Area (Santa Clara)"},
{"round": "Matchday 8",
"date": "2026-06-18",
"time": "12:00 UTC-7",
"team1": "Switzerland",
"team2": "Bosnia & Herzegovina",
"group": "Group B",
"ground": "Los Angeles (Inglewood)"},
{"round": "Matchday 8",
"date": "2026-06-18",
"time": "15:00 UTC-7",
"team1": "Canada",
"team2": "Qatar",
"group": "Group B",
"ground": "Vancouver"},
{"round": "Matchday 14",
"date": "2026-06-24",
"time": "12:00 UTC-7",
"team1": "Switzerland",
"team2": "Canada",
"group": "Group B",
"ground": "Vancouver"},
{"round": "Matchday 14",
"date": "2026-06-24",
"time": "12:00 UTC-7",
"team1": "Bosnia & Herzegovina",
"team2": "Qatar",
"group": "Group B",
"ground": "Seattle"},
{"round": "Matchday 3",
"date": "2026-06-13",
"time": "18:00 UTC-4",
"team1": "Brazil",
"team2": "Morocco",
"group": "Group C",
"ground": "New York/New Jersey (East Rutherford)"},
{"round": "Matchday 3",
"date": "2026-06-13",
"time": "21:00 UTC-4",
"team1": "Haiti",
"team2": "Scotland",
"group": "Group C",
"ground": "Boston (Foxborough)"},
{"round": "Matchday 9",
"date": "2026-06-19",
"time": "18:00 UTC-4",
"team1": "Scotland",
"team2": "Morocco",
"group": "Group C",
"ground": "Boston (Foxborough)"},
{"round": "Matchday 9",
"date": "2026-06-19",
"time": "20:30 UTC-4",
"team1": "Brazil",
"team2": "Haiti",
"group": "Group C",
"ground": "Philadelphia"},
{"round": "Matchday 14",
"date": "2026-06-24",
"time": "18:00 UTC-4",
"team1": "Scotland",
"team2": "Brazil",
"group": "Group C",
"ground": "Miami (Miami Gardens)"},
{"round": "Matchday 14",
"date": "2026-06-24",
"time": "18:00 UTC-4",
"team1": "Morocco",
"team2": "Haiti",
"group": "Group C",
"ground": "Atlanta"},
{"round": "Matchday 2",
"date": "2026-06-12",
"time": "18:00 UTC-7",
"team1": "USA",
"team2": "Paraguay",
"group": "Group D",
"ground": "Los Angeles (Inglewood)"},
{"round": "Matchday 3",
"date": "2026-06-13",
"time": "21:00 UTC-7",
"team1": "Australia",
"team2": "Turkey",
"group": "Group D",
"ground": "Vancouver"},
{"round": "Matchday 9",
"date": "2026-06-19",
"time": "12:00 UTC-7",
"team1": "USA",
"team2": "Australia",
"group": "Group D",
"ground": "Seattle"},
{"round": "Matchday 9",
"date": "2026-06-19",
"time": "20:00 UTC-7",
"team1": "Turkey",
"team2": "Paraguay",
"group": "Group D",
"ground": "San Francisco Bay Area (Santa Clara)"},
{"round": "Matchday 15",
"date": "2026-06-25",
"time": "19:00 UTC-7",
"team1": "Turkey",
"team2": "USA",
"group": "Group D",
"ground": "Los Angeles (Inglewood)"},
{"round": "Matchday 15",
"date": "2026-06-25",
"time": "19:00 UTC-7",
"team1": "Paraguay",
"team2": "Australia",
"group": "Group D",
"ground": "San Francisco Bay Area (Santa Clara)"},
{"round": "Matchday 4",
"date": "2026-06-14",
"time": "12:00 UTC-5",
"team1": "Germany",
"team2": "Curaçao",
"group": "Group E",
"ground": "Houston"},
{"round": "Matchday 4",
"date": "2026-06-14",
"time": "19:00 UTC-4",
"team1": "Ivory Coast",
"team2": "Ecuador",
"group": "Group E",
"ground": "Philadelphia"},
{"round": "Matchday 10",
"date": "2026-06-20",
"time": "16:00 UTC-4",
"team1": "Germany",
"team2": "Ivory Coast",
"group": "Group E",
"ground": "Toronto"},
{"round": "Matchday 10",
"date": "2026-06-20",
"time": "19:00 UTC-5",
"team1": "Ecuador",
"team2": "Curaçao",
"group": "Group E",
"ground": "Kansas City"},
{"round": "Matchday 15",
"date": "2026-06-25",
"time": "16:00 UTC-4",
"team1": "Curaçao",
"team2": "Ivory Coast",
"group": "Group E",
"ground": "Philadelphia"},
{"round": "Matchday 15",
"date": "2026-06-25",
"time": "16:00 UTC-4",
"team1": "Ecuador",
"team2": "Germany",
"group": "Group E",
"ground": "New York/New Jersey (East Rutherford)"},
{"round": "Matchday 4",
"date": "2026-06-14",
"time": "15:00 UTC-5",
"team1": "Netherlands",
"team2": "Japan",
"group": "Group F",
"ground": "Dallas (Arlington)"},
{"round": "Matchday 4",
"date": "2026-06-14",
"time": "20:00 UTC-6",
"team1": "Sweden",
"team2": "Tunisia",
"group": "Group F",
"ground": "Monterrey (Guadalupe)"},
{"round": "Matchday 10",
"date": "2026-06-20",
"time": "12:00 UTC-5",
"team1": "Netherlands",
"team2": "Sweden",
"group": "Group F",
"ground": "Houston"},
{"round": "Matchday 10",
"date": "2026-06-20",
"time": "22:00 UTC-6",
"team1": "Tunisia",
"team2": "Japan",
"group": "Group F",
"ground": "Monterrey (Guadalupe)"},
{"round": "Matchday 15",
"date": "2026-06-25",
"time": "18:00 UTC-5",
"team1": "Japan",
"team2": "Sweden",
"group": "Group F",
"ground": "Dallas (Arlington)"},
{"round": "Matchday 15",
"date": "2026-06-25",
"time": "18:00 UTC-5",
"team1": "Tunisia",
"team2": "Netherlands",
"group": "Group F",
"ground": "Kansas City"},
{"round": "Matchday 5",
"date": "2026-06-15",
"time": "12:00 UTC-7",
"team1": "Belgium",
"team2": "Egypt",
"group": "Group G",
"ground": "Seattle"},
{"round": "Matchday 5",
"date": "2026-06-15",
"time": "18:00 UTC-7",
"team1": "Iran",
"team2": "New Zealand",
"group": "Group G",
"ground": "Los Angeles (Inglewood)"},
{"round": "Matchday 11",
"date": "2026-06-21",
"time": "12:00 UTC-7",
"team1": "Belgium",
"team2": "Iran",
"group": "Group G",
"ground": "Los Angeles (Inglewood)"},
{"round": "Matchday 11",
"date": "2026-06-21",
"time": "18:00 UTC-7",
"team1": "New Zealand",
"team2": "Egypt",
"group": "Group G",
"ground": "Vancouver"},
{"round": "Matchday 16",
"date": "2026-06-26",
"time": "20:00 UTC-7",
"team1": "Egypt",
"team2": "Iran",
"group": "Group G",
"ground": "Seattle"},
{"round": "Matchday 16",
"date": "2026-06-26",
"time": "20:00 UTC-7",
"team1": "New Zealand",
"team2": "Belgium",
"group": "Group G",
"ground": "Vancouver"},
{"round": "Matchday 5",
"date": "2026-06-15",
"time": "12:00 UTC-4",
"team1": "Spain",
"team2": "Cape Verde",
"group": "Group H",
"ground": "Atlanta"},
{"round": "Matchday 5",
"date": "2026-06-15",
"time": "18:00 UTC-4",
"team1": "Saudi Arabia",
"team2": "Uruguay",
"group": "Group H",
"ground": "Miami (Miami Gardens)"},
{"round": "Matchday 11",
"date": "2026-06-21",
"time": "12:00 UTC-4",
"team1": "Spain",
"team2": "Saudi Arabia",
"group": "Group H",
"ground": "Atlanta"},
{"round": "Matchday 11",
"date": "2026-06-21",
"time": "18:00 UTC-4",
"team1": "Uruguay",
"team2": "Cape Verde",
"group": "Group H",
"ground": "Miami (Miami Gardens)"},
{"round": "Matchday 16",
"date": "2026-06-26",
"time": "19:00 UTC-5",
"team1": "Cape Verde",
"team2": "Saudi Arabia",
"group": "Group H",
"ground": "Houston"},
{"round": "Matchday 16",
"date": "2026-06-26",
"time": "18:00 UTC-6",
"team1": "Uruguay",
"team2": "Spain",
"group": "Group H",
"ground": "Guadalajara (Zapopan)"},
{"round": "Matchday 6",
"date": "2026-06-16",
"time": "15:00 UTC-4",
"team1": "France",
"team2": "Senegal",
"group": "Group I",
"ground": "New York/New Jersey (East Rutherford)"},
{"round": "Matchday 6",
"date": "2026-06-16",
"time": "18:00 UTC-4",
"team1": "Iraq",
"team2": "Norway",
"group": "Group I",
"ground": "Boston (Foxborough)"},
{"round": "Matchday 12",
"date": "2026-06-22",
"time": "17:00 UTC-4",
"team1": "France",
"team2": "Iraq",
"group": "Group I",
"ground": "Philadelphia"},
{"round": "Matchday 12",
"date": "2026-06-22",
"time": "20:00 UTC-4",
"team1": "Norway",
"team2": "Senegal",
"group": "Group I",
"ground": "New York/New Jersey (East Rutherford)"},
{"round": "Matchday 16",
"date": "2026-06-26",
"time": "15:00 UTC-4",
"team1": "Norway",
"team2": "France",
"group": "Group I",
"ground": "Boston (Foxborough)"},
{"round": "Matchday 16",
"date": "2026-06-26",
"time": "15:00 UTC-4",
"team1": "Senegal",
"team2": "Iraq",
"group": "Group I",
"ground": "Toronto"},
{"round": "Matchday 6",
"date": "2026-06-16",
"time": "20:00 UTC-5",
"team1": "Argentina",
"team2": "Algeria",
"group": "Group J",
"ground": "Kansas City"},
{"round": "Matchday 6",
"date": "2026-06-16",
"time": "21:00 UTC-7",
"team1": "Austria",
"team2": "Jordan",
"group": "Group J",
"ground": "San Francisco Bay Area (Santa Clara)"},
{"round": "Matchday 12",
"date": "2026-06-22",
"time": "12:00 UTC-5",
"team1": "Argentina",
"team2": "Austria",
"group": "Group J",
"ground": "Dallas (Arlington)"},
{"round": "Matchday 12",
"date": "2026-06-22",
"time": "20:00 UTC-7",
"team1": "Jordan",
"team2": "Algeria",
"group": "Group J",
"ground": "San Francisco Bay Area (Santa Clara)"},
{"round": "Matchday 17",
"date": "2026-06-27",
"time": "21:00 UTC-5",
"team1": "Algeria",
"team2": "Austria",
"group": "Group J",
"ground": "Kansas City"},
{"round": "Matchday 17",
"date": "2026-06-27",
"time": "21:00 UTC-5",
"team1": "Jordan",
"team2": "Argentina",
"group": "Group J",
"ground": "Dallas (Arlington)"},
{"round": "Matchday 7",
"date": "2026-06-17",
"time": "12:00 UTC-5",
"team1": "Portugal",
"team2": "DR Congo",
"group": "Group K",
"ground": "Houston"},
{"round": "Matchday 7",
"date": "2026-06-17",
"time": "20:00 UTC-6",
"team1": "Uzbekistan",
"team2": "Colombia",
"group": "Group K",
"ground": "Mexico City"},
{"round": "Matchday 13",
"date": "2026-06-23",
"time": "12:00 UTC-5",
"team1": "Portugal",
"team2": "Uzbekistan",
"group": "Group K",
"ground": "Houston"},
{"round": "Matchday 13",
"date": "2026-06-23",
"time": "20:00 UTC-6",
"team1": "Colombia",
"team2": "DR Congo",
"group": "Group K",
"ground": "Guadalajara (Zapopan)"},
{"round": "Matchday 17",
"date": "2026-06-27",
"time": "19:30 UTC-4",
"team1": "Colombia",
"team2": "Portugal",
"group": "Group K",
"ground": "Miami (Miami Gardens)"},
{"round": "Matchday 17",
"date": "2026-06-27",
"time": "19:30 UTC-4",
"team1": "DR Congo",
"team2": "Uzbekistan",
"group": "Group K",
"ground": "Atlanta"},
{"round": "Matchday 7",
"date": "2026-06-17",
"time": "15:00 UTC-5",
"team1": "England",
"team2": "Croatia",
"group": "Group L",
"ground": "Dallas (Arlington)"},
{"round": "Matchday 7",
"date": "2026-06-17",
"time": "19:00 UTC-4",
"team1": "Ghana",
"team2": "Panama",
"group": "Group L",
"ground": "Toronto"},
{"round": "Matchday 13",
"date": "2026-06-23",
"time": "16:00 UTC-4",
"team1": "England",
"team2": "Ghana",
"group": "Group L",
"ground": "Boston (Foxborough)"},
{"round": "Matchday 13",
"date": "2026-06-23",
"time": "19:00 UTC-4",
"team1": "Panama",
"team2": "Croatia",
"group": "Group L",
"ground": "Toronto"},
{"round": "Matchday 17",
"date": "2026-06-27",
"time": "17:00 UTC-4",
"team1": "Panama",
"team2": "England",
"group": "Group L",
"ground": "New York/New Jersey (East Rutherford)"},
{"round": "Matchday 17",
"date": "2026-06-27",
"time": "17:00 UTC-4",
"team1": "Croatia",
"team2": "Ghana",
"group": "Group L",
"ground": "Philadelphia"},
{"round": "Round of 32",
"num": 73,
"date": "2026-06-28",
"time": "12:00 UTC-7",
"team1": "2A",
"team2": "2B",
"ground": "Los Angeles (Inglewood)"},
{"round": "Round of 32",
"num": 74,
"date": "2026-06-29",
"time": "16:30 UTC-4",
"team1": "1E",
"team2": "3A/B/C/D/F",
"ground": "Boston (Foxborough)"},
{"round": "Round of 32",
"num": 75,
"date": "2026-06-29",
"time": "19:00 UTC-6",
"team1": "1F",
"team2": "2C",
"ground": "Monterrey (Guadalupe)"},
{"round": "Round of 32",
"num": 76,
"date": "2026-06-29",
"time": "12:00 UTC-5",
"team1": "1C",
"team2": "2F",
"ground": "Houston"},
{"round": "Round of 32",
"num": 77,
"date": "2026-06-30",
"time": "17:00 UTC-4",
"team1": "1I",
"team2": "3C/D/F/G/H",
"ground": "New York/New Jersey (East Rutherford)"},
{"round": "Round of 32",
"num": 78,
"date": "2026-06-30",
"time": "12:00 UTC-5",
"team1": "2E",
"team2": "2I",
"ground": "Dallas (Arlington)"},
{"round": "Round of 32",
"num": 79,
"date": "2026-06-30",
"time": "19:00 UTC-6",
"team1": "1A",
"team2": "3C/E/F/H/I",
"ground": "Mexico City"},
{"round": "Round of 32",
"num": 80,
"date": "2026-07-01",
"time": "12:00 UTC-4",
"team1": "1L",
"team2": "3E/H/I/J/K",
"ground": "Atlanta"},
{"round": "Round of 32",
"num": 81,
"date": "2026-07-01",
"time": "17:00 UTC-7",
"team1": "1D",
"team2": "3B/E/F/I/J",
"ground": "San Francisco Bay Area (Santa Clara)"},
{"round": "Round of 32",
"num": 82,
"date": "2026-07-01",
"time": "13:00 UTC-7",
"team1": "1G",
"team2": "3A/E/H/I/J",
"ground": "Seattle"},
{"round": "Round of 32",
"num": 83,
"date": "2026-07-02",
"time": "19:00 UTC-4",
"team1": "2K",
"team2": "2L",
"ground": "Toronto"},
{"round": "Round of 32",
"num": 84,
"date": "2026-07-02",
"time": "12:00 UTC-7",
"team1": "1H",
"team2": "2J",
"ground": "Los Angeles (Inglewood)"},
{"round": "Round of 32",
"num": 85,
"date": "2026-07-02",
"time": "20:00 UTC-7",
"team1": "1B",
"team2": "3E/F/G/I/J",
"ground": "Vancouver"},
{"round": "Round of 32",
"num": 86,
"date": "2026-07-03",
"time": "18:00 UTC-4",
"team1": "1J",
"team2": "2H",
"ground": "Miami (Miami Gardens)"},
{"round": "Round of 32",
"num": 87,
"date": "2026-07-03",
"time": "20:30 UTC-5",
"team1": "1K",
"team2": "3D/E/I/J/L",
"ground": "Kansas City"},
{"round": "Round of 32",
"num": 88,
"date": "2026-07-03",
"time": "13:00 UTC-5",
"team1": "2D",
"team2": "2G",
"ground": "Dallas (Arlington)"},
{"round": "Round of 16",
"num": 89,
"date": "2026-07-04",
"time": "17:00 UTC-4",
"team1": "W74",
"team2": "W77",
"ground": "Philadelphia"},
{"round": "Round of 16",
"num": 90,
"date": "2026-07-04",
"time": "12:00 UTC-5",
"team1": "W73",
"team2": "W75",
"ground": "Houston"},
{"round": "Round of 16",
"num": 91,
"date": "2026-07-05",
"time": "16:00 UTC-4",
"team1": "W76",
"team2": "W78",
"ground": "New York/New Jersey (East Rutherford)"},
{"round": "Round of 16",
"num": 92,
"date": "2026-07-05",
"time": "18:00 UTC-6",
"team1": "W79",
"team2": "W80",
"ground": "Mexico City"},
{"round": "Round of 16",
"num": 93,
"date": "2026-07-06",
"time": "14:00 UTC-5",
"team1": "W83",
"team2": "W84",
"ground": "Dallas (Arlington)"},
{"round": "Round of 16",
"num": 94,
"date": "2026-07-06",
"time": "17:00 UTC-7",
"team1": "W81",
"team2": "W82",
"ground": "Seattle"},
{"round": "Round of 16",
"num": 95,
"date": "2026-07-07",
"time": "12:00 UTC-4",
"team1": "W86",
"team2": "W88",
"ground": "Atlanta"},
{"round": "Round of 16",
"num": 96,
"date": "2026-07-07",
"time": "13:00 UTC-7",
"team1": "W85",
"team2": "W87",
"ground": "Vancouver"},
{"round": "Quarter-final",
"num": 97,
"date": "2026-07-09",
"time": "16:00 UTC-4",
"team1": "W89",
"team2": "W90",
"ground": "Boston (Foxborough)"},
{"round": "Quarter-final",
"num": 98,
"date": "2026-07-10",
"time": "12:00 UTC-7",
"team1": "W93",
"team2": "W94",
"ground": "Los Angeles (Inglewood)"},
{"round": "Quarter-final",
"num": 99,
"date": "2026-07-11",
"time": "17:00 UTC-4",
"team1": "W91",
"team2": "W92",
"ground": "Miami (Miami Gardens)"},
{"round": "Quarter-final",
"num": 100,
"date": "2026-07-11",
"time": "20:00 UTC-5",
"team1": "W95",
"team2": "W96",
"ground": "Kansas City"},
{"round": "Semi-final",
"num": 101,
"date": "2026-07-14",
"time": "14:00 UTC-5",
"team1": "W97",
"team2": "W98",
"ground": "Dallas (Arlington)"},
{"round": "Semi-final",
"num": 102,
"date": "2026-07-15",
"time": "15:00 UTC-4",
"team1": "W99",
"team2": "W100",
"ground": "Atlanta"},
{"round": "Match for third place",
"date": "2026-07-18",
"time": "17:00 UTC-4",
"team1": "L101",
"team2": "L102",
"ground": "Miami (Miami Gardens)"},
{"round": "Final",
"date": "2026-07-19",
"time": "15:00 UTC-4",
"team1": "W101",
"team2": "W102",
"ground": "New York/New Jersey (East Rutherford)"}]}
+38
View File
@@ -0,0 +1,38 @@
# Self-contained compose project for cup26.briggen.dev.
# Deploy: rsync the repo to /root/cup26/, place this file there as
# docker-compose.yml, then `docker compose up -d --build`.
# Joins the EXISTING external `proxy` network — the shared Traefik stack is untouched.
name: cup26
services:
cup26:
build: .
container_name: cup26
restart: unless-stopped
environment:
- PORT=8787
- STATIC_DIR=/app/dist
- NODE_ENV=production
- ALLOWED_ORIGINS=https://cup26.briggen.dev
# football-data.org free API token (https://www.football-data.org/client/register)
- FOOTBALL_DATA_TOKEN=${FOOTBALL_DATA_TOKEN:-}
# opt-in: layer the unofficial real-time source on top of the free API
- ENABLE_SOFASCORE=${ENABLE_SOFASCORE:-false}
security_opt:
- no-new-privileges:true
mem_limit: 384m
memswap_limit: 384m
pids_limit: 200
cpus: 1.0
labels:
- "traefik.enable=true"
- "traefik.http.routers.cup26.rule=Host(`cup26.briggen.dev`)"
- "traefik.http.routers.cup26.entrypoints=websecure"
- "traefik.http.routers.cup26.tls.certresolver=letsencrypt"
- "traefik.http.services.cup26.loadbalancer.server.port=8787"
networks:
- proxy
networks:
proxy:
external: true
+27
View File
@@ -0,0 +1,27 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist', 'dev-dist', 'server/dist', 'public/data'] },
{ files: ['server/**/*.ts', 'server/build.mjs', 'scripts/**/*'], languageOptions: { globals: globals.node } },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2022,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
},
},
);
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0a1410" />
<!--CSP-PLACEHOLDER-->
<title>Cup26 — World Cup 2026</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+64
View File
@@ -0,0 +1,64 @@
{
"name": "cup26",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint .",
"typecheck": "tsc -b --noEmit",
"typecheck:server": "tsc --noEmit -p tsconfig.server.json",
"test": "vitest run",
"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"
},
"dependencies": {
"@tanstack/react-router": "^1.95.0",
"clsx": "^2.1.1",
"d3-scale": "^4.0.2",
"lucide-react": "^1.17.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^2.15.0",
"tailwind-merge": "^2.6.0",
"zod": "^3.24.1",
"zustand": "^5.0.2"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@fastify/static": "^9.1.3",
"@fastify/websocket": "^11.2.0",
"@tailwindcss/vite": "^4.0.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/d3-scale": "^4.0.8",
"@types/node": "^22.10.5",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^4.3.4",
"esbuild": "^0.28.0",
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.16",
"fastify": "^5.8.5",
"globals": "^15.14.0",
"jsdom": "^25.0.1",
"tailwindcss": "^4.0.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2",
"typescript-eslint": "^8.19.0",
"vite": "^6.0.7",
"vite-plugin-pwa": "^0.21.1",
"vitest": "^2.1.8"
}
}
+137
View File
@@ -0,0 +1,137 @@
// Dependency-free icon generator: draws a "floodlit pitch" crest and writes
// favicon.svg + pwa-192x192.png + pwa-512x512.png into public/.
// Pure JS (zlib only) so it runs in the minimal Docker build image too — no
// ImageMagick / librsvg required.
import { deflateSync } from 'node:zlib';
import { writeFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const OUT = join(dirname(fileURLToPath(import.meta.url)), '..', 'public');
mkdirSync(OUT, { recursive: true });
// ---- palette (matches the dark theme) ----
const BG = [10, 20, 16]; // surface
const FIELD = [20, 83, 56]; // pitch green
const FIELD_DK = [15, 64, 44];
const LINE = [220, 240, 230];
const BALL = [224, 184, 92]; // gold
// ---- pure-JS PNG encoder (RGBA, filter 0) ----
const CRC = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
t[n] = c >>> 0;
}
return t;
})();
function crc32(buf) {
let c = 0xffffffff;
for (let i = 0; i < buf.length; i++) c = CRC[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length, 0);
const typeBuf = Buffer.from(type, 'ascii');
const body = Buffer.concat([typeBuf, data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(body), 0);
return Buffer.concat([len, body, crc]);
}
function encodePNG(width, height, rgba) {
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // color type RGBA
const raw = Buffer.alloc((width * 4 + 1) * height);
for (let y = 0; y < height; y++) {
raw[y * (width * 4 + 1)] = 0; // filter: none
rgba.copy(raw, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4);
}
return Buffer.concat([
sig,
chunk('IHDR', ihdr),
chunk('IDAT', deflateSync(raw, { level: 9 })),
chunk('IEND', Buffer.alloc(0)),
]);
}
// ---- drawing helpers ----
function makeIcon(size) {
const buf = Buffer.alloc(size * size * 4);
const set = (x, y, [r, g, b], a = 255) => {
if (x < 0 || y < 0 || x >= size || y >= size) return;
const i = (y * size + x) * 4;
const ia = a / 255;
buf[i] = Math.round(buf[i] * (1 - ia) + r * ia);
buf[i + 1] = Math.round(buf[i + 1] * (1 - ia) + g * ia);
buf[i + 2] = Math.round(buf[i + 2] * (1 - ia) + b * ia);
buf[i + 3] = Math.max(buf[i + 3], a);
};
// full-bleed background (good for maskable)
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) set(x, y, BG);
// inset rounded field
const pad = Math.round(size * 0.14);
const r = Math.round(size * 0.18);
const x0 = pad, y0 = pad, x1 = size - pad, y1 = size - pad;
const inField = (x, y) => {
if (x < x0 || x > x1 || y < y0 || y > y1) return false;
const cx = x < x0 + r ? x0 + r : x > x1 - r ? x1 - r : x;
const cy = y < y0 + r ? y0 + r : y > y1 - r ? y1 - r : y;
return (x - cx) ** 2 + (y - cy) ** 2 <= r * r;
};
const cx = size / 2;
for (let y = y0; y <= y1; y++) {
for (let x = x0; x <= x1; x++) {
if (!inField(x, y)) continue;
// subtle vertical mowing stripes
const stripe = Math.floor((x - x0) / (size * 0.09)) % 2 === 0;
set(x, y, stripe ? FIELD : FIELD_DK);
}
}
// markings (white): halfway line, center circle, center spot as gold ball
const lw = Math.max(2, Math.round(size * 0.012));
const ring = Math.round(size * 0.17);
const ringW = Math.max(2, Math.round(size * 0.016));
for (let y = y0; y <= y1; y++) {
for (let x = x0; x <= x1; x++) {
if (!inField(x, y)) continue;
// halfway line
if (Math.abs(x - cx) <= lw / 2) set(x, y, LINE, 220);
// center circle ring
const d = Math.hypot(x - cx, y - size / 2);
if (Math.abs(d - ring) <= ringW / 2) set(x, y, LINE, 220);
}
}
// center gold ball
const ballR = Math.round(size * 0.055);
for (let y = -ballR; y <= ballR; y++)
for (let x = -ballR; x <= ballR; x++)
if (x * x + y * y <= ballR * ballR) set(Math.round(cx + x), Math.round(size / 2 + y), BALL);
return encodePNG(size, size, buf);
}
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="0" fill="#0a1410"/>
<rect x="72" y="72" width="368" height="368" rx="92" fill="#14533a"/>
<g stroke="#dcf0e6" stroke-width="7" fill="none" opacity="0.85">
<line x1="256" y1="72" x2="256" y2="440"/>
<circle cx="256" cy="256" r="86"/>
</g>
<circle cx="256" cy="256" r="28" fill="#e0b85c"/>
</svg>
`;
writeFileSync(join(OUT, 'favicon.svg'), svg);
writeFileSync(join(OUT, 'pwa-192x192.png'), makeIcon(192));
writeFileSync(join(OUT, 'pwa-512x512.png'), makeIcon(512));
console.log('icons → public/favicon.svg, pwa-192x192.png, pwa-512x512.png');
+17
View File
@@ -0,0 +1,17 @@
import { build } from 'esbuild';
import { fileURLToPath } from 'node:url';
// Bundle the server + its shared `@/lib/*` imports (model, sync types) into one
// ESM file. npm deps (fastify, zod, …) stay external and install at runtime.
await build({
entryPoints: ['server/src/index.ts'],
outfile: 'server/dist/index.js',
bundle: true,
platform: 'node',
format: 'esm',
target: 'node20',
packages: 'external',
banner: { js: "import { createRequire } from 'module'; const require = createRequire(import.meta.url);" },
alias: { '@': fileURLToPath(new URL('../src', import.meta.url)) },
});
console.log('server bundled → server/dist/index.js');
+15
View File
@@ -0,0 +1,15 @@
{
"name": "cup26-server",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"start": "node dist/index.js"
},
"dependencies": {
"@fastify/static": "^9.1.3",
"@fastify/websocket": "^11.2.0",
"fastify": "^5.8.5",
"zod": "^3.25.76"
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Link } from '@tanstack/react-router';
export function NotFound() {
return (
<div className="grid place-items-center py-24 text-center">
<div>
<p className="font-display text-5xl font-extrabold text-accent">404</p>
<p className="mt-2 text-muted">Off the pitch that page doesn&apos;t exist.</p>
<Link to="/" className="mt-4 inline-block rounded-md bg-elevated px-4 py-2 text-sm font-medium text-ink hover:bg-line">
Back to Live
</Link>
</div>
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { Link, Outlet } from '@tanstack/react-router';
import { Moon, Sun, Trophy } from 'lucide-react';
import { useUiStore } from '@/stores/uiStore';
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;
function ThemeToggle() {
const theme = useUiStore((s) => s.theme);
const toggle = useUiStore((s) => s.toggleTheme);
return (
<button
type="button"
onClick={toggle}
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
className="grid h-9 w-9 place-items-center rounded-md text-muted hover:bg-elevated hover:text-ink transition-colors"
>
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
</button>
);
}
export function RootLayout() {
return (
<div className="flex min-h-full flex-col">
<header className="sticky top-0 z-30 border-b border-line bg-surface/85 backdrop-blur">
<div className="mx-auto flex h-14 max-w-6xl items-center gap-4 px-4">
<Link to="/" className="flex items-center gap-2 font-display text-lg font-extrabold tracking-tight text-ink">
<span className="grid h-7 w-7 place-items-center rounded-md bg-gradient-to-b from-accent-soft to-accent text-accent-ink">
<Trophy size={16} />
</span>
Cup<span className="text-accent">26</span>
</Link>
<nav className="flex items-center gap-0.5 overflow-x-auto">
{NAV.map((item) => (
<Link
key={item.to}
to={item.to}
{...(item.exact ? { activeOptions: { exact: true } } : {})}
className={cn(
'rounded-md px-3 py-1.5 text-sm font-medium text-muted transition-colors hover:bg-elevated hover:text-ink',
'[&.active]:bg-accent-glow [&.active]:text-accent',
)}
>
{item.label}
</Link>
))}
</nav>
<div className="ml-auto flex items-center gap-2">
<ThemeToggle />
</div>
</div>
</header>
<main className="mx-auto w-full max-w-6xl flex-1 px-4 py-6">
<Outlet />
</main>
<footer className="border-t border-line py-6 text-center text-xs text-faint">
<p>
Cup26 · World Cup 2026 · data from openfootball, football-data.org &amp; StatsBomb open data ·
model-driven odds, not betting advice
</p>
</footer>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import type { HTMLAttributes } from 'react';
import { cn } from '@/lib/cn';
type Tone = 'neutral' | 'accent' | 'live' | 'gold' | 'muted';
const tones: Record<Tone, string> = {
neutral: 'bg-elevated text-ink-soft border-line',
accent: 'bg-accent-glow text-accent border-accent-deep/40',
live: 'bg-live/15 text-live border-live/40',
gold: 'bg-gold/15 text-gold border-gold/40',
muted: 'bg-transparent text-muted border-line',
};
export function Badge({
className,
tone = 'neutral',
...props
}: HTMLAttributes<HTMLSpanElement> & { tone?: Tone }) {
return (
<span
className={cn(
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide',
tones[tone],
className,
)}
{...props}
/>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { forwardRef, type ButtonHTMLAttributes } from 'react';
import { cn } from '@/lib/cn';
type Variant = 'primary' | 'secondary' | 'subtle' | 'ghost' | 'danger';
type Size = 'sm' | 'md' | 'icon';
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
size?: Size;
}
const variants: Record<Variant, string> = {
primary: 'bg-gradient-to-b from-accent-soft to-accent text-accent-ink border border-accent-deep font-semibold shadow-[inset_0_1px_0_rgba(255,255,255,0.25)] hover:brightness-105 active:brightness-95',
secondary: 'bg-elevated text-ink hover:bg-line border border-line',
subtle: 'bg-elevated text-ink border border-transparent hover:border-line',
ghost: 'text-muted hover:text-ink hover:bg-elevated',
danger: 'bg-danger text-white hover:brightness-110 font-medium border border-danger',
};
const sizes: Record<Size, string> = {
sm: 'h-8 px-3 text-sm rounded-md',
md: 'h-10 px-4 text-sm rounded-md',
icon: 'h-9 w-9 rounded-md grid place-items-center',
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ className, variant = 'secondary', size = 'md', type, ...props },
ref,
) {
return (
<button
ref={ref}
type={type ?? 'button'}
className={cn(
'inline-flex items-center justify-center gap-2 transition-[filter,background-color,border-color,box-shadow]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60',
'disabled:opacity-50 disabled:pointer-events-none',
variants[variant],
sizes[size],
className,
)}
{...props}
/>
);
});
+20
View File
@@ -0,0 +1,20 @@
import type { HTMLAttributes } from 'react';
import { cn } from '@/lib/cn';
/** A panel surface with a hairline border. The app's basic content container. */
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('rounded-xl border border-line bg-panel', className)}
{...props}
/>
);
}
export function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn('px-4 py-3 border-b border-line', className)} {...props} />;
}
export function CardBody({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn('p-4', className)} {...props} />;
}
+22
View File
@@ -0,0 +1,22 @@
import type { ReactNode } from 'react';
/** Consistent page title + subtitle + optional right-aligned actions. */
export function PageHeader({
title,
subtitle,
actions,
}: {
title: string;
subtitle?: string;
actions?: ReactNode;
}) {
return (
<div className="mb-5 flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="font-display text-2xl font-extrabold tracking-tight text-ink">{title}</h1>
{subtitle ? <p className="mt-0.5 text-sm text-muted">{subtitle}</p> : null}
</div>
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import type { ReactNode } from 'react';
import { Card, CardBody } from './Card';
/** Temporary "built in a later phase" panel so routes render cleanly meanwhile. */
export function Placeholder({ icon, children }: { icon?: ReactNode; children: ReactNode }) {
return (
<Card>
<CardBody className="grid place-items-center gap-2 py-16 text-center text-muted">
{icon ? <div className="text-accent">{icon}</div> : null}
<p className="max-w-md text-sm">{children}</p>
</CardBody>
</Card>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { GitMerge } from 'lucide-react';
import { PageHeader } from '@/components/ui/PageHeader';
import { Placeholder } from '@/components/ui/Placeholder';
export function BracketPage() {
return (
<div>
<PageHeader title="Bracket" subtitle="Round of 32 through the Final." />
<Placeholder icon={<GitMerge size={28} />}>
The knockout bracket (32 16 QF SF Final) arrives in Phase 1, with model
win-probabilities overlaid in Phase 2.
</Placeholder>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { Table } from 'lucide-react';
import { PageHeader } from '@/components/ui/PageHeader';
import { Placeholder } from '@/components/ui/Placeholder';
export function GroupsPage() {
return (
<div>
<PageHeader title="Groups" subtitle="All 12 groups (AL), 48 teams." />
<Placeholder icon={<Table size={28} />}>
Standings tables for the 12 groups arrive in Phase 1.
</Placeholder>
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { Radio } from 'lucide-react';
import { PageHeader } from '@/components/ui/PageHeader';
import { Placeholder } from '@/components/ui/Placeholder';
export function LivePage() {
return (
<div>
<PageHeader title="Live" subtitle="Today's World Cup 2026 matches, scores and what's on now." />
<Placeholder icon={<Radio size={28} />}>
Live match cards land in Phase 1 fixtures from openfootball, scores polled from
football-data.org, pushed over WebSocket.
</Placeholder>
</div>
);
}
@@ -0,0 +1,15 @@
import { TrendingUp } from 'lucide-react';
import { PageHeader } from '@/components/ui/PageHeader';
import { Placeholder } from '@/components/ui/Placeholder';
export function PredictionsPage() {
return (
<div>
<PageHeader title="Predict" subtitle="Elo + Dixon-Coles + Monte Carlo — who lifts the trophy?" />
<Placeholder icon={<TrendingUp size={28} />}>
Match win/draw/loss bars, simulated championship odds and an odds-over-time chart arrive
in Phase 2.
</Placeholder>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { Sparkles } from 'lucide-react';
import { PageHeader } from '@/components/ui/PageHeader';
import { Placeholder } from '@/components/ui/Placeholder';
export function StoryPage() {
return (
<div>
<PageHeader title="Story" subtitle="A data-driven look back, powered by StatsBomb open data." />
<Placeholder icon={<Sparkles size={28} />}>
Shot maps, pass networks and xG races arrive in Phase 3.
</Placeholder>
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
/** Merge conditional class names, resolving Tailwind conflicts. */
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
+18
View File
@@ -0,0 +1,18 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from '@tanstack/react-router';
import { router } from './router';
import { useUiStore, applyTheme } from './stores/uiStore';
import './styles/globals.css';
// Apply persisted theme before first paint.
applyTheme(useUiStore.getState().theme);
const rootEl = document.getElementById('root');
if (!rootEl) throw new Error('Root element #root not found');
createRoot(rootEl).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
);
+29
View File
@@ -0,0 +1,29 @@
import { createRootRoute, createRoute, createRouter } from '@tanstack/react-router';
import { RootLayout } from './app/RootLayout';
import { NotFound } from './app/NotFound';
import { LivePage } from './features/live/LivePage';
import { GroupsPage } from './features/groups/GroupsPage';
import { BracketPage } from './features/bracket/BracketPage';
import { PredictionsPage } from './features/predictions/PredictionsPage';
import { StoryPage } from './features/story/StoryPage';
const rootRoute = createRootRoute({
component: RootLayout,
notFoundComponent: NotFound,
});
const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: LivePage });
const groupsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/groups', component: GroupsPage });
const bracketRoute = createRoute({ getParentRoute: () => rootRoute, path: '/bracket', component: BracketPage });
const predictRoute = createRoute({ getParentRoute: () => rootRoute, path: '/predict', component: PredictionsPage });
const storyRoute = createRoute({ getParentRoute: () => rootRoute, path: '/story', component: StoryPage });
const routeTree = rootRoute.addChildren([indexRoute, groupsRoute, bracketRoute, predictRoute, storyRoute]);
export const router = createRouter({ routeTree, defaultPreload: 'intent' });
declare module '@tanstack/react-router' {
interface Register {
router: typeof router;
}
}
+34
View File
@@ -0,0 +1,34 @@
import { create } from 'zustand';
export type Theme = 'light' | 'dark';
const STORAGE_KEY = 'cup26:theme';
function readTheme(): Theme {
if (typeof localStorage === 'undefined') return 'dark';
const v = localStorage.getItem(STORAGE_KEY);
return v === 'light' || v === 'dark' ? v : 'dark';
}
/** Toggle the `.dark` class on <html> and persist the choice. */
export function applyTheme(theme: Theme): void {
if (typeof document === 'undefined') return;
document.documentElement.classList.toggle('dark', theme === 'dark');
document.documentElement.classList.toggle('light', theme === 'light');
}
interface UiState {
theme: Theme;
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
}
export const useUiStore = create<UiState>((set, get) => ({
theme: readTheme(),
setTheme: (theme) => {
applyTheme(theme);
try { localStorage.setItem(STORAGE_KEY, theme); } catch { /* private mode */ }
set({ theme });
},
toggleTheme: () => get().setTheme(get().theme === 'dark' ? 'light' : 'dark'),
}));
+171
View File
@@ -0,0 +1,171 @@
@import 'tailwindcss';
/* Class-based dark mode: toggling `.dark` on <html> swaps the token values below. */
@custom-variant dark (&:where(.dark, .dark *));
/* ============================================================================
CUP26 — broadcast palette. Floodlit pitch (dark) · clean telecast (light).
Accent is pitch-green; gold is the highlight. Outcome colors (win/draw/loss)
drive the model bars. Components consume --app-* tokens, never raw hex.
============================================================================ */
/* ---- Light: clean telecast ---- */
:root {
--app-surface: oklch(0.975 0.005 160);
--app-surface-2: oklch(0.945 0.008 160);
--app-panel: oklch(1 0 0);
--app-panel-2: oklch(0.975 0.006 160);
--app-elevated: oklch(0.930 0.010 160);
--app-line: oklch(0.890 0.012 160);
--app-line-strong:oklch(0.800 0.018 158);
--app-ink: oklch(0.235 0.022 158);
--app-ink-soft: oklch(0.360 0.020 158);
--app-muted: oklch(0.520 0.018 160);
--app-faint: oklch(0.640 0.014 162);
--app-accent: oklch(0.585 0.150 150);
--app-accent-deep: oklch(0.500 0.140 150);
--app-accent-soft: oklch(0.700 0.160 150);
--app-accent-ink: oklch(0.985 0.010 150);
--app-accent-glow: oklch(0.585 0.150 150 / 0.16);
--app-gold: oklch(0.760 0.130 85);
--app-pitch: oklch(0.560 0.110 150);
--app-win: oklch(0.610 0.140 150);
--app-draw: oklch(0.730 0.120 85);
--app-loss: oklch(0.580 0.160 25);
--app-danger: oklch(0.560 0.170 28);
--app-danger-glow: oklch(0.560 0.170 28 / 0.16);
--app-success: oklch(0.600 0.130 150);
--app-warning: oklch(0.720 0.130 80);
--app-info: oklch(0.560 0.120 250);
--app-live: oklch(0.600 0.200 25);
color-scheme: light;
}
/* ---- Dark: floodlit pitch at night ---- */
.dark {
--app-surface: oklch(0.165 0.022 158);
--app-surface-2: oklch(0.135 0.020 158);
--app-panel: oklch(0.205 0.024 159);
--app-panel-2: oklch(0.235 0.026 159);
--app-elevated: oklch(0.275 0.028 160);
--app-line: oklch(0.320 0.026 160);
--app-line-strong:oklch(0.420 0.030 158);
--app-ink: oklch(0.955 0.012 150);
--app-ink-soft: oklch(0.860 0.014 152);
--app-muted: oklch(0.680 0.020 156);
--app-faint: oklch(0.540 0.022 158);
--app-accent: oklch(0.800 0.175 148);
--app-accent-deep: oklch(0.700 0.160 148);
--app-accent-soft: oklch(0.870 0.165 146);
--app-accent-ink: oklch(0.165 0.030 158);
--app-accent-glow: oklch(0.800 0.175 148 / 0.20);
--app-gold: oklch(0.840 0.140 88);
--app-pitch: oklch(0.620 0.130 150);
--app-win: oklch(0.800 0.165 148);
--app-draw: oklch(0.820 0.130 88);
--app-loss: oklch(0.680 0.180 25);
--app-danger: oklch(0.680 0.185 30);
--app-danger-glow: oklch(0.680 0.185 30 / 0.22);
--app-success: oklch(0.790 0.150 150);
--app-warning: oklch(0.820 0.140 82);
--app-info: oklch(0.720 0.130 252);
--app-live: oklch(0.700 0.220 25);
color-scheme: dark;
}
/* Expose tokens as Tailwind utilities: bg-surface, text-faint, border-line, text-win… */
@theme inline {
--color-surface: var(--app-surface);
--color-surface-2: var(--app-surface-2);
--color-panel: var(--app-panel);
--color-panel-2: var(--app-panel-2);
--color-elevated: var(--app-elevated);
--color-line: var(--app-line);
--color-line-strong: var(--app-line-strong);
--color-ink: var(--app-ink);
--color-ink-soft: var(--app-ink-soft);
--color-muted: var(--app-muted);
--color-faint: var(--app-faint);
--color-accent: var(--app-accent);
--color-accent-deep: var(--app-accent-deep);
--color-accent-soft: var(--app-accent-soft);
--color-accent-ink: var(--app-accent-ink);
--color-accent-glow: var(--app-accent-glow);
--color-gold: var(--app-gold);
--color-pitch: var(--app-pitch);
--color-win: var(--app-win);
--color-draw: var(--app-draw);
--color-loss: var(--app-loss);
--color-danger: var(--app-danger);
--color-danger-glow: var(--app-danger-glow);
--color-success: var(--app-success);
--color-warning: var(--app-warning);
--color-info: var(--app-info);
--color-live: var(--app-live);
--font-display: 'Hanken Grotesk', 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--font-sans: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
--font-mono: ui-monospace, 'SF Mono', 'Spline Sans Mono', Menlo, monospace;
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
background: var(--app-surface);
color: var(--app-ink);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* Small-caps section label */
.smallcaps {
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 11px;
font-weight: 700;
color: var(--app-muted);
}
/* Tabular figures for scores/odds so digits don't jitter as they tick. */
.tnum { font-variant-numeric: tabular-nums; font-family: var(--font-mono); }
/* Live pulse dot */
@keyframes live-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.45; transform: scale(0.8); }
}
.live-dot { animation: live-pulse 1.4s ease-in-out infinite; }
/* Reduced-motion friendliness */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
/* Thin themed scrollbars */
* {
scrollbar-width: thin;
scrollbar-color: var(--app-line) transparent;
}
+2
View File
@@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" />
+33
View File
@@ -0,0 +1,33 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"resolveJsonModule": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noUncheckedIndexedAccess": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src", "vitest.setup.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"resolveJsonModule": true,
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] },
"types": ["node"]
},
"include": ["vite.config.ts", "vitest.config.ts", "scripts/**/*.ts"]
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"target": "ES2022",
"lib": ["ES2022"],
"strict": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"noEmit": true,
"verbatimModuleSyntax": true,
"resolveJsonModule": true,
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] },
"types": ["node"]
},
"include": ["server/src/**/*"]
}
+71
View File
@@ -0,0 +1,71 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import { VitePWA } from 'vite-plugin-pwa';
import { fileURLToPath, URL } from 'node:url';
// Strict CSP injected at build time only (dev needs inline scripts for react-refresh).
// All external football APIs are called from the server, so the browser only ever
// talks to its own origin + WebSocket — connect-src can stay locked to 'self'.
const PROD_CSP =
"default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; " +
"script-src 'self'; connect-src 'self' ws: wss:; " +
"font-src 'self' data:; worker-src 'self' blob:; " +
"manifest-src 'self'; object-src 'none'; base-uri 'self'";
function cspPlugin() {
return {
name: 'inject-csp',
transformIndexHtml(html: string) {
const tag = `<meta http-equiv="Content-Security-Policy" content="${PROD_CSP}" />`;
return html.replace('<!--CSP-PLACEHOLDER-->', tag);
},
apply: 'build' as const,
};
}
export default defineConfig({
plugins: [
react(),
tailwindcss(),
cspPlugin(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.svg'],
manifest: {
name: 'Cup26 — World Cup 2026 Dashboard',
short_name: 'Cup26',
description: 'Live World Cup 2026 scores, model-driven odds, and data-story visualizations.',
theme_color: '#0a1410',
background_color: '#0a1410',
display: 'standalone',
icons: [
{ src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' },
{ src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png' },
{ src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
],
},
workbox: {
globPatterns: ['**/*.{js,css,html,svg,png,woff2,json}'],
maximumFileSizeToCacheInBytes: 6 * 1024 * 1024,
skipWaiting: true,
clientsClaim: true,
cleanupOutdatedCaches: true,
// Live data is fetched fresh from /api; never serve it from the precache.
navigateFallbackDenylist: [/^\/api/, /^\/ws/],
},
}),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
// Dev: proxy API + WS to the Fastify server so the browser only talks to :5173.
proxy: {
'/api': { target: 'http://localhost:8787', changeOrigin: true },
'/ws': { target: 'ws://localhost:8787', ws: true },
},
},
});
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vitest/config';
import { fileURLToPath, URL } from 'node:url';
export default defineConfig({
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
css: false,
exclude: ['**/node_modules/**', '**/dist/**'],
},
});
+7
View File
@@ -0,0 +1,7 @@
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => {
cleanup();
});