- {/* Hero */}
-
-
- {player.team &&
}
-
-
{player.playerName}
- {player.team && (
-
- {player.team.name} →
-
- )}
-
-
-
{player.goals}
-
World Cup Goals
-
-
-
-
- {/* Stats breakdown */}
-
- {[
- { label: 'Total Goals', value: player.goals },
- { label: 'Open Play', value: normalGoals },
- { label: 'Penalties', value: player.penalties },
- { label: 'Tournaments', value: player.tournaments },
- ].map(item => (
-
-
{item.label}
-
{item.value}
-
- ))}
-
-
- {player.ownGoals > 0 && (
-
- Includes {player.ownGoals} own goal{player.ownGoals !== 1 ? 's' : ''}
-
- )}
-
- {/* Back links */}
-
- ← All-time scorers
- {player.team && (
-
- → {player.team.name} team page
-
- )}
-
-
- )
+}
+
+export default function PlayerPage({ params }: Props) {
+ return
+
Search
+
+ {/* Search input */}
+
+
setQ(e.target.value)}
+ placeholder="Search teams, players, tournaments…"
+ autoFocus
+ className="w-full pl-10 pr-4 py-3 rounded-2xl text-text text-sm outline-none bg-green/[6%] border-green/20"
+ />
+
+
+
+ {loading &&
}
+
+
+ {/* Prompt */}
+ {skip && (
+
+
🔍
+
Search for nations, players, or tournaments…
+
Examples: "Brazil", "Ronaldo", "1966"
+
+ )}
+
+ {/* No results */}
+ {!skip && !loading && total === 0 && (
+
No results for "{debouncedQ}"
+ )}
+
+ {/* Results count */}
+ {!skip && total > 0 && (
+
{total} result{total !== 1 ? 's' : ''} for "{debouncedQ}"
+ )}
+
+
+ {/* Teams */}
+ {results?.teams?.length > 0 && (
+
+ Teams
+
+ {results.teams.map((t: { name: string; iso2?: string | null; slug: string; stats?: { appearances: number; titles: number } | null }) => (
+
+
+
+
+
{t.name}
+
+ {t.stats?.appearances ?? 0} WCs{t.stats?.titles ? · {t.stats.titles} : ''}
+
+
+
+
+ ))}
+
+
+ )}
+
+ {/* Players */}
+ {results?.players?.length > 0 && (
+
+ Players
+
+ {results.players.map((p: { playerName: string; goals: number; tournaments: number; team?: { name: string; iso2?: string | null } | null }) => (
+
+
+ {p.team &&
}
+
+
{p.playerName}
+
{p.team?.name} · {p.tournaments} WC{p.tournaments !== 1 ? 's' : ''}
+
+
{p.goals}
+
+
+ ))}
+
+
+ )}
+
+ {/* Tournaments */}
+ {results?.tournaments?.length > 0 && (
+
+ Tournaments
+
+ {results.tournaments.map((t: { year: number; host: string; winner?: string | null; totalGoals?: number | null; matchesCount?: number | null }) => (
+
+
+
{t.year}
+
{t.host}
+ {t.winner &&
{t.winner}
}
+ {t.totalGoals &&
{t.totalGoals} goals
}
+
+
+ ))}
+
+
+ )}
+
+ {/* Matches */}
+ {results?.matches?.length > 0 && (
+
+ Matches
+
+ {results.matches.map((m: SearchMatch) => (
+
+
+
+
{m.team1.name} vs {m.team2.name}
+ {m.scoreFt &&
{m.scoreFt[0]}–{m.scoreFt[1]} }
+
+
{m.year} · {m.round}
+
+
+ ))}
+
+
+ )}
+
+
+ )
+}
+
+export function SearchClient() {
+ return (
+
-
Search
-
- {/* Search input */}
-
-
setQ(e.target.value)}
- placeholder="Search teams, players, tournaments…"
- autoFocus
- className="w-full pl-10 pr-4 py-3 rounded-2xl text-text text-sm outline-none bg-green/[6%] border-green/20"
- />
-
-
-
- {loading &&
}
-
-
- {/* Prompt */}
- {skip && (
-
-
🔍
-
Search for nations, players, or tournaments…
-
Examples: "Brazil", "Ronaldo", "1966"
-
- )}
-
- {/* No results */}
- {!skip && !loading && total === 0 && (
-
No results for "{debouncedQ}"
- )}
-
- {/* Results count */}
- {!skip && total > 0 && (
-
{total} result{total !== 1 ? 's' : ''} for "{debouncedQ}"
- )}
-
-
- {/* Teams */}
- {results?.teams?.length > 0 && (
-
- Teams
-
- {results.teams.map((t: { name: string; iso2?: string | null; slug: string; stats?: { appearances: number; titles: number } | null }) => (
-
-
-
-
-
{t.name}
-
- {t.stats?.appearances ?? 0} WCs{t.stats?.titles ? · {t.stats.titles} : ''}
-
-
-
-
- ))}
-
-
- )}
-
- {/* Players */}
- {results?.players?.length > 0 && (
-
- Players
-
- {results.players.map((p: { playerName: string; goals: number; tournaments: number; team?: { name: string; iso2?: string | null } | null }) => (
-
-
- {p.team &&
}
-
-
{p.playerName}
-
{p.team?.name} · {p.tournaments} WC{p.tournaments !== 1 ? 's' : ''}
-
-
{p.goals}
-
-
- ))}
-
-
- )}
-
- {/* Tournaments */}
- {results?.tournaments?.length > 0 && (
-
- Tournaments
-
- {results.tournaments.map((t: { year: number; host: string; winner?: string | null; totalGoals?: number | null; matchesCount?: number | null }) => (
-
-
-
{t.year}
-
{t.host}
- {t.winner &&
{t.winner}
}
- {t.totalGoals &&
{t.totalGoals} goals
}
-
-
- ))}
-
-
- )}
-
- {/* Matches */}
- {results?.matches?.length > 0 && (
-
- Matches
-
- {results.matches.map((m: SearchMatch) => (
-
-
-
-
{m.team1.name} vs {m.team2.name}
- {m.scoreFt &&
{m.scoreFt[0]}–{m.scoreFt[1]} }
-
-
{m.year} · {m.round}
-
-
- ))}
-
-
- )}
-
-
- )
+export const metadata: Metadata = {
+ title: 'Search',
+ description: 'Search for teams, players, tournaments and stadiums across all FIFA World Cups.',
+ robots: { index: false },
}
export default function SearchPage() {
- return (
- {
+ const now = new Date()
+
+ const [allTournaments, allTeams, allPlayers] = await Promise.all([
+ db.select({ year: tournaments.year }).from(tournaments).orderBy(asc(tournaments.year)),
+ db.select({ name: teams.name }).from(teams).orderBy(asc(teams.name)),
+ db.selectDistinct({ playerName: goals.playerName }).from(goals),
+ ])
+
+ return [
+ { url: BASE, lastModified: now, changeFrequency: 'hourly', priority: 1 },
+ { url: `${BASE}/groups`, lastModified: now, changeFrequency: 'hourly', priority: 0.9 },
+ { url: `${BASE}/history`, changeFrequency: 'monthly', priority: 0.7 },
+ { url: `${BASE}/stats`, changeFrequency: 'daily', priority: 0.7 },
+ ...allTournaments.map(t => ({
+ url: `${BASE}/tournaments/${t.year}`,
+ changeFrequency: (t.year === 2026 ? 'hourly' : 'monthly') as 'hourly' | 'monthly',
+ priority: t.year === 2026 ? 0.95 : 0.6,
+ })),
+ ...allTeams.map(t => ({
+ url: `${BASE}/teams/${slugify(t.name)}`,
+ changeFrequency: 'weekly' as const,
+ priority: 0.5,
+ })),
+ ...allPlayers.map(p => ({
+ url: `${BASE}/players/${encodeURIComponent(p.playerName)}`,
+ changeFrequency: 'monthly' as const,
+ priority: 0.4,
+ })),
+ ]
+}
diff --git a/app/stats/client.tsx b/app/stats/client.tsx
new file mode 100644
index 0000000..16ac42e
--- /dev/null
+++ b/app/stats/client.tsx
@@ -0,0 +1,371 @@
+'use client'
+import { useQuery, gql } from '@/lib/graphql/hooks'
+import Link from 'next/link'
+import { TeamFlag } from '@/components/team-flag'
+import {
+ ChartBarIcon, StarIcon, TrophyIcon, ClockIcon, BoltIcon,
+ FireIcon, SparklesIcon, ArrowPathIcon, GlobeEuropeAfricaIcon, TableCellsIcon,
+} from '@heroicons/react/24/outline'
+
+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 goalDiff 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, icon: Icon }: { children: React.ReactNode; icon: React.ComponentType<{ className?: string }> }) {
+ return (
+
+
+ {children}
+
+ )
+}
+
+function Card({ children, className = '' }: { children: React.ReactNode; className?: string }) {
+ return (
+
+ {children}
+
+ )
+}
+
+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 function StatsClient() {
+
+ 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 (
+
+
Historical Statistics
+
+ {loading && !data && (
+
Loading statistics…
+ )}
+
+ {/* ── Goals per tournament bar chart ── */}
+ {tournaments.length > 0 && (
+
+
Goals Scored per Tournament
+
+
+
+ {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 (
+
+
{t.totalGoals}
+
+
+ )
+ })}
+
+
+ {tournaments.map(t => (
+
+ {t.year}
+
+ ))}
+
+
+
+
+ )}
+
+
+ {/* ── All-time top scorers ── */}
+
+
All-Time Top Scorers
+
+ {scorers.map((s, i) => (
+
+
+
{i + 1}
+ {s.team &&
}
+
+
{s.playerName}
+
{s.team?.name} · {s.tournaments} WC{s.tournaments !== 1 ? 's' : ''}{s.penalties > 0 ? ` · ${s.penalties}P` : ''}
+
+
+
{s.goals}
+
+
+ ))}
+
+
+
+ {/* ── World Cup titles ── */}
+
+
World Cup Titles by Nation
+
+ {titlesByNation.map((t, i) => (
+
+
+
{i + 1}
+
+
{t.name}
+
+ {Array.from({ length: t.stats?.titles ?? 0 }).map((_, j) => (
+
+ ))}
+
+
{t.stats?.titles}
+
+
+ ))}
+
+
+
+
+ {/* ── Goals by minute heatmap ── */}
+ {minuteBuckets.length > 0 && (
+
+
Goals by Minute (All-Time)
+
+
+
+ {minuteBuckets.map(b => {
+ const h = Math.max(8, Math.round((b.count / maxMinute) * 80))
+ return (
+
+
{b.count}
+
+
{b.bucket}
+
+ )
+ })}
+
+
+
+
+ )}
+
+
+ {/* ── Biggest wins ── */}
+
+
Biggest Victories
+
+ {biggestWins.map(m => (
+
+
+
+
+
{m.team1.name} vs {m.team2.name}
+
{m.year} · {m.round}
+
+
+ {m.scoreFt?.[0]}–{m.scoreFt?.[1]}
+
+
+{m.margin}
+
+
+ ))}
+
+
+
+ {/* ── Highest scoring matches ── */}
+
+
Highest Scoring Matches
+
+ {highScoring.map(m => (
+
+
+
+
+
{m.team1.name} vs {m.team2.name}
+
{m.year} · {m.round}
+
+
+ {m.scoreFt?.[0]}–{m.scoreFt?.[1]}
+
+
{m.totalGoals} goals
+
+
+ ))}
+
+
+
+
+ {/* ── Hat-tricks ── */}
+ {hatTricks.length > 0 && (
+
+
Hat-Tricks
+
+ {hatTricks.map((h, i) => (
+
+
+ {h.team &&
}
+
+
{h.playerName}
+
{h.team?.name}
+
+
{h.goals}
+
+
+ {h.year} · {h.round}
+ {h.opponent && vs {h.opponent.name} }
+
+
+ ))}
+
+
+ )}
+
+ {/* ── ET & Penalty stats ── */}
+ {etStats && (
+
+
Extra Time & Penalty Shootouts
+
+ {[
+ { 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 => (
+
+
{s.label}
+
{s.value}
+
+ ))}
+
+
+ )}
+
+ {/* ── Confederation stats ── */}
+ {confStats.length > 0 && (
+
+
Performance by Confederation
+
+
+
+
+ Confederation
+ Appearances
+ Titles
+ Goals
+
+
+
+ {confStats.map(c => (
+
+ {c.confederation}
+ {c.appearances}
+ {c.titles}
+ {c.totalGoals}
+
+ ))}
+
+
+
+
+ )}
+
+ {/* ── All-time team table ── */}
+ {teams.length > 0 && (
+
+
All-Time Team Table
+
+
+
+
+
+ {['#', 'Team', 'WC', 'W', 'D', 'L', 'GF', 'GA', 'GD', 'Win%'].map((h, i) => (
+ {h}
+ ))}
+
+
+
+ {teams.slice(0, 40).map((t, i) => (
+
+ {i + 1}
+
+
+
+ {t.name}
+
+
+ {t.stats?.appearances}
+ {t.stats?.wins}
+ {t.stats?.draws}
+ {t.stats?.losses}
+ {t.stats?.goalsFor}
+ {t.stats?.goalsAgainst}
+
+ {(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)}
+
+ {t.stats?.winPct}%
+
+ ))}
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/app/stats/page.tsx b/app/stats/page.tsx
index 16bb7cd..d1d2f6f 100644
--- a/app/stats/page.tsx
+++ b/app/stats/page.tsx
@@ -1,373 +1,16 @@
-'use client'
-import { useQuery, gql } from '@/lib/graphql/hooks'
-import { useEffect } from 'react'
-import Link from 'next/link'
-import { TeamFlag } from '@/components/team-flag'
-import {
- ChartBarIcon, StarIcon, TrophyIcon, ClockIcon, BoltIcon,
- FireIcon, SparklesIcon, ArrowPathIcon, GlobeEuropeAfricaIcon, TableCellsIcon,
-} from '@heroicons/react/24/outline'
+import type { Metadata } from 'next'
+import { StatsClient } from './client'
-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 goalDiff 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, icon: Icon }: { children: React.ReactNode; icon: React.ComponentType<{ className?: string }> }) {
- return (
-
-
- {children}
-
- )
+export const metadata: Metadata = {
+ title: 'All-Time Statistics',
+ description: 'All-time FIFA World Cup statistics: top scorers, hat-tricks, penalty records, biggest victories, and goals by tournament from 1930 to 2026.',
+ openGraph: {
+ title: 'FIFA World Cup All-Time Statistics',
+ description: 'All-time World Cup statistics: top scorers, hat-tricks, records and more.',
+ url: '/stats',
+ },
}
-function Card({ children, className = '' }: { children: React.ReactNode; className?: string }) {
- return (
-
- {children}
-
- )
-}
-
-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() {
- useEffect(() => { document.title = 'Statistics · World Cup' }, [])
-
- 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 (
-
-
Historical Statistics
-
- {loading && !data && (
-
Loading statistics…
- )}
-
- {/* ── Goals per tournament bar chart ── */}
- {tournaments.length > 0 && (
-
-
Goals Scored per Tournament
-
-
-
- {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 (
-
-
{t.totalGoals}
-
-
- )
- })}
-
-
- {tournaments.map(t => (
-
- {t.year}
-
- ))}
-
-
-
-
- )}
-
-
- {/* ── All-time top scorers ── */}
-
-
All-Time Top Scorers
-
- {scorers.map((s, i) => (
-
-
-
{i + 1}
- {s.team &&
}
-
-
{s.playerName}
-
{s.team?.name} · {s.tournaments} WC{s.tournaments !== 1 ? 's' : ''}{s.penalties > 0 ? ` · ${s.penalties}P` : ''}
-
-
-
{s.goals}
-
-
- ))}
-
-
-
- {/* ── World Cup titles ── */}
-
-
World Cup Titles by Nation
-
- {titlesByNation.map((t, i) => (
-
-
-
{i + 1}
-
-
{t.name}
-
- {Array.from({ length: t.stats?.titles ?? 0 }).map((_, j) => (
-
- ))}
-
-
{t.stats?.titles}
-
-
- ))}
-
-
-
-
- {/* ── Goals by minute heatmap ── */}
- {minuteBuckets.length > 0 && (
-
-
Goals by Minute (All-Time)
-
-
-
- {minuteBuckets.map(b => {
- const h = Math.max(8, Math.round((b.count / maxMinute) * 80))
- return (
-
-
{b.count}
-
-
{b.bucket}
-
- )
- })}
-
-
-
-
- )}
-
-
- {/* ── Biggest wins ── */}
-
-
Biggest Victories
-
- {biggestWins.map(m => (
-
-
-
-
-
{m.team1.name} vs {m.team2.name}
-
{m.year} · {m.round}
-
-
- {m.scoreFt?.[0]}–{m.scoreFt?.[1]}
-
-
+{m.margin}
-
-
- ))}
-
-
-
- {/* ── Highest scoring matches ── */}
-
-
Highest Scoring Matches
-
- {highScoring.map(m => (
-
-
-
-
-
{m.team1.name} vs {m.team2.name}
-
{m.year} · {m.round}
-
-
- {m.scoreFt?.[0]}–{m.scoreFt?.[1]}
-
-
{m.totalGoals} goals
-
-
- ))}
-
-
-
-
- {/* ── Hat-tricks ── */}
- {hatTricks.length > 0 && (
-
-
Hat-Tricks
-
- {hatTricks.map((h, i) => (
-
-
- {h.team &&
}
-
-
{h.playerName}
-
{h.team?.name}
-
-
{h.goals}
-
-
- {h.year} · {h.round}
- {h.opponent && vs {h.opponent.name} }
-
-
- ))}
-
-
- )}
-
- {/* ── ET & Penalty stats ── */}
- {etStats && (
-
-
Extra Time & Penalty Shootouts
-
- {[
- { 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 => (
-
-
{s.label}
-
{s.value}
-
- ))}
-
-
- )}
-
- {/* ── Confederation stats ── */}
- {confStats.length > 0 && (
-
-
Performance by Confederation
-
-
-
-
- Confederation
- Appearances
- Titles
- Goals
-
-
-
- {confStats.map(c => (
-
- {c.confederation}
- {c.appearances}
- {c.titles}
- {c.totalGoals}
-
- ))}
-
-
-
-
- )}
-
- {/* ── All-time team table ── */}
- {teams.length > 0 && (
-
-
All-Time Team Table
-
-
-
-
-
- {['#', 'Team', 'WC', 'W', 'D', 'L', 'GF', 'GA', 'GD', 'Win%'].map((h, i) => (
- {h}
- ))}
-
-
-
- {teams.slice(0, 40).map((t, i) => (
-
- {i + 1}
-
-
-
- {t.name}
-
-
- {t.stats?.appearances}
- {t.stats?.wins}
- {t.stats?.draws}
- {t.stats?.losses}
- {t.stats?.goalsFor}
- {t.stats?.goalsAgainst}
-
- {(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)}
-
- {t.stats?.winPct}%
-
- ))}
-
-
-
-
-
- )}
-
- )
+ return
}
diff --git a/app/teams/[slug]/client.tsx b/app/teams/[slug]/client.tsx
new file mode 100644
index 0000000..8e00a70
--- /dev/null
+++ b/app/teams/[slug]/client.tsx
@@ -0,0 +1,270 @@
+'use client'
+import { useQuery, gql } from '@/lib/graphql/hooks'
+import { use, useEffect } from 'react'
+import Link from 'next/link'
+import { TeamFlag } from '@/components/team-flag'
+import { TrophyIcon } from '@heroicons/react/24/outline'
+
+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($teamId: Int!) {
+ matches(teamId: $teamId, isQuali: false) {
+ id year round group date isLive scoreFt scoreEt scoreP
+ team1 { name iso2 slug } team2 { name iso2 slug }
+ }
+ }
+`
+
+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
+}
+
+interface MatchRow {
+ id: number; year: number; round: string; group?: string | null
+ date?: string | null; isLive: boolean
+ scoreFt?: number[] | null; scoreEt?: number[] | null; scoreP?: number[] | null
+ team1: { name: string; iso2?: string | null; slug?: string | null }
+ team2: { name: string; iso2?: string | null; slug?: string | null }
+}
+
+function formatDate(d: string) {
+ return new Date(d).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })
+}
+
+export function TeamClient({ 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
+
+ useEffect(() => {
+ }, [team])
+
+ const { data: matchesData } = useQuery(TEAM_MATCHES_QUERY, {
+ variables: { teamId: team?.id },
+ skip: !team?.id,
+ })
+
+ const { data: scorerData } = useQuery(gql`
+ query TeamScorers($teamId: Int!) {
+ topScorers(teamId: $teamId, limit: 30) {
+ playerName goals penalties ownGoals tournaments
+ team { id name iso2 }
+ }
+ }
+ `, { variables: { teamId: team?.id ?? 0 }, skip: !team?.id })
+
+ const teamScorers = scorerData?.topScorers ?? []
+ const teamMatches: MatchRow[] = matchesData?.matches ?? []
+
+ // Group matches by year for the history display
+ const matchesByYear = teamMatches.reduce((acc: Record, m) => {
+ ;(acc[m.year] ??= []).push(m)
+ return acc
+ }, {})
+ const years = Object.keys(matchesByYear).map(Number).sort((a, b) => b - a)
+
+ if (loading && !teamData) {
+ return Loading team…
+ }
+
+ if (!team) {
+ return Team not found.
+ }
+
+ 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 (
+
+ {/* Hero */}
+
+
+
+
+
{team.name}
+
+ {team.fifaCode && {team.fifaCode} }
+ {team.confederation && {team.confederation} }
+ {team.continent && {team.continent} }
+ {(s?.titles ?? 0) > 0 && (
+
+ {Array.from({ length: s?.titles ?? 0 }).map((_, i) => )}
+ {s?.titles} title{(s?.titles ?? 0) !== 1 ? 's' : ''}
+
+ )}
+
+
+
+
+
+
+
+ {/* Stats grid */}
+ {s && (
+
+
World Cup Record
+
+ {[
+ { label: 'Appearances', value: s.appearances },
+ { label: 'Matches', value: played },
+ { label: 'Win %', value: `${s.winPct}%` },
+ { label: 'Goals For', value: s.goalsFor },
+ ].map(item => (
+
+
{item.label}
+
{item.value}
+
+ ))}
+
+
+
+ Team W D
+ L GF
+ GA GD
+
+
+
+
+ {team.name}
+
+ {[s.wins, s.draws, s.losses, s.goalsFor, s.goalsAgainst].map((v, i) => (
+
{v}
+ ))}
+
{s.goalDiff >= 0 ? `+${s.goalDiff}` : s.goalDiff}
+
+
+
+ )}
+
+ {/* Tournament participations */}
+ {years.length > 0 && (
+
+
Tournament Participations
+
+ {years.map(year => (
+
+ {year}
+
+ ))}
+
+
+ )}
+
+ {/* Match history by year */}
+ {years.length > 0 && (
+
+
Match History
+
+ {years.map(year => {
+ const yMatches = matchesByYear[year]
+ return (
+
+
+ {year}
+
+
+ {yMatches.map((m, i) => {
+ const isHome = m.team1.name === team.name
+ const opponent = isHome ? m.team2 : m.team1
+ const ft = m.scoreFt
+ const scoreEt = m.scoreEt
+ const scoreP = m.scoreP
+ // Winner: PSO first, then ET, then FT
+ const decisive = scoreP ?? scoreEt ?? ft
+ const myScore = decisive ? (isHome ? decisive[0] : decisive[1]) : null
+ const theirScore = decisive ? (isHome ? decisive[1] : decisive[0]) : null
+ const result = myScore != null && theirScore != null
+ ? myScore > theirScore ? 'W' : myScore < theirScore ? 'L' : 'D'
+ : null
+ const resultColor = result === 'W' ? 'text-green' : result === 'L' ? 'text-red-500' : 'text-green-sec'
+ // Display the decisive score (ET score for AET matches, FT for normal, PSO for shootouts)
+ const displayScore = scoreP ? null : (scoreEt ?? ft)
+ return (
+
+
+
{result ?? '–'}
+
+
+
{opponent.name}
+
+ {m.round}{m.group ? ` · ${m.group}` : ''}{m.date ? ` · ${formatDate(m.date)}` : ''}
+
+
+
+
+ {scoreP
+ ? `${isHome ? scoreP[0] : scoreP[1]}–${isHome ? scoreP[1] : scoreP[0]}`
+ : displayScore
+ ? `${isHome ? displayScore[0] : displayScore[1]}–${isHome ? displayScore[1] : displayScore[0]}`
+ : '–'}
+
+ {scoreP && ft && (
+
+ {`${isHome ? ft[0] : ft[1]}–${isHome ? ft[1] : ft[0]}`} a.e.t.
+
+ )}
+ {scoreEt && !scoreP && (
+
a.e.t.
+ )}
+
+
+
+ )
+ })}
+
+
+ )
+ })}
+
+
+ )}
+
+
+ {/* Sidebar: top scorers */}
+
+ {teamScorers.length > 0 && (
+
+
Top Scorers
+
+ {teamScorers.map((sc: { playerName: string; goals: number; penalties: number; tournaments: number }, i: number) => (
+
+
+
{i + 1}
+
+
{sc.playerName}
+
+ {sc.tournaments} WC{sc.tournaments !== 1 ? 's' : ''}{sc.penalties > 0 ? ` · ${sc.penalties}P` : ''}
+
+
+
+
{sc.goals}
+
+
+ ))}
+
+
+ )}
+
+
+
+ )
+}
diff --git a/app/teams/[slug]/page.tsx b/app/teams/[slug]/page.tsx
index 531e9c6..a101c16 100644
--- a/app/teams/[slug]/page.tsx
+++ b/app/teams/[slug]/page.tsx
@@ -1,271 +1,28 @@
-'use client'
-import { useQuery, gql } from '@/lib/graphql/hooks'
-import { use, useEffect } from 'react'
-import Link from 'next/link'
-import { TeamFlag } from '@/components/team-flag'
-import { TrophyIcon } from '@heroicons/react/24/outline'
+import type { Metadata } from 'next'
+import { db } from '@/lib/db'
+import { teams } from '@/lib/db/schema'
+import { TeamClient } from './client'
-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($teamId: Int!) {
- matches(teamId: $teamId, isQuali: false) {
- id year round group date isLive scoreFt scoreEt scoreP
- team1 { name iso2 slug } team2 { name iso2 slug }
- }
- }
-`
+type Props = { params: Promise<{ slug: string }> }
-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
+function slugify(name: string) {
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
}
-interface MatchRow {
- id: number; year: number; round: string; group?: string | null
- date?: string | null; isLive: boolean
- scoreFt?: number[] | null; scoreEt?: number[] | null; scoreP?: number[] | null
- team1: { name: string; iso2?: string | null; slug?: string | null }
- team2: { name: string; iso2?: string | null; slug?: string | null }
-}
-
-function formatDate(d: string) {
- return new Date(d).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })
-}
-
-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
-
- useEffect(() => {
- document.title = team ? `${team.name} · World Cup` : 'Team · World Cup'
- }, [team])
-
- const { data: matchesData } = useQuery(TEAM_MATCHES_QUERY, {
- variables: { teamId: team?.id },
- skip: !team?.id,
- })
-
- const { data: scorerData } = useQuery(gql`
- query TeamScorers($teamId: Int!) {
- topScorers(teamId: $teamId, limit: 30) {
- playerName goals penalties ownGoals tournaments
- team { id name iso2 }
- }
- }
- `, { variables: { teamId: team?.id ?? 0 }, skip: !team?.id })
-
- const teamScorers = scorerData?.topScorers ?? []
- const teamMatches: MatchRow[] = matchesData?.matches ?? []
-
- // Group matches by year for the history display
- const matchesByYear = teamMatches.reduce((acc: Record, m) => {
- ;(acc[m.year] ??= []).push(m)
- return acc
- }, {})
- const years = Object.keys(matchesByYear).map(Number).sort((a, b) => b - a)
-
- if (loading && !teamData) {
- return Loading team…
+export async function generateMetadata({ params }: Props): Promise {
+ const { slug } = await params
+ const allTeams = await db.select({ name: teams.name }).from(teams)
+ const team = allTeams.find(t => slugify(t.name) === slug)
+ const name = team?.name ?? slug
+ const title = `${name} at the FIFA World Cup`
+ const description = `${name} World Cup history — all matches, results, goals and top scorers across every tournament appearance.`
+ return {
+ title,
+ description,
+ openGraph: { title, description, url: `/teams/${slug}` },
}
-
- if (!team) {
- return Team not found.
- }
-
- 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 (
-
- {/* Hero */}
-
-
-
-
-
{team.name}
-
- {team.fifaCode && {team.fifaCode} }
- {team.confederation && {team.confederation} }
- {team.continent && {team.continent} }
- {(s?.titles ?? 0) > 0 && (
-
- {Array.from({ length: s?.titles ?? 0 }).map((_, i) => )}
- {s?.titles} title{(s?.titles ?? 0) !== 1 ? 's' : ''}
-
- )}
-
-
-
-
-
-
-
- {/* Stats grid */}
- {s && (
-
-
World Cup Record
-
- {[
- { label: 'Appearances', value: s.appearances },
- { label: 'Matches', value: played },
- { label: 'Win %', value: `${s.winPct}%` },
- { label: 'Goals For', value: s.goalsFor },
- ].map(item => (
-
-
{item.label}
-
{item.value}
-
- ))}
-
-
-
- Team W D
- L GF
- GA GD
-
-
-
-
- {team.name}
-
- {[s.wins, s.draws, s.losses, s.goalsFor, s.goalsAgainst].map((v, i) => (
-
{v}
- ))}
-
{s.goalDiff >= 0 ? `+${s.goalDiff}` : s.goalDiff}
-
-
-
- )}
-
- {/* Tournament participations */}
- {years.length > 0 && (
-
-
Tournament Participations
-
- {years.map(year => (
-
- {year}
-
- ))}
-
-
- )}
-
- {/* Match history by year */}
- {years.length > 0 && (
-
-
Match History
-
- {years.map(year => {
- const yMatches = matchesByYear[year]
- return (
-
-
- {year}
-
-
- {yMatches.map((m, i) => {
- const isHome = m.team1.name === team.name
- const opponent = isHome ? m.team2 : m.team1
- const ft = m.scoreFt
- const scoreEt = m.scoreEt
- const scoreP = m.scoreP
- // Winner: PSO first, then ET, then FT
- const decisive = scoreP ?? scoreEt ?? ft
- const myScore = decisive ? (isHome ? decisive[0] : decisive[1]) : null
- const theirScore = decisive ? (isHome ? decisive[1] : decisive[0]) : null
- const result = myScore != null && theirScore != null
- ? myScore > theirScore ? 'W' : myScore < theirScore ? 'L' : 'D'
- : null
- const resultColor = result === 'W' ? 'text-green' : result === 'L' ? 'text-red-500' : 'text-green-sec'
- // Display the decisive score (ET score for AET matches, FT for normal, PSO for shootouts)
- const displayScore = scoreP ? null : (scoreEt ?? ft)
- return (
-
-
-
{result ?? '–'}
-
-
-
{opponent.name}
-
- {m.round}{m.group ? ` · ${m.group}` : ''}{m.date ? ` · ${formatDate(m.date)}` : ''}
-
-
-
-
- {scoreP
- ? `${isHome ? scoreP[0] : scoreP[1]}–${isHome ? scoreP[1] : scoreP[0]}`
- : displayScore
- ? `${isHome ? displayScore[0] : displayScore[1]}–${isHome ? displayScore[1] : displayScore[0]}`
- : '–'}
-
- {scoreP && ft && (
-
- {`${isHome ? ft[0] : ft[1]}–${isHome ? ft[1] : ft[0]}`} a.e.t.
-
- )}
- {scoreEt && !scoreP && (
-
a.e.t.
- )}
-
-
-
- )
- })}
-
-
- )
- })}
-
-
- )}
-
-
- {/* Sidebar: top scorers */}
-
- {teamScorers.length > 0 && (
-
-
Top Scorers
-
- {teamScorers.map((sc: { playerName: string; goals: number; penalties: number; tournaments: number }, i: number) => (
-
-
-
{i + 1}
-
-
{sc.playerName}
-
- {sc.tournaments} WC{sc.tournaments !== 1 ? 's' : ''}{sc.penalties > 0 ? ` · ${sc.penalties}P` : ''}
-
-
-
-
{sc.goals}
-
-
- ))}
-
-
- )}
-
-
-
- )
+}
+
+export default function TeamPage({ params }: Props) {
+ return
}
diff --git a/app/tournaments/[year]/client.tsx b/app/tournaments/[year]/client.tsx
new file mode 100644
index 0000000..724e06b
--- /dev/null
+++ b/app/tournaments/[year]/client.tsx
@@ -0,0 +1,284 @@
+'use client'
+import { useQuery, gql } from '@/lib/graphql/hooks'
+import { use, useEffect } 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], i: number) => (
+
+ {i > 0 && , }
+
+ {g.playerName}
+
+ {' '}{g.minute ?? ''}{g.minuteOffset ? `+${g.minuteOffset}` : ''}'{g.isPenalty ? ' (P)' : g.isOwnGoal ? ' (OG)' : ''}
+
+ )
+ return (
+
+
{t1Goals.map(renderGoal)}
+
{t2Goals.map(renderGoal)}
+
+ )
+}
+
+export function TournamentClient({ 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 })
+
+ useEffect(() => {
+ if (!data) return
+ const hash = window.location.hash
+ if (!hash) return
+ const el = document.getElementById(hash.slice(1))
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
+ }, [data])
+
+
+ const t = data?.tournament
+ const standings: Standing[] = data?.groupStandings ?? []
+ const byGroup = standings.reduce>((acc, s) => {
+ acc[s.groupName] = [...(acc[s.groupName] ?? []), s]
+ return acc
+ }, {})
+
+ const allMatches: MatchData[] = t?.matches ?? []
+ const byRound = allMatches.reduce>((acc, m) => {
+ const key = m.group ?? m.round
+ acc[key] = [...(acc[key] ?? []), m]
+ return acc
+ }, {})
+
+ // Union of groups from standings + groups from match data (handles groups with no played matches yet)
+ const groupNames = new Set([
+ ...Object.keys(byGroup),
+ ...allMatches.filter(m => m.group).map(m => m.group!),
+ ])
+ const groupRounds = [...groupNames].sort().map(g => [g, byGroup[g] ?? []] as [string, Standing[]])
+ const koRounds = allMatches.filter(m => !m.group && !m.isQualiPlayoff)
+ const koByRound = koRounds.reduce>((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 (
+
+
+
Loading {year} World Cup…
+
+ )
+ }
+
+ if (!t) return Tournament {year} not found.
+
+ return (
+
+ {/* Header */}
+
+ {liveMatches.length > 0 &&
}
+
+
+ {t.winner && (
+
+
+
{t.winner}
+ {t.runnerUp &&
def. {t.runnerUp}
}
+
+ )}
+
+
+ {[
+ { 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 => (
+
+
{s.label}
+
{s.value}
+
+ ))}
+
+
+
+
+
+ {/* Live matches first */}
+ {liveMatches.length > 0 && (
+
+
LIVE
+
+ {liveMatches.map(m => (
+
+
+
+
+ ))}
+
+
+ )}
+
+ {/* Group stage */}
+ {groupRounds.length > 0 && (
+
+
Group Stage
+ {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) => {
+ if (!a.date) return 1; if (!b.date) return -1
+ const cmp = a.date.localeCompare(b.date)
+ if (cmp !== 0) return cmp
+ return (a.time ?? '').localeCompare(b.time ?? '')
+ })
+ return (
+
+
{groupName}
+ {/* Standings mini */}
+
+ {sorted.map((s, i) => (
+
+
+
+ {s.team.name}
+ {s.played}
+ {s.won}
+ {s.drawn}
+ {s.lost}
+ {s.pts}
+
+
+ ))}
+
+ {/* Group matches */}
+
+ {groupMatches.map(m => (
+
+
+
+
+ ))}
+
+
+ )
+ })}
+
+ )}
+
+ {/* Knockout rounds */}
+ {Object.keys(koByRound).length > 0 && (
+
+
Knockout Stage
+ {Object.entries(koByRound).map(([round, roundMatches]) => (
+
+
{round}
+
+ {roundMatches.map(m => (
+
+
+
+
+ ))}
+
+
+ ))}
+
+ )}
+
+
+ {/* Sidebar: top scorers */}
+
+
+
TOP SCORERS
+
+ {t.topScorers?.map((s: { playerName: string; goals: number; penalties: number; team?: { name: string; iso2?: string | null; slug: string } | null }, i: number) => (
+
+
+
{i + 1}
+ {s.team &&
}
+
+
{s.playerName}
+ {s.penalties > 0 &&
{s.penalties} pen
}
+
+
+
{s.goals}
+
+
+ ))}
+
+
+ {t.thirdPlace && (
+
+
3rd Place
+
+
+ {t.thirdPlace}
+
+ {t.fourthPlace && (
+
+
+ {t.fourthPlace}
+
+ )}
+
+ )}
+
+
+
+
+ )
+}
diff --git a/app/tournaments/[year]/page.tsx b/app/tournaments/[year]/page.tsx
index 2676ac6..1181f15 100644
--- a/app/tournaments/[year]/page.tsx
+++ b/app/tournaments/[year]/page.tsx
@@ -1,289 +1,26 @@
-'use client'
-import { useQuery, gql } from '@/lib/graphql/hooks'
-import { use, useEffect } 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'
+import type { Metadata } from 'next'
+import { db } from '@/lib/db'
+import { tournaments } from '@/lib/db/schema'
+import { eq } from 'drizzle-orm'
+import { TournamentClient } from './client'
-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 }
- }
- }
-`
+type Props = { params: Promise<{ year: string }> }
-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], i: number) => (
-
- {i > 0 && , }
-
- {g.playerName}
-
- {' '}{g.minute ?? ''}{g.minuteOffset ? `+${g.minuteOffset}` : ''}'{g.isPenalty ? ' (P)' : g.isOwnGoal ? ' (OG)' : ''}
-
- )
- return (
-
-
{t1Goals.map(renderGoal)}
-
{t2Goals.map(renderGoal)}
-
- )
-}
-
-export default function TournamentPage({ params }: { params: Promise<{ year: string }> }) {
- const { year: yearStr } = use(params)
+export async function generateMetadata({ params }: Props): Promise {
+ const { year: yearStr } = await params
const year = parseInt(yearStr)
- const { data, loading } = useQuery(TOURNAMENT_QUERY, { variables: { year }, pollInterval: year === 2026 ? 60_000 : 0 })
-
- useEffect(() => {
- if (!data) return
- const hash = window.location.hash
- if (!hash) return
- const el = document.getElementById(hash.slice(1))
- if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
- }, [data])
-
- useEffect(() => {
- document.title = data?.tournament
- ? `${year} World Cup · World Cup`
- : `${year} · World Cup`
- }, [data, year])
-
- const t = data?.tournament
- const standings: Standing[] = data?.groupStandings ?? []
- const byGroup = standings.reduce>((acc, s) => {
- acc[s.groupName] = [...(acc[s.groupName] ?? []), s]
- return acc
- }, {})
-
- const allMatches: MatchData[] = t?.matches ?? []
- const byRound = allMatches.reduce>((acc, m) => {
- const key = m.group ?? m.round
- acc[key] = [...(acc[key] ?? []), m]
- return acc
- }, {})
-
- // Union of groups from standings + groups from match data (handles groups with no played matches yet)
- const groupNames = new Set([
- ...Object.keys(byGroup),
- ...allMatches.filter(m => m.group).map(m => m.group!),
- ])
- const groupRounds = [...groupNames].sort().map(g => [g, byGroup[g] ?? []] as [string, Standing[]])
- const koRounds = allMatches.filter(m => !m.group && !m.isQualiPlayoff)
- const koByRound = koRounds.reduce>((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 (
-
-
-
Loading {year} World Cup…
-
- )
+ const [t] = await db.select().from(tournaments).where(eq(tournaments.year, year)).limit(1)
+ const title = `${year} FIFA World Cup`
+ const description = t
+ ? `${year} FIFA World Cup hosted by ${t.host}.${t.winner ? ` Winner: ${t.winner}.` : ''} Matches, scores, group standings and statistics.`
+ : `${year} FIFA World Cup — matches, scores and statistics.`
+ return {
+ title,
+ description,
+ openGraph: { title, description, url: `/tournaments/${year}` },
}
-
- if (!t) return Tournament {year} not found.
-
- return (
-
- {/* Header */}
-
- {liveMatches.length > 0 &&
}
-
-
- {t.winner && (
-
-
-
{t.winner}
- {t.runnerUp &&
def. {t.runnerUp}
}
-
- )}
-
-
- {[
- { 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 => (
-
-
{s.label}
-
{s.value}
-
- ))}
-
-
-
-
-
- {/* Live matches first */}
- {liveMatches.length > 0 && (
-
-
LIVE
-
- {liveMatches.map(m => (
-
-
-
-
- ))}
-
-
- )}
-
- {/* Group stage */}
- {groupRounds.length > 0 && (
-
-
Group Stage
- {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) => {
- if (!a.date) return 1; if (!b.date) return -1
- const cmp = a.date.localeCompare(b.date)
- if (cmp !== 0) return cmp
- return (a.time ?? '').localeCompare(b.time ?? '')
- })
- return (
-
-
{groupName}
- {/* Standings mini */}
-
- {sorted.map((s, i) => (
-
-
-
- {s.team.name}
- {s.played}
- {s.won}
- {s.drawn}
- {s.lost}
- {s.pts}
-
-
- ))}
-
- {/* Group matches */}
-
- {groupMatches.map(m => (
-
-
-
-
- ))}
-
-
- )
- })}
-
- )}
-
- {/* Knockout rounds */}
- {Object.keys(koByRound).length > 0 && (
-
-
Knockout Stage
- {Object.entries(koByRound).map(([round, roundMatches]) => (
-
-
{round}
-
- {roundMatches.map(m => (
-
-
-
-
- ))}
-
-
- ))}
-
- )}
-
-
- {/* Sidebar: top scorers */}
-
-
-
TOP SCORERS
-
- {t.topScorers?.map((s: { playerName: string; goals: number; penalties: number; team?: { name: string; iso2?: string | null; slug: string } | null }, i: number) => (
-
-
-
{i + 1}
- {s.team &&
}
-
-
{s.playerName}
- {s.penalties > 0 &&
{s.penalties} pen
}
-
-
-
{s.goals}
-
-
- ))}
-
-
- {t.thirdPlace && (
-
-
3rd Place
-
-
- {t.thirdPlace}
-
- {t.fourthPlace && (
-
-
- {t.fourthPlace}
-
- )}
-
- )}
-
-
-
-
- )
+}
+
+export default function TournamentPage({ params }: Props) {
+ return
}