9a31e9f4db
- 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>
84 lines
3.2 KiB
TypeScript
84 lines
3.2 KiB
TypeScript
import { useMemo } from 'react';
|
|
import { Radio } from 'lucide-react';
|
|
import { PageHeader } from '@/components/ui/PageHeader';
|
|
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={`${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>
|
|
);
|
|
}
|