From a4b4e8287fb2accfc8a557b8fc737d0c4614408f Mon Sep 17 00:00:00 2001 From: Christian Abele Date: Sun, 21 Jun 2026 16:30:28 +0200 Subject: [PATCH 1/2] Add live results feed that auto-updates scores at startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new feed (services/resultsFeed.ts) pulls the latest played scores from the public openfootball dataset (CC0) every time the server boots — in the background, so a slow or offline network never delays startup. Elo ratings, win/draw/win predictions, group standings and the Monte Carlo simulator are all derived from match_results on each request, so they update live as games are played, with no regenerate or redeploy. - Result provenance: match_results gains a `source` column (feed vs user, via an idempotent migration). The feed only ever writes/overwrites its own `feed` rows, so any score you edit in-app (`user`) is never clobbered. - Manual refresh: POST /api/results/refresh and a "Refresh results now" button in Settings pull newly-played games on demand (reports added/updated/unchanged). - Shared parser (lib/openfootball.ts) is now used by both the build-time generator and the runtime feed, so the team map and score parsing can't drift (generator output verified byte-identical after the refactor). - Config: LIVE_RESULTS=off disables it; RESULTS_FEED_CUP_URL / _FINALS_URL / _TIMEOUT_MS override the source. - Refreshed the bundled dataset to 36 played games (only scores changed). Tests: +5 (parser + provenance + injected-fetch refresh). 39 pass; tsc clean; build OK. Verified live: startup refresh is idempotent, a cleared game is re-pulled, and a user-edited score is preserved across a refresh. Co-Authored-By: Claude Opus 4.8 --- .env.example | 8 ++ CHANGELOG.md | 21 +++++ README.md | 19 ++++- backend/scripts/generate-matches.ts | 53 +----------- backend/scripts/raw/cup.txt | 110 ++++++++++++++++-------- backend/src/app.ts | 2 + backend/src/data/matches.json | 112 ++++++++++++------------ backend/src/db/index.ts | 8 ++ backend/src/db/schema.ts | 4 + backend/src/index.ts | 25 ++++++ backend/src/lib/openfootball.ts | 103 ++++++++++++++++++++++ backend/src/routes/results.ts | 17 ++++ backend/src/services/matches.ts | 11 ++- backend/src/services/resultsFeed.ts | 127 ++++++++++++++++++++++++++++ backend/test/resultsFeed.test.ts | 104 +++++++++++++++++++++++ backend/test/routes.test.ts | 31 ++++--- frontend/src/api/client.ts | 7 ++ frontend/src/pages/SettingsView.tsx | 45 ++++++++++ 18 files changed, 649 insertions(+), 158 deletions(-) create mode 100644 backend/src/lib/openfootball.ts create mode 100644 backend/src/routes/results.ts create mode 100644 backend/src/services/resultsFeed.ts create mode 100644 backend/test/resultsFeed.test.ts diff --git a/.env.example b/.env.example index 781f21b..bd9bac6 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index bce27f3..a9ba6f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 4d9078b..87052e3 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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) | @@ -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. @@ -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 diff --git a/backend/scripts/generate-matches.ts b/backend/scripts/generate-matches.ts index 0ac5229..38f44c0 100644 --- a/backend/scripts/generate-matches.ts +++ b/backend/scripts/generate-matches.ts @@ -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 = { - 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 = { January: 0, February: 1, March: 2, April: 3, May: 4, June: 5, July: 6, August: 7, September: 8, October: 9, November: 10, December: 11, diff --git a/backend/scripts/raw/cup.txt b/backend/scripts/raw/cup.txt index 5fc138e..aa0f4fd 100644 --- a/backend/scripts/raw/cup.txt +++ b/backend/scripts/raw/cup.txt @@ -82,8 +82,11 @@ Thu June 11 (Hwang In-Beom 67' Oh Hyeon-Gyu 80'; Ladislav Krejcí 59') Thu June 18 - 12:00 UTC-4 Czech Republic v South Africa @ Atlanta - 19:00 UTC-6 Mexico v South Korea @ Guadalajara (Zapopan) + 12:00 UTC-4 Czech Republic 1-1 (1-0) South Africa @ Atlanta + (Michal Sadílek 6'; + Teboho Mokoena 83' (pen.)) + 19:00 UTC-6 Mexico 1-0 (0-0) South Korea @ Guadalajara (Zapopan) + (Luis Romo 50') Wed June 24 19:00 UTC-6 Czech Republic v Mexico @ Mexico City 19:00 UTC-6 South Africa v South Korea @ Monterrey (Guadalupe) @@ -92,15 +95,16 @@ Wed June 24 ▪ Group B Fri June 12 15:00 UTC-4 Canada 1-1 (0-1) Bosnia & Herzegovina @ Toronto - (Cyle Larin 78'; - Jovo Lukić 21') + (Cyle Larin 78'; Jovo Lukić 21') Sat June 13 12:00 UTC-7 Qatar 1-1 (0-1) Switzerland @ San Francisco Bay Area (Santa Clara) - (Boualem Khoukhi 90+4'; - Breel Embolo 17' (pen.)) + (Miro Muheim 90+4' (OG); Breel Embolo 17'(p)) Thu June 18 - 12:00 UTC-7 Switzerland v Bosnia & Herzegovina @ Los Angeles (Inglewood) - 15:00 UTC-7 Canada v Qatar @ Vancouver + 12:00 UTC-7 Switzerland 4-1 (0-0) Bosnia & Herzegovina @ Los Angeles (Inglewood) + (Johan Manzambi 74' Rubén Vargas 84' Johan Manzambi 90' Granit Xhaka 90+7' (pen.); + Ermin Mahmic 90+3') + 15:00 UTC-7 Canada 6-0 (3-0) Qatar @ Vancouver + (Cyle Larin 16' Jonathan David 29', 45+3' Nathan Saliba 64' Mohamed Manai 75'(OG) Jonathan David 90+2') Wed June 24 12:00 UTC-7 Switzerland v Canada @ Vancouver 12:00 UTC-7 Bosnia & Herzegovina v Qatar @ Seattle @@ -109,13 +113,14 @@ Wed June 24 ▪ Group C Sat June 13 18:00 UTC-4 Brazil 1-1 (1-1) Morocco @ New York/New Jersey (East Rutherford) - (Vinícius Júnior 32'; - Ismael Saibari 21') + (Vinícius Júnior 32'; Ismael Saibari 21') 21:00 UTC-4 Haiti 0-1 (0-1) Scotland @ Boston (Foxborough) (John McGinn 28') Fri June 19 - 18:00 UTC-4 Scotland v Morocco @ Boston (Foxborough) - 20:30 UTC-4 Brazil v Haiti @ Philadelphia + 18:00 UTC-4 Scotland 0-1 (0-1) Morocco @ Boston (Foxborough) + (Ismael Saibari 2') + 20:30 UTC-4 Brazil 3-0 (3-0) Haiti @ Philadelphia + (Matheus Cunha 23', 36' Vinícius Júnior 45+3') Wed June 24 18:00 UTC-4 Scotland v Brazil @ Miami (Miami Gardens) 18:00 UTC-4 Morocco v Haiti @ Atlanta @@ -124,14 +129,16 @@ Wed June 24 ▪ Group D Fri June 12 18:00 UTC-7 USA 4-1 (3-0) Paraguay @ Los Angeles (Inglewood) - (Damian Bobadilla 7'(OG) Folarin Balogun 31', 45+5' Giovanni Reyna 90+8' ; + (Damian Bobadilla 7'(og) Folarin Balogun 31', 45+5' Giovanni Reyna 90+8'; Mauricio 73' ) Sat June 13 21:00 UTC-7 Australia 2-0 (1-0) Turkey @ Vancouver (Nestory Irankunda 27' Connor Metcalfe 75') Fri June 19 - 12:00 UTC-7 USA v Australia @ Seattle - 20:00 UTC-7 Turkey v Paraguay @ San Francisco Bay Area (Santa Clara) + 12:00 UTC-7 USA 2-0 (2-0) Australia @ Seattle + (Cameron Burgess 11'(OG) Alex Freeman 43') + 20:00 UTC-7 Turkey 0-1 (0-1) Paraguay @ San Francisco Bay Area (Santa Clara) + (Matías Galarza 2') Thu June 25 19:00 UTC-7 Turkey v USA @ Los Angeles (Inglewood) 19:00 UTC-7 Paraguay v Australia @ San Francisco Bay Area (Santa Clara) @@ -139,11 +146,17 @@ Thu June 25 ▪ Group E Sun June 14 - 12:00 UTC-5 Germany v Curaçao @ Houston - 19:00 UTC-4 Ivory Coast v Ecuador @ Philadelphia + 12:00 UTC-5 Germany 7-1 (3-1) Curaçao @ Houston + (Felix Nmecha 6' Nico Schlotterbeck 38' Kai Havertz 45+5'(p), 88' Jamal Musiala 47' + Nathaniel Brown 68' Deniz Undav 78'; + Livano Comenencia 21') + 19:00 UTC-4 Ivory Coast 1-0 (0-0) Ecuador @ Philadelphia + (Amad Diallo 90') Sat June 20 - 16:00 UTC-4 Germany v Ivory Coast @ Toronto - 19:00 UTC-5 Ecuador v Curaçao @ Kansas City + 16:00 UTC-4 Germany 2-1 (0-1) Ivory Coast @ Toronto + (Deniz Undav 68', 90+4'; + Franck Kessié 30') + 19:00 UTC-5 Ecuador 0-0 (0-0) Curaçao @ Kansas City Thu June 25 16:00 UTC-4 Curaçao v Ivory Coast @ Philadelphia 16:00 UTC-4 Ecuador v Germany @ New York/New Jersey (East Rutherford) @@ -151,11 +164,18 @@ Thu June 25 ▪ Group F Sun June 14 - 15:00 UTC-5 Netherlands v Japan @ Dallas (Arlington) - 20:00 UTC-6 Sweden v Tunisia @ Monterrey (Guadalupe) + 15:00 UTC-5 Netherlands 2-2 (0-0) Japan @ Dallas (Arlington) + (Virgil van Dijk 51' Crysencio Summerville 64'; + Keito Nakamura 57' Daichi Kamada 88') + 20:00 UTC-6 Sweden 5-1 (2-1) Tunisia @ Monterrey (Guadalupe) + (Yasin Ayari 7' Alexander Isak 30' Viktor Gyökeres 59' Mattias Svanberg 84' Yasin Ayari 90+6'; + Omar Rekik 43') Sat June 20 - 12:00 UTC-5 Netherlands v Sweden @ Houston - 22:00 UTC-6 Tunisia v Japan @ Monterrey (Guadalupe) + 12:00 UTC-5 Netherlands 5-1 (2-0) Sweden @ Houston + (Brian Brobbey 5', 17' Cody Gakpo 47', 54' Crysencio Summerville 89'; + Anthony Elanga 59') + 22:00 UTC-6 Tunisia 0-4 (0-2) Japan @ Monterrey (Guadalupe) + (Daichi Kamada 4' Ayase Ueda 31' Junya Ito 69' Ayase Ueda 83') Thu June 25 18:00 UTC-5 Japan v Sweden @ Dallas (Arlington) 18:00 UTC-5 Tunisia v Netherlands @ Kansas City @@ -163,8 +183,12 @@ Thu June 25 ▪ Group G Mon June 15 - 12:00 UTC-7 Belgium v Egypt @ Seattle - 18:00 UTC-7 Iran v New Zealand @ Los Angeles (Inglewood) + 12:00 UTC-7 Belgium 1-1 (0-1) Egypt @ Seattle + (Mohamed Hany 66'(og); + Emam Ashour 19') + 18:00 UTC-7 Iran 2-2 (1-1) New Zealand @ Los Angeles (Inglewood) + (Ramin Rezaeian 32' Mohammad Mohebbi 64'; + Elijah Just 7', 54') Sun June 21 12:00 UTC-7 Belgium v Iran @ Los Angeles (Inglewood) 18:00 UTC-7 New Zealand v Egypt @ Vancouver @@ -175,8 +199,10 @@ Fri June 26 ▪ Group H Mon June 15 - 12:00 UTC-4 Spain v Cape Verde @ Atlanta - 18:00 UTC-4 Saudi Arabia v Uruguay @ Miami (Miami Gardens) + 12:00 UTC-4 Spain 0-0 (0-0) Cape Verde @ Atlanta + 18:00 UTC-4 Saudi Arabia 1-1 (1-0) Uruguay @ Miami (Miami Gardens) + (Abdulelah Al-Amri 41'; + Maxi Araújo 80') Sun June 21 12:00 UTC-4 Spain v Saudi Arabia @ Atlanta 18:00 UTC-4 Uruguay v Cape Verde @ Miami (Miami Gardens) @@ -187,8 +213,12 @@ Fri June 26 ▪ Group I Tue June 16 - 15:00 UTC-4 France v Senegal @ New York/New Jersey (East Rutherford) - 18:00 UTC-4 Iraq v Norway @ Boston (Foxborough) + 15:00 UTC-4 France 3-1 (0-0) Senegal @ New York/New Jersey (East Rutherford) + (Kylian Mbappé 66' Bradley Barcola 82' Kylian Mbappé 90+6'; + Ibrahim Mbaye 90+5') + 18:00 UTC-4 Iraq 1-4 (1-2) Norway @ Boston (Foxborough) + (Aymen Hussein 39'; + Erling Haaland 29', 43' Leo Østigard 76' Aymen Hussein 90+6'(OG)) Mon June 22 17:00 UTC-4 France v Iraq @ Philadelphia 20:00 UTC-4 Norway v Senegal @ New York/New Jersey (East Rutherford) @@ -199,8 +229,11 @@ Fri June 26 ▪ Group J Tue June 16 - 20:00 UTC-5 Argentina v Algeria @ Kansas City - 21:00 UTC-7 Austria v Jordan @ San Francisco Bay Area (Santa Clara) + 20:00 UTC-5 Argentina 3-0 (1-0) Algeria @ Kansas City + (Lionel Messi 17', 60', 76') + 21:00 UTC-7 Austria 3-1 (1-0) Jordan @ San Francisco Bay Area (Santa Clara) + (Romano Schmid 21' Yazan Al-Arab 76'(OG) Marko Arnautovic 90+12' (pen.); + Ali Olwan 50') Mon June 22 12:00 UTC-5 Argentina v Austria @ Dallas (Arlington) 20:00 UTC-7 Jordan v Algeria @ San Francisco Bay Area (Santa Clara) @@ -211,8 +244,12 @@ Sat June 27 ▪ Group K Wed June 17 - 12:00 UTC-5 Portugal v DR Congo @ Houston - 20:00 UTC-6 Uzbekistan v Colombia @ Mexico City + 12:00 UTC-5 Portugal 1-1 (1-1) DR Congo @ Houston + (João Neves 6'; + Yoane Wissa 45+5') + 20:00 UTC-6 Uzbekistan 1-3 (0-1) Colombia @ Mexico City + (Abbosbek Fayzullaev 60'; + Daniel Muñoz 40' Luis Díaz 65' Jáminton Campaz 90+9') Tue June 23 12:00 UTC-5 Portugal v Uzbekistan @ Houston 20:00 UTC-6 Colombia v DR Congo @ Guadalajara (Zapopan) @@ -223,8 +260,11 @@ Sat June 27 ▪ Group L Wed June 17 - 15:00 UTC-5 England v Croatia @ Dallas (Arlington) - 19:00 UTC-4 Ghana v Panama @ Toronto + 15:00 UTC-5 England 4-2 (2-2) Croatia @ Dallas (Arlington) + (Harry Kane 12' (pen.), 42' Jude Bellingham 47' Marcus Rashford 85'; + Martin Baturina 36' Petar Musa 45+5') + 19:00 UTC-4 Ghana 1-0 (0-0) Panama @ Toronto + (Caleb Yirenkyi 90+5') Tue June 23 16:00 UTC-4 England v Ghana @ Boston (Foxborough) 19:00 UTC-4 Panama v Croatia @ Toronto diff --git a/backend/src/app.ts b/backend/src/app.ts index 7ec9c2d..03a0a42 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -14,6 +14,7 @@ import { exportRoutes } from './routes/export.ts'; import { matchRoutes } from './routes/matches.ts'; import { simulateRoutes } from './routes/simulate.ts'; import { backupRoutes } from './routes/backups.ts'; +import { resultsRoutes } from './routes/results.ts'; declare module 'fastify' { interface FastifyInstance { @@ -47,6 +48,7 @@ export async function buildApp({ db, logger = false }: BuildOptions): Promise c.name, + ); + if (resultCols.length > 0 && !resultCols.includes('source')) { + db.exec(`ALTER TABLE match_results ADD COLUMN source TEXT NOT NULL DEFAULT 'feed'`); + } } /** For tests: open an isolated in-memory database. */ diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index c84bf86..e34b350 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -68,10 +68,14 @@ CREATE INDEX IF NOT EXISTS idx_matches_stage ON matches (stage); CREATE INDEX IF NOT EXISTS idx_matches_group ON matches (group_name); -- The only mutable tournament table: entered/known scores, one row per match. +-- The "source" column records who set the score: feed (auto from the live +-- results feed / seeded dataset) or user (you, in-app). The live feed only ever +-- overwrites feed rows, so your manual edits are never clobbered. CREATE TABLE IF NOT EXISTS match_results ( num INTEGER PRIMARY KEY REFERENCES matches (num) ON DELETE CASCADE, home_score INTEGER NOT NULL CHECK (home_score >= 0), away_score INTEGER NOT NULL CHECK (away_score >= 0), + source TEXT NOT NULL DEFAULT 'feed', updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); `; diff --git a/backend/src/index.ts b/backend/src/index.ts index 0d08510..076a089 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -6,9 +6,31 @@ import { getDb } from './db/index.ts'; import { seed } from './db/seed.ts'; import { seedTournament } from './db/seed-matches.ts'; import { createBackup } from './services/backups.ts'; +import { liveResultsEnabled, refreshResults } from './services/resultsFeed.ts'; const PORT = Number(process.env.API_PORT ?? 3001); +/** + * Best-effort: pull the latest played scores from the public results feed and + * fold them in. Runs in the background so a slow/offline network never delays + * startup; predictions/standings/simulator recompute from the data on demand. + */ +async function refreshLiveResults(db: ReturnType): Promise { + if (!liveResultsEnabled()) { + console.log('Live results feed disabled (LIVE_RESULTS=off).'); + return; + } + try { + const s = await refreshResults(db); + console.log( + `Live results: ${s.added} new, ${s.updated} updated, ${s.unchanged} unchanged ` + + `(${s.total} played in feed).`, + ); + } catch (err) { + console.warn('Live results refresh skipped:', (err as Error).message); + } +} + async function main(): Promise { const db = getDb(); @@ -30,6 +52,9 @@ async function main(): Promise { const app = await buildApp({ db, logger: true }); await app.listen({ port: PORT, host: '0.0.0.0' }); console.log(`StickerDex API listening on http://0.0.0.0:${PORT}`); + + // Pull live results in the background once we're up (non-blocking). + void refreshLiveResults(db); } main().catch((err) => { diff --git a/backend/src/lib/openfootball.ts b/backend/src/lib/openfootball.ts new file mode 100644 index 0000000..2e8528e --- /dev/null +++ b/backend/src/lib/openfootball.ts @@ -0,0 +1,103 @@ +/** + * Shared helpers for the openfootball World Cup 2026 text dataset (CC0). + * + * Used both by the build-time generator (`scripts/generate-matches.ts`) and the + * runtime live-results feed (`services/resultsFeed.ts`) so the team mapping and + * score parsing never drift between them. + */ + +/** openfootball team name -> FIFA code + flag colors for the 48 qualified teams. */ +export const OF_TEAMS: Record = { + 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' }, +}; + +/** Resolve an openfootball team label to its FIFA code (or null if unknown). */ +export function codeForLabel(label: string): string | null { + return OF_TEAMS[label.trim()]?.code ?? null; +} + +export interface ParsedResult { + homeCode: string; + awayCode: string; + homeScore: number; + awayScore: number; +} + +/** + * Scan an openfootball cup/finals text file and return every *played* fixture + * (a line carrying a score like `Mexico 2-0 (1-0) South Africa`) mapped to FIFA + * codes. Unplayed (`Team v Team`) lines and unknown teams are skipped. + */ +export function parseResults(text: string): ParsedResult[] { + const out: ParsedResult[] = []; + for (const raw of text.split(/\r?\n/)) { + // Strip an optional leading "(73)" match number. + const line = raw.replace(/^\s*\(\d+\)\s*/, ''); + // Require a "HH:MM UTC±n" prefix so we only look at fixture lines. + const head = line.match(/^\s*\d{1,2}:\d{2}\s+UTC[+-]\d+\s+(.+)$/); + if (!head) continue; + let body = head[1]; + const at = body.lastIndexOf('@'); + if (at !== -1) body = body.slice(0, at); + // "Home X-Y (h-h) Away" + const score = body.match(/^(.+?)\s+(\d+)-(\d+)(?:\s+\([\d-]+\))?\s+(.+?)\s*$/); + if (!score) continue; + const homeCode = codeForLabel(score[1]); + const awayCode = codeForLabel(score[4]); + if (!homeCode || !awayCode) continue; + out.push({ + homeCode, + awayCode, + homeScore: Number(score[2]), + awayScore: Number(score[3]), + }); + } + return out; +} diff --git a/backend/src/routes/results.ts b/backend/src/routes/results.ts new file mode 100644 index 0000000..00d5912 --- /dev/null +++ b/backend/src/routes/results.ts @@ -0,0 +1,17 @@ +import type { FastifyInstance } from 'fastify'; +import { liveResultsEnabled, refreshResults } from '../services/resultsFeed.ts'; + +export async function resultsRoutes(app: FastifyInstance): Promise { + // Manually pull the latest played scores from the live feed right now. + app.post('/api/results/refresh', async (_req, reply) => { + if (!liveResultsEnabled()) { + return reply.code(503).send({ error: 'Live results feed is disabled (LIVE_RESULTS=off).' }); + } + try { + const summary = await refreshResults(app.db); + return { summary }; + } catch (err) { + return reply.code(502).send({ error: (err as Error).message }); + } + }); +} diff --git a/backend/src/services/matches.ts b/backend/src/services/matches.ts index 8735807..d005e39 100644 --- a/backend/src/services/matches.ts +++ b/backend/src/services/matches.ts @@ -85,18 +85,23 @@ function matchExists(db: DB, num: number): boolean { return db.prepare('SELECT 1 FROM matches WHERE num = ?').get(num) !== undefined; } -/** Sets (or overwrites) a match result. Returns false if the match is unknown. */ +/** + * Sets (or overwrites) a match result from a user edit. Marks the row `source = + * 'user'` so the live results feed will never overwrite it. Returns false if the + * match is unknown. + */ export function setResult(db: DB, num: number, homeScore: number, awayScore: number): boolean { if (!matchExists(db, num)) return false; const h = Math.max(0, Math.floor(homeScore)); const a = Math.max(0, Math.floor(awayScore)); db.prepare( /* sql */ ` - INSERT INTO match_results (num, home_score, away_score, updated_at) - VALUES (?, ?, ?, datetime('now')) + INSERT INTO match_results (num, home_score, away_score, source, updated_at) + VALUES (?, ?, ?, 'user', datetime('now')) ON CONFLICT(num) DO UPDATE SET home_score = excluded.home_score, away_score = excluded.away_score, + source = 'user', updated_at = excluded.updated_at `, ).run(num, h, a); diff --git a/backend/src/services/resultsFeed.ts b/backend/src/services/resultsFeed.ts new file mode 100644 index 0000000..221a642 --- /dev/null +++ b/backend/src/services/resultsFeed.ts @@ -0,0 +1,127 @@ +/** + * Live results feed: at startup (and on demand) pull the latest played scores + * from the public openfootball dataset (CC0) and fold them into `match_results`, + * so the schedule, **Elo ratings, predictions, standings and the Monte Carlo + * simulator all reflect real games as they're played** — those are derived from + * `match_results` on every request, so updating it is all that's needed. + * + * SAFETY: the feed only ever writes/overwrites rows it owns (`source = 'feed'`). + * Any score you edited in-app (`source = 'user'`) is left untouched. Network + * failures are swallowed by callers — a missed refresh never blocks startup. + */ +import type { DB } from '../db/index.ts'; +import { parseResults, type ParsedResult } from '../lib/openfootball.ts'; + +const DEFAULT_CUP_URL = + 'https://raw.githubusercontent.com/openfootball/worldcup/master/2026--usa/cup.txt'; +const DEFAULT_FINALS_URL = + 'https://raw.githubusercontent.com/openfootball/worldcup/master/2026--usa/cup_finals.txt'; + +export interface RefreshSummary { + /** New results inserted. */ + added: number; + /** Existing feed results whose score changed upstream. */ + updated: number; + /** Already up to date or protected user edits. */ + unchanged: number; + /** Parsed results with no matching fixture (e.g. knockout slots unresolved). */ + unmatched: number; + /** Total played results parsed from the feed. */ + total: number; +} + +/** Whether the live feed is enabled (set LIVE_RESULTS=off to disable). */ +export function liveResultsEnabled(): boolean { + const v = (process.env.LIVE_RESULTS ?? 'on').toLowerCase(); + return v !== 'off' && v !== '0' && v !== 'false' && v !== 'no'; +} + +/** + * Fold parsed results into match_results. Matches each result to a fixture by + * (home_code, away_code). Never overwrites a row marked `source = 'user'`. + */ +export function applyResults(db: DB, results: ParsedResult[]): RefreshSummary { + const findNum = db.prepare('SELECT num FROM matches WHERE home_code = ? AND away_code = ?'); + const existing = db.prepare( + 'SELECT home_score AS h, away_score AS a, source FROM match_results WHERE num = ?', + ); + const upsert = db.prepare(/* sql */ ` + INSERT INTO match_results (num, home_score, away_score, source, updated_at) + VALUES (@num, @h, @a, 'feed', datetime('now')) + ON CONFLICT(num) DO UPDATE SET + home_score = excluded.home_score, + away_score = excluded.away_score, + updated_at = excluded.updated_at + WHERE match_results.source != 'user' + `); + + let added = 0; + let updated = 0; + let unchanged = 0; + let unmatched = 0; + + const run = db.transaction(() => { + for (const r of results) { + const fixture = findNum.get(r.homeCode, r.awayCode) as { num: number } | undefined; + if (!fixture) { + unmatched++; + continue; + } + const cur = existing.get(fixture.num) as { h: number; a: number; source: string } | undefined; + if (!cur) { + upsert.run({ num: fixture.num, h: r.homeScore, a: r.awayScore }); + added++; + } else if (cur.source === 'user' || (cur.h === r.homeScore && cur.a === r.awayScore)) { + unchanged++; // protected edit, or already current + } else { + upsert.run({ num: fixture.num, h: r.homeScore, a: r.awayScore }); + updated++; + } + } + }); + run(); + + return { added, updated, unchanged, unmatched, total: results.length }; +} + +async function fetchText(url: string, timeoutMs: number): Promise { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), timeoutMs); + try { + const res = await fetch(url, { signal: ctrl.signal }); + if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`); + return await res.text(); + } finally { + clearTimeout(timer); + } +} + +export interface RefreshOptions { + cupUrl?: string; + finalsUrl?: string; + timeoutMs?: number; + /** Injected fetcher for tests (bypasses the network). */ + fetchImpl?: (url: string) => Promise; +} + +/** + * Fetch the latest openfootball files and apply their results. The group-stage + * file is required; the knockout file is optional (tolerated if it fails). + */ +export async function refreshResults(db: DB, opts: RefreshOptions = {}): Promise { + const cupUrl = opts.cupUrl ?? process.env.RESULTS_FEED_CUP_URL ?? DEFAULT_CUP_URL; + const finalsUrl = opts.finalsUrl ?? process.env.RESULTS_FEED_FINALS_URL ?? DEFAULT_FINALS_URL; + const timeoutMs = opts.timeoutMs ?? Number(process.env.RESULTS_FEED_TIMEOUT_MS ?? 8000); + const getText = opts.fetchImpl ?? ((u: string) => fetchText(u, timeoutMs)); + + const cupText = await getText(cupUrl); + let finalsText = ''; + try { + finalsText = await getText(finalsUrl); + } catch { + /* knockout file is optional — group stage is enough */ + } + + const results = [...parseResults(cupText), ...parseResults(finalsText)]; + return applyResults(db, results); +} diff --git a/backend/test/resultsFeed.test.ts b/backend/test/resultsFeed.test.ts new file mode 100644 index 0000000..bbdb3c0 --- /dev/null +++ b/backend/test/resultsFeed.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; +import { createMemoryDb } from '../src/db/index.ts'; +import { seedTournament } from '../src/db/seed-matches.ts'; +import { setResult } from '../src/services/matches.ts'; +import { applyResults, refreshResults } from '../src/services/resultsFeed.ts'; +import { parseResults } from '../src/lib/openfootball.ts'; + +const CUP_SAMPLE = ` +▪ Matchday 1 | Thu Jun 11 + 13:00 UTC-6 Mexico 2-0 (1-0) South Africa @ Mexico City + 20:00 UTC-6 South Korea 2-1 (0-0) Czech Republic @ Guadalajara (Zapopan) + Ladislav Krejcí 59') +▪ Matchday 5 | Mon Jun 15 + 19:00 UTC-6 Mexico v South Korea @ Guadalajara (Zapopan) + 12:00 UTC-4 Atlantis 3-3 (1-1) Mythica @ Nowhere +`; + +function score(db: ReturnType, num: number) { + return db.prepare('SELECT home_score AS h, away_score AS a, source FROM match_results WHERE num = ?').get(num) as + | { h: number; a: number; source: string } + | undefined; +} + +describe('parseResults', () => { + it('parses only played fixtures and maps known teams to codes', () => { + const r = parseResults(CUP_SAMPLE); + // Mexico 2-0 RSA and KOR 2-1 CZE are played & known; the "v" line and the + // unknown "Atlantis/Mythica" line are skipped. + expect(r).toEqual([ + { homeCode: 'MEX', awayCode: 'RSA', homeScore: 2, awayScore: 0 }, + { homeCode: 'KOR', awayCode: 'CZE', homeScore: 2, awayScore: 1 }, + ]); + }); +}); + +describe('applyResults', () => { + it('inserts new feed results and is idempotent', () => { + const db = createMemoryDb(); + seedTournament(db); + db.exec('DELETE FROM match_results'); + + const fx = db + .prepare('SELECT num, home_code AS home, away_code AS away FROM matches WHERE home_code IS NOT NULL LIMIT 1') + .get() as { num: number; home: string; away: string }; + + const first = applyResults(db, [ + { homeCode: fx.home, awayCode: fx.away, homeScore: 3, awayScore: 1 }, + ]); + expect(first.added).toBe(1); + expect(score(db, fx.num)).toMatchObject({ h: 3, a: 1, source: 'feed' }); + + // Same score again → unchanged. + expect(applyResults(db, [{ homeCode: fx.home, awayCode: fx.away, homeScore: 3, awayScore: 1 }]).unchanged).toBe(1); + + // Upstream correction → updated. + expect(applyResults(db, [{ homeCode: fx.home, awayCode: fx.away, homeScore: 4, awayScore: 1 }]).updated).toBe(1); + expect(score(db, fx.num)).toMatchObject({ h: 4, a: 1 }); + }); + + it('never overwrites a user-entered result', () => { + const db = createMemoryDb(); + seedTournament(db); + db.exec('DELETE FROM match_results'); + + const fx = db + .prepare('SELECT num, home_code AS home, away_code AS away FROM matches WHERE home_code IS NOT NULL LIMIT 1') + .get() as { num: number; home: string; away: string }; + + setResult(db, fx.num, 9, 9); // user edit + expect(score(db, fx.num)).toMatchObject({ source: 'user' }); + + const summary = applyResults(db, [{ homeCode: fx.home, awayCode: fx.away, homeScore: 1, awayScore: 0 }]); + expect(summary.unchanged).toBe(1); + expect(summary.updated).toBe(0); + expect(score(db, fx.num)).toMatchObject({ h: 9, a: 9, source: 'user' }); // untouched + }); + + it('counts results with no matching fixture as unmatched', () => { + const db = createMemoryDb(); + seedTournament(db); + const summary = applyResults(db, [{ homeCode: 'XXX', awayCode: 'YYY', homeScore: 1, awayScore: 0 }]); + expect(summary.unmatched).toBe(1); + }); +}); + +describe('refreshResults (injected fetch, no network)', () => { + it('fetches, parses and applies, tolerating a missing finals file', async () => { + const db = createMemoryDb(); + seedTournament(db); + db.exec('DELETE FROM match_results'); + + const fetchImpl = async (url: string) => { + if (url.includes('finals')) throw new Error('404'); // optional file missing + return CUP_SAMPLE; + }; + const summary = await refreshResults(db, { + cupUrl: 'http://x/cup.txt', + finalsUrl: 'http://x/cup_finals.txt', + fetchImpl, + }); + expect(summary.added).toBe(2); // MEX-RSA and KOR-CZE + expect(summary.total).toBe(2); + }); +}); diff --git a/backend/test/routes.test.ts b/backend/test/routes.test.ts index 5ca003a..4a4ddb2 100644 --- a/backend/test/routes.test.ts +++ b/backend/test/routes.test.ts @@ -123,17 +123,28 @@ describe('API routes', () => { }); it('records a match result and reflects it in standings', async () => { - const put = await app.inject({ - method: 'PUT', - url: '/api/matches/1/result', - payload: { homeScore: 2, awayScore: 0 }, - }); + // Match 1 is Mexico (home) v South Africa. Test the *effect* of editing its + // result on Mexico's points, independent of however many other group games + // ship pre-seeded from the dataset. + const mexPoints = async () => { + const standings = (await app.inject({ method: 'GET', url: '/api/standings' })).json() + .standings as { code: string; points: number }[]; + return standings.find((r) => r.code === 'MEX')!.points; + }; + + // Force a known 2-0 home win, then clear it — points must drop by 3. + await app.inject({ method: 'PUT', url: '/api/matches/1/result', payload: { homeScore: 2, awayScore: 0 } }); + const withWin = await mexPoints(); + + const del = await app.inject({ method: 'DELETE', url: '/api/matches/1/result' }); + expect(del.statusCode).toBe(200); + const withoutWin = await mexPoints(); + expect(withWin - withoutWin).toBe(3); + + // Re-record it — points come back. + const put = await app.inject({ method: 'PUT', url: '/api/matches/1/result', payload: { homeScore: 2, awayScore: 0 } }); expect(put.statusCode).toBe(200); - - const standings = (await app.inject({ method: 'GET', url: '/api/standings' })).json().standings; - const groupA = standings.filter((r: { group: string }) => r.group === 'A'); - const leader = groupA.find((r: { rank: number }) => r.rank === 1); - expect(leader.points).toBe(3); + expect(await mexPoints()).toBe(withWin); }); it('rejects an invalid score and an unknown match', async () => { diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 9a53af4..ab0e399 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -89,4 +89,11 @@ export const api = { method: 'POST', body: JSON.stringify({ collection }), }), + + // --- Live results feed --- + refreshResults: () => + json<{ summary: { added: number; updated: number; unchanged: number; unmatched: number; total: number } }>( + '/results/refresh', + { method: 'POST' }, + ), }; diff --git a/frontend/src/pages/SettingsView.tsx b/frontend/src/pages/SettingsView.tsx index 337aa6b..2ea3c4b 100644 --- a/frontend/src/pages/SettingsView.tsx +++ b/frontend/src/pages/SettingsView.tsx @@ -115,11 +115,56 @@ export function SettingsView({ + + ); } +function ResultsFeedSection() { + const [busy, setBusy] = useState(false); + const [msg, setMsg] = useState(null); + + const refresh = async () => { + setBusy(true); + setMsg(null); + try { + const { summary } = await api.refreshResults(); + const changed = summary.added + summary.updated; + setMsg( + changed > 0 + ? `✓ ${summary.added} new, ${summary.updated} updated — reloading…` + : `✓ Already up to date (${summary.total} games in feed).`, + ); + if (changed > 0) setTimeout(() => window.location.reload(), 800); + } catch (e) { + setMsg(`⚠ Refresh failed: ${(e as Error).message}`); + } finally { + setBusy(false); + } + }; + + return ( +
+
+ + {msg && {msg}} +
+
+ ); +} + function BackupsSection() { const [backups, setBackups] = useState([]); const [busy, setBusy] = useState(false); From c9131af57129fcf27c67f5c50c7381b2a261756b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 21 Jun 2026 14:39:05 +0000 Subject: [PATCH 2/2] fix: ensure frontend docker build has node typings --- frontend/package.json | 1 + frontend/tsconfig.json | 2 +- package-lock.json | 27 +++++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/frontend/package.json b/frontend/package.json index 7e49f94..3451e3c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,6 +21,7 @@ "@testing-library/jest-dom": "^6.4.8", "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.5.2", + "@types/node": "^20.19.42", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 7ebe1f2..2958c82 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -16,7 +16,7 @@ "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, - "types": ["vitest/globals", "@testing-library/jest-dom"] + "types": ["node", "vitest/globals", "@testing-library/jest-dom"] }, "include": ["src", "test", "vite.config.ts"] } diff --git a/package-lock.json b/package-lock.json index b2d9515..83218fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,6 +49,7 @@ "@testing-library/jest-dom": "^6.4.8", "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.5.2", + "@types/node": "^20.19.42", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", @@ -522,6 +523,7 @@ "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } @@ -539,6 +541,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -556,6 +559,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -573,6 +577,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -590,6 +595,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -607,6 +613,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -624,6 +631,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -641,6 +649,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -658,6 +667,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -675,6 +685,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -692,6 +703,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -709,6 +721,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -726,6 +739,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -743,6 +757,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -760,6 +775,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -777,6 +793,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -794,6 +811,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -811,6 +829,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -828,6 +847,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -845,6 +865,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -862,6 +883,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -879,6 +901,7 @@ "os": [ "openharmony" ], + "peer": true, "engines": { "node": ">=18" } @@ -896,6 +919,7 @@ "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=18" } @@ -913,6 +937,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -930,6 +955,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -947,6 +973,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" }