From 58b41141596ccd86bc52eeccf5c0cc2d7e71e8f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Sun, 14 Jun 2026 15:36:44 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20initial=20commit=20=E2=80=94=20World=20?= =?UTF-8?q?Cup=20stats=20app=20with=20pnpm,=20Traefik,=20Docker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 8 + .gitignore | 42 + .npmrc | 1 + AGENTS.md | 5 + CLAUDE.md | 1 + Dockerfile | 28 + README.md | 262 ++ app/api/graphql/route.ts | 16 + app/favicon.ico | Bin 0 -> 25931 bytes app/globals.css | 57 + app/groups/page.tsx | 100 + app/history/page.tsx | 121 + app/layout.tsx | 29 + app/page.tsx | 216 ++ app/players/[name]/page.tsx | 117 + app/search/page.tsx | 193 ++ app/stats/page.tsx | 348 ++ app/teams/[slug]/page.tsx | 162 + app/tournaments/[year]/page.tsx | 262 ++ components/apollo-provider.tsx | 7 + components/live-badge.tsx | 8 + components/match-card.tsx | 86 + components/nav.tsx | 75 + components/team-flag.tsx | 20 + docker-compose.dev.yml | 14 + docker-compose.yml | 49 + drizzle.config.ts | 10 + eslint.config.mjs | 18 + lib/db/index.ts | 10 + lib/db/schema.ts | 91 + lib/graphql/client.ts | 19 + lib/graphql/hooks.ts | 12 + lib/graphql/resolvers/index.ts | 458 +++ lib/graphql/schema.ts | 199 ++ lib/iso-codes.ts | 77 + next.config.ts | 8 + package.json | 39 + pnpm-lock.yaml | 5400 +++++++++++++++++++++++++++++++ postcss.config.mjs | 7 + public/file.svg | 1 + public/globe.svg | 1 + public/next.svg | 1 + public/vercel.svg | 1 + public/window.svg | 1 + scripts/sync.ts | 426 +++ tsconfig.json | 34 + 46 files changed, 9040 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/api/graphql/route.ts create mode 100644 app/favicon.ico create mode 100644 app/globals.css create mode 100644 app/groups/page.tsx create mode 100644 app/history/page.tsx create mode 100644 app/layout.tsx create mode 100644 app/page.tsx create mode 100644 app/players/[name]/page.tsx create mode 100644 app/search/page.tsx create mode 100644 app/stats/page.tsx create mode 100644 app/teams/[slug]/page.tsx create mode 100644 app/tournaments/[year]/page.tsx create mode 100644 components/apollo-provider.tsx create mode 100644 components/live-badge.tsx create mode 100644 components/match-card.tsx create mode 100644 components/nav.tsx create mode 100644 components/team-flag.tsx create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.yml create mode 100644 drizzle.config.ts create mode 100644 eslint.config.mjs create mode 100644 lib/db/index.ts create mode 100644 lib/db/schema.ts create mode 100644 lib/graphql/client.ts create mode 100644 lib/graphql/hooks.ts create mode 100644 lib/graphql/resolvers/index.ts create mode 100644 lib/graphql/schema.ts create mode 100644 lib/iso-codes.ts create mode 100644 next.config.ts create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 postcss.config.mjs create mode 100644 public/file.svg create mode 100644 public/globe.svg create mode 100644 public/next.svg create mode 100644 public/vercel.svg create mode 100644 public/window.svg create mode 100644 scripts/sync.ts create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..edf4f42 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# Database +DB_PASSWORD=changeme +DATABASE_URL=postgres://wc:changeme@db:5432/worldcup + +# Traefik (set TRAEFIK_ENABLED=true when deploying behind Traefik) +TRAEFIK_ENABLED=false +TRAEFIK_HOST=worldcup.example.com +NETWORK_NAME=traefik-network diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7b8da95 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* +!.env.example + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..d67f374 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +node-linker=hoisted diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8bd0e39 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,5 @@ + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..66662a6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +FROM node:22-alpine AS base +RUN corepack enable && corepack prepare pnpm@10.28.0 --activate +WORKDIR /app + +FROM base AS deps +COPY package.json pnpm-lock.yaml .npmrc ./ +RUN pnpm install --frozen-lockfile + +FROM base AS builder +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN pnpm build + +FROM base AS runner +ENV NODE_ENV=production +RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001 +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/scripts ./scripts +COPY --from=builder /app/lib ./lib +COPY --from=builder /app/package.json ./package.json +COPY --from=builder /app/tsconfig.json ./tsconfig.json +USER nextjs +EXPOSE 3000 +ENV PORT=3000 HOSTNAME="0.0.0.0" +CMD ["node", "server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..3680213 --- /dev/null +++ b/README.md @@ -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 (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 } } +``` diff --git a/app/api/graphql/route.ts b/app/api/graphql/route.ts new file mode 100644 index 0000000..00cdcf7 --- /dev/null +++ b/app/api/graphql/route.ts @@ -0,0 +1,16 @@ +import { createYoga } from 'graphql-yoga' +import { makeExecutableSchema } from '@graphql-tools/schema' +import { typeDefs } from '@/lib/graphql/schema' +import { resolvers } from '@/lib/graphql/resolvers' + +const schema = makeExecutableSchema({ typeDefs, resolvers }) + +const yoga = createYoga({ + schema, + graphqlEndpoint: '/api/graphql', + fetchAPI: { Response, Request, ReadableStream }, +}) + +export const GET = yoga +export const POST = yoga +export const OPTIONS = yoga diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 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 LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..2feef9b --- /dev/null +++ b/app/globals.css @@ -0,0 +1,57 @@ +@import "tailwindcss"; +@import "flag-icons/css/flag-icons.min.css"; + +@theme inline { + --color-bg: #040d08; + --color-card: #0a1810; + --color-hero: #0d2416; + --color-green: #22c55e; + --color-green-light: #4ade80; + --color-green-sec: #6abf7a; + --color-green-muted: #2a5c35; + --color-green-dark: #1a3a22; + --color-text: #dff5e8; + --color-border: rgba(34,197,94,0.15); + + --font-display: "Bebas Neue", cursive; + --font-body: "Space Grotesk", system-ui, sans-serif; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +html { scroll-behavior: smooth; } + +body { + background: #040d08; + color: #dff5e8; + font-family: "Space Grotesk", system-ui, sans-serif; + min-height: 100vh; + overflow-x: hidden; +} + +::-webkit-scrollbar { width: 5px; } +::-webkit-scrollbar-track { background: #020a04; } +::-webkit-scrollbar-thumb { background: rgba(34,197,94,0.25); border-radius: 4px; } + +@keyframes livePulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.2; transform: scale(0.6); } +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +.animate-live { animation: livePulse 2s ease-in-out infinite; } +.animate-fade-in { animation: fadeIn 0.3s ease-out; } + +.pitch-grid { + background-image: repeating-linear-gradient( + 0deg, + transparent, + transparent 44px, + rgba(34,197,94,0.018) 44px, + rgba(34,197,94,0.018) 88px + ); +} diff --git a/app/groups/page.tsx b/app/groups/page.tsx new file mode 100644 index 0000000..0f06296 --- /dev/null +++ b/app/groups/page.tsx @@ -0,0 +1,100 @@ +'use client' +import { useQuery, gql } from '@/lib/graphql/hooks' +import Link from 'next/link' +import { TeamFlag } from '@/components/team-flag' + +const GROUPS_QUERY = gql` + query Groups { + groupStandings(year: 2026) { + groupName pos played won drawn lost goalsFor goalsAgainst goalDiff pts + team { id name iso2 slug } + } + } +` + +interface Standing { + groupName: string; pos?: number | null + played: number; won: number; drawn: number; lost: number + goalsFor: number; goalsAgainst: number; goalDiff: number; pts: number + team: { id: number; name: string; iso2?: string | null; slug: string } +} + +export default function GroupsPage() { + const { data, loading } = useQuery(GROUPS_QUERY, { pollInterval: 60_000 }) + + const standings: Standing[] = data?.groupStandings ?? [] + const byGroup = standings.reduce>((acc, s) => { + acc[s.groupName] = [...(acc[s.groupName] ?? []), s] + return acc + }, {}) + + const groups = Object.entries(byGroup).sort(([a], [b]) => a.localeCompare(b)) + + return ( +
+
+

2026 Groups

+

48 teams · 12 groups · Top 2 + 8 best 3rd-place advance

+
+ + {loading && !data && ( +
+ {Array.from({ length: 12 }).map((_, i) => ( +
+ ))} +
+ )} + +
+ {groups.map(([groupName, rows]) => { + const sorted = [...rows].sort((a, b) => { + if (b.pts !== a.pts) return b.pts - a.pts + if (b.goalDiff !== a.goalDiff) return b.goalDiff - a.goalDiff + return b.goalsFor - a.goalsFor + }) + const letter = groupName.replace('Group ', '') + return ( +
+
+ GROUP {letter} +
+
+ Team + PW + DL + GDPts +
+ {sorted.map((t, idx) => ( + +
+
+ + {t.team.name} +
+ {[t.played, t.won, t.drawn, t.lost].map((v, i) => ( + {v} + ))} + + {t.goalDiff > 0 ? `+${t.goalDiff}` : t.goalDiff} + + {t.pts} +
+ + ))} +
+ ) + })} +
+
+ ) +} diff --git a/app/history/page.tsx b/app/history/page.tsx new file mode 100644 index 0000000..f2bbc04 --- /dev/null +++ b/app/history/page.tsx @@ -0,0 +1,121 @@ +'use client' +import { useQuery, gql } from '@/lib/graphql/hooks' +import Link from 'next/link' +import { TeamFlag } from '@/components/team-flag' + +const HISTORY_QUERY = gql` + query History { + tournaments { + year host winner runnerUp thirdPlace fourthPlace + totalGoals matchesCount teamsCount avgGoalsPerGame + topScorers(limit: 1) { playerName goals team { name iso2 } } + } + } +` + +interface Tournament { + year: number; host: string; winner?: string | null; runnerUp?: string | null + thirdPlace?: string | null; fourthPlace?: string | null + totalGoals?: number | null; matchesCount?: number | null; teamsCount?: number | null + avgGoalsPerGame?: string | number | null + topScorers: Array<{ playerName: string; goals: number; team?: { name: string; iso2?: string | null } | null }> +} + +function HostIso(host: string): string { + const map: Record = { + 'Uruguay': 'uy', 'Italy': 'it', 'France': 'fr', 'Brazil': 'br', + 'Switzerland': 'ch', 'Sweden': 'se', 'Chile': 'cl', 'England': 'gb-eng', + 'Mexico': 'mx', 'Germany': 'de', 'Argentina': 'ar', 'Spain': 'es', + 'South Korea / Japan': 'kr', 'South Africa': 'za', 'Russia': 'ru', + 'Qatar': 'qa', 'USA': 'us', 'USA / Canada / Mexico': 'us', + } + return map[host] ?? 'un' +} + +export default function HistoryPage() { + const { data, loading } = useQuery(HISTORY_QUERY) + const tournaments: Tournament[] = data?.tournaments ?? [] + const is2026InProgress = !tournaments.find(t => t.year === 2026)?.winner + + return ( +
+

+ World Cup History +

+

+ Every edition — Uruguay 1930 through 2026 · {tournaments.length} tournaments +

+ + {loading && !data && ( +
+ {Array.from({ length: 24 }).map((_, i) => ( +
+ ))} +
+ )} + +
+ {tournaments.map(t => { + const inProgress = t.year === 2026 && is2026InProgress + const topScorer = t.topScorers?.[0] + return ( + +
+ {/* Year watermark */} +
+ {t.year} +
+ +
+
+
+
{t.year}
+
+ + {t.host} +
+
+ + {inProgress + ?
+ IN PROGRESS +
+ : t.winner && ( +
+ +
{t.winner}
+
+ )} +
+ + {!inProgress && t.winner && t.runnerUp && ( +
+ {t.winner} + def. + {t.runnerUp} +
+ )} + +
+ {t.totalGoals != null && ⚽ {t.totalGoals}} + {t.matchesCount != null && 🗓 {t.matchesCount} games} + {t.teamsCount != null && 🏳 {t.teamsCount} teams} +
+ + {topScorer && ( +
+ Golden Boot: {topScorer.playerName} ({topScorer.goals}⚽) +
+ )} +
+
+ + ) + })} +
+
+ ) +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..d8d747a --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,29 @@ +import type { Metadata } from 'next' +import { Bebas_Neue, Space_Grotesk } from 'next/font/google' +import './globals.css' +import { Nav } from '@/components/nav' +import { AppApolloProvider } from '@/components/apollo-provider' + +const bebasNeue = Bebas_Neue({ weight: '400', subsets: ['latin'], variable: '--font-bebas' }) +const spaceGrotesk = Space_Grotesk({ subsets: ['latin'], variable: '--font-space' }) + +export const metadata: Metadata = { + title: { default: 'World Cup', template: '%s · World Cup' }, + description: 'Comprehensive World Cup statistics from 1930 to 2026', + icons: { + icon: "data:image/svg+xml,", + }, +} + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + +