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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (A–L), 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
})();
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user