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