diff --git a/app/favicon.ico b/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/app/favicon.ico and /dev/null differ diff --git a/app/globals.css b/app/globals.css index aaec121..18c4300 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,6 +1,8 @@ @import "tailwindcss"; @import "flag-icons/css/flag-icons.min.css"; +@custom-variant hover (&:hover); + @theme { --color-bg: #040d08; --color-card: #0a1810; diff --git a/app/page.tsx b/app/page.tsx index bac34b7..1387567 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -11,15 +11,15 @@ const HOME_QUERY = gql` tournamentStats { totalTournaments totalMatches totalGoals avgGoalsPerGame } liveMatches { id year round group date time isLive scoreFt scoreEt scoreP isQualiPlayoff - team1 { name iso2 } team2 { name iso2 } + team1 { name iso2 slug } team2 { name iso2 slug } } recentMatches(limit: 9) { id year round group date time isLive isQualiPlayoff scoreFt scoreEt scoreP - team1 { name iso2 } team2 { name iso2 } + team1 { name iso2 slug } team2 { name iso2 slug } } upcomingMatches(limit: 9) { id year round group date time isLive isQualiPlayoff scoreFt - team1 { name iso2 } team2 { name iso2 } + team1 { name iso2 slug } team2 { name iso2 slug } } topScorers(year: 2026, limit: 8) { playerName goals penalties ownGoals @@ -80,8 +80,8 @@ 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 } + team1: { name: string; iso2?: string | null; slug?: string | null } + team2: { name: string; iso2?: string | null; slug?: string | null } } export default function HomePage() { @@ -185,15 +185,15 @@ export default function HomePage() {
{scorers.map((s, i) => ( -
{i + 1} {s.team && }
{s.playerName}
-
{s.team?.name}{s.penalties > 0 ? ` · ${s.penalties}P` : ''}
+
{s.team?.name}{s.penalties > 0 ? ` · ${s.penalties}P` : ''}
-
+
{s.goals} diff --git a/app/stats/page.tsx b/app/stats/page.tsx index 848c122..07dd88e 100644 --- a/app/stats/page.tsx +++ b/app/stats/page.tsx @@ -94,14 +94,14 @@ export default function StatsPage() {
⚽ 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}
+ +
{t.totalGoals}
-
+
{tournaments.map(t => (
{t.year} @@ -129,15 +129,15 @@ export default function StatsPage() { {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.team?.name} · {s.tournaments} WC{s.tournaments !== 1 ? 's' : ''}{s.penalties > 0 ? ` · ${s.penalties}P` : ''}
-
+
{s.goals} @@ -153,12 +153,12 @@ export default function StatsPage() { {titlesByNation.map((t, i) => ( -
{i + 1} -
{t.name}
-
+
{t.name}
+
{Array.from({ length: t.stats?.titles ?? 0 }).map((_, j) => ( 🏆 ))} @@ -175,18 +175,20 @@ export default function StatsPage() { {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} -
- ) - })} + +
+
+ {minuteBuckets.map(b => { + const h = Math.max(8, Math.round((b.count / maxMinute) * 80)) + return ( +
+ {b.count} +
+ {b.bucket} +
+ ) + })} +
diff --git a/app/teams/[slug]/page.tsx b/app/teams/[slug]/page.tsx index 50911c3..c25dc50 100644 --- a/app/teams/[slug]/page.tsx +++ b/app/teams/[slug]/page.tsx @@ -3,7 +3,6 @@ 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' const TEAM_QUERY = gql` query Team($slug: String!) { @@ -14,10 +13,10 @@ const TEAM_QUERY = gql` } ` const TEAM_MATCHES_QUERY = gql` - query TeamMatches($teamName: String!) { - topScorers(limit: 100) { - playerName goals penalties ownGoals tournaments - team { name iso2 } + 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 } } } ` @@ -31,6 +30,18 @@ interface TeamData { } | 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 default function TeamPage({ params }: { params: Promise<{ slug: string }> }) { const { slug } = use(params) const { data: teamData, loading } = useQuery(TEAM_QUERY, { variables: { slug } }) @@ -40,6 +51,11 @@ export default function TeamPage({ params }: { params: Promise<{ slug: string }> 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, + }) + // Load all scorers to filter by team const { data: scorerData } = useQuery(gql` query TeamScorers { @@ -52,6 +68,14 @@ export default function TeamPage({ params }: { params: Promise<{ slug: string }> const allScorers = scorerData?.topScorers ?? [] const teamScorers = team ? allScorers.filter((s: { team?: { id: number } | null }) => s.team?.id === team.id).slice(0, 15) : [] + 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…
@@ -130,6 +154,93 @@ export default function TeamPage({ params }: { params: Promise<{ slug: string }>
)} + + {/* 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-[#22c55e]' : result === 'L' ? 'text-[#ef4444]' : 'text-[#6abf7a]' + // 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 */} diff --git a/app/tournaments/[year]/page.tsx b/app/tournaments/[year]/page.tsx index 442cefa..f967f61 100644 --- a/app/tournaments/[year]/page.tsx +++ b/app/tournaments/[year]/page.tsx @@ -49,12 +49,20 @@ 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)' : ''}` + 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).join(', ')}
-
{t2Goals.map(renderGoal).join(', ')}
+
{t1Goals.map(renderGoal)}
+
{t2Goals.map(renderGoal)}
) } diff --git a/components/match-card.tsx b/components/match-card.tsx index 6d13fe3..74680d2 100644 --- a/components/match-card.tsx +++ b/components/match-card.tsx @@ -2,7 +2,7 @@ import Link from 'next/link' import { TeamFlag } from './team-flag' import { LiveBadge } from './live-badge' -interface Team { name: string; iso2?: string | null } +interface Team { name: string; iso2?: string | null; slug?: string | null } interface Match { id: number year: number @@ -25,8 +25,8 @@ function formatDate(d: string) { export function MatchCard({ match, compact = false }: { match: Match; compact?: boolean }) { const ft = match.scoreFt const hasScore = ft != null - // Penalty score determines the winner when present - const decisive = match.scoreP ?? ft + // Winner: penalties first, then ET, then FT + const decisive = match.scoreP ?? match.scoreEt ?? ft const winner = decisive ? (decisive[0] > decisive[1] ? 'home' : decisive[0] < decisive[1] ? 'away' : 'draw') : null if (compact) { @@ -48,7 +48,9 @@ export function MatchCard({ match, compact = false }: { match: Match; compact?: {hasScore ? match.scoreP ? `${match.scoreP[0]} – ${match.scoreP[1]}` - : `${ft![0]} – ${ft![1]}` + : match.scoreEt + ? `${match.scoreEt[0]} – ${match.scoreEt[1]}` + : `${ft![0]} – ${ft![1]}` : match.isLive ? : '–'}
{match.scoreP && ( @@ -56,6 +58,9 @@ export function MatchCard({ match, compact = false }: { match: Match; compact?: {ft![0]}–{ft![1]} a.e.t.
)} + {match.scoreEt && !match.scoreP && ( +
a.e.t.
+ )}
@@ -72,42 +77,46 @@ export function MatchCard({ match, compact = false }: { match: Match; compact?: ) } + const matchHref = `/tournaments/${match.year}#match-${match.id}` + return ( - -
- {match.isLive &&
} -
-
- -
- {match.team1.name} -
+
+ {match.isLive &&
} +
+ + +
+ {match.team1.name}
-
-
- {hasScore - ? match.scoreP - ? `${match.scoreP[0]}–${match.scoreP[1]}` + + +
+ {hasScore + ? match.scoreP + ? `${match.scoreP[0]}–${match.scoreP[1]}` + : match.scoreEt + ? `${match.scoreEt[0]}–${match.scoreEt[1]}` : `${ft![0]}–${ft![1]}` - : '?–?'} -
- {match.scoreP && ( -
{ft![0]}–{ft![1]} a.e.t.
- )} - {match.scoreEt && !match.scoreP && ( -
a.e.t.
- )} -
{match.round}
-
{match.date ? formatDate(match.date) : ''}
+ : '?–?'}
-
- -
- {match.team2.name} -
+ {match.scoreP && ( +
{ft![0]}–{ft![1]} a.e.t.
+ )} + {match.scoreEt && !match.scoreP && ( +
{ft![0]}–{ft![1]} (a.e.t.)
+ )} +
{match.round}
+
{match.date ? formatDate(match.date) : ''}
+ + + +
+ {match.team2.name}
-
+
- +
) } diff --git a/lib/graphql/resolvers/index.ts b/lib/graphql/resolvers/index.ts index bad88d7..902b6f9 100644 --- a/lib/graphql/resolvers/index.ts +++ b/lib/graphql/resolvers/index.ts @@ -81,13 +81,14 @@ export const resolvers = { return { ...rows[0], avgGoalsPerGame: rows[0].avgGoalsPerGame ? parseFloat(rows[0].avgGoalsPerGame) : null } } catch (e) { if (isMissingTable(e)) return null; throw e } }, - async matches(_: unknown, args: { year?: number; group?: string; round?: string; isQuali?: boolean }) { + async matches(_: unknown, args: { year?: number; group?: string; round?: string; isQuali?: boolean; teamId?: number }) { try { 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)) + if (args.teamId) conditions.push(or(eq(matches.team1Id, args.teamId), eq(matches.team2Id, args.teamId))!) const rows = await db.select().from(matches) .where(conditions.length > 0 ? and(...conditions) : undefined) .orderBy(asc(matches.date), asc(matches.id)) diff --git a/lib/graphql/schema.ts b/lib/graphql/schema.ts index cb56b21..3794cd9 100644 --- a/lib/graphql/schema.ts +++ b/lib/graphql/schema.ts @@ -161,7 +161,7 @@ export const typeDefs = /* GraphQL */ ` tournaments: [Tournament!]! tournament(year: Int!): Tournament - matches(year: Int, group: String, round: String, isQuali: Boolean): [Match!]! + matches(year: Int, group: String, round: String, isQuali: Boolean, teamId: Int): [Match!]! match(id: Int!): Match liveMatches: [Match!]! recentMatches(limit: Int): [Match!]!