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>
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>
- 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>
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>
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>
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>
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>
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>
Real teams missing from TEAM_ISO: Bosnia-Herzegovina (ba), Kosovo (xk),
New Caledonia (nc), Suriname (sr). Defunct/dissolved with no flag-icons
code: Serbia and Montenegro (cs retired), Zaire (zr retired) → null.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Soviet Union (su), Yugoslavia (yu), East Germany, Germany DR, FR Yugoslavia,
and Czechoslovakia have no valid entry in flag-icons. Map them to null in
TEAM_ISO so getIso() returns null, and render a muted initials badge in
TeamFlag instead of a broken/empty sprite. Also drop the buggy 2-char
substring fallback that generated random valid codes for unknown teams.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
setHours() used the server's local timezone, so "15:00 UTC-5" on a UTC server
was treated as 15:00 UTC instead of 20:00 UTC — causing wrong live detection.
Now parses the UTC offset from the time string and converts to actual UTC
kickoff before comparing: UTC = local_time - offset (15:00 UTC-5 = 20:00 UTC).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Drizzle ORM mutates client.options (parsers/serializers) after the postgres
client is created, which causes the separately-passed password option to be
lost on the actual connection attempt. Root cause confirmed on VPS: raw
postgres.js query succeeded while drizzle.execute() failed with auth error.
Fix: encode the password directly in DATABASE_URL (%23 = #, %5D = ], %3D = =).
postgres.js decodes percent-encoding correctly. No separate DB_PASSWORD env
var needed in the app container anymore.
DB_PASSWORD is still used by the Postgres container (POSTGRES_PASSWORD).
Coolify env var to set: DATABASE_URL=postgres://wc:<encoded-pass>@db:5432/worldcup
Also adds resolver-level isMissingTable() guards so the app returns empty
results instead of GraphQL errors on a fresh deploy before sync runs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Coolify overrides container_name, so the DB service is only reachable
via its compose service name ("db"), not "worldcup_db". Also, passwords
containing URL-special characters (#, ], =) break postgres URL parsing
because the driver uses new URL() internally.
- docker-compose.yml: DATABASE_URL now uses "db" hostname with no
embedded password; DB_PASSWORD is passed as a separate env var
- lib/db/index.ts: when DB_PASSWORD env var is set it is passed as a
postgres driver option, bypassing URL parsing entirely
- .env.example: documents production vs local dev env var usage;
removes DATABASE_URL from the Coolify section (not needed there)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Full-stack World Cup web app (1930–2026):
- Next.js 16 + TailwindCSS 4 + GraphQL Yoga + Apollo Client 4 + Drizzle + PostgreSQL 16
- 23 tournaments synced from openfootball/worldcup.json (matches, goals, teams, stadiums, squads, standings)
- Pages: home (live), groups, stats, history, search, /tournaments/[year], /teams/[slug], /players/[name]
- Live match detection via isLive() + Apollo 60 s poll
- pnpm with node-linker=hoisted for Docker compatibility
- docker-compose.yml with Traefik labels (HTTPS redirect, TLS, security middleware)
- docker-compose.dev.yml for local dev (DB only, port 5432 exposed)
- Dockerfile: multi-stage pnpm build, standalone Next.js output, sync script bundled
- .env.example with all required variables documented
- Comprehensive README with local dev, deployment, schema, and GraphQL API reference
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>