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
+8
View File
@@ -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
+42
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
node-linker=hoisted
+5
View File
@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# 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.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+28
View File
@@ -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"]
+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 } }
```
+16
View File
@@ -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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+57
View File
@@ -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
);
}
+100
View File
@@ -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<Record<string, Standing[]>>((acc, s) => {
acc[s.groupName] = [...(acc[s.groupName] ?? []), s]
return acc
}, {})
const groups = Object.entries(byGroup).sort(([a], [b]) => a.localeCompare(b))
return (
<div className="max-w-[1200px] mx-auto px-7 py-10 pb-16">
<div className="mb-9">
<h1 className="font-['Bebas_Neue'] text-[52px] tracking-[0.04em] text-[#22c55e] leading-none">2026 Groups</h1>
<p className="text-[#2a5c35] text-sm mt-1.5">48 teams · 12 groups · Top 2 + 8 best 3rd-place advance</p>
</div>
{loading && !data && (
<div className="grid grid-cols-[repeat(auto-fill,minmax(268px,1fr))] gap-3.5">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="h-56 rounded-2xl animate-pulse" style={{ background: '#0a1810' }} />
))}
</div>
)}
<div className="grid grid-cols-[repeat(auto-fill,minmax(268px,1fr))] gap-3.5">
{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 (
<div key={groupName} className="rounded-2xl overflow-hidden" style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.15)' }}>
<div className="px-4 py-3 border-b" style={{
background: 'linear-gradient(90deg,rgba(34,197,94,0.12) 0%,rgba(34,197,94,0.04) 100%)',
borderColor: 'rgba(34,197,94,0.1)',
}}>
<span className="font-['Bebas_Neue'] text-[28px] text-[#22c55e] tracking-[0.05em]">GROUP {letter}</span>
</div>
<div className="grid px-4 py-2 text-[9px] text-[#2a5c35] tracking-[0.1em] uppercase"
style={{ gridTemplateColumns: '1fr 22px 22px 22px 22px 22px 30px', gap: '3px' }}>
<span>Team</span>
<span className="text-center">P</span><span className="text-center">W</span>
<span className="text-center">D</span><span className="text-center">L</span>
<span className="text-center">GD</span><span className="text-center">Pts</span>
</div>
{sorted.map((t, idx) => (
<Link key={t.team.id} href={`/teams/${t.team.slug}`}>
<div className="grid px-4 py-2.5 items-center border-t hover:bg-[rgba(34,197,94,0.03)] transition-colors cursor-pointer"
style={{
gridTemplateColumns: '1fr 22px 22px 22px 22px 22px 30px',
gap: '3px',
borderColor: 'rgba(34,197,94,0.06)',
background: idx < 2 ? 'rgba(34,197,94,0.025)' : undefined,
}}>
<div className="flex items-center gap-2 overflow-hidden">
<TeamFlag name={t.team.name} iso2={t.team.iso2} size="sm" />
<span className={`text-sm truncate font-medium ${idx < 2 ? 'text-[#dff5e8]' : 'text-[#6abf7a]'}`}>{t.team.name}</span>
</div>
{[t.played, t.won, t.drawn, t.lost].map((v, i) => (
<span key={i} className="text-center text-[13px] text-[#4a7a55]">{v}</span>
))}
<span className="text-center text-[13px] text-[#4a7a55]">
{t.goalDiff > 0 ? `+${t.goalDiff}` : t.goalDiff}
</span>
<span className="text-center text-[13px] font-bold text-[#22c55e]">{t.pts}</span>
</div>
</Link>
))}
</div>
)
})}
</div>
</div>
)
}
+121
View File
@@ -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<string, string> = {
'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 (
<div className="max-w-[1200px] mx-auto px-7 py-10 pb-16">
<h1 className="font-['Bebas_Neue'] text-[52px] tracking-[0.04em] text-[#22c55e] leading-none mb-2">
World Cup History
</h1>
<p className="text-[#2a5c35] text-sm mb-9">
Every edition Uruguay 1930 through 2026 · {tournaments.length} tournaments
</p>
{loading && !data && (
<div className="grid grid-cols-[repeat(auto-fill,minmax(238px,1fr))] gap-3.5">
{Array.from({ length: 24 }).map((_, i) => (
<div key={i} className="h-52 rounded-2xl animate-pulse" style={{ background: '#0a1810' }} />
))}
</div>
)}
<div className="grid grid-cols-[repeat(auto-fill,minmax(238px,1fr))] gap-3.5">
{tournaments.map(t => {
const inProgress = t.year === 2026 && is2026InProgress
const topScorer = t.topScorers?.[0]
return (
<Link key={t.year} href={`/tournaments/${t.year}`}>
<div className="rounded-2xl p-5 relative overflow-hidden cursor-pointer hover:border-[rgba(34,197,94,0.3)] transition-colors"
style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.13)' }}>
{/* Year watermark */}
<div className="absolute right-[-6px] bottom-[-18px] font-['Bebas_Neue'] text-[88px] leading-none pointer-events-none select-none"
style={{ color: 'rgba(34,197,94,0.04)' }}>
{t.year}
</div>
<div className="relative">
<div className="flex justify-between items-start mb-3.5">
<div>
<div className="font-['Bebas_Neue'] text-[34px] text-[#22c55e] leading-none">{t.year}</div>
<div className="text-xs text-[#2a5c35] mt-0.5 flex items-center gap-1.5">
<span className={`fi fi-${HostIso(t.host)} rounded-sm text-sm inline-block`} />
{t.host}
</div>
</div>
{inProgress
? <div className="text-[10px] text-[#22c55e] font-bold tracking-[0.12em] bg-[rgba(34,197,94,0.1)] px-2.5 py-1 rounded-full mt-1">
IN PROGRESS
</div>
: t.winner && (
<div className="text-right">
<TeamFlag name={t.winner} size="md" />
<div className="text-[11px] text-[#6abf7a] mt-0.5">{t.winner}</div>
</div>
)}
</div>
{!inProgress && t.winner && t.runnerUp && (
<div className="rounded-lg px-3 py-2 text-xs text-[#6abf7a] mb-3"
style={{ background: 'rgba(34,197,94,0.07)' }}>
<span className="font-semibold text-[#dff5e8]">{t.winner}</span>
<span className="mx-2 text-[#2a5c35]">def.</span>
{t.runnerUp}
</div>
)}
<div className="flex gap-3.5 text-[11px] text-[#2a5c35] flex-wrap">
{t.totalGoals != null && <span> {t.totalGoals}</span>}
{t.matchesCount != null && <span>🗓 {t.matchesCount} games</span>}
{t.teamsCount != null && <span>🏳 {t.teamsCount} teams</span>}
</div>
{topScorer && (
<div className="mt-2 text-[10px] text-[#1a3a22]">
Golden Boot: <span className="text-[#2a5c35]">{topScorer.playerName} ({topScorer.goals})</span>
</div>
)}
</div>
</div>
</Link>
)
})}
</div>
</div>
)
}
+29
View File
@@ -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,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><circle cx='50' cy='50' r='46' fill='%230a1810'/><polygon points='54,38 63,50 54,62 39,57 40,42' fill='none' stroke='%2322c55e' stroke-width='3'/><line x1='54' y1='38' x2='65' y2='9' stroke='%2322c55e' stroke-width='2.5'/><line x1='63' y1='50' x2='94' y2='51' stroke='%2322c55e' stroke-width='2.5'/><line x1='54' y1='62' x2='62' y2='92' stroke='%2322c55e' stroke-width='2.5'/><line x1='39' y1='57' x2='14' y2='75' stroke='%2322c55e' stroke-width='2.5'/><line x1='40' y1='42' x2='15' y2='23' stroke='%2322c55e' stroke-width='2.5'/><circle cx='50' cy='50' r='46' fill='none' stroke='%2322c55e' stroke-width='3'/></svg>",
},
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${bebasNeue.variable} ${spaceGrotesk.variable}`}>
<body>
<AppApolloProvider>
<Nav />
<main className="pt-[60px] min-h-screen">{children}</main>
</AppApolloProvider>
</body>
</html>
)
}
+216
View File
@@ -0,0 +1,216 @@
'use client'
import { useQuery, gql } from '@/lib/graphql/hooks'
import Link from 'next/link'
import { TeamFlag } from '@/components/team-flag'
import { LiveBadge } from '@/components/live-badge'
import { MatchCard } from '@/components/match-card'
const HOME_QUERY = gql`
query Home {
tournamentStats { totalTournaments totalMatches totalGoals avgGoalsPerGame }
liveMatches {
id year round group date time isLive scoreFt scoreEt scoreP isQualiPlayoff
team1 { name iso2 } team2 { name iso2 }
}
recentMatches(limit: 9) {
id year round group date time isLive isQualiPlayoff scoreFt scoreEt scoreP
team1 { name iso2 } team2 { name iso2 }
}
upcomingMatches(limit: 9) {
id year round group date time isLive isQualiPlayoff scoreFt
team1 { name iso2 } team2 { name iso2 }
}
topScorers(year: 2026, limit: 8) {
playerName goals penalties ownGoals
team { name iso2 }
}
tournament(year: 2026) { year totalGoals matchesCount avgGoalsPerGame }
}
`
function SectionHeader({ label }: { label: string }) {
return (
<div className="flex items-center gap-2.5 mb-4">
<div className="w-[3px] h-[18px] bg-[#22c55e] rounded-sm" />
<span className="text-[11px] text-[#2a5c35] font-bold tracking-[0.12em] uppercase">{label}</span>
</div>
)
}
function StatPill({ label, value }: { label: string; value: string | number }) {
return (
<div className="flex-1 min-w-[90px] rounded-xl p-3.5 px-5"
style={{ background: 'rgba(34,197,94,0.05)', border: '1px solid rgba(34,197,94,0.12)' }}>
<div className="text-[9px] text-[#2a5c35] tracking-[0.13em] uppercase mb-1.5 whitespace-nowrap">{label}</div>
<div className="font-['Bebas_Neue'] text-[30px] text-[#22c55e] leading-none">{value ?? ''}</div>
</div>
)
}
interface UpcomingMatch {
id: number; year: number; time?: string | null; date?: string | null
team1: { name: string; iso2?: string | null }
team2: { name: string; iso2?: string | null }
}
function UpcomingFixture({ match }: { match: UpcomingMatch }) {
const time = match.time?.split(' ')[0] ?? ''
return (
<Link href={`/tournaments/${match.year}#match-${match.id}`}>
<div className="rounded-[10px] p-3 px-4 flex items-center gap-2.5 hover:border-[rgba(34,197,94,0.2)] transition-colors cursor-pointer"
style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.07)' }}>
<TeamFlag name={match.team1.name} iso2={match.team1.iso2} size="sm" />
<div className="flex-1 text-[13px] text-[#6abf7a] font-medium truncate">
{match.team1.name} <span className="text-[#2a5c35]">vs</span> {match.team2.name}
</div>
<TeamFlag name={match.team2.name} iso2={match.team2.iso2} size="sm" />
{time && <div className="text-[11px] text-[#2a5c35] whitespace-nowrap ml-1">{time}</div>}
</div>
</Link>
)
}
interface ScorerEntry {
playerName: string; goals: number; penalties: number
team?: { name: string; iso2?: string | null } | null
}
interface MatchData {
id: number; year: number; round: string; group?: string | null
date?: string | null; time?: string | null; isLive: boolean; isQualiPlayoff: boolean
scoreFt?: number[] | null; scoreEt?: number[] | null; scoreP?: number[] | null
team1: { name: string; iso2?: string | null }
team2: { name: string; iso2?: string | null }
}
export default function HomePage() {
const { data, loading } = useQuery(HOME_QUERY, { pollInterval: 60_000 })
const stats = data?.tournamentStats
const live: MatchData[] = data?.liveMatches ?? []
const recent: MatchData[] = data?.recentMatches ?? []
const upcoming: UpcomingMatch[] = data?.upcomingMatches ?? []
const scorers: ScorerEntry[] = data?.topScorers ?? []
const wc2026 = data?.tournament
const maxGoals = Math.max(...scorers.map(s => s.goals), 1)
return (
<div>
{/* ── Hero ── */}
<div className="pitch-grid border-b" style={{
background: 'linear-gradient(145deg,#0a1a0e 0%,#0d2416 55%,#0a1a0e 100%)',
borderColor: 'rgba(34,197,94,0.15)',
padding: '52px 0 44px',
}}>
<div className="max-w-[1200px] mx-auto px-7">
<div className="mb-4">
{live.length > 0
? <LiveBadge label="Live · Group Stage in Progress" />
: <div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-[#22c55e] inline-block" />
<span className="text-[11px] font-bold text-[#22c55e] tracking-[0.14em] uppercase">World Cup 2026 · In Progress</span>
</div>
}
</div>
<h1 className="font-['Bebas_Neue'] text-[clamp(50px,9vw,100px)] tracking-[0.04em] text-white leading-[0.92] mb-2.5">
World Cup 2026
</h1>
<p className="text-[#2a5c35] text-sm mb-9">
<span className="fi fi-us rounded-sm text-lg mx-0.5 inline-block" /> USA ·{' '}
<span className="fi fi-ca rounded-sm text-lg mx-0.5 inline-block" /> Canada ·{' '}
<span className="fi fi-mx rounded-sm text-lg mx-0.5 inline-block" /> Mexico
&nbsp;·&nbsp; 11 June 19 July 2026 · 48 Teams
</p>
<div className="flex gap-2.5 flex-wrap max-w-[760px]">
{stats ? <>
<StatPill label="Tournaments" value={stats.totalTournaments} />
<StatPill label="Matches" value={stats.totalMatches} />
<StatPill label="Goals" value={stats.totalGoals} />
<StatPill label="Goals/Game" value={stats.avgGoalsPerGame?.toFixed(2) ?? ''} />
{wc2026 && <>
<StatPill label="2026 Goals" value={wc2026.totalGoals ?? 0} />
<StatPill label="2026 Avg" value={wc2026.avgGoalsPerGame ? Number(wc2026.avgGoalsPerGame).toFixed(2) : ''} />
</>}
</> : [1,2,3,4].map(i => (
<div key={i} className="flex-1 min-w-[90px] h-20 rounded-xl animate-pulse" style={{ background: 'rgba(34,197,94,0.04)' }} />
))}
</div>
</div>
</div>
<div className="max-w-[1200px] mx-auto px-7">
{/* Live matches */}
{live.length > 0 && (
<div className="pt-9">
<SectionHeader label="Live Now" />
<div className="grid gap-4">
{live.map(m => <MatchCard key={m.id} match={m} />)}
</div>
</div>
)}
{/* Latest result */}
{recent.length > 0 && (
<div className="pt-9">
<SectionHeader label="Latest Result" />
<MatchCard match={recent[0]} />
</div>
)}
{/* Recent grid */}
{recent.length > 1 && (
<div className="pt-8">
<SectionHeader label="Recent Results" />
<div className="grid grid-cols-[repeat(auto-fill,minmax(290px,1fr))] gap-2.5">
{recent.slice(1).map(m => <MatchCard key={m.id} match={m} compact />)}
</div>
</div>
)}
{/* Upcoming */}
{upcoming.length > 0 && (
<div className="pt-8">
<SectionHeader label="Upcoming Fixtures" />
<div className="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-2">
{upcoming.map(m => <UpcomingFixture key={m.id} match={m} />)}
</div>
</div>
)}
{/* Golden Boot 2026 */}
{scorers.length > 0 && (
<div className="pt-8 pb-16">
<SectionHeader label="2026 Golden Boot Race" />
<div className="rounded-2xl overflow-hidden" style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.16)' }}>
{scorers.map((s, i) => (
<Link key={s.playerName} href={`/players/${encodeURIComponent(s.playerName)}`}>
<div className="flex items-center gap-3 px-4 py-3 border-b hover:bg-[rgba(34,197,94,0.03)] transition-colors cursor-pointer"
style={{ borderColor: 'rgba(34,197,94,0.06)', background: i === 0 ? 'rgba(34,197,94,0.04)' : undefined }}>
<span className="text-[11px] text-[#2a5c35] w-5 text-right font-bold flex-shrink-0">{i + 1}</span>
{s.team && <TeamFlag name={s.team.name} iso2={s.team.iso2} size="sm" />}
<div className="flex-1 min-w-0">
<div className={`text-sm font-semibold truncate ${i === 0 ? 'text-[#dff5e8]' : 'text-[#6abf7a]'}`}>{s.playerName}</div>
<div className="text-[10px] text-[#2a5c35]">{s.team?.name}{s.penalties > 0 ? ` · ${s.penalties}P` : ''}</div>
</div>
<div className="w-24 h-1 rounded-full overflow-hidden flex-shrink-0" style={{ background: 'rgba(34,197,94,0.1)' }}>
<div className="h-full rounded-full bg-[#22c55e] transition-all" style={{ width: `${(s.goals / maxGoals) * 100}%` }} />
</div>
<span className="font-['Bebas_Neue'] text-[22px] text-[#22c55e] min-w-[24px] text-right flex-shrink-0">{s.goals}</span>
</div>
</Link>
))}
</div>
<p className="text-[10px] text-[#1a3a22] mt-3 text-center">
<Link href="/stats" className="hover:text-[#2a5c35]">View all-time top scorers </Link>
</p>
</div>
)}
{loading && !data && (
<div className="py-16 text-center text-[#2a5c35] text-sm">Loading live World Cup data</div>
)}
</div>
</div>
)
}
+117
View File
@@ -0,0 +1,117 @@
'use client'
import { useQuery, gql } from '@/lib/graphql/hooks'
import { use } from 'react'
import Link from 'next/link'
import { TeamFlag } from '@/components/team-flag'
import { MatchCard } from '@/components/match-card'
const PLAYER_QUERY = gql`
query Player($name: String!) {
player(name: $name) {
playerName goals penalties ownGoals tournaments
team { id name iso2 slug }
}
}
`
const PLAYER_MATCHES_QUERY = gql`
query PlayerMatches($name: String!) {
tournaments { year }
}
`
interface PlayerData {
playerName: string; goals: number; penalties: number; ownGoals: number; tournaments: number
team?: { id: number; name: string; iso2?: string | null; slug: string } | null
}
export default function PlayerPage({ params }: { params: Promise<{ name: string }> }) {
const { name: encodedName } = use(params)
const name = decodeURIComponent(encodedName)
const { data, loading } = useQuery(PLAYER_QUERY, { variables: { name } })
const player: PlayerData | null = data?.player ?? null
// Fetch all goals for this player broken down by year
const { data: goalsData } = useQuery(gql`
query PlayerGoalsByYear($name: String!) {
tournaments { year }
topScorers(limit: 1000) {
playerName goals team { id }
}
}
`, { variables: { name } })
if (loading && !data) {
return <div className="max-w-[1200px] mx-auto px-7 py-10 text-[#2a5c35]">Loading player</div>
}
if (!player) {
return (
<div className="max-w-[1200px] mx-auto px-7 py-10">
<h1 className="font-['Bebas_Neue'] text-[52px] text-[#22c55e]">{name}</h1>
<p className="text-[#2a5c35] mt-4">No goal data found for this player in World Cup history.</p>
<Link href="/stats" className="text-[#22c55e] text-sm mt-4 inline-block hover:underline"> All-time scorers</Link>
</div>
)
}
const normalGoals = player.goals - player.penalties - player.ownGoals
return (
<div className="max-w-[900px] mx-auto px-7 py-10 pb-16">
{/* Hero */}
<div className="pitch-grid rounded-2xl p-8 mb-8" style={{
background: 'linear-gradient(145deg,#0a1a0e,#0d2416)',
border: '1px solid rgba(34,197,94,0.2)',
}}>
<div className="flex items-center gap-6 flex-wrap">
{player.team && <TeamFlag name={player.team.name} iso2={player.team.iso2} size="xl" />}
<div>
<h1 className="font-['Bebas_Neue'] text-[clamp(36px,6vw,64px)] text-[#22c55e] leading-none">{player.playerName}</h1>
{player.team && (
<Link href={`/teams/${player.team.slug}`} className="text-[#6abf7a] text-sm mt-1 hover:text-[#dff5e8] transition-colors inline-block">
{player.team.name}
</Link>
)}
</div>
<div className="ml-auto text-right">
<div className="font-['Bebas_Neue'] text-[80px] text-[#22c55e] leading-none">{player.goals}</div>
<div className="text-[10px] text-[#2a5c35] tracking-[0.12em] uppercase">World Cup Goals</div>
</div>
</div>
</div>
{/* Stats breakdown */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8">
{[
{ label: 'Total Goals', value: player.goals },
{ label: 'Open Play', value: normalGoals },
{ label: 'Penalties', value: player.penalties },
{ label: 'Tournaments', value: player.tournaments },
].map(item => (
<div key={item.label} className="rounded-xl p-4" style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.12)' }}>
<div className="text-[9px] text-[#2a5c35] tracking-[0.1em] uppercase mb-1.5">{item.label}</div>
<div className="font-['Bebas_Neue'] text-3xl text-[#22c55e]">{item.value}</div>
</div>
))}
</div>
{player.ownGoals > 0 && (
<div className="mb-6 rounded-xl p-3 px-4 text-sm text-[#2a5c35]" style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.08)' }}>
Includes {player.ownGoals} own goal{player.ownGoals !== 1 ? 's' : ''}
</div>
)}
{/* Back links */}
<div className="flex gap-4 mt-8">
<Link href="/stats" className="text-[#22c55e] text-sm hover:underline"> All-time scorers</Link>
{player.team && (
<Link href={`/teams/${player.team.slug}`} className="text-[#22c55e] text-sm hover:underline">
{player.team.name} team page
</Link>
)}
</div>
</div>
)
}
+193
View File
@@ -0,0 +1,193 @@
'use client'
import { useQuery, gql } from '@/lib/graphql/hooks'
import { useSearchParams, useRouter } from 'next/navigation'
import { useState, useEffect, Suspense } from 'react'
import Link from 'next/link'
import { TeamFlag } from '@/components/team-flag'
const SEARCH_QUERY = gql`
query Search($q: String!) {
search(query: $q) {
tournaments { year host winner totalGoals matchesCount }
teams { name iso2 slug stats { appearances titles } }
players { playerName goals tournaments team { name iso2 } }
matches {
id year round group date scoreFt isQualiPlayoff
team1 { name iso2 } team2 { name iso2 }
}
}
}
`
interface SearchMatch {
id: number; year: number; round: string; group?: string | null
date?: string | null; scoreFt?: number[] | null; isQualiPlayoff: boolean
team1: { name: string; iso2?: string | null }
team2: { name: string; iso2?: string | null }
}
function SearchContent() {
const searchParams = useSearchParams()
const router = useRouter()
const initialQ = searchParams.get('q') ?? ''
const [q, setQ] = useState(initialQ)
const [debouncedQ, setDebouncedQ] = useState(initialQ)
useEffect(() => {
const t = setTimeout(() => {
setDebouncedQ(q)
if (q.trim()) router.replace(`/search?q=${encodeURIComponent(q.trim())}`, { scroll: false })
}, 300)
return () => clearTimeout(t)
}, [q, router])
const skip = debouncedQ.trim().length < 2
const { data, loading } = useQuery(SEARCH_QUERY, {
variables: { q: debouncedQ },
skip,
})
const results = data?.search
const total = skip ? 0 : (
(results?.tournaments?.length ?? 0) +
(results?.teams?.length ?? 0) +
(results?.players?.length ?? 0) +
(results?.matches?.length ?? 0)
)
return (
<div className="max-w-[1200px] mx-auto px-7 py-10 pb-16">
<h1 className="font-['Bebas_Neue'] text-[52px] tracking-[0.04em] text-[#22c55e] leading-none mb-6">Search</h1>
{/* Search input */}
<div className="relative max-w-lg mb-8">
<input
type="text" value={q} onChange={e => setQ(e.target.value)}
placeholder="Search teams, players, tournaments…"
autoFocus
className="w-full pl-10 pr-4 py-3 rounded-2xl text-[#dff5e8] text-sm outline-none"
style={{ background: 'rgba(34,197,94,0.06)', border: '1px solid rgba(34,197,94,0.2)' }}
/>
<svg className="absolute left-3.5 top-1/2 -translate-y-1/2 opacity-40" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#dff5e8" strokeWidth="2.5">
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
{loading && <div className="absolute right-3.5 top-1/2 -translate-y-1/2 w-4 h-4 border-2 border-[#22c55e] border-t-transparent rounded-full animate-spin" />}
</div>
{/* Prompt */}
{skip && (
<div className="flex flex-col items-center py-20 text-center">
<div className="text-[56px] mb-5">🔍</div>
<div className="text-[#2a5c35] text-base">Search for nations, players, or tournaments</div>
<div className="text-[#1a3a22] text-sm mt-2">Examples: "Brazil", "Ronaldo", "1966"</div>
</div>
)}
{/* No results */}
{!skip && !loading && total === 0 && (
<div className="text-center text-[#1a3a22] py-16 text-sm">No results for "{debouncedQ}"</div>
)}
{/* Results count */}
{!skip && total > 0 && (
<div className="text-[13px] text-[#2a5c35] mb-6">{total} result{total !== 1 ? 's' : ''} for "{debouncedQ}"</div>
)}
<div className="flex flex-col gap-6">
{/* Teams */}
{results?.teams?.length > 0 && (
<section>
<h3 className="text-[11px] text-[#2a5c35] font-bold tracking-[0.12em] uppercase mb-3">Teams</h3>
<div className="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-2.5">
{results.teams.map((t: { name: string; iso2?: string | null; slug: string; stats?: { appearances: number; titles: number } | null }) => (
<Link key={t.name} href={`/teams/${t.slug}`}>
<div className="flex items-center gap-3 p-3 px-4 rounded-xl hover:border-[rgba(34,197,94,0.25)] transition-colors cursor-pointer"
style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.12)' }}>
<TeamFlag name={t.name} iso2={t.iso2} size="md" />
<div>
<div className="text-sm font-semibold text-[#dff5e8]">{t.name}</div>
<div className="text-[10px] text-[#2a5c35]">
{t.stats?.appearances ?? 0} WCs{t.stats?.titles ? ` · ${t.stats.titles} 🏆` : ''}
</div>
</div>
</div>
</Link>
))}
</div>
</section>
)}
{/* Players */}
{results?.players?.length > 0 && (
<section>
<h3 className="text-[11px] text-[#2a5c35] font-bold tracking-[0.12em] uppercase mb-3">Players</h3>
<div className="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-2.5">
{results.players.map((p: { playerName: string; goals: number; tournaments: number; team?: { name: string; iso2?: string | null } | null }) => (
<Link key={p.playerName} href={`/players/${encodeURIComponent(p.playerName)}`}>
<div className="flex items-center gap-3 p-3 px-4 rounded-xl hover:border-[rgba(34,197,94,0.25)] transition-colors cursor-pointer"
style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.12)' }}>
{p.team && <TeamFlag name={p.team.name} iso2={p.team.iso2} size="sm" />}
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-[#dff5e8] truncate">{p.playerName}</div>
<div className="text-[10px] text-[#2a5c35]">{p.team?.name} · {p.tournaments} WC{p.tournaments !== 1 ? 's' : ''}</div>
</div>
<span className="font-['Bebas_Neue'] text-xl text-[#22c55e] flex-shrink-0">{p.goals}</span>
</div>
</Link>
))}
</div>
</section>
)}
{/* Tournaments */}
{results?.tournaments?.length > 0 && (
<section>
<h3 className="text-[11px] text-[#2a5c35] font-bold tracking-[0.12em] uppercase mb-3">Tournaments</h3>
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-2.5">
{results.tournaments.map((t: { year: number; host: string; winner?: string | null; totalGoals?: number | null; matchesCount?: number | null }) => (
<Link key={t.year} href={`/tournaments/${t.year}`}>
<div className="p-4 rounded-xl hover:border-[rgba(34,197,94,0.25)] transition-colors cursor-pointer"
style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.12)' }}>
<div className="font-['Bebas_Neue'] text-3xl text-[#22c55e]">{t.year}</div>
<div className="text-sm text-[#dff5e8]">{t.host}</div>
{t.winner && <div className="text-[10px] text-[#2a5c35] mt-1">🏆 {t.winner}</div>}
{t.totalGoals && <div className="text-[10px] text-[#1a3a22]"> {t.totalGoals} goals</div>}
</div>
</Link>
))}
</div>
</section>
)}
{/* Matches */}
{results?.matches?.length > 0 && (
<section>
<h3 className="text-[11px] text-[#2a5c35] font-bold tracking-[0.12em] uppercase mb-3">Matches</h3>
<div className="flex flex-col gap-2">
{results.matches.map((m: SearchMatch) => (
<Link key={m.id} href={`/tournaments/${m.year}#match-${m.id}`}>
<div className="flex items-center gap-3 p-3 px-4 rounded-xl hover:border-[rgba(34,197,94,0.25)] transition-colors cursor-pointer"
style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.12)' }}>
<TeamFlag name={m.team1.name} iso2={m.team1.iso2} size="sm" />
<div className="flex-1 text-sm text-[#dff5e8]">{m.team1.name} vs {m.team2.name}</div>
{m.scoreFt && <span className="font-['Bebas_Neue'] text-lg text-[#22c55e]">{m.scoreFt[0]}{m.scoreFt[1]}</span>}
<TeamFlag name={m.team2.name} iso2={m.team2.iso2} size="sm" />
<div className="text-[10px] text-[#2a5c35] whitespace-nowrap">{m.year} · {m.round}</div>
</div>
</Link>
))}
</div>
</section>
)}
</div>
</div>
)
}
export default function SearchPage() {
return (
<Suspense fallback={<div className="p-10 text-[#2a5c35]">Loading</div>}>
<SearchContent />
</Suspense>
)
}
+348
View File
@@ -0,0 +1,348 @@
'use client'
import { useQuery, gql } from '@/lib/graphql/hooks'
import Link from 'next/link'
import { TeamFlag } from '@/components/team-flag'
const STATS_QUERY = gql`
query Stats {
tournaments { year host totalGoals matchesCount avgGoalsPerGame winner }
topScorers(limit: 20) {
playerName goals penalties ownGoals tournaments
team { name iso2 slug }
}
teams {
id name iso2 slug
stats { appearances titles wins draws losses goalsFor goalsAgainst winPct }
}
goalsByMinute { bucket count }
confederationStats { confederation appearances titles totalGoals }
hatTricks {
playerName year round goals
team { name iso2 }
opponent { name iso2 }
}
biggestWins(limit: 10) {
id year round date margin totalGoals scoreFt
team1 { name iso2 } team2 { name iso2 }
}
highestScoringMatches(limit: 10) {
id year round date totalGoals scoreFt
team1 { name iso2 } team2 { name iso2 }
}
extraTimeStats {
totalKnockoutMatches wentToExtraTime wentToPenalties extraTimePct penaltiesPct
}
}
`
function SectionTitle({ children }: { children: React.ReactNode }) {
return <h2 className="text-[11px] font-bold tracking-[0.14em] uppercase text-[#2a5c35] mb-4">{children}</h2>
}
function Card({ children, className = '' }: { children: React.ReactNode; className?: string }) {
return (
<div className={`rounded-2xl overflow-hidden ${className}`} style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.15)' }}>
{children}
</div>
)
}
interface Tournament { year: number; host: string; totalGoals?: number | null; matchesCount?: number | null; avgGoalsPerGame?: string | number | null; winner?: string | null }
interface Scorer { playerName: string; goals: number; penalties: number; ownGoals: number; tournaments: number; team?: { name: string; iso2?: string | null; slug: string } | null }
interface TeamRow { id: number; name: string; iso2?: string | null; slug: string; stats?: { appearances: number; titles: number; wins: number; draws: number; losses: number; goalsFor: number; goalsAgainst: number; winPct: number } | null }
interface MinuteBucket { bucket: string; count: number }
interface ConfStat { confederation: string; appearances: number; titles: number; totalGoals: number }
interface HatTrick { playerName: string; year: number; round: string; goals: number; team?: { name: string; iso2?: string | null } | null; opponent?: { name: string; iso2?: string | null } | null }
interface MatchRow { id: number; year: number; round: string; date?: string | null; margin?: number | null; totalGoals?: number | null; scoreFt?: number[] | null; team1: { name: string; iso2?: string | null }; team2: { name: string; iso2?: string | null } }
interface ETStats { totalKnockoutMatches: number; wentToExtraTime: number; wentToPenalties: number; extraTimePct: number; penaltiesPct: number }
export default function StatsPage() {
const { data, loading } = useQuery(STATS_QUERY)
const tournaments: Tournament[] = (data?.tournaments ?? []).filter((t: Tournament) => t.totalGoals != null).sort((a: Tournament, b: Tournament) => a.year - b.year)
const scorers: Scorer[] = data?.topScorers ?? []
const teams: TeamRow[] = (data?.teams ?? []).filter((t: TeamRow) => t.stats && t.stats.appearances > 0).sort((a: TeamRow, b: TeamRow) => (b.stats?.appearances ?? 0) - (a.stats?.appearances ?? 0))
const minuteBuckets: MinuteBucket[] = data?.goalsByMinute ?? []
const confStats: ConfStat[] = data?.confederationStats ?? []
const hatTricks: HatTrick[] = data?.hatTricks ?? []
const biggestWins: MatchRow[] = data?.biggestWins ?? []
const highScoring: MatchRow[] = data?.highestScoringMatches ?? []
const etStats: ETStats | null = data?.extraTimeStats ?? null
const titlesByNation = teams
.filter(t => (t.stats?.titles ?? 0) > 0)
.sort((a, b) => (b.stats?.titles ?? 0) - (a.stats?.titles ?? 0))
.slice(0, 10)
const maxGoals = Math.max(...tournaments.map(t => t.totalGoals ?? 0), 1)
const maxScorer = Math.max(...scorers.map(s => s.goals), 1)
const maxMinute = Math.max(...minuteBuckets.map(b => b.count), 1)
return (
<div className="max-w-[1200px] mx-auto px-7 py-10 pb-16">
<h1 className="font-['Bebas_Neue'] text-[52px] tracking-[0.04em] text-[#22c55e] leading-none mb-10">Historical Statistics</h1>
{loading && !data && (
<div className="text-[#2a5c35] text-sm py-16 text-center">Loading statistics</div>
)}
{/* ── Goals per tournament bar chart ── */}
{tournaments.length > 0 && (
<div className="mb-12">
<SectionTitle> Goals Scored per Tournament</SectionTitle>
<Card>
<div className="p-7 pb-0">
<div className="flex items-end gap-[3px] h-[170px]">
{tournaments.map(t => {
const h = Math.max(4, Math.round(((t.totalGoals ?? 0) / maxGoals) * 140))
const avg = t.avgGoalsPerGame ? Number(t.avgGoalsPerGame).toFixed(1) : null
return (
<Link key={t.year} href={`/tournaments/${t.year}`} className="flex flex-col items-center flex-1 min-w-[16px] group">
<div className="text-[7px] text-[#2a5c35] font-semibold mb-1 leading-none group-hover:text-[#22c55e]">{t.totalGoals}</div>
<div className="w-full rounded-t-sm border-t-2 transition-colors group-hover:bg-[rgba(34,197,94,0.35)]"
style={{ height: `${h}px`, background: 'rgba(34,197,94,0.18)', borderColor: 'rgba(34,197,94,0.45)' }}
title={`${t.year}: ${t.totalGoals} goals${avg ? ` · ${avg}/game` : ''}`}
/>
</Link>
)
})}
</div>
<div className="flex gap-[3px] pt-1.5 pb-3.5 border-t mt-0" style={{ borderColor: 'rgba(34,197,94,0.06)' }}>
{tournaments.map(t => (
<div key={t.year} className="flex-1 text-center text-[6px] text-[#1a3a22]" style={{ transform: 'rotate(-45deg)', transformOrigin: 'center top' }}>
{t.year}
</div>
))}
</div>
</div>
</Card>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-12">
{/* ── All-time top scorers ── */}
<div>
<SectionTitle>🏅 All-Time Top Scorers</SectionTitle>
<Card>
{scorers.map((s, i) => (
<Link key={s.playerName} href={`/players/${encodeURIComponent(s.playerName)}`}>
<div className="flex items-center gap-3 px-4 py-3 border-b hover:bg-[rgba(34,197,94,0.03)] cursor-pointer"
style={{ borderColor: 'rgba(34,197,94,0.05)', background: i === 0 ? 'rgba(34,197,94,0.04)' : undefined }}>
<span className="text-[11px] text-[#2a5c35] w-5 text-right font-bold flex-shrink-0">{i + 1}</span>
{s.team && <TeamFlag name={s.team.name} iso2={s.team.iso2} size="sm" />}
<div className="flex-1 min-w-0">
<div className={`text-sm font-semibold truncate ${i < 3 ? 'text-[#dff5e8]' : 'text-[#6abf7a]'}`}>{s.playerName}</div>
<div className="text-[10px] text-[#2a5c35]">{s.team?.name} · {s.tournaments} WC{s.tournaments !== 1 ? 's' : ''}{s.penalties > 0 ? ` · ${s.penalties}P` : ''}</div>
</div>
<div className="w-16 h-1 rounded-full flex-shrink-0" style={{ background: 'rgba(34,197,94,0.1)' }}>
<div className="h-full rounded-full bg-[#22c55e]" style={{ width: `${(s.goals / maxScorer) * 100}%` }} />
</div>
<span className="font-['Bebas_Neue'] text-[22px] text-[#22c55e] min-w-[28px] text-right flex-shrink-0">{s.goals}</span>
</div>
</Link>
))}
</Card>
</div>
{/* ── World Cup titles ── */}
<div>
<SectionTitle>🏆 World Cup Titles by Nation</SectionTitle>
<Card>
{titlesByNation.map((t, i) => (
<Link key={t.name} href={`/teams/${t.slug}`}>
<div className="flex items-center gap-3 px-4 py-3.5 border-b hover:bg-[rgba(34,197,94,0.03)] cursor-pointer"
style={{ borderColor: 'rgba(34,197,94,0.05)' }}>
<span className="text-[11px] text-[#2a5c35] w-5 text-right font-bold flex-shrink-0">{i + 1}</span>
<TeamFlag name={t.name} iso2={t.iso2} size="sm" />
<div className="flex-1 text-sm font-semibold text-[#dff5e8]">{t.name}</div>
<div className="flex gap-0.5 flex-shrink-0">
{Array.from({ length: t.stats?.titles ?? 0 }).map((_, j) => (
<span key={j} className="text-sm">🏆</span>
))}
</div>
<span className="font-['Bebas_Neue'] text-[28px] text-[#22c55e] flex-shrink-0">{t.stats?.titles}</span>
</div>
</Link>
))}
</Card>
</div>
</div>
{/* ── Goals by minute heatmap ── */}
{minuteBuckets.length > 0 && (
<div className="mb-12">
<SectionTitle> Goals by Minute (All-Time)</SectionTitle>
<Card className="p-6">
<div className="flex items-end gap-3 h-24">
{minuteBuckets.map(b => {
const h = Math.max(8, Math.round((b.count / maxMinute) * 80))
return (
<div key={b.bucket} className="flex-1 flex flex-col items-center gap-1.5">
<span className="text-[9px] text-[#2a5c35] font-bold">{b.count}</span>
<div className="w-full rounded-t" style={{ height: `${h}px`, background: 'rgba(34,197,94,0.3)', border: '1px solid rgba(34,197,94,0.5)' }} />
<span className="text-[9px] text-[#1a3a22]">{b.bucket}</span>
</div>
)
})}
</div>
</Card>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-12">
{/* ── Biggest wins ── */}
<div>
<SectionTitle>💥 Biggest Victories</SectionTitle>
<Card>
{biggestWins.map(m => (
<Link key={m.id} href={`/tournaments/${m.year}#match-${m.id}`}>
<div className="flex items-center gap-3 px-4 py-2.5 border-b hover:bg-[rgba(34,197,94,0.03)] cursor-pointer"
style={{ borderColor: 'rgba(34,197,94,0.05)' }}>
<TeamFlag name={m.team1.name} iso2={m.team1.iso2} size="sm" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-[#dff5e8] truncate">{m.team1.name} vs {m.team2.name}</div>
<div className="text-[10px] text-[#2a5c35]">{m.year} · {m.round}</div>
</div>
<span className="font-['Bebas_Neue'] text-xl text-[#22c55e] flex-shrink-0">
{m.scoreFt?.[0]}{m.scoreFt?.[1]}
</span>
<span className="text-[10px] text-[#2a5c35] flex-shrink-0">+{m.margin}</span>
</div>
</Link>
))}
</Card>
</div>
{/* ── Highest scoring matches ── */}
<div>
<SectionTitle>🔥 Highest Scoring Matches</SectionTitle>
<Card>
{highScoring.map(m => (
<Link key={m.id} href={`/tournaments/${m.year}#match-${m.id}`}>
<div className="flex items-center gap-3 px-4 py-2.5 border-b hover:bg-[rgba(34,197,94,0.03)] cursor-pointer"
style={{ borderColor: 'rgba(34,197,94,0.05)' }}>
<TeamFlag name={m.team1.name} iso2={m.team1.iso2} size="sm" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-[#dff5e8] truncate">{m.team1.name} vs {m.team2.name}</div>
<div className="text-[10px] text-[#2a5c35]">{m.year} · {m.round}</div>
</div>
<span className="font-['Bebas_Neue'] text-xl text-[#22c55e] flex-shrink-0">
{m.scoreFt?.[0]}{m.scoreFt?.[1]}
</span>
<span className="text-[10px] text-[#4ade80] flex-shrink-0">{m.totalGoals} goals</span>
</div>
</Link>
))}
</Card>
</div>
</div>
{/* ── Hat-tricks ── */}
{hatTricks.length > 0 && (
<div className="mb-12">
<SectionTitle>🎩 Hat-Tricks</SectionTitle>
<div className="grid grid-cols-[repeat(auto-fill,minmax(240px,1fr))] gap-3">
{hatTricks.map((h, i) => (
<div key={i} className="rounded-xl p-4" style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.12)' }}>
<div className="flex items-center gap-2 mb-2">
{h.team && <TeamFlag name={h.team.name} iso2={h.team.iso2} size="sm" />}
<div>
<div className="text-sm font-semibold text-[#dff5e8]">{h.playerName}</div>
<div className="text-[10px] text-[#2a5c35]">{h.team?.name}</div>
</div>
<span className="ml-auto font-['Bebas_Neue'] text-2xl text-[#22c55e]">{h.goals}</span>
</div>
<div className="text-[10px] text-[#2a5c35]">
{h.year} · {h.round}
{h.opponent && <span> vs {h.opponent.name}</span>}
</div>
</div>
))}
</div>
</div>
)}
{/* ── ET & Penalty stats ── */}
{etStats && (
<div className="mb-12">
<SectionTitle> Extra Time & Penalty Shootouts</SectionTitle>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ label: 'Knockout Matches', value: etStats.totalKnockoutMatches },
{ label: 'Went to AET', value: `${etStats.wentToExtraTime} (${etStats.extraTimePct}%)` },
{ label: 'Decided by PSO', value: `${etStats.wentToPenalties} (${etStats.penaltiesPct}%)` },
{ label: 'Decided in 90min', value: etStats.totalKnockoutMatches - etStats.wentToExtraTime },
].map(s => (
<div key={s.label} className="rounded-xl p-4" style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.12)' }}>
<div className="text-[9px] text-[#2a5c35] tracking-[0.1em] uppercase mb-2">{s.label}</div>
<div className="font-['Bebas_Neue'] text-2xl text-[#22c55e]">{s.value}</div>
</div>
))}
</div>
</div>
)}
{/* ── Confederation stats ── */}
{confStats.length > 0 && (
<div className="mb-12">
<SectionTitle>🌍 Performance by Confederation</SectionTitle>
<Card>
<div className="grid px-4 py-2 text-[9px] text-[#2a5c35] tracking-[0.1em] uppercase"
style={{ gridTemplateColumns: '1fr 80px 60px 80px', gap: '8px' }}>
<span>Confederation</span><span className="text-center">Appearances</span><span className="text-center">Titles</span><span className="text-center">Goals</span>
</div>
{confStats.map(c => (
<div key={c.confederation} className="grid px-4 py-3 border-t items-center"
style={{ gridTemplateColumns: '1fr 80px 60px 80px', gap: '8px', borderColor: 'rgba(34,197,94,0.06)' }}>
<span className="text-sm font-medium text-[#dff5e8]">{c.confederation}</span>
<span className="text-center text-sm text-[#6abf7a]">{c.appearances}</span>
<span className="text-center font-['Bebas_Neue'] text-xl text-[#22c55e]">{c.titles}</span>
<span className="text-center text-sm text-[#6abf7a]">{c.totalGoals}</span>
</div>
))}
</Card>
</div>
)}
{/* ── All-time team table ── */}
{teams.length > 0 && (
<div>
<SectionTitle>📊 All-Time Team Table</SectionTitle>
<Card>
<div className="grid px-4 py-2 text-[9px] text-[#2a5c35] tracking-[0.1em] uppercase"
style={{ gridTemplateColumns: '28px 1fr 50px 44px 44px 44px 60px 60px 60px 52px', gap: '4px' }}>
<span>#</span><span>Team</span><span className="text-center">WC</span>
<span className="text-center">W</span><span className="text-center">D</span><span className="text-center">L</span>
<span className="text-center">GF</span><span className="text-center">GA</span><span className="text-center">GD</span>
<span className="text-center">Win%</span>
</div>
{teams.slice(0, 40).map((t, i) => (
<Link key={t.id} href={`/teams/${t.slug}`}>
<div className="grid px-4 py-2.5 border-t items-center hover:bg-[rgba(34,197,94,0.03)] cursor-pointer"
style={{ gridTemplateColumns: '28px 1fr 50px 44px 44px 44px 60px 60px 60px 52px', gap: '4px', borderColor: 'rgba(34,197,94,0.05)' }}>
<span className="text-[11px] text-[#2a5c35] font-bold">{i + 1}</span>
<div className="flex items-center gap-2 overflow-hidden">
<TeamFlag name={t.name} iso2={t.iso2} size="sm" />
<span className="text-sm text-[#dff5e8] truncate">{t.name}</span>
</div>
<span className="text-center text-sm text-[#4a7a55]">{t.stats?.appearances}</span>
<span className="text-center text-sm text-[#4a7a55]">{t.stats?.wins}</span>
<span className="text-center text-sm text-[#4a7a55]">{t.stats?.draws}</span>
<span className="text-center text-sm text-[#4a7a55]">{t.stats?.losses}</span>
<span className="text-center text-sm text-[#4a7a55]">{t.stats?.goalsFor}</span>
<span className="text-center text-sm text-[#4a7a55]">{t.stats?.goalsAgainst}</span>
<span className="text-center text-sm text-[#4a7a55]">
{(t.stats?.goalsFor ?? 0) - (t.stats?.goalsAgainst ?? 0) >= 0
? `+${(t.stats?.goalsFor ?? 0) - (t.stats?.goalsAgainst ?? 0)}`
: (t.stats?.goalsFor ?? 0) - (t.stats?.goalsAgainst ?? 0)}
</span>
<span className="text-center text-[13px] font-bold text-[#22c55e]">{t.stats?.winPct}%</span>
</div>
</Link>
))}
</Card>
</div>
)}
</div>
)
}
+162
View File
@@ -0,0 +1,162 @@
'use client'
import { useQuery, gql } from '@/lib/graphql/hooks'
import { use } from 'react'
import Link from 'next/link'
import { TeamFlag } from '@/components/team-flag'
import { MatchCard } from '@/components/match-card'
const TEAM_QUERY = gql`
query Team($slug: String!) {
team(slug: $slug) {
id name iso2 slug fifaCode continent confederation
stats { appearances wins draws losses goalsFor goalsAgainst goalDiff titles winPct }
}
}
`
const TEAM_MATCHES_QUERY = gql`
query TeamMatches($teamName: String!) {
topScorers(limit: 100) {
playerName goals penalties ownGoals tournaments
team { name iso2 }
}
}
`
interface TeamData {
id: number; name: string; iso2?: string | null; slug: string
fifaCode?: string | null; continent?: string | null; confederation?: string | null
stats?: {
appearances: number; wins: number; draws: number; losses: number
goalsFor: number; goalsAgainst: number; goalDiff: number; titles: number; winPct: number
} | null
}
export default function TeamPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = use(params)
const { data: teamData, loading } = useQuery(TEAM_QUERY, { variables: { slug } })
const team: TeamData | null = teamData?.team ?? null
// Load all scorers to filter by team
const { data: scorerData } = useQuery(gql`
query TeamScorers {
topScorers(limit: 200) {
playerName goals penalties ownGoals tournaments
team { id name iso2 }
}
}
`)
const allScorers = scorerData?.topScorers ?? []
const teamScorers = team ? allScorers.filter((s: { team?: { id: number } | null }) => s.team?.id === team.id).slice(0, 15) : []
if (loading && !teamData) {
return <div className="max-w-[1200px] mx-auto px-7 py-10 text-[#2a5c35]">Loading team</div>
}
if (!team) {
return <div className="max-w-[1200px] mx-auto px-7 py-10 text-[#2a5c35]">Team not found.</div>
}
const s = team.stats
const played = (s?.wins ?? 0) + (s?.draws ?? 0) + (s?.losses ?? 0)
const maxScorer = Math.max(...teamScorers.map((sc: { goals: number }) => sc.goals), 1)
return (
<div className="max-w-[1200px] mx-auto px-7 py-10 pb-16">
{/* Hero */}
<div className="pitch-grid rounded-2xl p-8 mb-8" style={{
background: 'linear-gradient(145deg,#0a1a0e,#0d2416)',
border: '1px solid rgba(34,197,94,0.2)',
}}>
<div className="flex items-center gap-6 flex-wrap">
<TeamFlag name={team.name} iso2={team.iso2} size="xl" />
<div>
<h1 className="font-['Bebas_Neue'] text-[56px] text-[#22c55e] leading-none">{team.name}</h1>
<div className="flex gap-3 mt-2 flex-wrap">
{team.fifaCode && <span className="text-[11px] text-[#2a5c35] font-bold tracking-wider">{team.fifaCode}</span>}
{team.confederation && <span className="text-[11px] text-[#2a5c35]">{team.confederation}</span>}
{team.continent && <span className="text-[11px] text-[#2a5c35]">{team.continent}</span>}
{(s?.titles ?? 0) > 0 && (
<span className="text-[11px] text-[#22c55e] font-bold">
{Array.from({ length: s?.titles ?? 0 }).map(() => '🏆').join('')} {s?.titles} title{(s?.titles ?? 0) !== 1 ? 's' : ''}
</span>
)}
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-[1fr_260px] gap-8">
<div>
{/* Stats grid */}
{s && (
<div className="mb-8">
<h2 className="text-[11px] text-[#2a5c35] font-bold tracking-[0.14em] uppercase mb-4">World Cup Record</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-3">
{[
{ label: 'Appearances', value: s.appearances },
{ label: 'Matches', value: played },
{ label: 'Win %', value: `${s.winPct}%` },
{ label: 'Goals For', value: s.goalsFor },
].map(item => (
<div key={item.label} className="rounded-xl p-4" style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.12)' }}>
<div className="text-[9px] text-[#2a5c35] tracking-[0.1em] uppercase mb-1.5">{item.label}</div>
<div className="font-['Bebas_Neue'] text-3xl text-[#22c55e]">{item.value}</div>
</div>
))}
</div>
<div className="rounded-xl overflow-hidden" style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.12)' }}>
<div className="grid px-4 py-2.5 text-[9px] text-[#2a5c35] tracking-[0.1em] uppercase"
style={{ gridTemplateColumns: '1fr 44px 44px 44px 60px 60px 60px' }}>
<span>Team</span><span className="text-center">W</span><span className="text-center">D</span>
<span className="text-center">L</span><span className="text-center">GF</span>
<span className="text-center">GA</span><span className="text-center">GD</span>
</div>
<div className="grid px-4 py-3 border-t items-center"
style={{ gridTemplateColumns: '1fr 44px 44px 44px 60px 60px 60px', borderColor: 'rgba(34,197,94,0.06)' }}>
<div className="flex items-center gap-2">
<TeamFlag name={team.name} iso2={team.iso2} size="sm" />
<span className="text-sm text-[#dff5e8]">{team.name}</span>
</div>
{[s.wins, s.draws, s.losses, s.goalsFor, s.goalsAgainst].map((v, i) => (
<span key={i} className="text-center text-sm text-[#4a7a55]">{v}</span>
))}
<span className="text-center text-sm text-[#4a7a55]">{s.goalDiff >= 0 ? `+${s.goalDiff}` : s.goalDiff}</span>
</div>
</div>
</div>
)}
</div>
{/* Sidebar: top scorers */}
<div>
{teamScorers.length > 0 && (
<div>
<h2 className="text-[11px] text-[#2a5c35] font-bold tracking-[0.14em] uppercase mb-4">Top Scorers</h2>
<div className="rounded-2xl overflow-hidden" style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.15)' }}>
{teamScorers.map((sc: { playerName: string; goals: number; penalties: number; tournaments: number }, i: number) => (
<Link key={sc.playerName} href={`/players/${encodeURIComponent(sc.playerName)}`}>
<div className="flex items-center gap-2.5 px-3.5 py-2.5 border-b hover:bg-[rgba(34,197,94,0.03)] cursor-pointer"
style={{ borderColor: 'rgba(34,197,94,0.06)', background: i === 0 ? 'rgba(34,197,94,0.04)' : undefined }}>
<span className="text-[10px] text-[#2a5c35] w-4 text-right font-bold flex-shrink-0">{i + 1}</span>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-semibold text-[#dff5e8] truncate">{sc.playerName}</div>
<div className="text-[10px] text-[#2a5c35]">
{sc.tournaments} WC{sc.tournaments !== 1 ? 's' : ''}{sc.penalties > 0 ? ` · ${sc.penalties}P` : ''}
</div>
</div>
<div className="w-10 h-1 rounded-full flex-shrink-0" style={{ background: 'rgba(34,197,94,0.1)' }}>
<div className="h-full rounded-full bg-[#22c55e]" style={{ width: `${(sc.goals / maxScorer) * 100}%` }} />
</div>
<span className="font-['Bebas_Neue'] text-xl text-[#22c55e] flex-shrink-0">{sc.goals}</span>
</div>
</Link>
))}
</div>
</div>
)}
</div>
</div>
</div>
)
}
+262
View File
@@ -0,0 +1,262 @@
'use client'
import { useQuery, gql } from '@/lib/graphql/hooks'
import { use } from 'react'
import Link from 'next/link'
import { TeamFlag } from '@/components/team-flag'
import { MatchCard } from '@/components/match-card'
import { LiveBadge } from '@/components/live-badge'
const TOURNAMENT_QUERY = gql`
query Tournament($year: Int!) {
tournament(year: $year) {
year host winner runnerUp thirdPlace fourthPlace
totalGoals matchesCount teamsCount avgGoalsPerGame
topScorers(limit: 10) {
playerName goals penalties ownGoals
team { name iso2 slug }
}
matches {
id year round group date time isLive isQualiPlayoff
scoreFt scoreHt scoreEt scoreP
team1 { id name iso2 slug } team2 { id name iso2 slug }
goals { playerName minute minuteOffset isPenalty isOwnGoal team { id } }
}
}
groupStandings(year: $year) {
groupName pos played won drawn lost goalsFor goalsAgainst goalDiff pts
team { id name iso2 slug }
}
}
`
interface MatchData {
id: number; year: number; round: string; group?: string | null
date?: string | null; time?: string | null; isLive: boolean; isQualiPlayoff: boolean
scoreFt?: number[] | null; scoreHt?: number[] | null; scoreEt?: number[] | null; scoreP?: number[] | null
team1: { id: number; name: string; iso2?: string | null; slug: string }
team2: { id: number; name: string; iso2?: string | null; slug: string }
goals: Array<{ playerName: string; minute?: number | null; minuteOffset?: number | null; isPenalty: boolean; isOwnGoal: boolean; team: { id: number } }>
}
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 }
}
function GoalList({ match }: { match: MatchData }) {
if (!match.goals?.length) return null
const t1Goals = match.goals.filter(g => !g.isOwnGoal ? g.team.id === match.team1.id : g.team.id !== match.team1.id)
const t2Goals = match.goals.filter(g => !g.isOwnGoal ? g.team.id === match.team2.id : g.team.id !== match.team2.id)
const renderGoal = (g: MatchData['goals'][0]) =>
`${g.playerName} ${g.minute ?? ''}${g.minuteOffset ? `+${g.minuteOffset}` : ''}'${g.isPenalty ? ' (P)' : g.isOwnGoal ? ' (OG)' : ''}`
return (
<div className="flex justify-between gap-4 px-4 pb-2 text-[10px] text-[#2a5c35]">
<div className="text-left">{t1Goals.map(renderGoal).join(', ')}</div>
<div className="text-right">{t2Goals.map(renderGoal).join(', ')}</div>
</div>
)
}
export default function TournamentPage({ params }: { params: Promise<{ year: string }> }) {
const { year: yearStr } = use(params)
const year = parseInt(yearStr)
const { data, loading } = useQuery(TOURNAMENT_QUERY, { variables: { year }, pollInterval: year === 2026 ? 60_000 : 0 })
const t = data?.tournament
const standings: Standing[] = data?.groupStandings ?? []
const byGroup = standings.reduce<Record<string, Standing[]>>((acc, s) => {
acc[s.groupName] = [...(acc[s.groupName] ?? []), s]
return acc
}, {})
const allMatches: MatchData[] = t?.matches ?? []
const byRound = allMatches.reduce<Record<string, MatchData[]>>((acc, m) => {
const key = m.group ?? m.round
acc[key] = [...(acc[key] ?? []), m]
return acc
}, {})
const groupRounds = Object.entries(byGroup).sort(([a], [b]) => a.localeCompare(b))
const koRounds = allMatches.filter(m => !m.group && !m.isQualiPlayoff)
const koByRound = koRounds.reduce<Record<string, MatchData[]>>((acc, m) => {
acc[m.round] = [...(acc[m.round] ?? []), m]
return acc
}, {})
const liveMatches = allMatches.filter(m => m.isLive)
const maxScorer = Math.max(...(t?.topScorers?.map((s: { goals: number }) => s.goals) ?? [1]), 1)
if (loading && !data) {
return (
<div className="max-w-[1200px] mx-auto px-7 py-10">
<div className="h-24 w-48 rounded-xl animate-pulse mb-6" style={{ background: '#0a1810' }} />
<div className="text-[#2a5c35] text-sm">Loading {year} World Cup</div>
</div>
)
}
if (!t) return <div className="max-w-[1200px] mx-auto px-7 py-10 text-[#2a5c35]">Tournament {year} not found.</div>
return (
<div className="max-w-[1200px] mx-auto px-7 py-10 pb-16">
{/* Header */}
<div className="pitch-grid rounded-2xl p-8 mb-8" style={{
background: 'linear-gradient(145deg,#0a1a0e 0%,#0d2416 100%)',
border: '1px solid rgba(34,197,94,0.2)',
}}>
{liveMatches.length > 0 && <div className="mb-3"><LiveBadge label="Live Now" /></div>}
<div className="flex items-start justify-between flex-wrap gap-4">
<div>
<h1 className="font-['Bebas_Neue'] text-[64px] text-[#22c55e] leading-none">{year}</h1>
<p className="text-[#6abf7a] text-lg mt-1">{t.host}</p>
</div>
{t.winner && (
<div className="text-center">
<TeamFlag name={t.winner} size="xl" className="mb-2" />
<div className="font-['Bebas_Neue'] text-2xl text-[#dff5e8]">{t.winner}</div>
{t.runnerUp && <div className="text-xs text-[#2a5c35] mt-1">def. {t.runnerUp}</div>}
</div>
)}
</div>
<div className="flex gap-6 mt-4 flex-wrap">
{[
{ label: 'Teams', value: t.teamsCount },
{ label: 'Matches', value: t.matchesCount },
{ label: 'Goals', value: t.totalGoals },
{ label: 'Goals/Game', value: t.avgGoalsPerGame ? Number(t.avgGoalsPerGame).toFixed(2) : null },
].filter(s => s.value != null).map(s => (
<div key={s.label}>
<div className="text-[9px] text-[#2a5c35] tracking-[0.12em] uppercase">{s.label}</div>
<div className="font-['Bebas_Neue'] text-3xl text-[#22c55e]">{s.value}</div>
</div>
))}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-[1fr_280px] gap-8">
<div>
{/* Live matches first */}
{liveMatches.length > 0 && (
<div className="mb-8">
<h2 className="font-['Bebas_Neue'] text-2xl text-[#4ade80] mb-4">LIVE</h2>
<div className="flex flex-col gap-4">
{liveMatches.map(m => (
<div key={m.id} id={`match-${m.id}`}>
<MatchCard match={m} />
<GoalList match={m} />
</div>
))}
</div>
</div>
)}
{/* Group stage */}
{groupRounds.length > 0 && (
<div className="mb-8">
<h2 className="font-['Bebas_Neue'] text-2xl text-[#22c55e] mb-5">Group Stage</h2>
{groupRounds.map(([groupName, rows]) => {
const sorted = [...rows].sort((a, b) => b.pts - a.pts || b.goalDiff - a.goalDiff)
const groupMatches = (byRound[groupName] ?? []).sort((a, b) => (a.date ?? '') < (b.date ?? '') ? -1 : 1)
return (
<div key={groupName} className="mb-8">
<h3 className="text-[13px] font-bold text-[#22c55e] tracking-wide uppercase mb-3">{groupName}</h3>
{/* Standings mini */}
<div className="rounded-xl overflow-hidden mb-3" style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.1)' }}>
{sorted.map((s, i) => (
<Link key={s.team.id} href={`/teams/${s.team.slug}`}>
<div className="flex items-center gap-2 px-3 py-2 border-b hover:bg-[rgba(34,197,94,0.03)] cursor-pointer"
style={{ borderColor: 'rgba(34,197,94,0.05)', background: i < 2 ? 'rgba(34,197,94,0.02)' : undefined }}>
<TeamFlag name={s.team.name} iso2={s.team.iso2} size="sm" />
<span className="flex-1 text-[13px] text-[#6abf7a] truncate">{s.team.name}</span>
<span className="text-[11px] text-[#4a7a55] w-6 text-center">{s.played}</span>
<span className="text-[11px] text-[#4a7a55] w-6 text-center">{s.won}</span>
<span className="text-[11px] text-[#4a7a55] w-6 text-center">{s.drawn}</span>
<span className="text-[11px] text-[#4a7a55] w-6 text-center">{s.lost}</span>
<span className="text-[11px] font-bold text-[#22c55e] w-6 text-center">{s.pts}</span>
</div>
</Link>
))}
</div>
{/* Group matches */}
<div className="flex flex-col gap-2">
{groupMatches.map(m => (
<div key={m.id} id={`match-${m.id}`}>
<MatchCard match={m} compact />
<GoalList match={m} />
</div>
))}
</div>
</div>
)
})}
</div>
)}
{/* Knockout rounds */}
{Object.keys(koByRound).length > 0 && (
<div>
<h2 className="font-['Bebas_Neue'] text-2xl text-[#22c55e] mb-5">Knockout Stage</h2>
{Object.entries(koByRound).map(([round, roundMatches]) => (
<div key={round} className="mb-6">
<h3 className="text-[13px] font-bold text-[#22c55e] tracking-wide uppercase mb-3">{round}</h3>
<div className="flex flex-col gap-3">
{roundMatches.map(m => (
<div key={m.id} id={`match-${m.id}`}>
<MatchCard match={m} compact />
<GoalList match={m} />
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
{/* Sidebar: top scorers */}
<div>
<div className="sticky top-[76px]">
<h2 className="font-['Bebas_Neue'] text-xl text-[#22c55e] mb-4">TOP SCORERS</h2>
<div className="rounded-2xl overflow-hidden" style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.15)' }}>
{t.topScorers?.map((s: { playerName: string; goals: number; penalties: number; team?: { name: string; iso2?: string | null; slug: string } | null }, i: number) => (
<Link key={s.playerName} href={`/players/${encodeURIComponent(s.playerName)}`}>
<div className="flex items-center gap-2.5 px-3.5 py-2.5 border-b hover:bg-[rgba(34,197,94,0.03)] cursor-pointer"
style={{ borderColor: 'rgba(34,197,94,0.06)', background: i === 0 ? 'rgba(34,197,94,0.04)' : undefined }}>
<span className="text-[10px] text-[#2a5c35] w-4 text-right font-bold flex-shrink-0">{i + 1}</span>
{s.team && <TeamFlag name={s.team.name} iso2={s.team.iso2} size="sm" />}
<div className="flex-1 min-w-0">
<div className="text-[13px] font-semibold text-[#dff5e8] truncate">{s.playerName}</div>
{s.penalties > 0 && <div className="text-[9px] text-[#2a5c35]">{s.penalties} pen</div>}
</div>
<div className="w-12 h-1 rounded-full flex-shrink-0" style={{ background: 'rgba(34,197,94,0.1)' }}>
<div className="h-full rounded-full bg-[#22c55e]" style={{ width: `${(s.goals / maxScorer) * 100}%` }} />
</div>
<span className="font-['Bebas_Neue'] text-xl text-[#22c55e] flex-shrink-0">{s.goals}</span>
</div>
</Link>
))}
</div>
{t.thirdPlace && (
<div className="mt-4 rounded-xl p-4" style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.1)' }}>
<div className="text-[9px] text-[#2a5c35] tracking-[0.1em] uppercase mb-2">3rd Place</div>
<div className="flex items-center gap-2">
<TeamFlag name={t.thirdPlace} size="sm" />
<span className="text-sm text-[#6abf7a]">{t.thirdPlace}</span>
</div>
{t.fourthPlace && (
<div className="flex items-center gap-2 mt-1.5">
<TeamFlag name={t.fourthPlace} size="sm" />
<span className="text-sm text-[#4a7a55]">{t.fourthPlace}</span>
</div>
)}
</div>
)}
</div>
</div>
</div>
</div>
)
}
+7
View File
@@ -0,0 +1,7 @@
'use client'
import { ApolloProvider } from '@apollo/client/react'
import { getApolloClient } from '@/lib/graphql/client'
export function AppApolloProvider({ children }: { children: React.ReactNode }) {
return <ApolloProvider client={getApolloClient()}>{children}</ApolloProvider>
}
+8
View File
@@ -0,0 +1,8 @@
export function LiveBadge({ label = 'Live' }: { label?: string }) {
return (
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-[#4ade80] flex-shrink-0 animate-live" />
<span className="text-[11px] font-bold text-[#4ade80] tracking-[0.14em] uppercase">{label}</span>
</div>
)
}
+86
View File
@@ -0,0 +1,86 @@
import Link from 'next/link'
import { TeamFlag } from './team-flag'
import { LiveBadge } from './live-badge'
interface Team { name: string; iso2?: string | null }
interface Match {
id: number
year: number
round: string
group?: string | null
date?: string | null
time?: string | null
team1: Team
team2: Team
scoreFt?: number[] | null
scoreEt?: number[] | null
scoreP?: number[] | null
isLive: boolean
}
function formatDate(d: string) {
return new Date(d).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })
}
export function MatchCard({ match, compact = false }: { match: Match; compact?: boolean }) {
const ft = match.scoreFt
const hasScore = ft != null
const winner = ft ? (ft[0] > ft[1] ? 'home' : ft[0] < ft[1] ? 'away' : 'draw') : null
if (compact) {
return (
<Link href={`/tournaments/${match.year}#match-${match.id}`} className="block">
<div className="bg-[#0a1810] border border-[rgba(34,197,94,0.08)] rounded-xl p-3.5 hover:border-[rgba(34,197,94,0.22)] transition-colors">
<div className="text-[9px] text-[#2a5c35] tracking-[0.1em] uppercase mb-2.5">
{match.round}{match.group ? ` · ${match.group}` : ''} · {match.date ? formatDate(match.date) : ''}
</div>
<div className="flex items-center gap-2">
<div className="flex-1 flex items-center gap-2 overflow-hidden">
<TeamFlag name={match.team1.name} iso2={match.team1.iso2} size="sm" />
<span className={`text-sm font-medium truncate ${winner === 'home' ? 'text-[#dff5e8]' : 'text-[#4a7a55]'}`}>
{match.team1.name}
</span>
</div>
<div className="font-['Bebas_Neue'] text-xl text-[#22c55e] flex-shrink-0 min-w-[44px] text-center">
{hasScore ? `${ft![0]} ${ft![1]}` : match.isLive ? <LiveBadge label="•" /> : ''}
</div>
<div className="flex-1 flex items-center justify-end gap-2 overflow-hidden">
<span className={`text-sm font-medium truncate ${winner === 'away' ? 'text-[#dff5e8]' : 'text-[#4a7a55]'}`}>
{match.team2.name}
</span>
<TeamFlag name={match.team2.name} iso2={match.team2.iso2} size="sm" />
</div>
</div>
{match.scoreEt && <div className="text-[9px] text-[#2a5c35] mt-1 text-center">AET · {match.scoreP ? `PSO ${match.scoreP[0]}-${match.scoreP[1]}` : ''}</div>}
</div>
</Link>
)
}
return (
<Link href={`/tournaments/${match.year}#match-${match.id}`} className="block">
<div className="bg-gradient-to-br from-[#0d2016] to-[#102a1c] border border-[rgba(34,197,94,0.28)] rounded-2xl p-9 hover:border-[rgba(34,197,94,0.45)] transition-colors">
{match.isLive && <div className="mb-4"><LiveBadge label="Live Now" /></div>}
<div className="flex items-center justify-center gap-8 flex-wrap">
<div className="text-center flex-1 min-w-[100px]">
<TeamFlag name={match.team1.name} iso2={match.team1.iso2} size="xl" className="mb-2.5" />
<div className="font-['Bebas_Neue'] text-xl tracking-[0.07em] text-[#dff5e8]">{match.team1.name}</div>
</div>
<div className="text-center flex-shrink-0">
<div className="font-['Bebas_Neue'] text-[76px] text-[#22c55e] leading-none">
{hasScore ? `${ft![0]} ${ft![1]}` : '? ?'}
</div>
<div className="text-[10px] text-[#2a5c35] tracking-[0.12em] uppercase mt-1.5">{match.round}</div>
<div className="text-xs text-[#1a3a22] mt-1">{match.date ? formatDate(match.date) : ''}</div>
{match.scoreEt && <div className="text-[10px] text-[#2a5c35] mt-1">AET {match.scoreEt[0]}{match.scoreEt[1]}</div>}
{match.scoreP && <div className="text-[10px] text-[#4ade80] mt-0.5">PSO {match.scoreP[0]}{match.scoreP[1]}</div>}
</div>
<div className="text-center flex-1 min-w-[100px]">
<TeamFlag name={match.team2.name} iso2={match.team2.iso2} size="xl" className="mb-2.5" />
<div className="font-['Bebas_Neue'] text-xl tracking-[0.07em] text-[#dff5e8]">{match.team2.name}</div>
</div>
</div>
</div>
</Link>
)
}
+75
View File
@@ -0,0 +1,75 @@
'use client'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { useState } from 'react'
const WC_BALL = (
<svg width="28" height="28" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="50" r="46" fill="#0a1810" />
<polygon points="54,38 63,50 54,62 39,57 40,42" fill="none" stroke="#22c55e" strokeWidth="3" strokeLinejoin="round" />
<line x1="54" y1="38" x2="65" y2="9" stroke="#22c55e" strokeWidth="2.5" opacity=".9" />
<line x1="63" y1="50" x2="94" y2="51" stroke="#22c55e" strokeWidth="2.5" opacity=".9" />
<line x1="54" y1="62" x2="62" y2="92" stroke="#22c55e" strokeWidth="2.5" opacity=".9" />
<line x1="39" y1="57" x2="14" y2="75" stroke="#22c55e" strokeWidth="2.5" opacity=".9" />
<line x1="40" y1="42" x2="15" y2="23" stroke="#22c55e" strokeWidth="2.5" opacity=".9" />
<path d="M65,9 Q86,26 94,51" stroke="#22c55e" strokeWidth="2" fill="none" opacity=".5" />
<path d="M94,51 Q84,76 62,92" stroke="#22c55e" strokeWidth="2" fill="none" opacity=".5" />
<path d="M62,92 Q35,91 14,75" stroke="#22c55e" strokeWidth="2" fill="none" opacity=".5" />
<path d="M14,75 Q7,49 15,23" stroke="#22c55e" strokeWidth="2" fill="none" opacity=".5" />
<path d="M15,23 Q38,8 65,9" stroke="#22c55e" strokeWidth="2" fill="none" opacity=".5" />
<circle cx="50" cy="50" r="46" fill="none" stroke="#22c55e" strokeWidth="3" />
</svg>
)
const NAV_LINKS = [
{ href: '/', label: 'Home' },
{ href: '/groups', label: 'Groups' },
{ href: '/stats', label: 'Statistics' },
{ href: '/history', label: 'History' },
]
export function Nav() {
const pathname = usePathname()
const router = useRouter()
const [q, setQ] = useState('')
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
if (q.trim()) router.push(`/search?q=${encodeURIComponent(q.trim())}`)
}
return (
<nav className="fixed top-0 left-0 right-0 z-50 h-[60px] flex items-center px-5 gap-0"
style={{ background: 'rgba(4,13,8,0.97)', backdropFilter: 'blur(18px)', borderBottom: '1px solid rgba(34,197,94,0.18)' }}>
<Link href="/" className="flex items-center gap-2.5 mr-5 flex-shrink-0 cursor-pointer select-none">
{WC_BALL}
<span className="font-['Bebas_Neue'] text-lg tracking-[3px] text-[#22c55e] whitespace-nowrap">WORLD CUP</span>
</Link>
<div className="flex gap-0.5 flex-1 min-w-0">
{NAV_LINKS.map(({ href, label }) => {
const active = href === '/' ? pathname === '/' : pathname.startsWith(href)
return (
<Link key={href} href={href}
className={`px-3.5 py-1.5 rounded-lg text-[13px] font-medium whitespace-nowrap flex-shrink-0 transition-colors
${active ? 'bg-[rgba(34,197,94,0.12)] text-[#22c55e]' : 'text-[#4a7a55] hover:text-[#6abf7a]'}`}>
{label}
</Link>
)
})}
</div>
<form onSubmit={handleSearch} className="relative flex-shrink-0 ml-2">
<input
type="text" value={q} onChange={e => setQ(e.target.value)}
placeholder="Search…"
className="w-44 pl-8 pr-3.5 py-1.5 rounded-[20px] text-[13px] text-[#dff5e8] outline-none"
style={{ background: 'rgba(34,197,94,0.06)', border: '1px solid rgba(34,197,94,0.18)' }}
/>
<svg className="absolute left-2.5 top-1/2 -translate-y-1/2 opacity-30 pointer-events-none" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#dff5e8" strokeWidth="2.5">
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</form>
</nav>
)
}
+20
View File
@@ -0,0 +1,20 @@
import { getIso } from '@/lib/iso-codes'
interface Props {
name: string
iso2?: string | null
size?: 'sm' | 'md' | 'lg' | 'xl'
className?: string
}
const sizes = { sm: 'text-lg', md: 'text-2xl', lg: 'text-4xl', xl: 'text-[60px]' }
export function TeamFlag({ name, iso2, size = 'md', className = '' }: Props) {
const code = iso2 ?? getIso(name)
return (
<span
className={`fi fi-${code} rounded-sm inline-block flex-shrink-0 ${sizes[size]} ${className}`}
title={name}
/>
)
}
+14
View File
@@ -0,0 +1,14 @@
services:
db:
image: postgres:16-alpine
ports:
- "5432:5432"
environment:
POSTGRES_DB: worldcup
POSTGRES_USER: wc
POSTGRES_PASSWORD: wc
volumes:
- pgdata_dev:/var/lib/postgresql/data
volumes:
pgdata_dev:
+49
View File
@@ -0,0 +1,49 @@
services:
app:
build: .
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgres://wc:${DB_PASSWORD}@db:5432/worldcup
NODE_ENV: production
labels:
- "traefik.enable=${TRAEFIK_ENABLED:-false}"
- "traefik.http.middlewares.worldcup-redirect-web-secure.redirectscheme.scheme=https"
- "traefik.http.routers.worldcup-web.middlewares=worldcup-redirect-web-secure"
- "traefik.http.routers.worldcup-web.rule=Host(`${TRAEFIK_HOST}`)"
- "traefik.http.routers.worldcup-web.entrypoints=web"
- "traefik.http.routers.worldcup-web-secure.rule=Host(`${TRAEFIK_HOST}`)"
- "traefik.http.routers.worldcup-web-secure.tls.certresolver=resolver"
- "traefik.http.routers.worldcup-web-secure.entrypoints=web-secure"
- "traefik.http.routers.worldcup-web-secure.middlewares=security-headers@file,no-index@file"
- "traefik.http.services.worldcup-web-secure.loadbalancer.server.port=3000"
- "traefik.docker.network=${NETWORK_NAME}"
networks:
- compose_network
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: worldcup
POSTGRES_USER: wc
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U wc -d worldcup"]
interval: 5s
timeout: 5s
retries: 10
networks:
- compose_network
networks:
compose_network:
name: ${NETWORK_NAME}
external: true
volumes:
pgdata:
+10
View File
@@ -0,0 +1,10 @@
import type { Config } from 'drizzle-kit'
export default {
schema: './lib/db/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL ?? 'postgres://wc:wc@localhost:5432/worldcup',
},
} satisfies Config
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+10
View File
@@ -0,0 +1,10 @@
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import * as schema from './schema'
const connectionString = process.env.DATABASE_URL ?? 'postgres://wc:wc@localhost:5432/worldcup'
const client = postgres(connectionString, { max: 10 })
export const db = drizzle(client, { schema })
export * from './schema'
+91
View File
@@ -0,0 +1,91 @@
import { pgTable, serial, integer, text, boolean, numeric, date, primaryKey } from 'drizzle-orm/pg-core'
export const tournaments = pgTable('tournaments', {
year: integer('year').primaryKey(),
host: text('host').notNull(),
winner: text('winner'),
runnerUp: text('runner_up'),
thirdPlace: text('third_place'),
fourthPlace: text('fourth_place'),
teamsCount: integer('teams_count'),
matchesCount: integer('matches_count'),
totalGoals: integer('total_goals'),
avgGoalsPerGame: numeric('avg_goals_per_game', { precision: 4, scale: 2 }),
})
export const teams = pgTable('teams', {
id: serial('id').primaryKey(),
name: text('name').unique().notNull(),
iso2: text('iso2'),
fifaCode: text('fifa_code'),
continent: text('continent'),
confederation: text('confederation'),
})
export const stadiums = pgTable('stadiums', {
id: serial('id').primaryKey(),
tournamentYear: integer('tournament_year'),
name: text('name').notNull(),
city: text('city'),
countryCode: text('country_code'),
capacity: integer('capacity'),
timezone: text('timezone'),
coordinates: text('coordinates'),
})
export const matches = pgTable('matches', {
id: serial('id').primaryKey(),
tournamentYear: integer('tournament_year').notNull(),
round: text('round').notNull(),
groupName: text('group_name'),
date: date('date'),
timeLocal: text('time_local'),
stadiumId: integer('stadium_id'),
team1Id: integer('team1_id').notNull(),
team2Id: integer('team2_id').notNull(),
scoreFtHome: integer('score_ft_home'),
scoreFtAway: integer('score_ft_away'),
scoreHtHome: integer('score_ht_home'),
scoreHtAway: integer('score_ht_away'),
scoreEtHome: integer('score_et_home'),
scoreEtAway: integer('score_et_away'),
scorePHome: integer('score_p_home'),
scorePAway: integer('score_p_away'),
isQualiPlayoff: boolean('is_quali_playoff').default(false),
})
export const goals = pgTable('goals', {
id: serial('id').primaryKey(),
matchId: integer('match_id').notNull(),
teamId: integer('team_id').notNull(),
playerName: text('player_name').notNull(),
minute: integer('minute'),
minuteOffset: integer('minute_offset').default(0),
isPenalty: boolean('is_penalty').default(false),
isOwnGoal: boolean('is_own_goal').default(false),
})
export const groupStandings = pgTable('group_standings', {
tournamentYear: integer('tournament_year').notNull(),
groupName: text('group_name').notNull(),
teamId: integer('team_id').notNull(),
pos: integer('pos'),
played: integer('played').default(0),
won: integer('won').default(0),
drawn: integer('drawn').default(0),
lost: integer('lost').default(0),
goalsFor: integer('goals_for').default(0),
goalsAgainst: integer('goals_against').default(0),
goalDiff: integer('goal_diff').default(0),
pts: integer('pts').default(0),
}, (t) => [primaryKey({ columns: [t.tournamentYear, t.groupName, t.teamId] })])
export const squads = pgTable('squads', {
id: serial('id').primaryKey(),
tournamentYear: integer('tournament_year').notNull(),
teamId: integer('team_id').notNull(),
playerName: text('player_name').notNull(),
shirtNumber: integer('shirt_number'),
position: text('position'),
dateOfBirth: date('date_of_birth'),
})
+19
View File
@@ -0,0 +1,19 @@
'use client'
import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client/core'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let client: InstanceType<typeof ApolloClient> | null = null
function createClient() {
return new ApolloClient({
link: new HttpLink({ uri: '/api/graphql' }),
cache: new InMemoryCache(),
defaultOptions: { watchQuery: { fetchPolicy: 'cache-and-network' } },
})
}
export function getApolloClient() {
if (typeof window === 'undefined') return createClient()
if (!client) client = createClient()
return client
}
+12
View File
@@ -0,0 +1,12 @@
'use client'
/* Apollo Client v4 defaults TData to unknown — wrap to restore convenient any typing */
import { useQuery as _useQuery } from '@apollo/client/react'
import type { DocumentNode } from '@apollo/client/core'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function useQuery(query: DocumentNode, options?: Record<string, unknown>) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return _useQuery<Record<string, any>>(query, options)
}
export { gql } from '@apollo/client/core'
+458
View File
@@ -0,0 +1,458 @@
import { db } from '@/lib/db'
import { tournaments, teams, matches, goals, groupStandings, stadiums, squads } from '@/lib/db/schema'
import { slugify, getIso } from '@/lib/iso-codes'
import { eq, and, desc, asc, sql, ilike, or, isNotNull, lt, gt, gte } from 'drizzle-orm'
function teamWithSlug(t: typeof teams.$inferSelect) {
return { ...t, slug: slugify(t.name), iso2: t.iso2 ?? getIso(t.name) }
}
async function getTeamById(id: number) {
const rows = await db.select().from(teams).where(eq(teams.id, id)).limit(1)
return rows[0] ? teamWithSlug(rows[0]) : null
}
function isLive(dateStr: string | null, timeStr: string | null): boolean {
if (!dateStr) return false
const now = new Date()
const today = now.toISOString().slice(0, 10)
if (dateStr !== today) return false
if (!timeStr) return true
// parse time like "20:00 CET" or "13:00 UTC-6"
const timePart = timeStr.split(' ')[0]
const [h, m] = timePart.split(':').map(Number)
if (isNaN(h)) return true
const kickoff = new Date(now)
kickoff.setHours(h, m ?? 0, 0, 0)
const diffMin = (now.getTime() - kickoff.getTime()) / 60000
return diffMin >= -5 && diffMin <= 125
}
async function hydrateMatch(row: typeof matches.$inferSelect) {
const [t1, t2] = await Promise.all([getTeamById(row.team1Id), getTeamById(row.team2Id)])
const matchGoals = await db.select().from(goals).where(eq(goals.matchId, row.id)).orderBy(asc(goals.minute))
const goalsHydrated = await Promise.all(matchGoals.map(async g => ({
...g,
team: (await getTeamById(g.teamId)) ?? { id: g.teamId, name: '', slug: '', iso2: null, fifaCode: null, continent: null, confederation: null },
isPenalty: g.isPenalty ?? false,
isOwnGoal: g.isOwnGoal ?? false,
minuteOffset: g.minuteOffset ?? 0,
})))
const ft = row.scoreFtHome !== null ? [row.scoreFtHome, row.scoreFtAway!] : null
const ht = row.scoreHtHome !== null ? [row.scoreHtHome, row.scoreHtAway!] : null
const et = row.scoreEtHome !== null ? [row.scoreEtHome, row.scoreEtAway!] : null
const p = row.scorePHome !== null ? [row.scorePHome, row.scorePAway!] : null
const margin = ft ? Math.abs(ft[0] - ft[1]) : null
const totalGoalsCount = ft ? ft[0] + ft[1] : null
return {
...row,
year: row.tournamentYear,
group: row.groupName,
time: row.timeLocal,
stadium: null,
team1: t1!, team2: t2!,
scoreFt: ft, scoreHt: ht, scoreEt: et, scoreP: p,
goals: goalsHydrated,
isLive: isLive(row.date, row.timeLocal),
isQualiPlayoff: row.isQualiPlayoff ?? false,
margin, totalGoals: totalGoalsCount,
}
}
export const resolvers = {
Query: {
async tournaments() {
const rows = await db.select().from(tournaments).orderBy(desc(tournaments.year))
return rows.map(r => ({ ...r, avgGoalsPerGame: r.avgGoalsPerGame ? parseFloat(r.avgGoalsPerGame) : null }))
},
async tournament(_: unknown, { year }: { year: number }) {
const rows = await db.select().from(tournaments).where(eq(tournaments.year, year)).limit(1)
if (!rows[0]) return null
return { ...rows[0], avgGoalsPerGame: rows[0].avgGoalsPerGame ? parseFloat(rows[0].avgGoalsPerGame) : null }
},
async matches(_: unknown, args: { year?: number; group?: string; round?: string; isQuali?: boolean }) {
const conditions = []
if (args.year) conditions.push(eq(matches.tournamentYear, args.year))
if (args.group) conditions.push(eq(matches.groupName, args.group))
if (args.round) conditions.push(eq(matches.round, args.round))
if (args.isQuali != null) conditions.push(eq(matches.isQualiPlayoff, args.isQuali))
const rows = await db.select().from(matches)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(asc(matches.date), asc(matches.id))
return Promise.all(rows.map(hydrateMatch))
},
async match(_: unknown, { id }: { id: number }) {
const rows = await db.select().from(matches).where(eq(matches.id, id)).limit(1)
return rows[0] ? hydrateMatch(rows[0]) : null
},
async liveMatches() {
const today = new Date().toISOString().slice(0, 10)
const rows = await db.select().from(matches)
.where(and(eq(matches.date, today), eq(matches.isQualiPlayoff, false)))
.orderBy(asc(matches.id))
const hydrated = await Promise.all(rows.map(hydrateMatch))
return hydrated.filter(m => m.isLive)
},
async recentMatches(_: unknown, { limit = 10 }: { limit?: number }) {
const today = new Date().toISOString().slice(0, 10)
const rows = await db.select().from(matches)
.where(and(
lt(matches.date, today),
isNotNull(matches.scoreFtHome),
eq(matches.isQualiPlayoff, false),
))
.orderBy(desc(matches.date), desc(matches.id))
.limit(limit)
return Promise.all(rows.map(hydrateMatch))
},
async upcomingMatches(_: unknown, { limit = 10 }: { limit?: number }) {
const today = new Date().toISOString().slice(0, 10)
const rows = await db.select().from(matches)
.where(and(
gte(matches.date, today),
sql`${matches.scoreFtHome} IS NULL`,
eq(matches.isQualiPlayoff, false),
))
.orderBy(asc(matches.date), asc(matches.id))
.limit(limit)
return Promise.all(rows.map(hydrateMatch))
},
async teams() {
const rows = await db.select().from(teams).orderBy(asc(teams.name))
return rows.map(teamWithSlug)
},
async team(_: unknown, { slug }: { slug: string }) {
const rows = await db.select().from(teams)
const found = rows.find(r => slugify(r.name) === slug)
return found ? teamWithSlug(found) : null
},
async topScorers(_: unknown, { year, limit = 20 }: { year?: number; limit?: number }) {
const conditions = year
? sql`AND m.tournament_year = ${year} AND m.is_quali_playoff = false`
: sql`AND m.is_quali_playoff = false`
const rows = await db.execute(sql`
SELECT
g.player_name,
g.team_id,
COUNT(*)::int AS goals,
SUM(CASE WHEN g.is_penalty THEN 1 ELSE 0 END)::int AS penalties,
SUM(CASE WHEN g.is_own_goal THEN 1 ELSE 0 END)::int AS own_goals,
COUNT(DISTINCT m.tournament_year)::int AS tournaments
FROM goals g
JOIN matches m ON g.match_id = m.id
WHERE true ${conditions}
GROUP BY g.player_name, g.team_id
ORDER BY goals DESC
LIMIT ${limit}
`)
return Promise.all(rows.map(async (r: Record<string, unknown>) => ({
playerName: r.player_name,
goals: r.goals,
penalties: r.penalties,
ownGoals: r.own_goals,
tournaments: r.tournaments,
team: r.team_id ? await getTeamById(r.team_id as number) : null,
})))
},
async player(_: unknown, { name }: { name: string }) {
const rows = await db.execute(sql`
SELECT
g.player_name,
g.team_id,
COUNT(*)::int AS goals,
SUM(CASE WHEN g.is_penalty THEN 1 ELSE 0 END)::int AS penalties,
SUM(CASE WHEN g.is_own_goal THEN 1 ELSE 0 END)::int AS own_goals,
COUNT(DISTINCT m.tournament_year)::int AS tournaments
FROM goals g
JOIN matches m ON g.match_id = m.id
WHERE LOWER(g.player_name) LIKE LOWER(${`%${name}%`})
AND m.is_quali_playoff = false
GROUP BY g.player_name, g.team_id
ORDER BY goals DESC
LIMIT 1
`)
if (!rows[0]) return null
const r = rows[0] as Record<string, unknown>
return {
playerName: r.player_name,
goals: r.goals,
penalties: r.penalties,
ownGoals: r.own_goals,
tournaments: r.tournaments,
team: r.team_id ? await getTeamById(r.team_id as number) : null,
}
},
async hatTricks(_: unknown, { year }: { year?: number }) {
const conditions = year ? sql`AND m.tournament_year = ${year}` : sql``
const rows = await db.execute(sql`
SELECT
g.player_name,
g.team_id,
g.match_id,
COUNT(*)::int AS goals,
m.tournament_year AS year,
m.round,
CASE WHEN m.team1_id = g.team_id THEN m.team2_id ELSE m.team1_id END AS opponent_id
FROM goals g
JOIN matches m ON g.match_id = m.id
WHERE m.is_quali_playoff = false ${conditions}
GROUP BY g.player_name, g.team_id, g.match_id, m.tournament_year, m.round, m.team1_id, m.team2_id
HAVING COUNT(*) >= 3
ORDER BY goals DESC, m.tournament_year DESC
`)
return Promise.all(rows.map(async (r: Record<string, unknown>) => ({
playerName: r.player_name,
year: r.year,
round: r.round,
goals: r.goals,
team: r.team_id ? await getTeamById(r.team_id as number) : null,
opponent: r.opponent_id ? await getTeamById(r.opponent_id as number) : null,
})))
},
async groupStandings(_: unknown, { year }: { year: number }) {
const rows = await db.select({
groupName: groupStandings.groupName,
pos: groupStandings.pos,
teamId: groupStandings.teamId,
played: groupStandings.played,
won: groupStandings.won,
drawn: groupStandings.drawn,
lost: groupStandings.lost,
goalsFor: groupStandings.goalsFor,
goalsAgainst: groupStandings.goalsAgainst,
goalDiff: groupStandings.goalDiff,
pts: groupStandings.pts,
}).from(groupStandings)
.where(eq(groupStandings.tournamentYear, year))
.orderBy(asc(groupStandings.groupName), asc(groupStandings.pos))
return Promise.all(rows.map(async r => ({
...r,
played: r.played ?? 0,
won: r.won ?? 0,
drawn: r.drawn ?? 0,
lost: r.lost ?? 0,
goalsFor: r.goalsFor ?? 0,
goalsAgainst: r.goalsAgainst ?? 0,
goalDiff: r.goalDiff ?? 0,
pts: r.pts ?? 0,
team: (await getTeamById(r.teamId))!,
})))
},
async stadiums(_: unknown, { year }: { year?: number }) {
const rows = year
? await db.select().from(stadiums).where(eq(stadiums.tournamentYear, year))
: await db.select().from(stadiums).orderBy(asc(stadiums.name))
return rows.map(r => ({ ...r, matchCount: 0 }))
},
async squads(_: unknown, { year, team }: { year: number; team?: string }) {
const rows = await db.select().from(squads).where(eq(squads.tournamentYear, year)).orderBy(asc(squads.shirtNumber))
const filtered = team ? rows.filter(r => r.teamId !== null) : rows
return Promise.all(filtered.map(async r => {
const t = await getTeamById(r.teamId)
const dob = r.dateOfBirth
const age = dob ? Math.floor((Date.now() - new Date(dob).getTime()) / (1000 * 60 * 60 * 24 * 365.25)) : null
return { ...r, team: t!, age }
}))
},
async tournamentStats() {
const [totals] = await db.execute(sql`
SELECT
COUNT(DISTINCT t.year)::int AS total_tournaments,
COUNT(DISTINCT m.id)::int AS total_matches,
COALESCE(SUM(m.score_ft_home + m.score_ft_away), 0)::int AS total_goals,
ROUND(COALESCE(SUM(m.score_ft_home + m.score_ft_away), 0)::numeric / NULLIF(COUNT(DISTINCT m.id), 0), 2)::float AS avg_goals
FROM tournaments t
LEFT JOIN matches m ON m.tournament_year = t.year AND m.is_quali_playoff = false AND m.score_ft_home IS NOT NULL
`)
const r = totals as Record<string, unknown>
return {
totalTournaments: r.total_tournaments,
totalMatches: r.total_matches,
totalGoals: r.total_goals,
avgGoalsPerGame: r.avg_goals,
mostGoalsInTournament: null,
highestScoringMatch: null,
biggestWin: null,
mostTitles: null,
}
},
async goalsByMinute() {
const rows = await db.execute(sql`
SELECT
CASE
WHEN g.minute BETWEEN 0 AND 15 THEN '0-15'
WHEN g.minute BETWEEN 16 AND 30 THEN '16-30'
WHEN g.minute BETWEEN 31 AND 45 THEN '31-45'
WHEN g.minute BETWEEN 46 AND 60 THEN '46-60'
WHEN g.minute BETWEEN 61 AND 75 THEN '61-75'
WHEN g.minute BETWEEN 76 AND 90 THEN '76-90'
WHEN g.minute > 90 THEN '90+'
ELSE 'Unknown'
END AS bucket,
COUNT(*)::int AS count
FROM goals g
JOIN matches m ON g.match_id = m.id
WHERE g.minute IS NOT NULL AND m.is_quali_playoff = false
GROUP BY bucket
ORDER BY MIN(g.minute)
`)
return rows as unknown as { bucket: string; count: number }[]
},
async confederationStats() {
const rows = await db.execute(sql`
SELECT
t.confederation,
COUNT(DISTINCT m.tournament_year || '-' || t.id)::int AS appearances,
COUNT(DISTINCT CASE WHEN tr.winner = t.name THEN tr.year END)::int AS titles,
COALESCE(SUM(
CASE WHEN m.team1_id = t.id THEN COALESCE(m.score_ft_home, 0)
WHEN m.team2_id = t.id THEN COALESCE(m.score_ft_away, 0)
ELSE 0 END
), 0)::int AS total_goals
FROM teams t
JOIN matches m ON (m.team1_id = t.id OR m.team2_id = t.id) AND m.is_quali_playoff = false
JOIN tournaments tr ON tr.year = m.tournament_year
WHERE t.confederation IS NOT NULL
GROUP BY t.confederation
ORDER BY appearances DESC
`)
return rows
},
async biggestWins(_: unknown, { limit = 10 }: { limit?: number }) {
const rows = await db.select().from(matches)
.where(and(isNotNull(matches.scoreFtHome), eq(matches.isQualiPlayoff, false)))
.orderBy(desc(sql`ABS(${matches.scoreFtHome} - ${matches.scoreFtAway})`), desc(matches.date))
.limit(limit)
return Promise.all(rows.map(hydrateMatch))
},
async highestScoringMatches(_: unknown, { limit = 10 }: { limit?: number }) {
const rows = await db.select().from(matches)
.where(and(isNotNull(matches.scoreFtHome), eq(matches.isQualiPlayoff, false)))
.orderBy(desc(sql`${matches.scoreFtHome} + ${matches.scoreFtAway}`), desc(matches.date))
.limit(limit)
return Promise.all(rows.map(hydrateMatch))
},
async extraTimeStats() {
const [r] = await db.execute(sql`
SELECT
COUNT(*)::int AS total_knockout,
SUM(CASE WHEN score_et_home IS NOT NULL THEN 1 ELSE 0 END)::int AS went_et,
SUM(CASE WHEN score_p_home IS NOT NULL THEN 1 ELSE 0 END)::int AS went_pens
FROM matches
WHERE group_name IS NULL AND is_quali_playoff = false AND score_ft_home IS NOT NULL
`)
const row = r as Record<string, number>
const total = row.total_knockout || 1
return {
totalKnockoutMatches: row.total_knockout,
wentToExtraTime: row.went_et,
wentToPenalties: row.went_pens,
extraTimePct: Math.round((row.went_et / total) * 100),
penaltiesPct: Math.round((row.went_pens / total) * 100),
}
},
async search(_: unknown, { query }: { query: string }) {
if (!query || query.trim().length < 2) return { tournaments: [], teams: [], players: [], matches: [] }
const q = `%${query.trim()}%`
const [tourRows, teamRows, playerRows, matchRows] = await Promise.all([
db.select().from(tournaments).where(ilike(tournaments.host, q)).limit(5),
db.select().from(teams).where(or(ilike(teams.name, q), ilike(teams.fifaCode!, q))).limit(8),
db.execute(sql`
SELECT g.player_name, g.team_id,
COUNT(*)::int AS goals,
SUM(CASE WHEN g.is_penalty THEN 1 ELSE 0 END)::int AS penalties,
SUM(CASE WHEN g.is_own_goal THEN 1 ELSE 0 END)::int AS own_goals,
COUNT(DISTINCT m.tournament_year)::int AS tournaments
FROM goals g
JOIN matches m ON g.match_id = m.id
WHERE LOWER(g.player_name) LIKE LOWER(${q}) AND m.is_quali_playoff = false
GROUP BY g.player_name, g.team_id
ORDER BY goals DESC LIMIT 8
`),
db.select().from(matches)
.where(and(
or(ilike(matches.round, q), ilike(matches.groupName!, q)),
eq(matches.isQualiPlayoff, false),
))
.limit(5),
])
return {
tournaments: tourRows.map(r => ({ ...r, avgGoalsPerGame: r.avgGoalsPerGame ? parseFloat(r.avgGoalsPerGame) : null })),
teams: teamRows.map(teamWithSlug),
players: await Promise.all(playerRows.map(async (r: Record<string, unknown>) => ({
playerName: r.player_name, goals: r.goals, penalties: r.penalties,
ownGoals: r.own_goals, tournaments: r.tournaments,
team: r.team_id ? await getTeamById(r.team_id as number) : null,
}))),
matches: await Promise.all(matchRows.map(hydrateMatch)),
}
},
},
Tournament: {
async topScorers(parent: { year: number }, { limit = 10 }: { limit?: number }) {
const rows = await db.execute(sql`
SELECT g.player_name, g.team_id,
COUNT(*)::int AS goals,
SUM(CASE WHEN g.is_penalty THEN 1 ELSE 0 END)::int AS penalties,
SUM(CASE WHEN g.is_own_goal THEN 1 ELSE 0 END)::int AS own_goals,
1 AS tournaments
FROM goals g
JOIN matches m ON g.match_id = m.id
WHERE m.tournament_year = ${parent.year} AND m.is_quali_playoff = false
GROUP BY g.player_name, g.team_id
ORDER BY goals DESC LIMIT ${limit}
`)
return Promise.all(rows.map(async (r: Record<string, unknown>) => ({
playerName: r.player_name, goals: r.goals, penalties: r.penalties,
ownGoals: r.own_goals, tournaments: r.tournaments,
team: r.team_id ? await getTeamById(r.team_id as number) : null,
})))
},
async matches(parent: { year: number }) {
const rows = await db.select().from(matches)
.where(and(eq(matches.tournamentYear, parent.year), eq(matches.isQualiPlayoff, false)))
.orderBy(asc(matches.date), asc(matches.id))
return Promise.all(rows.map(hydrateMatch))
},
},
Team: {
async stats(parent: { id: number; name: string }) {
const [r] = await db.execute(sql`
SELECT
COUNT(DISTINCT m.tournament_year)::int AS appearances,
SUM(CASE
WHEN m.team1_id = ${parent.id} AND m.score_ft_home > m.score_ft_away THEN 1
WHEN m.team2_id = ${parent.id} AND m.score_ft_away > m.score_ft_home THEN 1
ELSE 0 END)::int AS wins,
SUM(CASE WHEN m.score_ft_home = m.score_ft_away AND m.score_ft_home IS NOT NULL THEN 1 ELSE 0 END)::int AS draws,
SUM(CASE
WHEN m.team1_id = ${parent.id} AND m.score_ft_home < m.score_ft_away THEN 1
WHEN m.team2_id = ${parent.id} AND m.score_ft_away < m.score_ft_home THEN 1
ELSE 0 END)::int AS losses,
SUM(CASE WHEN m.team1_id = ${parent.id} THEN COALESCE(m.score_ft_home, 0)
WHEN m.team2_id = ${parent.id} THEN COALESCE(m.score_ft_away, 0) ELSE 0 END)::int AS goals_for,
SUM(CASE WHEN m.team1_id = ${parent.id} THEN COALESCE(m.score_ft_away, 0)
WHEN m.team2_id = ${parent.id} THEN COALESCE(m.score_ft_home, 0) ELSE 0 END)::int AS goals_against,
COUNT(DISTINCT CASE WHEN t.winner = ${parent.name} THEN t.year END)::int AS titles
FROM matches m
JOIN tournaments t ON t.year = m.tournament_year
WHERE (m.team1_id = ${parent.id} OR m.team2_id = ${parent.id})
AND m.is_quali_playoff = false
AND m.score_ft_home IS NOT NULL
`)
const row = r as Record<string, number>
const played = (row.wins ?? 0) + (row.draws ?? 0) + (row.losses ?? 0)
return {
appearances: row.appearances ?? 0,
wins: row.wins ?? 0,
draws: row.draws ?? 0,
losses: row.losses ?? 0,
goalsFor: row.goals_for ?? 0,
goalsAgainst: row.goals_against ?? 0,
goalDiff: (row.goals_for ?? 0) - (row.goals_against ?? 0),
titles: row.titles ?? 0,
winPct: played > 0 ? Math.round(((row.wins ?? 0) / played) * 100) : 0,
}
},
},
}
+199
View File
@@ -0,0 +1,199 @@
export const typeDefs = /* GraphQL */ `
type Tournament {
year: Int!
host: String!
winner: String
runnerUp: String
thirdPlace: String
fourthPlace: String
teamsCount: Int
matchesCount: Int
totalGoals: Int
avgGoalsPerGame: Float
topScorers: [ScorerEntry!]!
matches: [Match!]!
}
type Team {
id: Int!
name: String!
slug: String!
iso2: String
fifaCode: String
continent: String
confederation: String
stats: TeamStats
}
type TeamStats {
appearances: Int!
wins: Int!
draws: Int!
losses: Int!
goalsFor: Int!
goalsAgainst: Int!
goalDiff: Int!
titles: Int!
winPct: Float!
}
type Stadium {
id: Int!
tournamentYear: Int
name: String!
city: String
countryCode: String
capacity: Int
timezone: String
coordinates: String
matchCount: Int
}
type Match {
id: Int!
year: Int!
round: String!
group: String
date: String
time: String
stadium: String
team1: Team!
team2: Team!
scoreFt: [Int!]
scoreHt: [Int!]
scoreEt: [Int!]
scoreP: [Int!]
goals: [Goal!]!
isLive: Boolean!
isQualiPlayoff: Boolean!
margin: Int
totalGoals: Int
}
type Goal {
id: Int!
team: Team!
playerName: String!
minute: Int
minuteOffset: Int
isPenalty: Boolean!
isOwnGoal: Boolean!
}
type ScorerEntry {
playerName: String!
team: Team
goals: Int!
penalties: Int!
ownGoals: Int!
tournaments: Int!
}
type GroupStanding {
groupName: String!
pos: Int
team: Team!
played: Int!
won: Int!
drawn: Int!
lost: Int!
goalsFor: Int!
goalsAgainst: Int!
goalDiff: Int!
pts: Int!
}
type SquadPlayer {
playerName: String!
shirtNumber: Int
position: String
dateOfBirth: String
team: Team!
age: Int
}
type GlobalStats {
totalTournaments: Int!
totalMatches: Int!
totalGoals: Int!
avgGoalsPerGame: Float!
mostGoalsInTournament: TournamentGoalRecord
highestScoringMatch: Match
biggestWin: Match
mostTitles: ScorerEntry
}
type TournamentGoalRecord {
year: Int!
host: String!
totalGoals: Int!
}
type HatTrick {
playerName: String!
team: Team
year: Int!
round: String!
opponent: Team
goals: Int!
}
type MinuteBucket {
bucket: String!
count: Int!
}
type ConfederationStat {
confederation: String!
appearances: Int!
titles: Int!
totalGoals: Int!
}
type SearchResults {
tournaments: [Tournament!]!
teams: [Team!]!
players: [ScorerEntry!]!
matches: [Match!]!
}
type Query {
tournaments: [Tournament!]!
tournament(year: Int!): Tournament
matches(year: Int, group: String, round: String, isQuali: Boolean): [Match!]!
match(id: Int!): Match
liveMatches: [Match!]!
recentMatches(limit: Int): [Match!]!
upcomingMatches(limit: Int): [Match!]!
teams: [Team!]!
team(slug: String!): Team
topScorers(year: Int, limit: Int): [ScorerEntry!]!
player(name: String!): ScorerEntry
hatTricks(year: Int): [HatTrick!]!
groupStandings(year: Int!): [GroupStanding!]!
stadiums(year: Int): [Stadium!]!
squads(year: Int!, team: String): [SquadPlayer!]!
tournamentStats: GlobalStats!
goalsByMinute: [MinuteBucket!]!
confederationStats: [ConfederationStat!]!
biggestWins(limit: Int): [Match!]!
highestScoringMatches(limit: Int): [Match!]!
extraTimeStats: ExtraTimeStats!
search(query: String!): SearchResults!
}
type ExtraTimeStats {
totalKnockoutMatches: Int!
wentToExtraTime: Int!
wentToPenalties: Int!
extraTimePct: Float!
penaltiesPct: Float!
}
`
+77
View File
@@ -0,0 +1,77 @@
export const TEAM_ISO: Record<string, string> = {
// A
'Afghanistan': 'af', 'Albania': 'al', 'Algeria': 'dz', 'Angola': 'ao',
'Argentina': 'ar', 'Armenia': 'am', 'Australia': 'au', 'Austria': 'at',
// B
'Bahrain': 'bh', 'Belgium': 'be', 'Bolivia': 'bo',
'Bosnia & Herzegovina': 'ba', 'Bosnia and Herzegovina': 'ba', 'Brazil': 'br',
'Bulgaria': 'bg', 'Burkina Faso': 'bf',
// C
'Cameroon': 'cm', 'Canada': 'ca', 'Cape Verde': 'cv', 'Chile': 'cl',
'China': 'cn', "China PR": 'cn', 'Colombia': 'co', 'Costa Rica': 'cr',
'Croatia': 'hr', 'Cuba': 'cu', 'Curaçao': 'cw', 'Curacao': 'cw',
'Cyprus': 'cy', 'Czech Republic': 'cz', 'Czechia': 'cz',
// D
'Denmark': 'dk', 'DR Congo': 'cd', 'Dutch East Indies': 'id',
// E
'Ecuador': 'ec', 'Egypt': 'eg', 'El Salvador': 'sv',
'England': 'gb-eng', 'Estonia': 'ee', 'Ethiopia': 'et',
// F
'Finland': 'fi', 'France': 'fr',
// G
'Gabon': 'ga', 'Germany': 'de', 'West Germany': 'de', 'East Germany': 'de',
'Ghana': 'gh', 'Greece': 'gr', 'Guatemala': 'gt', 'Guinea': 'gn',
// H
'Haiti': 'ht', 'Honduras': 'hn', 'Hungary': 'hu',
// I
'Iceland': 'is', 'India': 'in', 'Indonesia': 'id', 'Iran': 'ir',
'Iraq': 'iq', 'Ireland': 'ie', 'Republic of Ireland': 'ie',
'Israel': 'il', 'Italy': 'it', 'Ivory Coast': 'ci', "Côte d'Ivoire": 'ci',
// J
'Jamaica': 'jm', 'Japan': 'jp', 'Jordan': 'jo',
// K
'Kazakhstan': 'kz', 'Kenya': 'ke', 'Kuwait': 'kw',
// L
'Latvia': 'lv', 'Lebanon': 'lb', 'Liberia': 'lr', 'Lithuania': 'lt',
// M
'Mali': 'ml', 'Malta': 'mt', 'Mexico': 'mx', 'Moldova': 'md',
'Montenegro': 'me', 'Morocco': 'ma', 'Mozambique': 'mz',
// N
'Netherlands': 'nl', 'New Zealand': 'nz', 'Nigeria': 'ng',
'North Korea': 'kp', "Korea DPR": 'kp', 'Northern Ireland': 'gb-nir',
'North Macedonia': 'mk', 'Norway': 'no',
// O
'Oman': 'om',
// P
'Panama': 'pa', 'Paraguay': 'py', 'Peru': 'pe', 'Philippines': 'ph',
'Poland': 'pl', 'Portugal': 'pt',
// Q
'Qatar': 'qa',
// R
'Romania': 'ro', 'Russia': 'ru', 'Soviet Union': 'su',
// S
'Saudi Arabia': 'sa', 'Scotland': 'gb-sct', 'Senegal': 'sn',
'Serbia': 'rs', 'Yugoslavia': 'yu', 'Slovakia': 'sk', 'Slovenia': 'si',
'Somalia': 'so', 'South Africa': 'za', 'South Korea': 'kr',
'Korea Republic': 'kr', 'Spain': 'es', 'Sweden': 'se', 'Switzerland': 'ch',
// T
'Taiwan': 'tw', 'Tanzania': 'tz', 'Thailand': 'th', 'Togo': 'tg',
'Trinidad and Tobago': 'tt', 'Tunisia': 'tn', 'Turkey': 'tr',
// U
'UAE': 'ae', 'United Arab Emirates': 'ae', 'Uganda': 'ug', 'Ukraine': 'ua',
'Uruguay': 'uy', 'USA': 'us', 'United States': 'us', 'Uzbekistan': 'uz',
// V
'Venezuela': 've', 'Vietnam': 'vn',
// W
'Wales': 'gb-wls',
// Z
'Zambia': 'zm', 'Zimbabwe': 'zw',
}
export function getIso(teamName: string): string {
return TEAM_ISO[teamName] ?? teamName.toLowerCase().replace(/\s+/g, '-').substring(0, 2)
}
export function slugify(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
}
+8
View File
@@ -0,0 +1,8 @@
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
output: 'standalone',
serverExternalPackages: ['postgres'],
}
export default nextConfig
+39
View File
@@ -0,0 +1,39 @@
{
"name": "worldcup",
"version": "0.1.0",
"private": true,
"packageManager": "pnpm@10.28.0",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"sync": "tsx scripts/sync.ts",
"db:generate": "drizzle-kit generate",
"db:push": "drizzle-kit push"
},
"dependencies": {
"@apollo/client": "^4.2.3",
"@graphql-tools/schema": "^10.0.33",
"drizzle-orm": "^0.45.2",
"flag-icons": "^7.5.0",
"graphql": "^16.14.2",
"graphql-yoga": "^5.21.2",
"next": "16.2.9",
"postgres": "^3.4.9",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20.19.43",
"@types/react": "^19",
"@types/react-dom": "^19",
"drizzle-kit": "^0.31.10",
"eslint": "^9",
"eslint-config-next": "16.2.9",
"tailwindcss": "^4",
"tsx": "^4.22.4",
"typescript": "^5"
}
}
+5400
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+426
View File
@@ -0,0 +1,426 @@
import postgres from 'postgres'
import { drizzle } from 'drizzle-orm/postgres-js'
import { sql } from 'drizzle-orm'
import { TEAM_ISO, getIso } from '../lib/iso-codes'
const DATABASE_URL = process.env.DATABASE_URL ?? 'postgres://wc:wc@localhost:5432/worldcup'
const BASE = 'https://raw.githubusercontent.com/openfootball/worldcup.json/master'
const YEARS = [
1930, 1934, 1938, 1950, 1954, 1958, 1962, 1966, 1970,
1974, 1978, 1982, 1986, 1990, 1994, 1998, 2002, 2006,
2010, 2014, 2018, 2022, 2026,
]
const HOSTS: Record<number, string> = {
1930: 'Uruguay', 1934: 'Italy', 1938: 'France', 1950: 'Brazil',
1954: 'Switzerland', 1958: 'Sweden', 1962: 'Chile', 1966: 'England',
1970: 'Mexico', 1974: 'Germany', 1978: 'Argentina', 1982: 'Spain',
1986: 'Mexico', 1990: 'Italy', 1994: 'USA', 1998: 'France',
2002: 'South Korea / Japan', 2006: 'Germany', 2010: 'South Africa',
2014: 'Brazil', 2018: 'Russia', 2022: 'Qatar', 2026: 'USA / Canada / Mexico',
}
const WINNERS: Record<number, { winner: string; runnerUp: string; third?: string; fourth?: string }> = {
1930: { winner: 'Uruguay', runnerUp: 'Argentina', third: 'USA', fourth: 'Yugoslavia' },
1934: { winner: 'Italy', runnerUp: 'Czechoslovakia', third: 'Germany', fourth: 'Austria' },
1938: { winner: 'Italy', runnerUp: 'Hungary', third: 'Brazil', fourth: 'Sweden' },
1950: { winner: 'Uruguay', runnerUp: 'Brazil', third: 'Sweden', fourth: 'Spain' },
1954: { winner: 'Germany', runnerUp: 'Hungary', third: 'Austria', fourth: 'Uruguay' },
1958: { winner: 'Brazil', runnerUp: 'Sweden', third: 'France', fourth: 'Germany' },
1962: { winner: 'Brazil', runnerUp: 'Czechoslovakia', third: 'Chile', fourth: 'Yugoslavia' },
1966: { winner: 'England', runnerUp: 'Germany', third: 'Portugal', fourth: 'Soviet Union' },
1970: { winner: 'Brazil', runnerUp: 'Italy', third: 'Germany', fourth: 'Uruguay' },
1974: { winner: 'Germany', runnerUp: 'Netherlands', third: 'Poland', fourth: 'Brazil' },
1978: { winner: 'Argentina', runnerUp: 'Netherlands', third: 'Brazil', fourth: 'Italy' },
1982: { winner: 'Italy', runnerUp: 'Germany', third: 'Poland', fourth: 'France' },
1986: { winner: 'Argentina', runnerUp: 'Germany', third: 'France', fourth: 'Belgium' },
1990: { winner: 'Germany', runnerUp: 'Argentina', third: 'Italy', fourth: 'England' },
1994: { winner: 'Brazil', runnerUp: 'Italy', third: 'Sweden', fourth: 'Bulgaria' },
1998: { winner: 'France', runnerUp: 'Brazil', third: 'Croatia', fourth: 'Netherlands' },
2002: { winner: 'Brazil', runnerUp: 'Germany', third: 'Turkey', fourth: 'South Korea' },
2006: { winner: 'Italy', runnerUp: 'France', third: 'Germany', fourth: 'Portugal' },
2010: { winner: 'Spain', runnerUp: 'Netherlands', third: 'Germany', fourth: 'Uruguay' },
2014: { winner: 'Germany', runnerUp: 'Argentina', third: 'Netherlands', fourth: 'Brazil' },
2018: { winner: 'France', runnerUp: 'Croatia', third: 'Belgium', fourth: 'England' },
2022: { winner: 'Argentina', runnerUp: 'France', third: 'Croatia', fourth: 'Morocco' },
}
async function fetchJson(url: string): Promise<unknown> {
try {
const res = await fetch(url)
if (!res.ok) return null
return res.json()
} catch {
return null
}
}
type RawGoal = { name: string; minute?: string | number; offset?: number; penalty?: boolean; owngoal?: boolean }
type RawScore = { ft?: number[]; ht?: number[]; et?: number[]; p?: number[] } | number[]
type RawMatch = {
round?: string; date?: string; time?: string;
team1: string; team2: string; score?: RawScore;
goals1?: RawGoal[]; goals2?: RawGoal[];
group?: string; ground?: string;
}
type RawData = { matches: RawMatch[] }
function parseScore(score: RawScore | undefined) {
if (!score) return {}
if (Array.isArray(score)) return { ft: score }
return { ft: score.ft, ht: score.ht, et: score.et, p: score.p }
}
async function run() {
const client = postgres(DATABASE_URL, { max: 5 })
const db = drizzle(client)
console.log('Creating tables...')
await db.execute(sql`
CREATE TABLE IF NOT EXISTS tournaments (
year INTEGER PRIMARY KEY,
host TEXT NOT NULL,
winner TEXT,
runner_up TEXT,
third_place TEXT,
fourth_place TEXT,
teams_count INTEGER,
matches_count INTEGER,
total_goals INTEGER,
avg_goals_per_game NUMERIC(4,2)
);
CREATE TABLE IF NOT EXISTS teams (
id SERIAL PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
iso2 TEXT,
fifa_code TEXT,
continent TEXT,
confederation TEXT
);
CREATE TABLE IF NOT EXISTS stadiums (
id SERIAL PRIMARY KEY,
tournament_year INTEGER,
name TEXT NOT NULL,
city TEXT,
country_code TEXT,
capacity INTEGER,
timezone TEXT,
coordinates TEXT
);
CREATE TABLE IF NOT EXISTS matches (
id SERIAL PRIMARY KEY,
tournament_year INTEGER NOT NULL,
round TEXT NOT NULL,
group_name TEXT,
date DATE,
time_local TEXT,
stadium_id INTEGER,
team1_id INTEGER NOT NULL,
team2_id INTEGER NOT NULL,
score_ft_home INTEGER,
score_ft_away INTEGER,
score_ht_home INTEGER,
score_ht_away INTEGER,
score_et_home INTEGER,
score_et_away INTEGER,
score_p_home INTEGER,
score_p_away INTEGER,
is_quali_playoff BOOLEAN DEFAULT false
);
CREATE UNIQUE INDEX IF NOT EXISTS matches_unique ON matches (tournament_year, team1_id, team2_id, date, is_quali_playoff);
CREATE TABLE IF NOT EXISTS goals (
id SERIAL PRIMARY KEY,
match_id INTEGER NOT NULL,
team_id INTEGER NOT NULL,
player_name TEXT NOT NULL,
minute INTEGER,
minute_offset INTEGER DEFAULT 0,
is_penalty BOOLEAN DEFAULT false,
is_own_goal BOOLEAN DEFAULT false
);
CREATE TABLE IF NOT EXISTS group_standings (
tournament_year INTEGER NOT NULL,
group_name TEXT NOT NULL,
team_id INTEGER NOT NULL,
pos INTEGER,
played INTEGER DEFAULT 0,
won INTEGER DEFAULT 0,
drawn INTEGER DEFAULT 0,
lost INTEGER DEFAULT 0,
goals_for INTEGER DEFAULT 0,
goals_against INTEGER DEFAULT 0,
goal_diff INTEGER DEFAULT 0,
pts INTEGER DEFAULT 0,
PRIMARY KEY (tournament_year, group_name, team_id)
);
CREATE TABLE IF NOT EXISTS squads (
id SERIAL PRIMARY KEY,
tournament_year INTEGER NOT NULL,
team_id INTEGER NOT NULL,
player_name TEXT NOT NULL,
shirt_number INTEGER,
position TEXT,
date_of_birth DATE
);
CREATE UNIQUE INDEX IF NOT EXISTS squads_unique ON squads (tournament_year, team_id, shirt_number);
`)
const teamCache = new Map<string, number>()
async function upsertTeam(name: string, extra?: { iso2?: string; fifaCode?: string; continent?: string; confederation?: string }) {
if (teamCache.has(name)) return teamCache.get(name)!
const iso2 = extra?.iso2 ?? getIso(name)
const [row] = await db.execute(sql`
INSERT INTO teams (name, iso2, fifa_code, continent, confederation)
VALUES (${name}, ${iso2 ?? null}, ${extra?.fifaCode ?? null}, ${extra?.continent ?? null}, ${extra?.confederation ?? null})
ON CONFLICT (name) DO UPDATE SET
iso2 = COALESCE(EXCLUDED.iso2, teams.iso2),
fifa_code = COALESCE(EXCLUDED.fifa_code, teams.fifa_code),
continent = COALESCE(EXCLUDED.continent, teams.continent),
confederation = COALESCE(EXCLUDED.confederation, teams.confederation)
RETURNING id
`)
const id = (row as { id: number }).id
teamCache.set(name, id)
return id
}
async function upsertMatch(
year: number, round: string, group: string | null, dateStr: string | null,
timeStr: string | null, team1Id: number, team2Id: number, score: ReturnType<typeof parseScore>,
isQuali: boolean
) {
const rows = await db.execute(sql`
INSERT INTO matches (tournament_year, round, group_name, date, time_local, team1_id, team2_id,
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)
VALUES (
${year}, ${round}, ${group}, ${dateStr ?? null}, ${timeStr ?? null},
${team1Id}, ${team2Id},
${score.ft?.[0] ?? null}, ${score.ft?.[1] ?? null},
${score.ht?.[0] ?? null}, ${score.ht?.[1] ?? null},
${score.et?.[0] ?? null}, ${score.et?.[1] ?? null},
${score.p?.[0] ?? null}, ${score.p?.[1] ?? null},
${isQuali}
)
ON CONFLICT (tournament_year, team1_id, team2_id, date, is_quali_playoff) DO UPDATE SET
round = EXCLUDED.round,
score_ft_home = COALESCE(EXCLUDED.score_ft_home, matches.score_ft_home),
score_ft_away = COALESCE(EXCLUDED.score_ft_away, matches.score_ft_away),
score_ht_home = COALESCE(EXCLUDED.score_ht_home, matches.score_ht_home),
score_ht_away = COALESCE(EXCLUDED.score_ht_away, matches.score_ht_away),
score_et_home = COALESCE(EXCLUDED.score_et_home, matches.score_et_home),
score_et_away = COALESCE(EXCLUDED.score_et_away, matches.score_et_away),
score_p_home = COALESCE(EXCLUDED.score_p_home, matches.score_p_home),
score_p_away = COALESCE(EXCLUDED.score_p_away, matches.score_p_away),
time_local = COALESCE(EXCLUDED.time_local, matches.time_local)
RETURNING id
`)
return (rows[0] as { id: number }).id
}
async function syncGoals(matchId: number, teamId: number, rawGoals: RawGoal[], isOwnGoalTeamId: number) {
await db.execute(sql`DELETE FROM goals WHERE match_id = ${matchId}`)
for (const g of rawGoals) {
if (!g.name) continue
const minute = g.minute != null ? parseInt(String(g.minute)) : null
const scoringTeamId = g.owngoal ? isOwnGoalTeamId : teamId
await db.execute(sql`
INSERT INTO goals (match_id, team_id, player_name, minute, minute_offset, is_penalty, is_own_goal)
VALUES (${matchId}, ${scoringTeamId}, ${g.name}, ${isNaN(minute!) ? null : minute},
${g.offset ?? 0}, ${g.penalty ?? false}, ${g.owngoal ?? false})
`)
}
}
for (const year of YEARS) {
console.log(`\nSyncing ${year}...`)
// 1. Upsert tournament
const winData = WINNERS[year]
await db.execute(sql`
INSERT INTO tournaments (year, host, winner, runner_up, third_place, fourth_place)
VALUES (${year}, ${HOSTS[year]}, ${winData?.winner ?? null}, ${winData?.runnerUp ?? null},
${winData?.third ?? null}, ${winData?.fourth ?? null})
ON CONFLICT (year) DO UPDATE SET
winner = COALESCE(EXCLUDED.winner, tournaments.winner),
runner_up = COALESCE(EXCLUDED.runner_up, tournaments.runner_up)
`)
// 2. Teams enrichment
const teamsData = await fetchJson(`${BASE}/${year}/worldcup.teams.json`) as Record<string, unknown>[] | null
if (teamsData && Array.isArray(teamsData)) {
for (const t of teamsData) {
const name = (t.name ?? t.name_normalised) as string
const iso2 = (t.flag_icon as string)?.match(/[\uD83C][\uDDE6-\uDDFF][\uD83C][\uDDE6-\uDDFF]/)?.[0]
? TEAM_ISO[name as string] ?? getIso(name)
: TEAM_ISO[name as string] ?? getIso(name)
await upsertTeam(name, {
iso2: iso2,
fifaCode: t.fifa_code as string,
continent: t.continent as string,
confederation: t.confed as string,
})
}
}
// 3. Stadiums
const stadiumsData = await fetchJson(`${BASE}/${year}/worldcup.stadiums.json`) as { stadiums?: Record<string, unknown>[] } | null
if (stadiumsData?.stadiums) {
for (const s of stadiumsData.stadiums) {
await db.execute(sql`
INSERT INTO stadiums (tournament_year, name, city, country_code, capacity, timezone, coordinates)
VALUES (${year}, ${s.name as string}, ${s.city as string}, ${(s.cc as string | undefined) ?? null},
${(s.capacity as number | undefined) ?? null}, ${(s.timezone as string | undefined) ?? null}, ${(s.coords as string | undefined) ?? null})
ON CONFLICT DO NOTHING
`)
}
}
// 4. Main matches
const mainData = await fetchJson(`${BASE}/${year}/worldcup.json`) as RawData | null
if (!mainData?.matches) { console.log(` No match data`); continue }
let matchCount = 0, goalCount = 0
for (const m of mainData.matches) {
const t1Id = await upsertTeam(m.team1)
const t2Id = await upsertTeam(m.team2)
const score = parseScore(m.score)
const group = m.group ?? null
const matchId = await upsertMatch(year, m.round ?? 'Unknown', group, m.date ?? null, m.time ?? null, t1Id, t2Id, score, false)
if (m.goals1?.length) await syncGoals(matchId, t1Id, m.goals1, t2Id)
if (m.goals2?.length) await syncGoals(matchId, t2Id, m.goals2, t1Id)
matchCount++
goalCount += (m.goals1?.length ?? 0) + (m.goals2?.length ?? 0)
}
// 5. Standings (2014, 2018)
const standingsData = await fetchJson(`${BASE}/${year}/worldcup.standings.json`) as { groups?: Record<string, unknown>[] } | null
if (standingsData?.groups) {
for (const grp of standingsData.groups) {
const standings = grp.standings as Record<string, unknown>[]
for (const s of standings) {
const t = s.team as { name: string; code: string }
const teamId = await upsertTeam(t.name, { fifaCode: t.code })
await db.execute(sql`
INSERT INTO group_standings (tournament_year, group_name, team_id, pos, played, won, drawn, lost, goals_for, goals_against, goal_diff, pts)
VALUES (${year}, ${grp.name as string}, ${teamId}, ${s.pos as number ?? null},
${s.played as number ?? 0}, ${s.won as number ?? 0}, ${s.drawn as number ?? 0}, ${s.lost as number ?? 0},
${s.goals_for as number ?? 0}, ${s.goals_against as number ?? 0},
${((s.goals_for as number ?? 0) - (s.goals_against as number ?? 0))},
${s.pts as number ?? 0})
ON CONFLICT (tournament_year, group_name, team_id) DO UPDATE SET
pos = EXCLUDED.pos, played = EXCLUDED.played, won = EXCLUDED.won,
drawn = EXCLUDED.drawn, lost = EXCLUDED.lost, goals_for = EXCLUDED.goals_for,
goals_against = EXCLUDED.goals_against, goal_diff = EXCLUDED.goal_diff, pts = EXCLUDED.pts
`)
}
}
} else if (year !== 2026) {
// Compute standings from match results for years without standings.json
await db.execute(sql`
WITH match_results AS (
SELECT tournament_year, group_name,
team1_id AS team_id,
score_ft_home AS gf, score_ft_away AS ga
FROM matches WHERE tournament_year = ${year} AND group_name IS NOT NULL AND is_quali_playoff = false AND score_ft_home IS NOT NULL
UNION ALL
SELECT tournament_year, group_name,
team2_id, score_ft_away, score_ft_home
FROM matches WHERE tournament_year = ${year} AND group_name IS NOT NULL AND is_quali_playoff = false AND score_ft_home IS NOT NULL
)
INSERT INTO group_standings (tournament_year, group_name, team_id, played, won, drawn, lost, goals_for, goals_against, goal_diff, pts)
SELECT
tournament_year, group_name, team_id,
COUNT(*)::int, SUM(CASE WHEN gf > ga THEN 1 ELSE 0 END)::int,
SUM(CASE WHEN gf = ga THEN 1 ELSE 0 END)::int,
SUM(CASE WHEN gf < ga THEN 1 ELSE 0 END)::int,
SUM(gf)::int, SUM(ga)::int, SUM(gf - ga)::int,
(SUM(CASE WHEN gf > ga THEN 3 WHEN gf = ga THEN 1 ELSE 0 END))::int
FROM match_results
GROUP BY tournament_year, group_name, team_id
ON CONFLICT (tournament_year, group_name, team_id) DO UPDATE SET
played = EXCLUDED.played, won = EXCLUDED.won, drawn = EXCLUDED.drawn,
lost = EXCLUDED.lost, goals_for = EXCLUDED.goals_for, goals_against = EXCLUDED.goals_against,
goal_diff = EXCLUDED.goal_diff, pts = EXCLUDED.pts
`)
}
// 6. Squads (2026)
const squadsData = await fetchJson(`${BASE}/${year}/worldcup.squads.json`) as Record<string, unknown>[] | null
if (squadsData && Array.isArray(squadsData)) {
for (const sq of squadsData) {
const teamId = await upsertTeam(sq.name as string)
for (const p of (sq.players as Record<string, unknown>[])) {
await db.execute(sql`
INSERT INTO squads (tournament_year, team_id, player_name, shirt_number, position, date_of_birth)
VALUES (${year}, ${teamId}, ${p.name as string}, ${p.number as number ?? null},
${p.pos as string ?? null}, ${p.date_of_birth as string ?? null})
ON CONFLICT (tournament_year, team_id, shirt_number) DO UPDATE SET
player_name = EXCLUDED.player_name, position = EXCLUDED.position, date_of_birth = EXCLUDED.date_of_birth
`)
}
}
console.log(` Squads loaded for ${year}`)
}
// 7. Quali playoffs (2026)
const qualiData = await fetchJson(`${BASE}/${year}/worldcup.quali_playoffs.json`) as RawData | null
if (qualiData?.matches) {
for (const m of qualiData.matches) {
const t1Id = await upsertTeam(m.team1)
const t2Id = await upsertTeam(m.team2)
const score = parseScore(m.score)
const matchId = await upsertMatch(year, m.round ?? 'Qualifier', null, m.date ?? null, m.time ?? null, t1Id, t2Id, score, true)
if (m.goals1?.length) await syncGoals(matchId, t1Id, m.goals1, t2Id)
if (m.goals2?.length) await syncGoals(matchId, t2Id, m.goals2, t1Id)
}
console.log(` Quali playoffs: ${qualiData.matches.length} matches`)
}
// 8. Recompute tournament aggregates
await db.execute(sql`
UPDATE tournaments SET
matches_count = (SELECT COUNT(*)::int FROM matches WHERE tournament_year = ${year} AND is_quali_playoff = false),
total_goals = (SELECT COALESCE(SUM(score_ft_home + score_ft_away), 0)::int FROM matches WHERE tournament_year = ${year} AND is_quali_playoff = false AND score_ft_home IS NOT NULL),
avg_goals_per_game = (
SELECT ROUND(COALESCE(SUM(score_ft_home + score_ft_away), 0)::numeric / NULLIF(COUNT(*), 0), 2)
FROM matches
WHERE tournament_year = ${year} AND is_quali_playoff = false AND score_ft_home IS NOT NULL
)
WHERE year = ${year}
`)
console.log(`${matchCount} matches, ${goalCount} goals`)
}
// Compute 2026 group standings from match results
await db.execute(sql`
WITH match_results AS (
SELECT tournament_year, group_name, team1_id AS team_id, score_ft_home AS gf, score_ft_away AS ga
FROM matches WHERE tournament_year = 2026 AND group_name IS NOT NULL AND is_quali_playoff = false AND score_ft_home IS NOT NULL
UNION ALL
SELECT tournament_year, group_name, team2_id, score_ft_away, score_ft_home
FROM matches WHERE tournament_year = 2026 AND group_name IS NOT NULL AND is_quali_playoff = false AND score_ft_home IS NOT NULL
)
INSERT INTO group_standings (tournament_year, group_name, team_id, played, won, drawn, lost, goals_for, goals_against, goal_diff, pts)
SELECT tournament_year, group_name, team_id,
COUNT(*)::int, SUM(CASE WHEN gf > ga THEN 1 ELSE 0 END)::int,
SUM(CASE WHEN gf = ga THEN 1 ELSE 0 END)::int,
SUM(CASE WHEN gf < ga THEN 1 ELSE 0 END)::int,
SUM(gf)::int, SUM(ga)::int, SUM(gf - ga)::int,
SUM(CASE WHEN gf > ga THEN 3 WHEN gf = ga THEN 1 ELSE 0 END)::int
FROM match_results
GROUP BY tournament_year, group_name, team_id
ON CONFLICT (tournament_year, group_name, team_id) DO UPDATE SET
played = EXCLUDED.played, won = EXCLUDED.won, drawn = EXCLUDED.drawn,
lost = EXCLUDED.lost, goals_for = EXCLUDED.goals_for, goals_against = EXCLUDED.goals_against,
goal_diff = EXCLUDED.goal_diff, pts = EXCLUDED.pts
`)
console.log('\n✅ Sync complete!')
await client.end()
}
run().catch(e => { console.error(e); process.exit(1) })
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}