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,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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user