valknar c3ddb6e874 feat: replace emoji icons with Heroicons SVG set
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>
2026-06-14 21:23:38 +02:00

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 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 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

# 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.

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 behind a Traefik 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:

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

# 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 outputnext.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:

# 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 } }
S
Description
A full-stack World Cup statistics web app covering every tournament from 1930 to 2026.
https://worldcup.pivoine.art
Readme 2.2 MiB
Languages
TypeScript 97.9%
CSS 1.3%
Dockerfile 0.5%
JavaScript 0.3%