263 lines
12 KiB
Markdown
263 lines
12 KiB
Markdown
|
|
# 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 <repo-url> 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=<strong-random-password>
|
|||
|
|
DATABASE_URL=postgres://wc:<DB_PASSWORD>@db:5432/worldcup
|
|||
|
|
TRAEFIK_ENABLED=true
|
|||
|
|
TRAEFIK_HOST=worldcup.yourdomain.com
|
|||
|
|
NETWORK_NAME=<your-traefik-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<string, any>` 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 } }
|
|||
|
|
```
|