# World Cup A full-stack World Cup statistics web app covering every tournament from 1930 to 2026. Built with Next.js 16, TailwindCSS 4, GraphQL, and PostgreSQL. Data is sourced from [openfootball/worldcup.json](https://github.com/openfootball/worldcup.json) and synced on a schedule so live 2026 results appear within minutes. ## Features - **Live 2026 matches** — detected automatically when today's date matches a scheduled fixture; Apollo polls every 60 seconds for score updates - **All-time statistics** — goals, hat-tricks, biggest wins, highest-scoring games, penalty stats, goals-by-minute heatmap, confederation performance, title counts - **Group standings** — computed from match results for every tournament, pre-seeded from openfootball's standings files where available - **Deep-linked pages** — every tournament, team, and player has a permanent URL (`/tournaments/1966`, `/teams/brazil`, `/players/Pelé`) - **Full-text search** — across teams, tournaments, and players - **Squad data** — 26-man rosters for 2026 with position, shirt number, and date of birth - **Qualification playoffs** — 2026 inter-confederation playoff results stored separately - **Country flags** — via `flag-icons` CSS classes, ~200 nations covered - **Dark pitch aesthetic** — Bebas Neue headings, Space Grotesk body, green-on-black design ## Pages | Route | Content | |---|---| | `/` | Home: live matches, stat pills, latest result, upcoming fixtures, Golden Boot race | | `/groups` | All 12 group tables for 2026 (P/W/D/L/GD/Pts) | | `/stats` | Historical stats: goals chart, top scorers, hat-tricks, biggest wins, goals by minute, ET/shootout stats, confederation stats | | `/history` | All 23 tournament cards newest-first, each with host, winner, top scorer | | `/search?q=…` | Full-text search across teams, players, tournaments | | `/tournaments/[year]` | Tournament detail: group stage with standings + matches, knockout rounds, scorer sidebar | | `/teams/[slug]` | Team profile: all-time record, top scorers, WC appearances | | `/players/[name]` | Player profile: goals by tournament, penalties vs open play breakdown | ## Tech stack | Layer | Technology | |---|---| | Framework | Next.js 16.2 (App Router, standalone output) | | Styling | TailwindCSS 4 (CSS-first `@theme` config) | | GraphQL server | GraphQL Yoga in `/api/graphql` Next.js route | | GraphQL client | Apollo Client 4 with 60 s poll for live matches | | ORM | Drizzle ORM with `postgres` driver | | Database | PostgreSQL 16 | | Flags | `flag-icons` npm package | | Fonts | Bebas Neue + Space Grotesk (Google Fonts) | | Container | Docker multi-stage build, Traefik-compatible | ## Data sources All data is fetched from the [openfootball/worldcup.json](https://github.com/openfootball/worldcup.json) GitHub repository via raw URLs. The sync script fetches up to seven files per tournament year depending on availability: | File | Content | Years available | |---|---|---| | `worldcup.json` | Matches, scores (FT/HT/ET/P), goal-scorer events | All (1930–2026) | | `worldcup.teams.json` | Team details, FIFA codes, confederation | 2014–2026 | | `worldcup.stadiums.json` | Stadium name, city, capacity, coordinates | 2014–2026 | | `worldcup.groups.json` | Group compositions | 2014–2026 | | `worldcup.standings.json` | Pre-computed group standings | 2014, 2018 | | `worldcup.squads.json` | 26-man player rosters | 2026 | | `worldcup.quali_playoffs.json` | Inter-confederation playoff results | 2026 | **Note:** Individual goal-scorer records are only available from openfootball for 1930–1950, 1990, 2006, and 2014–2026. Match scores (used for standings, biggest wins, etc.) are complete for all years. ## Database schema ``` tournaments year PK, host, winner, runner_up, third, fourth, teams_count, matches_count, total_goals, avg_goals_per_game teams id, name UNIQUE, iso2, fifa_code, continent, confederation stadiums id, tournament_year FK, name, city, country_code, capacity, timezone, coordinates matches id, tournament_year FK, round, group_name, date, time_local, stadium_id FK, team1_id FK, team2_id FK, score_ft_home, score_ft_away, score_ht_home, score_ht_away, score_et_home, score_et_away, score_p_home, score_p_away, is_quali_playoff goals id, match_id FK, team_id FK, player_name, minute, minute_offset, is_penalty, is_own_goal group_standings tournament_year FK, group_name, team_id FK, pos, played, won, drawn, lost, goals_for, goals_against, goal_diff, pts squads id, tournament_year FK, team_id FK, player_name, shirt_number, position, date_of_birth ``` ## Local development **Prerequisites:** Node.js 22+, pnpm 10+, Docker ```bash # 1. Clone and install git clone worldcup cd worldcup pnpm install # 2. Start the database docker compose -f docker-compose.dev.yml up -d # 3. Seed all 23 tournaments DATABASE_URL="postgres://wc:wc@localhost:5432/worldcup" pnpm sync # 4. Start the dev server DATABASE_URL="postgres://wc:wc@localhost:5432/worldcup" pnpm dev ``` Open [http://localhost:3000](http://localhost:3000). To stop the database: `docker compose -f docker-compose.dev.yml down` ## Environment variables | Variable | Required | Description | |---|---|---| | `DATABASE_URL` | Yes | PostgreSQL connection string | | `DB_PASSWORD` | Production | Password for the `wc` DB user (used by docker-compose.yml) | | `TRAEFIK_ENABLED` | Production | Set to `true` to activate Traefik router labels | | `TRAEFIK_HOST` | Production | Public hostname, e.g. `worldcup.example.com` | | `NETWORK_NAME` | Production | Name of the external Docker network Traefik is attached to | Copy `.env.example` to `.env` and fill in the values before deploying. ## Deployment (Coolify + Traefik) The app is designed for self-hosted deployment via [Coolify](https://coolify.io) behind a [Traefik](https://traefik.io) reverse proxy. ### 1. Configure environment In Coolify's environment variable editor set: ``` DB_PASSWORD= DATABASE_URL=postgres://wc:@db:5432/worldcup TRAEFIK_ENABLED=true TRAEFIK_HOST=worldcup.yourdomain.com NETWORK_NAME= ``` ### 2. Deploy Coolify builds the Docker image via `docker compose up` and attaches the container to the Traefik network automatically. TLS certificates are issued by the `resolver` cert resolver configured in Traefik. ### 3. Initial data sync After the first deployment run the sync once manually in Coolify's terminal: ```bash docker compose exec app pnpm sync ``` ### 4. Scheduled sync (live updates) In Coolify → your service → **Scheduled Tasks**, add: | Field | Value | |---|---| | Command | `pnpm sync` | | Schedule | `*/10 * * * *` | | Container | `app` | This re-syncs from openfootball every 10 minutes. During the 2026 group stage new match results appear within 10 minutes of the final whistle. ## Running the sync manually ```bash # From host (dev) DATABASE_URL="postgres://wc:wc@localhost:5432/worldcup" pnpm sync # Inside the app container (production) docker compose exec app pnpm sync ``` The sync is fully idempotent — safe to run repeatedly. It upserts every record and recomputes tournament aggregates at the end of each year. ## Project structure ``` worldcup/ ├── app/ │ ├── layout.tsx # Root layout: nav, fonts, Apollo provider │ ├── page.tsx # Home page │ ├── groups/page.tsx # 2026 group standings │ ├── stats/page.tsx # All-time statistics │ ├── history/page.tsx # Tournament history cards │ ├── search/page.tsx # Full-text search │ ├── tournaments/[year]/page.tsx # Tournament detail │ ├── teams/[slug]/page.tsx # Team profile │ ├── players/[name]/page.tsx # Player profile │ └── api/graphql/route.ts # GraphQL Yoga endpoint ├── components/ │ ├── apollo-provider.tsx # Apollo Client provider wrapper │ ├── nav.tsx # Top navigation bar │ ├── team-flag.tsx # flag-icons wrapper component │ ├── match-card.tsx # Match result / fixture card │ └── live-badge.tsx # Pulsing LIVE indicator ├── lib/ │ ├── db/ │ │ ├── schema.ts # Drizzle table definitions │ │ └── index.ts # DB connection singleton │ ├── graphql/ │ │ ├── schema.ts # GraphQL SDL │ │ ├── resolvers/index.ts # All resolvers │ │ ├── hooks.ts # Apollo v4 useQuery wrapper │ │ └── client.ts # Apollo Client factory │ └── iso-codes.ts # Team name → ISO2 country code map ├── scripts/ │ └── sync.ts # Data sync script (all years, idempotent) ├── docker-compose.yml # Production (Traefik + external network) ├── docker-compose.dev.yml # Local dev (DB only, port 5432 exposed) ├── Dockerfile # Multi-stage pnpm build ├── .env.example # Environment variable template ├── next.config.ts # standalone output, serverExternalPackages ├── drizzle.config.ts # Drizzle Kit config └── tsconfig.json ``` ## Architecture notes **Live match detection** — A match is considered live when its date equals today and the current time is within 5 minutes before kick-off to 125 minutes after. Time zones are stripped; all times are treated as local tournament time. Apollo's `pollInterval: 60_000` re-queries `liveMatches` every minute. **Apollo Client v4** — This project uses Apollo Client 4 which moved hooks to `@apollo/client/react` and core utilities to `@apollo/client/core`. A thin wrapper in `lib/graphql/hooks.ts` re-exports `useQuery` typed as `Record` to avoid the v4 `TData = {}` default breaking all field accesses. **Standalone Docker output** — `next.config.ts` sets `output: 'standalone'` which produces a self-contained `server.js`. The `scripts/` and `lib/` directories are copied separately into the runner stage so `pnpm sync` works inside the container without needing a full Node/TypeScript toolchain reinstall. **Group standings** — Pre-computed standings from openfootball are stored directly. For all other years (and 2026 during the tournament) standings are computed live from match results via a SQL `GROUP BY` query in the `groupStandings` resolver. **Total goals** — Tournament goal counts are derived from match score totals (`score_ft_home + score_ft_away`), not from the goals table. This ensures correct numbers for all years, including those where individual scorer records are not available in the openfootball dataset. ## GraphQL API The GraphQL playground is available at `/api/graphql` in development. Key queries: ```graphql # Live matches right now { liveMatches { id date time team1 { name } team2 { name } scoreFt isLive } } # All-time top scorers { topScorers(limit: 10) { playerName goals penalties team { name iso2 } } } # 2026 group standings { groupStandings(year: 2026) { groupName pos team { name iso2 } played won drawn lost goalsFor goalsAgainst pts } } # Tournament detail { tournament(year: 2022) { year host winner totalGoals avgGoalsPerGame } } # Team stats { team(slug: "brazil") { name stats { appearances wins losses titles goalsFor } } } # Full-text search { search(query: "Ronaldo") { teams { name } players { playerName goals } } } # Hat-tricks in World Cup history { hatTricks { playerName goals year round team { name } opponent { name } } } # Global stats { tournamentStats { totalTournaments totalMatches totalGoals avgGoalsPerGame } } ```