From 71e7e47acaa11a735e40d28e5f3852b9e6f74aa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Mon, 15 Jun 2026 19:47:52 +0200 Subject: [PATCH] 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 --- app/groups/page.tsx | 119 +++++++++++++++++++++++++++++++- app/tournaments/[year]/page.tsx | 14 +++- scripts/sync.ts | 11 +++ 3 files changed, 139 insertions(+), 5 deletions(-) diff --git a/app/groups/page.tsx b/app/groups/page.tsx index 20d2592..fcca6dd 100644 --- a/app/groups/page.tsx +++ b/app/groups/page.tsx @@ -10,6 +10,10 @@ const GROUPS_QUERY = gql` 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 } + } } ` @@ -20,17 +24,54 @@ interface Standing { 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>((acc, s) => { acc[s.groupName] = [...(acc[s.groupName] ?? []), s] return acc }, {}) + const matchesByGroup = allMatches + .filter(m => m.group) + .reduce>((acc, m) => { + acc[m.group!] = [...(acc[m.group!] ?? []), m] + return acc + }, {}) + const groups = Object.entries(byGroup).sort(([a], [b]) => a.localeCompare(b)) return ( @@ -41,14 +82,14 @@ export default function GroupsPage() { {loading && !data && ( -
+
{Array.from({ length: 12 }).map((_, i) => ( -
+
))}
)} -
+
{groups.map(([groupName, rows]) => { const sorted = [...rows].sort((a, b) => { if (b.pts !== a.pts) return b.pts - a.pts @@ -56,12 +97,28 @@ export default function GroupsPage() { 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 (
+ {/* Header */}
GROUP {letter}
+ + {/* Standings */}
Team @@ -87,6 +144,62 @@ export default function GroupsPage() {
))} + + {/* Live matches */} + {live.length > 0 && ( +
+ {live.map(m => ( + +
+ LIVE + + {m.team1.name} + vs + {m.team2.name} + +
+ + ))} +
+ )} + + {/* Results */} + {played.length > 0 && ( +
+ {played.map(m => ( + +
+ + {m.team1.name} + + {m.scoreFt![0]}–{m.scoreFt![1]} + + {m.team2.name} + +
+ + ))} +
+ )} + + {/* Upcoming */} + {upcoming.length > 0 && ( +
+ {upcoming.map(m => ( + +
+ + {m.team1.name} + + {m.date ? formatKickoff(m.date, m.time) : '–'} + + {m.team2.name} + +
+ + ))} +
+ )}
) })} diff --git a/app/tournaments/[year]/page.tsx b/app/tournaments/[year]/page.tsx index e98cec7..2676ac6 100644 --- a/app/tournaments/[year]/page.tsx +++ b/app/tournaments/[year]/page.tsx @@ -100,7 +100,12 @@ export default function TournamentPage({ params }: { params: Promise<{ year: str 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 koByRound = koRounds.reduce>((acc, m) => { acc[m.round] = [...(acc[m.round] ?? []), m] @@ -177,7 +182,12 @@ export default function TournamentPage({ params }: { params: Promise<{ year: str

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) => (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 (

{groupName}

diff --git a/scripts/sync.ts b/scripts/sync.ts index 67ae6c7..f2dbbc6 100644 --- a/scripts/sync.ts +++ b/scripts/sync.ts @@ -227,6 +227,17 @@ async function run() { 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 await db.execute(sql` UPDATE tournaments SET