- Split all page.tsx files into server wrapper (metadata export) + client.tsx (Apollo/interactive)
- Add robots.ts and sitemap.ts (tournaments, teams, players)
- Add metadataBase, OpenGraph and Twitter card metadata to root layout
- Replace hardcoded worldcup.pivoine.art with NEXT_PUBLIC_SITE_URL env var (sitemap/robots) and relative paths (page metadata, resolved by Next.js against metadataBase)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
MatchCard: display '0–0' instead of '?–?' when a match is live but
score_ft_home is still NULL (sync hasn't picked up the score yet, or
Wikipedia hasn't been updated — every match starts at 0-0).
recentMatches resolver: fetch limit*2 rows then filter out live matches
so a match with score_ft_home=0 that is still in progress doesn't appear
in both the live section and recent results simultaneously.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
sync.ts: after computing standings from played matches, seed 0-0-0-0 rows
for every team in any group match, so all 12 groups always appear.
/groups: fetch all 2026 matches alongside standings; each group card now
shows results (score), live badge, and upcoming fixtures with local
kickoff time, sorted by UTC kickoff.
/tournaments/[year]: derive group list from union of standings + match
group names, so groups with no played matches still render with their
fixtures.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SPLIT_PART sort ignored UTC offsets — a match at 18:00 UTC-7 (01:00 UTC
next day) sorted before 12:00 UTC-4 (16:00 UTC same day). Now computes
the actual UTC timestamp from date + HH:MM + offset and orders by that.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously used the stored venue date for Today/Tomorrow logic, which
gave wrong results when the UTC kickoff crossed midnight into the next
local day (e.g. 18:00 UTC-7 = 01:00 UTC next day). Now computes UTC
kickoff first, converts to the viewer's local time, and derives the
day label from that local Date object.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
formatKickoff() converts "HH:MM UTC-4" + ISO date into the viewer's
local timezone using Date.UTC arithmetic. Shows "Today", "Tomorrow", or
"Mon 16 Jun" as the day label, appended with the local kickoff time.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Detect Wikipedia plain-text rate-limit response ("You are making too many
requests") and wait 30s before retrying, rather than silently failing
- Increase inter-attempt delay from 3s to 15s per attempt
- Increase group subpage delay from 1.2s to 3s, year delay from 0.6s to 2s
- Re-scrape 1982, 1998, 2002, 2006 which had failed groups; all groups now
complete — e.g. 2002 now has 64 matches including Group E (Germany/Klose)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Merge data/wikipedia/{year}/ into data/{year}/ so there is a single
canonical location for World Cup JSON files. Update scrape and seed
scripts to use data/ instead of data/wikipedia/.
Re-scraped all 22 years (1930-2022) with fixed player name extraction
(full name from <a title="..."> rather than abbreviated display text)
so historical goals now show e.g. "Thomas Müller" not "Müller".
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wikipedia abbreviates goal scorer display text (e.g. "Müller") but the
<a title="Thomas Müller"> attribute always has the full name. Switch
parseGoals() to prefer title attr and strip disambiguation suffixes like
"(soccer, born 1993)". This ensures Gerd Müller and Thomas Müller get
separate player pages.
Also preserve the UTC offset from Wikipedia's ftime (e.g. "12:00 UTC-4")
so that isLive() can accurately compute UTC kickoff time instead of
treating local time as UTC. upcomingMatches sorts by SPLIT_PART on the
HH:MM part to ignore the timezone suffix.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wikipedia stores match times as "6:00 p.m." (1-digit hour) which didn't
match the \d{2}:\d{2} regex, producing NULL for those matches. Introduced
parseTime12h() to handle 1-2 digit hours + AM/PM and convert to 24h.
Also sort upcomingMatches by NULLS LAST so unscheduled games appear after
timed ones rather than first. Dropped "openfootball" data attribution.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026 FIFA World Cup has 12 groups (A-L). The previous regex only matched A-H,
causing Groups I, J, K, L to fall through undetected and collapse into Group H.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Needed to recover from duplicate team entries (Bosnia & Herzegovina / USA)
that persisted because ON CONFLICT matching is on team IDs, so old rows
with wrong team IDs are never updated. --force clears all 2026 data and
orphaned teams before re-syncing clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add TEAM_ALIASES to lib/wiki-scraper.ts applied at extraction time so both
scraper and sync consistently produce canonical names. Removes the duplicate
alias map from seed.ts in favour of the shared normalizeTeam() export.
Aliases added:
Bosnia & Herzegovina → Bosnia and Herzegovina
USA → United States
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Move all scraping logic (fetchWikiHtml, scrapeYear, scrapeSquads and all
helpers) into lib/wiki-scraper.ts as exported functions shared by both scripts.
scrape-wikipedia.ts becomes a composable CLI:
pnpm scrape [year] — matches + squads (default)
pnpm scrape [year] --matches — matches/meta/stadiums only
pnpm scrape [year] --squads — squads only
sync.ts drops the openfootball GitHub dependency entirely and scrapes
Wikipedia directly. Incremental: completed groups (all matches have FT
scores) are detected via DB query and their sub-pages are skipped each run.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add worldcup.meta.json per year with host, teams_count, winner, runner_up,
third_place, fourth_place — derived from match results (Final/Third-place
match) with infobox as fallback for edge cases like 1950's round-robin final.
Fix infobox host extraction to handle <br>-separated multi-host entries
(2002: Japan / South Korea). Fix squad scraper to filter out zero-player
phantom sections that Wikipedia appends (References, Captains, etc.).
Drop app/data/world_cup.csv and the PLACEMENTS/parseCsv code in seed.ts —
all tournament metadata now comes from the scraped JSON files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Move world_cup.csv to app/data/ directly (the only remaining Kaggle file
used by seed.ts for tournament metadata). Delete the rest of the Kaggle CSVs.
Update path constants in scrape-wikipedia.ts and seed.ts accordingly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add scripts/scrape-wikipedia.ts that fetches all 22 World Cups (1930–2022)
from English Wikipedia via MediaWiki API, handles group sub-pages, AET/penalty
detection, and goal parsing, writing openfootball-format JSON to app/data/openfootball/.
Rewrite scripts/seed.ts to read these local JSON files instead of the Kaggle
CSV, producing 965 matches and 2716 goals with per-group assignments for all
historical tournaments (enabling group standings on tournament pages).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously each match goal sync did: DELETE (auto-commit) → N
individual INSERTs (each auto-commit). During those ~50ms readers
saw 0 goals for the match — the inconsistency window.
Now: collectGoals() builds the rows in memory, replaceGoals() wraps
the DELETE + single bulk VALUES INSERT in a transaction. Under
Postgres READ COMMITTED, readers see the old goals until commit and
the full new set after — never an empty window.
Also drop sync pool from max:5 → max:2; the job is fully sequential
and was holding idle connections unnecessarily.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TypeScript doesn't narrow module-level consts across closure
boundaries, so the explicit process.exit(1) guard isn't enough —
add ! assertion at the usage site inside run().
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The DDL block in sync.ts was a "safety net" but caused misleading
password auth errors when Coolify's scheduled task ran without
DATABASE_URL injected — the fallback `wc:wc` password was wrong.
- Drop the silent `?? 'postgres://wc:wc@...'` fallback; exit with a
clear message if DATABASE_URL is missing so the root cause is obvious
- Remove the 90-line CREATE TABLE IF NOT EXISTS block — seed.ts runs
before the server starts and guarantees all tables exist
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Different queries fetch Team with different field sets (some include slug,
others don't). merge: true tells InMemoryCache to combine fields rather
than replace, avoiding the "cache data may be lost" warning.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Script is injected with lazyOnload strategy and omitted entirely when
the env vars are not set, so dev and staging environments stay clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diagonal ±45° goal-net texture on body background. All card surfaces
converted from opaque #0a1810 to glass-card (backdrop-blur + semi-transparent
rgba) or glass-card-hero (gradient rgba) so the net pattern shows through.
Covers all pages: home, groups, history, search, stats, teams, tournaments,
players, match cards, and 404.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Nav keeps full-width background; inner content wrapped in max-w-[1200px]
mx-auto px-7 container to align with page content width.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
GraphQL validation rejected the operation because \$name was declared
but never referenced in the query body.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@heroicons/react was installed with npm which created package-lock.json
instead of updating pnpm-lock.yaml. Docker build uses pnpm --frozen-lockfile
so the wrong lockfile caused build failure.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
lt(date, today) excluded same-day results once the live window closed.
Changed to lte so finished matches from today appear on the homepage.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Install @heroicons/react and replace all emoji usage across stats, history,
search, and team pages with proper SVG icons (outline style, w-3 to w-4).
SectionTitle in stats page refactored to accept an icon component prop.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add merge: true policy for Team.stats so InMemoryCache merges partial
TeamStats selections (e.g. search page) with fuller ones (team/stats pages)
instead of replacing and losing fields. Also align stats page query to
include goalDiff for consistency.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Teams with few goals (e.g. Qatar) were missing from the sidebar because
topScorers(limit:200) only returned all-time top scorers. Now the query
filters by teamId in SQL so every team shows their own scorers.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Team page: add Tournament Participations (year pills → /tournaments/[year]) and
Match History (grouped by year, W/D/L badge, opponent, score from team's perspective,
PSO/AET annotations, each row → match anchor)
- GraphQL: extend matches() query with teamId filter (OR team1_id/team2_id)
- Match card: link team names to /teams/[slug]; fix ET score display — show scoreEt
as headline for AET matches, scoreFt as footnote; winner determination uses
scoreP ?? scoreEt ?? scoreFt
- Tournament page: scorer names below each match linked to /players/[name] with
dotted underline (solid + green on hover)
- Stats page: reduce mobile padding on Goals chart, Top Scorers, Titles, Goals by
Minute — hide progress bars and trophy emojis on small screens
- Homepage: Golden Boot Race same mobile padding/bar treatment; add slug to match
team queries
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Team table: overflow-x-auto wrapper + min-w-[560px] so flags and names
never collapse; columns are right-aligned numeric data, left-aligned team.
Confederation: replace CSS grid with <table> — browser handles column
alignment automatically, no more misalignment between header and rows.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Inline the trophy paths directly into a 100×100 viewBox wrapper SVG.
Use the original inkscape layer viewBox (0 1002.3622 20 50) so the
paths render correctly without any transform. Strip inkscape/sodipodi
namespace attrs so rsvg-convert parses cleanly. Regenerate all PNGs
from this single vector source — no rasterization artefacts.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Old favicon.svg was 20×50 (portrait). New version is 100×100 viewBox
with #040d08 background and trophy centred (x=34,y=10,w=32,h=80).
Regenerate all PNGs from it via rsvg-convert. Remove unused Next.js
default public files (file.svg, globe.svg, next.svg, vercel.svg, window.svg).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Generate PNGs from the trophy SVG (dark #040d08 background, centred):
- favicon.svg — primary, all modern browsers
- favicon-32x32.png — 32×32 fallback for older browsers
- apple-touch-icon.png — 180×180 for iOS home screen
- icon-192x192.png / icon-512x512.png — webmanifest / PWA
app/manifest.ts provides /manifest.webmanifest via Next.js file convention.
layout.tsx metadata wires up all icon sizes via the icons API.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaced flex-wrap with grid-cols-[1fr_auto_1fr] so team columns fill
equally on either side of the score. Score font scales down on mobile,
padding tightens, team names truncate instead of wrapping.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Desktop nav unchanged. On mobile: hamburger animates to X on open,
panel slides down with nav links + search, backdrop dims the page,
menu closes on route change or backdrop tap, body scroll locked while open.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
All pages are 'use client' so metadata exports don't work. Each page now
sets document.title via useEffect — static pages with a fixed string,
dynamic pages keyed on data so the title reflects the loaded content.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
For shootout games the FT score (e.g. 2–2) was the main display, which
was misleading. Now the penalty score is the headline (4–2) with
"2–2 a.e.t." below it. Winner highlighting also uses the penalty score.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The browser fires native hash-scroll before useQuery resolves, so the
target element doesn't exist yet. A useEffect keyed on data re-scrolls
once the matches are in the DOM.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>