Files
worldcup/app/stats/page.tsx
T
valknar 58b4114159 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>
2026-06-14 15:36:44 +02:00

349 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
)
}