Live tab shows the next three match days, always

Upcoming fixtures now appear grouped under localized day headers
(Coming up · Mon 15 Jun) regardless of whether something is on today —
not just when the day was empty. Pure grouping logic lives in
src/lib/fixtures.ts with day-boundary tests; prediction chips on the
cards come for free from MatchCard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 00:22:52 +02:00
parent 9cf9900b96
commit dc4079fe4b
3 changed files with 117 additions and 16 deletions
+21 -16
View File
@@ -3,10 +3,13 @@ import { Radio } from 'lucide-react';
import { PageHeader } from '@/components/ui/PageHeader'; import { PageHeader } from '@/components/ui/PageHeader';
import { MatchCard } from '@/components/MatchCard'; import { MatchCard } from '@/components/MatchCard';
import { useTournamentStore } from '@/stores/tournamentStore'; import { useTournamentStore } from '@/stores/tournamentStore';
import { dayKeyOf, isToday, kickoffDay } from '@/lib/format'; import { isToday } from '@/lib/format';
import { upcomingByDay, type DayGroup } from '@/lib/fixtures';
import { fmt, useFormat } from '@/lib/i18n'; import { fmt, useFormat } from '@/lib/i18n';
import type { Fixture } from '@/lib/types'; import type { Fixture } from '@/lib/types';
const UPCOMING_DAYS = 3;
function Section({ title, fixtures }: { title: string; fixtures: Fixture[] }) { function Section({ title, fixtures }: { title: string; fixtures: Fixture[] }) {
if (!fixtures.length) return null; if (!fixtures.length) return null;
return ( return (
@@ -21,7 +24,7 @@ function Section({ title, fixtures }: { title: string; fixtures: Fixture[] }) {
export function LivePage() { export function LivePage() {
const snapshot = useTournamentStore((s) => s.snapshot); const snapshot = useTournamentStore((s) => s.snapshot);
const { t, locale } = useFormat(); const { t, kickoffDay } = useFormat();
const groups = useMemo(() => { const groups = useMemo(() => {
const fixtures = snapshot?.fixtures ?? []; const fixtures = snapshot?.fixtures ?? [];
@@ -30,20 +33,16 @@ export function LivePage() {
.filter((f) => f.status !== 'live' && isToday(f.kickoff)) .filter((f) => f.status !== 'live' && isToday(f.kickoff))
.sort((a, b) => a.kickoff.localeCompare(b.kickoff)); .sort((a, b) => a.kickoff.localeCompare(b.kickoff));
// If nothing is on today, surface the next match day in full. // The next few match days, each under its own day header.
const upcoming = fixtures const upcoming: DayGroup[] = upcomingByDay(fixtures, UPCOMING_DAYS);
.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 const recent = fixtures
.filter((f) => f.status === 'finished') .filter((f) => f.status === 'finished')
.sort((a, b) => b.kickoff.localeCompare(a.kickoff)) .sort((a, b) => b.kickoff.localeCompare(a.kickoff))
.slice(0, 6); .slice(0, 6);
return { live, today, nextDay, nextDayLabel: upcoming[0] ? kickoffDay(upcoming[0].kickoff, locale) : '', recent }; return { live, today, upcoming, recent };
}, [snapshot, locale]); }, [snapshot]);
if (!snapshot) { if (!snapshot) {
return ( return (
@@ -58,7 +57,7 @@ export function LivePage() {
); );
} }
const nothingToday = !groups.live.length && !groups.today.length; const nothingNow = !groups.live.length && !groups.today.length;
return ( return (
<div> <div>
@@ -68,13 +67,19 @@ export function LivePage() {
/> />
<Section title={t.live.liveNowHeading} fixtures={groups.live} /> <Section title={t.live.liveNowHeading} fixtures={groups.live} />
<Section title={nothingToday ? '' : t.live.todayHeading} fixtures={groups.today} /> <Section title={t.live.todayHeading} fixtures={groups.today} />
{nothingToday && ( {groups.upcoming.map((g, i) => (
<Section title={groups.nextDayLabel ? fmt(t.live.nextUpDay, { day: groups.nextDayLabel }) : t.live.nextUp} fixtures={groups.nextDay} /> <Section
)} key={g.key}
title={i === 0 && nothingNow
? fmt(t.live.nextUpDay, { day: kickoffDay(g.firstKickoff) })
: `${t.live.comingUp} · ${kickoffDay(g.firstKickoff)}`}
fixtures={g.fixtures}
/>
))}
<Section title={t.live.latestResults} fixtures={groups.recent} /> <Section title={t.live.latestResults} fixtures={groups.recent} />
{nothingToday && !groups.nextDay.length && !groups.recent.length && ( {nothingNow && !groups.upcoming.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"> <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" /> <Radio size={28} className="text-accent" />
<p className="text-sm">{t.live.empty}</p> <p className="text-sm">{t.live.empty}</p>
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { upcomingByDay } from './fixtures';
import type { Fixture } from './types';
const fx = (num: number, kickoff: string, status: Fixture['status'] = 'scheduled'): Fixture => ({
num, stage: 'group', group: 'A', round: 'Matchday 1', groupRound: 1,
kickoff, venue: 'Somewhere',
home: { team: 'A', placeholder: null, label: 'A' },
away: { team: 'B', placeholder: null, label: 'B' },
status, homeScore: null, awayScore: null, minute: null,
} as Fixture);
// fixed "now": midday UTC so local-day edges don't flip the assertions
const NOW = new Date('2026-06-14T12:00:00Z');
describe('upcomingByDay', () => {
it('groups scheduled fixtures after today into at most maxDays days', () => {
const groups = upcomingByDay([
fx(1, '2026-06-15T18:00:00Z'),
fx(2, '2026-06-15T21:00:00Z'),
fx(3, '2026-06-16T18:00:00Z'),
fx(4, '2026-06-17T18:00:00Z'),
fx(5, '2026-06-18T18:00:00Z'),
], 3, NOW);
expect(groups).toHaveLength(3);
expect(groups[0]!.fixtures.map((f) => f.num)).toEqual([1, 2]);
expect(groups[2]!.fixtures.map((f) => f.num)).toEqual([4]);
});
it('excludes today, live and finished fixtures', () => {
const groups = upcomingByDay([
fx(1, '2026-06-14T20:00:00Z'), // today → excluded
fx(2, '2026-06-15T18:00:00Z', 'live'), // live → excluded
fx(3, '2026-06-15T21:00:00Z', 'finished'), // finished → excluded
fx(4, '2026-06-15T15:00:00Z'),
], 3, NOW);
expect(groups).toHaveLength(1);
expect(groups[0]!.fixtures.map((f) => f.num)).toEqual([4]);
});
it('sorts within a day by kickoff and keeps day order', () => {
const groups = upcomingByDay([
fx(2, '2026-06-15T21:00:00Z'),
fx(1, '2026-06-15T15:00:00Z'),
], 2, NOW);
expect(groups[0]!.fixtures.map((f) => f.num)).toEqual([1, 2]);
expect(groups[0]!.firstKickoff).toBe('2026-06-15T15:00:00Z');
});
it('returns fewer groups when fewer days remain', () => {
expect(upcomingByDay([fx(1, '2026-06-15T18:00:00Z')], 3, NOW)).toHaveLength(1);
});
it('handles the tournament-over case', () => {
expect(upcomingByDay([fx(1, '2026-06-10T18:00:00Z', 'finished')], 3, NOW)).toEqual([]);
});
});
+39
View File
@@ -0,0 +1,39 @@
// Pure fixture grouping helpers (no store/DOM imports — vitest-friendly).
import { dayKeyOf } from './format';
import type { Fixture } from './types';
export interface DayGroup {
/** Stable local-day key (YYYY-MM-DD). */
key: string;
/** Kickoff ISO of the day's first match (for localized day headers). */
firstKickoff: string;
fixtures: Fixture[];
}
/** Group fixtures (as given) by local day, first `maxDays` distinct days,
* sorted by kickoff within and across days. Pass Infinity for all days. */
export function groupByDay(fixtures: Fixture[], maxDays: number): DayGroup[] {
const sorted = [...fixtures].sort((a, b) => a.kickoff.localeCompare(b.kickoff));
const groups: DayGroup[] = [];
for (const f of sorted) {
const key = dayKeyOf(f.kickoff);
const last = groups[groups.length - 1];
if (last && last.key === key) {
last.fixtures.push(f);
} else {
if (groups.length >= maxDays) break;
groups.push({ key, firstKickoff: f.kickoff, fixtures: [f] });
}
}
return groups;
}
/** Scheduled fixtures after today, grouped by local day, first `maxDays`
* distinct days. Days and fixtures are sorted by kickoff. */
export function upcomingByDay(fixtures: Fixture[], maxDays: number, now = new Date()): DayGroup[] {
const todayKey = dayKeyOf(now.toISOString());
return groupByDay(
fixtures.filter((f) => f.status === 'scheduled' && dayKeyOf(f.kickoff) > todayKey),
maxDays,
);
}