Files
worldcup/app/groups/page.tsx
T
valknar 71e7e47aca feat: show all groups including unplayed, add upcoming matches per group
sync.ts: after computing standings from played matches, seed 0-0-0-0 rows
for every team in any group match, so all 12 groups always appear.

/groups: fetch all 2026 matches alongside standings; each group card now
shows results (score), live badge, and upcoming fixtures with local
kickoff time, sorted by UTC kickoff.

/tournaments/[year]: derive group list from union of standings + match
group names, so groups with no played matches still render with their
fixtures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 19:47:52 +02:00

210 lines
9.9 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 { useEffect } from 'react'
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 }
}
matches(year: 2026, isQuali: false) {
id group date time isLive scoreFt
team1 { name iso2 slug } team2 { 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 }
}
interface MatchRow {
id: number; group?: string | null; date?: string | null; time?: string | null
isLive: boolean; scoreFt?: number[] | null
team1: { name: string; iso2?: string | null; slug: string }
team2: { name: string; iso2?: string | null; slug: string }
}
function utcKickoff(date: string, time: string): number {
const m = time.match(/^(\d{2}):(\d{2})(?:\s+UTC([+-]\d+(?:\.\d+)?))?/)
if (!m) return new Date(date).getTime()
const [y, mo, d] = date.split('-').map(Number)
const offsetH = m[3] ? parseFloat(m[3]) : 0
return Date.UTC(y, mo - 1, d, parseInt(m[1]) - offsetH, parseInt(m[2]))
}
function formatKickoff(date: string, time: string | null | undefined): string {
if (!time) return new Date(date + 'T00:00:00').toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' })
const ms = utcKickoff(date, time)
const local = new Date(ms)
const today = new Date()
const tomorrow = new Date(today); tomorrow.setDate(today.getDate() + 1)
const isToday = local.toDateString() === today.toDateString()
const isTomorrow = local.toDateString() === tomorrow.toDateString()
const day = isToday ? 'Today' : isTomorrow ? 'Tomorrow'
: local.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' })
return `${day} · ${local.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}`
}
export default function GroupsPage() {
const { data, loading } = useQuery(GROUPS_QUERY, { pollInterval: 60_000 })
useEffect(() => { document.title = 'Group Stage · World Cup' }, [])
const standings: Standing[] = data?.groupStandings ?? []
const allMatches: MatchRow[] = data?.matches ?? []
const byGroup = standings.reduce<Record<string, Standing[]>>((acc, s) => {
acc[s.groupName] = [...(acc[s.groupName] ?? []), s]
return acc
}, {})
const matchesByGroup = allMatches
.filter(m => m.group)
.reduce<Record<string, MatchRow[]>>((acc, m) => {
acc[m.group!] = [...(acc[m.group!] ?? []), m]
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-green leading-none">2026 Groups</h1>
<p className="text-green-muted 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(300px,1fr))] gap-3.5">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="h-72 rounded-2xl animate-pulse bg-card" />
))}
</div>
)}
<div className="grid grid-cols-[repeat(auto-fill,minmax(300px,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 ', '')
const groupMatches = (matchesByGroup[groupName] ?? [])
.sort((a, b) => {
if (!a.date) return 1
if (!b.date) return -1
const ta = a.time ? utcKickoff(a.date, a.time) : new Date(a.date).getTime()
const tb = b.time ? utcKickoff(b.date!, b.time) : new Date(b.date!).getTime()
return ta - tb
})
const played = groupMatches.filter(m => m.scoreFt)
const upcoming = groupMatches.filter(m => !m.scoreFt && !m.isLive)
const live = groupMatches.filter(m => m.isLive)
return (
<div key={groupName} className="glass-card">
{/* Header */}
<div className="px-4 py-3 border-b border-green/10"
style={{ background: 'linear-gradient(90deg,color-mix(in srgb,var(--color-green) 12%,transparent) 0%,color-mix(in srgb,var(--color-green) 4%,transparent) 100%)' }}>
<span className="font-['Bebas_Neue'] text-[28px] text-green tracking-[0.05em]">GROUP {letter}</span>
</div>
{/* Standings */}
<div className="grid px-4 py-2 text-[9px] text-green-muted 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 border-green/[6%] hover:bg-green/[3%] transition-colors cursor-pointer ${idx < 2 ? 'bg-green/[2.5%]' : ''}`}
style={{ gridTemplateColumns: '1fr 22px 22px 22px 22px 22px 30px', gap: '3px' }}>
<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-text' : 'text-green-sec'}`}>{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-green-mid">{v}</span>
))}
<span className="text-center text-[13px] text-green-mid">
{t.goalDiff > 0 ? `+${t.goalDiff}` : t.goalDiff}
</span>
<span className="text-center text-[13px] font-bold text-green">{t.pts}</span>
</div>
</Link>
))}
{/* Live matches */}
{live.length > 0 && (
<div className="border-t border-green/10 px-4 py-2.5 space-y-1.5">
{live.map(m => (
<Link key={m.id} href={`/tournaments/2026#match-${m.id}`}>
<div className="flex items-center gap-2 py-1 hover:opacity-80">
<span className="text-[9px] font-bold text-green-light tracking-wider animate-pulse">LIVE</span>
<TeamFlag name={m.team1.name} iso2={m.team1.iso2} size="sm" />
<span className="text-[12px] text-text font-medium">{m.team1.name}</span>
<span className="text-[11px] text-green-muted mx-0.5">vs</span>
<span className="text-[12px] text-text font-medium">{m.team2.name}</span>
<TeamFlag name={m.team2.name} iso2={m.team2.iso2} size="sm" />
</div>
</Link>
))}
</div>
)}
{/* Results */}
{played.length > 0 && (
<div className="border-t border-green/10 px-4 py-2.5 space-y-1">
{played.map(m => (
<Link key={m.id} href={`/tournaments/2026#match-${m.id}`}>
<div className="flex items-center gap-2 py-1 text-[12px] hover:opacity-80">
<TeamFlag name={m.team1.name} iso2={m.team1.iso2} size="sm" />
<span className="text-green-sec truncate flex-1">{m.team1.name}</span>
<span className="font-['Bebas_Neue'] text-[15px] text-green tabular-nums">
{m.scoreFt![0]}{m.scoreFt![1]}
</span>
<span className="text-green-sec truncate flex-1 text-right">{m.team2.name}</span>
<TeamFlag name={m.team2.name} iso2={m.team2.iso2} size="sm" />
</div>
</Link>
))}
</div>
)}
{/* Upcoming */}
{upcoming.length > 0 && (
<div className="border-t border-green/[6%] px-4 py-2.5 space-y-1">
{upcoming.map(m => (
<Link key={m.id} href={`/tournaments/2026#match-${m.id}`}>
<div className="flex items-center gap-2 py-1 text-[12px] hover:opacity-80">
<TeamFlag name={m.team1.name} iso2={m.team1.iso2} size="sm" />
<span className="text-green-sec truncate flex-1">{m.team1.name}</span>
<span className="text-[10px] text-green-muted whitespace-nowrap tabular-nums">
{m.date ? formatKickoff(m.date, m.time) : ''}
</span>
<span className="text-green-sec truncate flex-1 text-right">{m.team2.name}</span>
<TeamFlag name={m.team2.name} iso2={m.team2.iso2} size="sm" />
</div>
</Link>
))}
</div>
)}
</div>
)
})}
</div>
</div>
)
}