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
+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>
);
}