Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,13 @@ STICKERDEX_PASSWORD=
# Change this to a long random string for any non-local deployment.
STICKERDEX_SECRET=change-me-to-a-long-random-string

# Live results feed: pull the latest played scores from openfootball (CC0) at
# startup and on demand. Set to "off" to disable (use only the bundled snapshot).
LIVE_RESULTS=on
# Optional overrides for the results source and network timeout.
# RESULTS_FEED_CUP_URL=https://raw.githubusercontent.com/openfootball/worldcup/master/2026--usa/cup.txt
# RESULTS_FEED_FINALS_URL=https://raw.githubusercontent.com/openfootball/worldcup/master/2026--usa/cup_finals.txt
RESULTS_FEED_TIMEOUT_MS=8000

# Frontend -> backend API base URL (used at build time by Vite)
VITE_API_BASE_URL=/api
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,27 @@ All notable changes to this project are documented here. The format is based on

## [Unreleased]

### Added — Live results feed (auto-updating)
- **Results refresh automatically at startup**: a new feed (`services/resultsFeed.ts`) pulls the
latest played scores from the public [openfootball](https://github.com/openfootball/worldcup)
dataset (CC0) every time the server boots, in the background (best-effort — a slow/offline
network never delays startup). Because Elo, predictions, standings and the Monte Carlo simulator
are all derived from the results on each request, they update **live** as new games are played —
no regenerate, no redeploy.
- **Your edits are protected**: results now carry a `source` (`feed` vs `user`). The feed only ever
writes/overwrites its own `feed` rows — any score you entered or corrected in-app is never
clobbered. (Idempotent DB migration adds the column.)
- **Manual refresh**: `POST /api/results/refresh` and a **"Refresh results now"** button in Settings
pull newly-played games on demand (reports added / updated / unchanged).
- Configurable via env: `LIVE_RESULTS=off` disables it; `RESULTS_FEED_CUP_URL` /
`RESULTS_FEED_FINALS_URL` / `RESULTS_FEED_TIMEOUT_MS` override the source.
- Shared openfootball parser (`lib/openfootball.ts`) is now used by both the build-time generator
and the runtime feed, so the team mapping and score parsing can't drift.

### Changed — Live results refresh
- Bundled dataset refreshed to **36 played games** (matchdays 1–2 plus early matchday-3 fixtures).
Only scores changed — fixtures, numbering, venues and teams are untouched.

### Added — Real national flags & smarter pack estimate
- **SVG national flags** (`lib/flagSvg.tsx`): every team now renders its *actual* flag —
correct layout plus the defining motifs (USA's stars & stripes, Türkiye/Tunisia/Algeria
Expand Down
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ to the next kick-off. All on your own machine.
### Tournament companion
- 🗓️ **Full schedule** — all 104 fixtures with real venues, dates and kick-off times (shown in
both venue-local and your local time), filterable by stage / upcoming / your team.
- 🔄 **Live results feed** — played scores are pulled automatically from the public openfootball
dataset every time the server starts (and on demand from Settings), so standings, predictions and
the simulator stay current as games finish. Scores you enter yourself are never overwritten.
- ✍️ **Enter results** — record any score; everything downstream updates instantly.
- 🏆 **Live group standings** — auto-computed P/W/D/L/GF/GA/GD/Pts tables, top-two highlighted.
- 🧮 **Knockout bracket** — Round of 32 → Final, with slots (`1A`, `W74`, …) resolving
Expand Down Expand Up @@ -142,8 +145,8 @@ StickerDex/
│ ├── scripts/raw/ vendored openfootball source files (CC0)
│ ├── src/data/ stickers.json · teams.json · checklist.json · players.json · matches.json · venues.json · match-teams.json
│ ├── src/db/ schema, connection, idempotent seeders (stickers + tournament)
│ ├── src/routes/ stickers, collection, stats, export, matches, simulate, backups, auth
│ └── src/services/ catalog · collection · stats · exporter · matches · standings · predictions · simulator · backups
│ ├── src/routes/ stickers, collection, stats, export, matches, simulate, backups, results, auth
│ └── src/services/ catalog · collection · stats · exporter · matches · standings · predictions · simulator · backups · resultsFeed
├── frontend/ React + Vite + TypeScript + Tailwind (booklet + companion UI)
│ └── src/ components · pages · hooks · lib · api client
└── docker-compose.yml api + web, persistent SQLite volume
Expand Down Expand Up @@ -171,6 +174,7 @@ always reflect the latest scores. Single-user by design; the optional password g
| `GET` | `/api/venues` · `/api/match-teams` | 16 host venues · 48 tournament teams |
| `PUT` | `/api/matches/:num/result` | Enter/overwrite a score `{ homeScore, awayScore }` |
| `DELETE`| `/api/matches/:num/result` | Clear a score (mark not played) |
| `POST` | `/api/results/refresh` | Pull the latest played scores from the live feed |
| `GET` | `/api/standings` | Live group tables computed from results |
| `GET` | `/api/predictions` | Elo win/draw/win probabilities for upcoming fixtures |
| `GET` | `/api/simulate?runs=N` | Monte Carlo odds: each team's P(win group / advance / … / champion) |
Expand Down Expand Up @@ -223,6 +227,13 @@ results — is generated from the [openfootball](https://github.com/openfootball
(public domain, **CC0**), vendored under `backend/scripts/raw/`. Every fixture and result is also
**fully editable in-app**, so corrections never require a regenerate.

On top of the bundled snapshot, a **live results feed** (`services/resultsFeed.ts`) re-pulls the
latest played scores from openfootball on every server start (and on demand via Settings →
*Refresh results now* / `POST /api/results/refresh`). It's best-effort (offline is fine) and tags
each score's `source`, so feed updates flow into standings, Elo, predictions and the simulator
automatically **without ever overwriting a score you entered yourself**. Disable with
`LIVE_RESULTS=off`.

**Predictions** come from a small, fully self-hosted **Elo model** — no external API, no network.
Teams start from coarse seed ratings (an estimate, _not_ an official ranking) and the model
re-rates them from the results you enter, so probabilities improve over the tournament.
Expand All @@ -249,6 +260,10 @@ All via environment variables (see [`.env.example`](.env.example)):
| `STICKERDEX_PASSWORD` | _(empty)_ | If set, writes require login |
| `STICKERDEX_SECRET` | `change-me…` | Cookie signing secret (set this!) |
| `DATABASE_PATH` | `/app/data/stickerdex.db` | SQLite file location |
| `LIVE_RESULTS` | `on` | Set to `off` to disable the startup results feed |
| `RESULTS_FEED_CUP_URL` | _(openfootball)_ | Override the live group-stage results source |
| `RESULTS_FEED_FINALS_URL` | _(openfootball)_ | Override the live knockout results source |
| `RESULTS_FEED_TIMEOUT_MS` | `8000` | Per-request timeout for the results feed |

## 🤝 Contributing

Expand Down
53 changes: 1 addition & 52 deletions backend/scripts/generate-matches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,63 +22,12 @@ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { Match, MatchStage, MatchTeam, MatchVenue } from '../src/types.ts';
import { OF_TEAMS as TEAMS } from '../src/lib/openfootball.ts';

const __dirname = dirname(fileURLToPath(import.meta.url));
const rawDir = resolve(__dirname, 'raw');
const outDir = resolve(__dirname, '../src/data');

/** openfootball team name -> FIFA code + flag colors for the 48 qualified teams. */
const TEAMS: Record<string, { code: string; primary: string; secondary: string }> = {
Mexico: { code: 'MEX', primary: '#006847', secondary: '#ce1126' },
'South Africa': { code: 'RSA', primary: '#007749', secondary: '#ffb81c' },
'South Korea': { code: 'KOR', primary: '#cd2e3a', secondary: '#0047a0' },
'Czech Republic': { code: 'CZE', primary: '#11457e', secondary: '#d7141a' },
Canada: { code: 'CAN', primary: '#d52b1e', secondary: '#ffffff' },
'Bosnia & Herzegovina': { code: 'BIH', primary: '#002395', secondary: '#ffec00' },
Qatar: { code: 'QAT', primary: '#8a1538', secondary: '#ffffff' },
Switzerland: { code: 'SUI', primary: '#d52b1e', secondary: '#ffffff' },
Brazil: { code: 'BRA', primary: '#ffdf00', secondary: '#009b3a' },
Morocco: { code: 'MAR', primary: '#c1272d', secondary: '#006233' },
Haiti: { code: 'HAI', primary: '#00209f', secondary: '#d21034' },
Scotland: { code: 'SCO', primary: '#0065bf', secondary: '#ffffff' },
USA: { code: 'USA', primary: '#0a3161', secondary: '#b31942' },
Paraguay: { code: 'PAR', primary: '#d52b1e', secondary: '#0038a8' },
Australia: { code: 'AUS', primary: '#00843d', secondary: '#ffcd00' },
Turkey: { code: 'TUR', primary: '#e30a17', secondary: '#ffffff' },
Germany: { code: 'GER', primary: '#000000', secondary: '#dd0000' },
'Curaçao': { code: 'CUW', primary: '#002b7f', secondary: '#f9d616' },
'Ivory Coast': { code: 'CIV', primary: '#f77f00', secondary: '#009e60' },
Ecuador: { code: 'ECU', primary: '#ffd100', secondary: '#0072ce' },
Netherlands: { code: 'NED', primary: '#ae1c28', secondary: '#21468b' },
Japan: { code: 'JPN', primary: '#000091', secondary: '#bc002d' },
Sweden: { code: 'SWE', primary: '#006aa7', secondary: '#fecc00' },
Tunisia: { code: 'TUN', primary: '#e70013', secondary: '#ffffff' },
Belgium: { code: 'BEL', primary: '#000000', secondary: '#fdda24' },
Egypt: { code: 'EGY', primary: '#ce1126', secondary: '#000000' },
Iran: { code: 'IRN', primary: '#239f40', secondary: '#da0000' },
'New Zealand': { code: 'NZL', primary: '#000000', secondary: '#ffffff' },
Spain: { code: 'ESP', primary: '#aa151b', secondary: '#f1bf00' },
'Cape Verde': { code: 'CPV', primary: '#003893', secondary: '#cf2027' },
'Saudi Arabia': { code: 'KSA', primary: '#006c35', secondary: '#ffffff' },
Uruguay: { code: 'URU', primary: '#5cbfeb', secondary: '#001489' },
France: { code: 'FRA', primary: '#0055a4', secondary: '#ef4135' },
Senegal: { code: 'SEN', primary: '#00853f', secondary: '#fdef42' },
Iraq: { code: 'IRQ', primary: '#007a3d', secondary: '#ce1126' },
Norway: { code: 'NOR', primary: '#ba0c2f', secondary: '#00205b' },
Argentina: { code: 'ARG', primary: '#75aadb', secondary: '#ffffff' },
Algeria: { code: 'ALG', primary: '#006233', secondary: '#ffffff' },
Austria: { code: 'AUT', primary: '#ed2939', secondary: '#ffffff' },
Jordan: { code: 'JOR', primary: '#007a3d', secondary: '#ce1126' },
Portugal: { code: 'POR', primary: '#006600', secondary: '#ff0000' },
'DR Congo': { code: 'COD', primary: '#007fff', secondary: '#f7d618' },
Uzbekistan: { code: 'UZB', primary: '#1eb53a', secondary: '#0099b5' },
Colombia: { code: 'COL', primary: '#fcd116', secondary: '#003893' },
England: { code: 'ENG', primary: '#ffffff', secondary: '#ce1124' },
Croatia: { code: 'CRO', primary: '#ff0000', secondary: '#171796' },
Ghana: { code: 'GHA', primary: '#006b3f', secondary: '#fcd116' },
Panama: { code: 'PAN', primary: '#005293', secondary: '#d21034' },
};

const MONTHS: Record<string, number> = {
January: 0, February: 1, March: 2, April: 3, May: 4, June: 5,
July: 6, August: 7, September: 8, October: 9, November: 10, December: 11,
Expand Down
Loading
Loading