Phase 1: live dashboard — fixtures, scores, group tables, bracket

- Shared domain model (types, team metadata/flags/aliases, FIFA-tiebreak standings)
- buildFixtures.ts: openfootball 2026 → normalized fixtures.json (104 matches,
  ISO-UTC kickoffs, pretty knockout placeholders)
- Fastify live layer: seed load, football-data.org poller + SofaScore enhancement
  (feature-flagged, fallback), dynamic live-window polling, WS snapshot push,
  REST /api/snapshot, dev /api/dev/score injection
- Client: tournament store (REST + WS + static-file fallback), MatchCard,
  TeamLabel, GroupTable, Live/Groups/Bracket pages, live connection indicator
- Verified: WS pushes a dev-injected result; standings recompute live in-browser

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 13:04:31 +02:00
parent 4e4e75a1d8
commit 9a31e9f4db
18 changed files with 1461 additions and 21 deletions
+32
View File
@@ -1,6 +1,8 @@
import { useEffect } from 'react';
import { Link, Outlet } from '@tanstack/react-router';
import { Moon, Sun, Trophy } from 'lucide-react';
import { useUiStore } from '@/stores/uiStore';
import { useTournamentStore } from '@/stores/tournamentStore';
import { cn } from '@/lib/cn';
const NAV = [
@@ -11,6 +13,31 @@ const NAV = [
{ to: '/story', label: 'Story', exact: false },
] as const;
function ConnectionStatus() {
const status = useTournamentStore((s) => s.status);
const source = useTournamentStore((s) => s.snapshot?.source);
if (status === 'live') {
return (
<span
title={source === 'sofascore' ? 'Live · SofaScore' : source === 'football-data' ? 'Live · football-data.org' : 'Connected'}
className="inline-flex items-center gap-1.5 rounded-full border border-accent-deep/40 bg-accent-glow px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-accent"
>
<span className="live-dot h-1.5 w-1.5 rounded-full bg-accent" />
Live
</span>
);
}
return (
<span
title={status === 'connecting' ? 'Connecting…' : 'Offline — showing the schedule'}
className="inline-flex items-center gap-1.5 rounded-full border border-line px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-muted"
>
<span className="h-1.5 w-1.5 rounded-full bg-faint" />
{status === 'connecting' ? '…' : 'Offline'}
</span>
);
}
function ThemeToggle() {
const theme = useUiStore((s) => s.theme);
const toggle = useUiStore((s) => s.toggleTheme);
@@ -27,6 +54,10 @@ function ThemeToggle() {
}
export function RootLayout() {
useEffect(() => {
useTournamentStore.getState().init();
}, []);
return (
<div className="flex min-h-full flex-col">
<header className="sticky top-0 z-30 border-b border-line bg-surface/85 backdrop-blur">
@@ -53,6 +84,7 @@ export function RootLayout() {
))}
</nav>
<div className="ml-auto flex items-center gap-2">
<ConnectionStatus />
<ThemeToggle />
</div>
</div>
+57
View File
@@ -0,0 +1,57 @@
import { cn } from '@/lib/cn';
import { teamFlag } from '@/lib/teams';
import type { StandingRow } from '@/lib/types';
/** One group's standings. Top 2 qualify directly; 3rd may advance as a best-third. */
export function GroupTable({ group, rows }: { group: string; rows: StandingRow[] }) {
return (
<div className="overflow-hidden rounded-xl border border-line bg-panel">
<div className="flex items-center justify-between border-b border-line px-3 py-2">
<span className="font-display text-sm font-bold text-ink">Group {group}</span>
<span className="smallcaps">Pld · Pts</span>
</div>
<table className="w-full border-collapse text-sm">
<thead>
<tr className="text-[11px] uppercase tracking-wide text-faint">
<th className="px-3 py-1.5 text-left font-semibold">Team</th>
<th className="w-8 py-1.5 text-center font-semibold">P</th>
<th className="w-8 py-1.5 text-center font-semibold">GD</th>
<th className="w-9 py-1.5 text-center font-semibold">Pts</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr
key={r.team}
className={cn(
'border-t border-line/60',
r.rank <= 2 && 'bg-accent-glow',
r.rank === 3 && 'bg-gold/5',
)}
>
<td className="px-3 py-1.5">
<span className="flex items-center gap-2">
<span
className={cn(
'w-4 text-center text-[11px] font-bold tabular-nums',
r.rank <= 2 ? 'text-accent' : r.rank === 3 ? 'text-gold' : 'text-faint',
)}
>
{r.rank}
</span>
<span aria-hidden className="text-base leading-none">{teamFlag(r.team)}</span>
<span className="truncate text-ink">{r.team}</span>
</span>
</td>
<td className="py-1.5 text-center tabular-nums text-muted">{r.played}</td>
<td className="py-1.5 text-center tabular-nums text-muted">
{r.gd > 0 ? `+${r.gd}` : r.gd}
</td>
<td className="py-1.5 text-center font-bold tabular-nums text-ink">{r.points}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { cn } from '@/lib/cn';
import type { Fixture } from '@/lib/types';
import { kickoffTime, relativeKickoff } from '@/lib/format';
import { TeamLabel } from './TeamLabel';
const STAGE_LABEL: Record<Fixture['stage'], string> = {
group: 'Group',
r32: 'Round of 32',
r16: 'Round of 16',
qf: 'Quarter-final',
sf: 'Semi-final',
third: 'Third place',
final: 'Final',
};
function tag(f: Fixture): string {
if (f.stage === 'group') return `Group ${f.group}`;
return STAGE_LABEL[f.stage];
}
function StatusPill({ f }: { f: Fixture }) {
if (f.status === 'live') {
return (
<span className="inline-flex items-center gap-1.5 rounded-full border border-live/40 bg-live/15 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-live">
<span className="live-dot h-1.5 w-1.5 rounded-full bg-live" />
{f.minute ? `${f.minute}'` : 'Live'}
</span>
);
}
if (f.status === 'finished') {
return <span className="text-[11px] font-bold uppercase tracking-wide text-faint">FT</span>;
}
return <span className="text-[11px] font-semibold text-muted">{relativeKickoff(f.kickoff)}</span>;
}
export function MatchCard({ f }: { f: Fixture }) {
const hasScore = f.status === 'live' || f.status === 'finished';
return (
<div
className={cn(
'rounded-xl border border-line bg-panel px-4 py-3 transition-colors',
f.status === 'live' && 'border-live/40 ring-1 ring-live/30',
)}
>
<div className="mb-2 flex items-center justify-between">
<span className="smallcaps">{tag(f)}</span>
<StatusPill f={f} />
</div>
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-3">
<TeamLabel slot={f.home} align="right" />
<div className="min-w-[64px] text-center">
{hasScore ? (
<div className="tnum text-xl font-bold text-ink">
{f.homeScore ?? 0}<span className="px-1.5 text-faint"></span>{f.awayScore ?? 0}
</div>
) : (
<div className="tnum text-sm font-semibold text-muted">{kickoffTime(f.kickoff)}</div>
)}
</div>
<TeamLabel slot={f.away} align="left" />
</div>
<div className="mt-1.5 text-center text-[11px] text-faint">{f.venue}</div>
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { teamFlag } from '@/lib/teams';
import { cn } from '@/lib/cn';
import type { TeamSlot } from '@/lib/types';
/** A team identity (flag + name) or, for an unresolved knockout slot, a muted
* placeholder label ("Runner-up Group A"). */
export function TeamLabel({
slot,
align = 'left',
className,
}: {
slot: TeamSlot;
align?: 'left' | 'right';
className?: string;
}) {
const right = align === 'right';
if (!slot.team) {
return (
<span
className={cn('truncate text-sm italic text-faint', right && 'text-right', className)}
title={slot.label}
>
{slot.label}
</span>
);
}
return (
<span
className={cn(
'flex min-w-0 items-center gap-2',
right && 'flex-row-reverse',
className,
)}
>
<span aria-hidden className="text-lg leading-none">{teamFlag(slot.team)}</span>
<span className="truncate font-medium text-ink">{slot.team}</span>
</span>
);
}
+81 -9
View File
@@ -1,15 +1,87 @@
import { GitMerge } from 'lucide-react';
import { useMemo } from 'react';
import { PageHeader } from '@/components/ui/PageHeader';
import { Placeholder } from '@/components/ui/Placeholder';
import { useTournamentStore } from '@/stores/tournamentStore';
import { teamFlag } from '@/lib/teams';
import { kickoffDay, kickoffTime } from '@/lib/format';
import { cn } from '@/lib/cn';
import type { Fixture, Stage, TeamSlot } from '@/lib/types';
export function BracketPage() {
const COLUMNS: { stage: Stage; label: string }[] = [
{ stage: 'r32', label: 'Round of 32' },
{ stage: 'r16', label: 'Round of 16' },
{ stage: 'qf', label: 'Quarter-finals' },
{ stage: 'sf', label: 'Semi-finals' },
{ stage: 'final', label: 'Final' },
];
function Side({ slot, score, winner }: { slot: TeamSlot; score: number | null; winner: boolean }) {
return (
<div>
<PageHeader title="Bracket" subtitle="Round of 32 through the Final." />
<Placeholder icon={<GitMerge size={28} />}>
The knockout bracket (32 16 QF SF Final) arrives in Phase 1, with model
win-probabilities overlaid in Phase 2.
</Placeholder>
<div className={cn('flex items-center justify-between gap-2 px-2.5 py-1.5', winner && 'font-bold')}>
<span className="flex min-w-0 items-center gap-1.5">
{slot.team ? (
<>
<span aria-hidden className="text-sm leading-none">{teamFlag(slot.team)}</span>
<span className="truncate text-ink">{slot.team}</span>
</>
) : (
<span className="truncate text-xs italic text-faint" title={slot.label}>{slot.label}</span>
)}
</span>
{score !== null && <span className="tnum shrink-0 text-ink">{score}</span>}
</div>
);
}
function BracketMatch({ f }: { f: Fixture }) {
const decided = f.status === 'finished' && f.homeScore !== null && f.awayScore !== null;
return (
<div className="w-52 overflow-hidden rounded-lg border border-line bg-panel text-sm">
<Side slot={f.home} score={f.homeScore} winner={decided && f.homeScore! > f.awayScore!} />
<div className="h-px bg-line" />
<Side slot={f.away} score={f.awayScore} winner={decided && f.awayScore! > f.homeScore!} />
<div className="border-t border-line bg-surface-2 px-2.5 py-1 text-[10px] text-faint">
M{f.num} · {f.status === 'finished' ? 'FT' : `${kickoffDay(f.kickoff)} ${kickoffTime(f.kickoff)}`}
</div>
</div>
);
}
export function BracketPage() {
const snapshot = useTournamentStore((s) => s.snapshot);
const { byStage, third } = useMemo(() => {
const fixtures = snapshot?.fixtures ?? [];
const byStage: Record<Stage, Fixture[]> = {
group: [], r32: [], r16: [], qf: [], sf: [], third: [], final: [],
};
for (const f of fixtures) if (f.stage !== 'group') byStage[f.stage].push(f);
for (const s of Object.keys(byStage) as Stage[]) byStage[s].sort((a, b) => a.num - b.num);
return { byStage, third: byStage.third[0] };
}, [snapshot]);
return (
<div>
<PageHeader title="Bracket" subtitle="The knockout road — Round of 32 to the Final." />
<div className="overflow-x-auto pb-4">
<div className="flex gap-5">
{COLUMNS.map((col) => (
<div key={col.stage} className="flex shrink-0 flex-col gap-3">
<h2 className="smallcaps">{col.label}</h2>
{byStage[col.stage].length
? byStage[col.stage].map((f) => <BracketMatch key={f.num} f={f} />)
: <div className="h-20 w-52 animate-pulse rounded-lg border border-line bg-panel" />}
</div>
))}
</div>
</div>
{third && (
<div className="mt-2">
<h2 className="smallcaps mb-2">Third-place play-off</h2>
<BracketMatch f={third} />
</div>
)}
</div>
);
}
+32 -6
View File
@@ -1,14 +1,40 @@
import { Table } from 'lucide-react';
import { PageHeader } from '@/components/ui/PageHeader';
import { Placeholder } from '@/components/ui/Placeholder';
import { GroupTable } from '@/components/GroupTable';
import { useTournamentStore } from '@/stores/tournamentStore';
export function GroupsPage() {
const snapshot = useTournamentStore((s) => s.snapshot);
const tables = snapshot?.tables ?? {};
const groups = Object.keys(tables).sort();
return (
<div>
<PageHeader title="Groups" subtitle="All 12 groups (AL), 48 teams." />
<Placeholder icon={<Table size={28} />}>
Standings tables for the 12 groups arrive in Phase 1.
</Placeholder>
<PageHeader
title="Groups"
subtitle="All 12 groups · top 2 advance, plus the 8 best third-placed teams."
/>
{groups.length ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{groups.map((g) => (
<GroupTable key={g} group={g} rows={tables[g]!} />
))}
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-44 animate-pulse rounded-xl border border-line bg-panel" />
))}
</div>
)}
<div className="mt-5 flex items-center gap-4 text-xs text-faint">
<span className="inline-flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded bg-accent-glow ring-1 ring-accent-deep/40" /> Top 2 advance
</span>
<span className="inline-flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded bg-gold/20 ring-1 ring-gold/40" /> 3rd best-third race
</span>
</div>
</div>
);
}
+74 -6
View File
@@ -1,15 +1,83 @@
import { useMemo } from 'react';
import { Radio } from 'lucide-react';
import { PageHeader } from '@/components/ui/PageHeader';
import { Placeholder } from '@/components/ui/Placeholder';
import { MatchCard } from '@/components/MatchCard';
import { useTournamentStore } from '@/stores/tournamentStore';
import { dayKeyOf, isToday, kickoffDay } from '@/lib/format';
import type { Fixture } from '@/lib/types';
function Section({ title, fixtures }: { title: string; fixtures: Fixture[] }) {
if (!fixtures.length) return null;
return (
<section className="mb-7">
<h2 className="smallcaps mb-2.5">{title}</h2>
<div className="grid gap-3 sm:grid-cols-2">
{fixtures.map((f) => <MatchCard key={f.num} f={f} />)}
</div>
</section>
);
}
export function LivePage() {
const snapshot = useTournamentStore((s) => s.snapshot);
const groups = useMemo(() => {
const fixtures = snapshot?.fixtures ?? [];
const live = fixtures.filter((f) => f.status === 'live').sort((a, b) => a.kickoff.localeCompare(b.kickoff));
const today = fixtures
.filter((f) => f.status !== 'live' && isToday(f.kickoff))
.sort((a, b) => a.kickoff.localeCompare(b.kickoff));
// If nothing is on today, surface the next match day in full.
const upcoming = fixtures
.filter((f) => f.status === 'scheduled' && !isToday(f.kickoff) && new Date(f.kickoff).getTime() > Date.now())
.sort((a, b) => a.kickoff.localeCompare(b.kickoff));
const nextDayKey = upcoming[0] ? dayKeyOf(upcoming[0].kickoff) : null;
const nextDay = nextDayKey ? upcoming.filter((f) => dayKeyOf(f.kickoff) === nextDayKey) : [];
const recent = fixtures
.filter((f) => f.status === 'finished')
.sort((a, b) => b.kickoff.localeCompare(a.kickoff))
.slice(0, 6);
return { live, today, nextDay, nextDayLabel: upcoming[0] ? kickoffDay(upcoming[0].kickoff) : '', recent };
}, [snapshot]);
if (!snapshot) {
return (
<div>
<PageHeader title="Live" subtitle="Loading the tournament…" />
<div className="grid gap-3 sm:grid-cols-2">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-24 animate-pulse rounded-xl border border-line bg-panel" />
))}
</div>
</div>
);
}
const nothingToday = !groups.live.length && !groups.today.length;
return (
<div>
<PageHeader title="Live" subtitle="Today's World Cup 2026 matches, scores and what's on now." />
<Placeholder icon={<Radio size={28} />}>
Live match cards land in Phase 1 fixtures from openfootball, scores polled from
football-data.org, pushed over WebSocket.
</Placeholder>
<PageHeader
title="Live"
subtitle={`${snapshot.season} · 48 teams · 104 matches`}
/>
<Section title="Live now" fixtures={groups.live} />
<Section title={nothingToday ? '' : "Today's matches"} fixtures={groups.today} />
{nothingToday && (
<Section title={groups.nextDayLabel ? `Next up · ${groups.nextDayLabel}` : 'Next up'} fixtures={groups.nextDay} />
)}
<Section title="Latest results" fixtures={groups.recent} />
{nothingToday && !groups.nextDay.length && !groups.recent.length && (
<div className="grid place-items-center gap-2 rounded-xl border border-line bg-panel py-16 text-center text-muted">
<Radio size={28} className="text-accent" />
<p className="text-sm">The schedule is loaded matches will appear here as the tournament unfolds.</p>
</div>
)}
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
// Date/score formatting in the viewer's local timezone.
const time = new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' });
const dayLong = new Intl.DateTimeFormat(undefined, { weekday: 'short', day: 'numeric', month: 'short' });
const dayKey = new Intl.DateTimeFormat('en-CA', { year: 'numeric', month: '2-digit', day: '2-digit' });
export function kickoffTime(iso: string): string {
return time.format(new Date(iso));
}
export function kickoffDay(iso: string): string {
return dayLong.format(new Date(iso));
}
/** Stable local-day key (YYYY-MM-DD) for grouping fixtures by date. */
export function dayKeyOf(iso: string): string {
return dayKey.format(new Date(iso));
}
export function isToday(iso: string): boolean {
return dayKeyOf(iso) === dayKey.format(new Date());
}
/** "in 2h", "live", "today 19:00", "Thu 11 Jun" — a compact when-label. */
export function relativeKickoff(iso: string): string {
const diffMin = Math.round((new Date(iso).getTime() - Date.now()) / 60000);
if (diffMin < 0) return kickoffTime(iso);
if (diffMin < 60) return `in ${diffMin}m`;
if (isToday(iso)) return `today ${kickoffTime(iso)}`;
if (diffMin < 60 * 24 * 7) return `${kickoffDay(iso)} · ${kickoffTime(iso)}`;
return kickoffDay(iso);
}
+136
View File
@@ -0,0 +1,136 @@
import type { Fixture, StandingRow } from './types';
interface Tally {
team: string;
played: number;
won: number;
drawn: number;
lost: number;
gf: number;
ga: number;
points: number;
}
function blank(team: string): Tally {
return { team, played: 0, won: 0, drawn: 0, lost: 0, gf: 0, ga: 0, points: 0 };
}
/** A single finished result between two known teams. */
export interface Result {
home: string;
away: string;
homeScore: number;
awayScore: number;
}
function applyResult(tallies: Map<string, Tally>, r: Result): void {
const h = tallies.get(r.home);
const a = tallies.get(r.away);
if (!h || !a) return;
h.played++; a.played++;
h.gf += r.homeScore; h.ga += r.awayScore;
a.gf += r.awayScore; a.ga += r.homeScore;
if (r.homeScore > r.awayScore) { h.won++; a.lost++; h.points += 3; }
else if (r.homeScore < r.awayScore) { a.won++; h.lost++; a.points += 3; }
else { h.drawn++; a.drawn++; h.points++; a.points++; }
}
/** Head-to-head sub-ranking among a set of teams that are level on pts/gd/gf. */
function headToHead(tied: string[], results: Result[]): Map<string, number> {
const sub = new Map(tied.map((t) => [t, blank(t)]));
for (const r of results) {
if (sub.has(r.home) && sub.has(r.away)) applyResult(sub, r);
}
// Rank within the mini-table by pts, gd, gf.
const ranked = [...sub.values()].sort((x, y) =>
y.points - x.points || (y.gf - y.ga) - (x.gf - x.ga) || y.gf - x.gf,
);
return new Map(ranked.map((t, i) => [t.team, i]));
}
/**
* Rank a group's teams by the FIFA criteria: points, goal difference, goals
* for, then head-to-head (points/GD/GF among the tied), then name as a stable
* final fallback (the real tournament uses fair-play points / drawing of lots).
*/
export function rankGroup(teams: string[], results: Result[]): StandingRow[] {
const tallies = new Map(teams.map((t) => [t, blank(t)]));
for (const r of results) applyResult(tallies, r);
const rows = [...tallies.values()];
rows.sort((x, y) => {
const byPts = y.points - x.points;
if (byPts) return byPts;
const byGd = (y.gf - y.ga) - (x.gf - x.ga);
if (byGd) return byGd;
const byGf = y.gf - x.gf;
if (byGf) return byGf;
return 0; // resolve ties below via head-to-head
});
// Resolve any cluster level on pts+gd+gf with a head-to-head mini-table.
const out: Tally[] = [];
let i = 0;
while (i < rows.length) {
let j = i + 1;
const a = rows[i]!;
while (
j < rows.length &&
rows[j]!.points === a.points &&
rows[j]!.gf - rows[j]!.ga === a.gf - a.ga &&
rows[j]!.gf === a.gf
) j++;
const cluster = rows.slice(i, j);
if (cluster.length > 1) {
const order = headToHead(cluster.map((t) => t.team), results);
cluster.sort(
(x, y) => (order.get(x.team) ?? 0) - (order.get(y.team) ?? 0) || x.team.localeCompare(y.team),
);
}
out.push(...cluster);
i = j;
}
return out.map((t, idx) => ({
team: t.team,
played: t.played,
won: t.won,
drawn: t.drawn,
lost: t.lost,
gf: t.gf,
ga: t.ga,
gd: t.gf - t.ga,
points: t.points,
rank: idx + 1,
}));
}
/** Build ranked standings for every group from the current fixtures. */
export function computeGroupTables(fixtures: Fixture[]): Record<string, StandingRow[]> {
const groups = new Map<string, Set<string>>();
const results = new Map<string, Result[]>();
for (const f of fixtures) {
if (f.stage !== 'group' || !f.group) continue;
const g = f.group;
if (!groups.has(g)) { groups.set(g, new Set()); results.set(g, []); }
if (f.home.team) groups.get(g)!.add(f.home.team);
if (f.away.team) groups.get(g)!.add(f.away.team);
if (
f.status === 'finished' &&
f.home.team && f.away.team &&
f.homeScore !== null && f.awayScore !== null
) {
results.get(g)!.push({
home: f.home.team, away: f.away.team,
homeScore: f.homeScore, awayScore: f.awayScore,
});
}
}
const tables: Record<string, StandingRow[]> = {};
for (const [g, teamSet] of [...groups.entries()].sort(([a], [b]) => a.localeCompare(b))) {
tables[g] = rankGroup([...teamSet].sort(), results.get(g) ?? []);
}
return tables;
}
+117
View File
@@ -0,0 +1,117 @@
// Canonical team identities: ISO code, flag, and alias normalization so the
// three data sources (openfootball, martj42 results CSV, football-data.org)
// resolve to ONE name. Names follow openfootball's spelling as canonical.
interface TeamMeta {
/** ISO 3166-1 alpha-2 (drives the flag emoji), or a special key. */
iso2: string;
/** Override flag emoji for non-ISO entities (home nations). */
flag?: string;
}
/** Canonical name → metadata. The 48 World Cup 2026 qualifiers. */
const TEAMS: Record<string, TeamMeta> = {
'Czech Republic': { iso2: 'CZ' },
Mexico: { iso2: 'MX' },
'South Africa': { iso2: 'ZA' },
'South Korea': { iso2: 'KR' },
'Bosnia & Herzegovina': { iso2: 'BA' },
Canada: { iso2: 'CA' },
Qatar: { iso2: 'QA' },
Switzerland: { iso2: 'CH' },
Brazil: { iso2: 'BR' },
Haiti: { iso2: 'HT' },
Morocco: { iso2: 'MA' },
Scotland: { iso2: 'GB', flag: '🏴\u{E0067}\u{E0062}\u{E0073}\u{E0063}\u{E0074}\u{E007F}' },
Australia: { iso2: 'AU' },
Paraguay: { iso2: 'PY' },
Turkey: { iso2: 'TR' },
USA: { iso2: 'US' },
'Curaçao': { iso2: 'CW' },
Ecuador: { iso2: 'EC' },
Germany: { iso2: 'DE' },
'Ivory Coast': { iso2: 'CI' },
Japan: { iso2: 'JP' },
Netherlands: { iso2: 'NL' },
Sweden: { iso2: 'SE' },
Tunisia: { iso2: 'TN' },
Belgium: { iso2: 'BE' },
Egypt: { iso2: 'EG' },
Iran: { iso2: 'IR' },
'New Zealand': { iso2: 'NZ' },
'Cape Verde': { iso2: 'CV' },
'Saudi Arabia': { iso2: 'SA' },
Spain: { iso2: 'ES' },
Uruguay: { iso2: 'UY' },
France: { iso2: 'FR' },
Iraq: { iso2: 'IQ' },
Norway: { iso2: 'NO' },
Senegal: { iso2: 'SN' },
Algeria: { iso2: 'DZ' },
Argentina: { iso2: 'AR' },
Austria: { iso2: 'AT' },
Jordan: { iso2: 'JO' },
Colombia: { iso2: 'CO' },
'DR Congo': { iso2: 'CD' },
Portugal: { iso2: 'PT' },
Uzbekistan: { iso2: 'UZ' },
Croatia: { iso2: 'HR' },
England: { iso2: 'GB', flag: '🏴\u{E0067}\u{E0062}\u{E0065}\u{E006E}\u{E0067}\u{E007F}' },
Ghana: { iso2: 'GH' },
Panama: { iso2: 'PA' },
};
/** Variant spelling → canonical name (lowercased keys for case-insensitivity). */
const ALIASES: Record<string, string> = {
'bosnia and herzegovina': 'Bosnia & Herzegovina',
'bosnia-herzegovina': 'Bosnia & Herzegovina',
'korea republic': 'South Korea',
'republic of korea': 'South Korea',
korea: 'South Korea',
'united states': 'USA',
'united states of america': 'USA',
'usmnt': 'USA',
'türkiye': 'Turkey',
turkiye: 'Turkey',
"côte d'ivoire": 'Ivory Coast',
'cote d ivoire': 'Ivory Coast',
'ivory coast': 'Ivory Coast',
'cape verde islands': 'Cape Verde',
'cabo verde': 'Cape Verde',
'dr congo': 'DR Congo',
'congo dr': 'DR Congo',
'democratic republic of the congo': 'DR Congo',
'czechia': 'Czech Republic',
curacao: 'Curaçao',
'iran': 'Iran',
'ir iran': 'Iran',
};
function isoToFlag(iso2: string): string {
if (iso2.length !== 2) return '🏳️';
const A = 0x1f1e6;
const cc = iso2.toUpperCase();
return String.fromCodePoint(A + (cc.charCodeAt(0) - 65), A + (cc.charCodeAt(1) - 65));
}
/** Resolve any source spelling to the canonical team name (or the input trimmed). */
export function canonicalTeam(name: string): string {
const trimmed = name.trim();
if (TEAMS[trimmed]) return trimmed;
const alias = ALIASES[trimmed.toLowerCase()];
return alias ?? trimmed;
}
/** Flag emoji for a team name, '🏳️' if unknown. */
export function teamFlag(name: string): string {
const meta = TEAMS[canonicalTeam(name)];
if (!meta) return '🏳️';
return meta.flag ?? isoToFlag(meta.iso2);
}
/** True when the name is one of the known 2026 qualifiers. */
export function isKnownTeam(name: string): boolean {
return Boolean(TEAMS[canonicalTeam(name)]);
}
export const ALL_TEAMS = Object.keys(TEAMS);
+77
View File
@@ -0,0 +1,77 @@
// Shared domain types — used by the client, the server, and the build scripts.
export type Stage = 'group' | 'r32' | 'r16' | 'qf' | 'sf' | 'third' | 'final';
export type MatchStatus = 'scheduled' | 'live' | 'finished';
/** One side of a fixture: a known team, or an unresolved knockout placeholder. */
export interface TeamSlot {
/** Canonical team name when known, else null (unresolved knockout slot). */
team: string | null;
/** Raw openfootball placeholder: '2A' | 'W74' | 'L101' | '3A/B/C/D/F' | null. */
placeholder: string | null;
/** Display label: the team name, or a pretty placeholder ("Runner-up A"). */
label: string;
}
export interface Fixture {
/** Canonical 1..104 match number (group matches 1-72, knockout 73-104). */
num: number;
stage: Stage;
/** 'A'..'L' for group stage, else null. */
group: string | null;
/** Human round label from the source ("Matchday 1", "Round of 32", …). */
round: string;
/** 1 | 2 | 3 — the team's nth group game, else null. */
groupRound: number | null;
/** Kickoff as an ISO 8601 UTC timestamp. */
kickoff: string;
venue: string;
home: TeamSlot;
away: TeamSlot;
status: MatchStatus;
homeScore: number | null;
awayScore: number | null;
/** Live match minute when status === 'live'. */
minute: number | null;
}
export interface FixturesFile {
season: string;
generatedAt: string;
groups: string[];
venues: string[];
fixtures: Fixture[];
}
export interface StandingRow {
team: string;
played: number;
won: number;
drawn: number;
lost: number;
gf: number;
ga: number;
gd: number;
points: number;
/** 1-based rank within the group after sorting. */
rank: number;
}
/** What the server pushes to clients: fixtures with any live overlay + tables. */
export interface Snapshot {
season: string;
/** When the live layer last refreshed (ISO), or null if seed-only. */
updatedAt: string | null;
/** Which live source produced the current scores. */
source: 'seed' | 'football-data' | 'sofascore';
fixtures: Fixture[];
/** Group letter → ranked standings. */
tables: Record<string, StandingRow[]>;
}
// ---- WebSocket protocol ----
// The snapshot is small (~104 fixtures), so the server simply re-pushes the full
// snapshot on every change — no diffing, no client-side merge races.
/** Server → client. */
export type ServerMessage = { t: 'snapshot'; snapshot: Snapshot };
+84
View File
@@ -0,0 +1,84 @@
import { create } from 'zustand';
import type { FixturesFile, ServerMessage, Snapshot } from '@/lib/types';
import { computeGroupTables } from '@/lib/standings';
export type ConnStatus = 'connecting' | 'live' | 'offline';
interface TournamentStore {
snapshot: Snapshot | null;
status: ConnStatus;
init: () => void;
}
let started = false;
let ws: WebSocket | null = null;
let retry = 0;
function wsUrl(): string {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
return `${proto}://${location.host}/ws`;
}
/** Last-resort fallback: build a seed snapshot straight from the static file
* (works on a plain static host with no server — degraded, no live updates). */
async function loadStaticSeed(): Promise<Snapshot | null> {
try {
const res = await fetch('/data/fixtures.json');
if (!res.ok) return null;
const file = (await res.json()) as FixturesFile;
return {
season: file.season,
updatedAt: null,
source: 'seed',
fixtures: file.fixtures,
tables: computeGroupTables(file.fixtures),
};
} catch {
return null;
}
}
export const useTournamentStore = create<TournamentStore>((set) => ({
snapshot: null,
status: 'connecting',
init: () => {
if (started) return;
started = true;
const connect = (): void => {
try {
ws = new WebSocket(wsUrl());
} catch {
set({ status: 'offline' });
return;
}
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data as string) as ServerMessage;
if (msg.t === 'snapshot') set({ snapshot: msg.snapshot, status: 'live' });
} catch { /* ignore malformed frame */ }
};
ws.onopen = () => { retry = 0; set({ status: 'live' }); };
ws.onclose = () => {
set({ status: 'offline' });
retry = Math.min(retry + 1, 6);
setTimeout(connect, 1000 * 2 ** retry); // capped exponential backoff
};
ws.onerror = () => { try { ws?.close(); } catch { /* noop */ } };
};
// Initial paint from REST (fast), then the WS takes over for live updates.
void (async () => {
try {
const res = await fetch('/api/snapshot');
if (res.ok) set({ snapshot: (await res.json()) as Snapshot });
} catch { /* server may be absent on a static host */ }
if (!useTournamentStore.getState().snapshot) {
const seed = await loadStaticSeed();
if (seed) set({ snapshot: seed, status: 'offline' });
}
connect();
})();
},
}));