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>
This commit is contained in:
+116
-3
@@ -10,6 +10,10 @@ const GROUPS_QUERY = gql`
|
|||||||
groupName pos played won drawn lost goalsFor goalsAgainst goalDiff pts
|
groupName pos played won drawn lost goalsFor goalsAgainst goalDiff pts
|
||||||
team { id name iso2 slug }
|
team { id name iso2 slug }
|
||||||
}
|
}
|
||||||
|
matches(year: 2026, isQuali: false) {
|
||||||
|
id group date time isLive scoreFt
|
||||||
|
team1 { name iso2 slug } team2 { name iso2 slug }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -20,17 +24,54 @@ interface Standing {
|
|||||||
team: { id: number; name: string; iso2?: string | null; slug: string }
|
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() {
|
export default function GroupsPage() {
|
||||||
const { data, loading } = useQuery(GROUPS_QUERY, { pollInterval: 60_000 })
|
const { data, loading } = useQuery(GROUPS_QUERY, { pollInterval: 60_000 })
|
||||||
|
|
||||||
useEffect(() => { document.title = 'Group Stage · World Cup' }, [])
|
useEffect(() => { document.title = 'Group Stage · World Cup' }, [])
|
||||||
|
|
||||||
const standings: Standing[] = data?.groupStandings ?? []
|
const standings: Standing[] = data?.groupStandings ?? []
|
||||||
|
const allMatches: MatchRow[] = data?.matches ?? []
|
||||||
|
|
||||||
const byGroup = standings.reduce<Record<string, Standing[]>>((acc, s) => {
|
const byGroup = standings.reduce<Record<string, Standing[]>>((acc, s) => {
|
||||||
acc[s.groupName] = [...(acc[s.groupName] ?? []), s]
|
acc[s.groupName] = [...(acc[s.groupName] ?? []), s]
|
||||||
return acc
|
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))
|
const groups = Object.entries(byGroup).sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -41,14 +82,14 @@ export default function GroupsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading && !data && (
|
{loading && !data && (
|
||||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(268px,1fr))] gap-3.5">
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(300px,1fr))] gap-3.5">
|
||||||
{Array.from({ length: 12 }).map((_, i) => (
|
{Array.from({ length: 12 }).map((_, i) => (
|
||||||
<div key={i} className="h-56 rounded-2xl animate-pulse bg-card" />
|
<div key={i} className="h-72 rounded-2xl animate-pulse bg-card" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(268px,1fr))] gap-3.5">
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(300px,1fr))] gap-3.5">
|
||||||
{groups.map(([groupName, rows]) => {
|
{groups.map(([groupName, rows]) => {
|
||||||
const sorted = [...rows].sort((a, b) => {
|
const sorted = [...rows].sort((a, b) => {
|
||||||
if (b.pts !== a.pts) return b.pts - a.pts
|
if (b.pts !== a.pts) return b.pts - a.pts
|
||||||
@@ -56,12 +97,28 @@ export default function GroupsPage() {
|
|||||||
return b.goalsFor - a.goalsFor
|
return b.goalsFor - a.goalsFor
|
||||||
})
|
})
|
||||||
const letter = groupName.replace('Group ', '')
|
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 (
|
return (
|
||||||
<div key={groupName} className="glass-card">
|
<div key={groupName} className="glass-card">
|
||||||
|
{/* Header */}
|
||||||
<div className="px-4 py-3 border-b border-green/10"
|
<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%)' }}>
|
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>
|
<span className="font-['Bebas_Neue'] text-[28px] text-green tracking-[0.05em]">GROUP {letter}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Standings */}
|
||||||
<div className="grid px-4 py-2 text-[9px] text-green-muted tracking-[0.1em] uppercase"
|
<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' }}>
|
style={{ gridTemplateColumns: '1fr 22px 22px 22px 22px 22px 30px', gap: '3px' }}>
|
||||||
<span>Team</span>
|
<span>Team</span>
|
||||||
@@ -87,6 +144,62 @@ export default function GroupsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -100,7 +100,12 @@ export default function TournamentPage({ params }: { params: Promise<{ year: str
|
|||||||
return acc
|
return acc
|
||||||
}, {})
|
}, {})
|
||||||
|
|
||||||
const groupRounds = Object.entries(byGroup).sort(([a], [b]) => a.localeCompare(b))
|
// 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 koRounds = allMatches.filter(m => !m.group && !m.isQualiPlayoff)
|
||||||
const koByRound = koRounds.reduce<Record<string, MatchData[]>>((acc, m) => {
|
const koByRound = koRounds.reduce<Record<string, MatchData[]>>((acc, m) => {
|
||||||
acc[m.round] = [...(acc[m.round] ?? []), m]
|
acc[m.round] = [...(acc[m.round] ?? []), m]
|
||||||
@@ -177,7 +182,12 @@ export default function TournamentPage({ params }: { params: Promise<{ year: str
|
|||||||
<h2 className="font-['Bebas_Neue'] text-2xl text-green mb-5">Group Stage</h2>
|
<h2 className="font-['Bebas_Neue'] text-2xl text-green mb-5">Group Stage</h2>
|
||||||
{groupRounds.map(([groupName, rows]) => {
|
{groupRounds.map(([groupName, rows]) => {
|
||||||
const sorted = [...rows].sort((a, b) => b.pts - a.pts || b.goalDiff - a.goalDiff)
|
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)
|
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 (
|
return (
|
||||||
<div key={groupName} className="mb-8">
|
<div key={groupName} className="mb-8">
|
||||||
<h3 className="text-[13px] font-bold text-green tracking-wide uppercase mb-3">{groupName}</h3>
|
<h3 className="text-[13px] font-bold text-green tracking-wide uppercase mb-3">{groupName}</h3>
|
||||||
|
|||||||
@@ -227,6 +227,17 @@ async function run() {
|
|||||||
goal_diff = EXCLUDED.goal_diff, pts = EXCLUDED.pts
|
goal_diff = EXCLUDED.goal_diff, pts = EXCLUDED.pts
|
||||||
`)
|
`)
|
||||||
|
|
||||||
|
// Ensure every team that appears in a group match has a standings row (0-0-0-0 for unplayed teams)
|
||||||
|
await db.execute(sql`
|
||||||
|
INSERT INTO group_standings (tournament_year, group_name, team_id, played, won, drawn, lost, goals_for, goals_against, goal_diff, pts)
|
||||||
|
SELECT DISTINCT 2026, group_name, team1_id, 0, 0, 0, 0, 0, 0, 0, 0
|
||||||
|
FROM matches WHERE tournament_year = 2026 AND group_name IS NOT NULL AND is_quali_playoff = false
|
||||||
|
UNION
|
||||||
|
SELECT DISTINCT 2026, group_name, team2_id, 0, 0, 0, 0, 0, 0, 0, 0
|
||||||
|
FROM matches WHERE tournament_year = 2026 AND group_name IS NOT NULL AND is_quali_playoff = false
|
||||||
|
ON CONFLICT (tournament_year, group_name, team_id) DO NOTHING
|
||||||
|
`)
|
||||||
|
|
||||||
// Tournament aggregates
|
// Tournament aggregates
|
||||||
await db.execute(sql`
|
await db.execute(sql`
|
||||||
UPDATE tournaments SET
|
UPDATE tournaments SET
|
||||||
|
|||||||
Reference in New Issue
Block a user