feat: initial commit — World Cup stats app with pnpm, Traefik, Docker

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>
This commit is contained in:
2026-06-14 15:36:44 +02:00
commit 58b4114159
46 changed files with 9040 additions and 0 deletions
+262
View File
@@ -0,0 +1,262 @@
# 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 (19302026) |
| `worldcup.teams.json` | Team details, FIFA codes, confederation | 20142026 |
| `worldcup.stadiums.json` | Stadium name, city, capacity, coordinates | 20142026 |
| `worldcup.groups.json` | Group compositions | 20142026 |
| `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 19301950, 1990, 2006, and 20142026. 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 } }
```