commit 58b41141596ccd86bc52eeccf5c0cc2d7e71e8f3 Author: Sebastian Krüger Date: Sun Jun 14 15:36:44 2026 +0200 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 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 0000000..718d6fe Binary files /dev/null and b/app/favicon.ico differ 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 ( + + + +