Predict view covers every upcoming match, organized by day
The hardcoded 8-match cap is gone: all upcoming fixtures with a prediction render in day-grouped sections (every match the model can predict — slots resolve as the bracket fills). Filter chips narrow to the next 3 days (default) or everything, and group stage vs knockout. Match count shown in the heading. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import { Card, CardBody } from '@/components/ui/Card';
|
||||
import { WinProbBar } from '@/components/WinProbBar';
|
||||
import { fmt, useFormat } from '@/lib/i18n';
|
||||
import type { Fixture, MatchPrediction } from '@/lib/types';
|
||||
|
||||
export interface PredictionDay {
|
||||
key: string;
|
||||
firstKickoff: string;
|
||||
items: { f: Fixture; m: MatchPrediction }[];
|
||||
}
|
||||
|
||||
/** Day-grouped prediction cards: a localized day header, then one card per match. */
|
||||
export function PredictionList({ days }: { days: PredictionDay[] }) {
|
||||
const { t, kickoffDay, kickoffTime } = useFormat();
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{days.map((d) => (
|
||||
<section key={d.key}>
|
||||
<h3 className="smallcaps mb-2">{kickoffDay(d.firstKickoff)}</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{d.items.map(({ m, f }) => (
|
||||
<Card key={m.num}>
|
||||
<CardBody className="space-y-2">
|
||||
<div className="flex items-center justify-between text-[11px] text-faint">
|
||||
<span>{f.group ? fmt(t.common.group, { group: f.group }) : t.stage[f.stage]}</span>
|
||||
<span>{kickoffTime(f.kickoff)}</span>
|
||||
</div>
|
||||
<WinProbBar home={m.home} away={m.away} probs={m.probs} topScore={m.topScore} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,46 @@
|
||||
import { lazy, Suspense, useMemo } from 'react';
|
||||
import { lazy, Suspense, useMemo, useState } from 'react';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { Gauge, TrendingUp, Trophy } from 'lucide-react';
|
||||
import { PageHeader } from '@/components/ui/PageHeader';
|
||||
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
||||
import { OddsTable } from '@/components/OddsTable';
|
||||
import { WinProbBar } from '@/components/WinProbBar';
|
||||
import { PredictionList, type PredictionDay } from '@/components/PredictionList';
|
||||
|
||||
// Recharts is heavy and only used here — load it as a separate on-demand chunk.
|
||||
const OddsChart = lazy(() => import('@/components/OddsChart').then((m) => ({ default: m.OddsChart })));
|
||||
import { teamFlag } from '@/lib/teams';
|
||||
import { groupByDay } from '@/lib/fixtures';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { fmt, useFormat } from '@/lib/i18n';
|
||||
import { useTournamentStore } from '@/stores/tournamentStore';
|
||||
|
||||
type Range = 'next3' | 'all';
|
||||
type StageFilter = 'all' | 'group' | 'ko';
|
||||
|
||||
function Chips<T extends string>({ value, onChange, options }: {
|
||||
value: T;
|
||||
onChange: (v: T) => void;
|
||||
options: { v: T; label: string }[];
|
||||
}) {
|
||||
return (
|
||||
<div className="flex overflow-hidden rounded-lg border border-line text-xs font-medium">
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.v}
|
||||
type="button"
|
||||
onClick={() => onChange(o.v)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 transition-colors',
|
||||
o.v === value ? 'bg-accent-glow font-bold text-accent' : 'text-muted hover:bg-elevated hover:text-ink',
|
||||
)}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChampionBoard({ odds }: { odds: { team: string; champion: number }[] }) {
|
||||
const top = odds.slice(0, 8);
|
||||
const max = Math.max(...top.map((t) => t.champion), 0.01);
|
||||
@@ -38,19 +67,29 @@ function ChampionBoard({ odds }: { odds: { team: string; champion: number }[] })
|
||||
}
|
||||
|
||||
export function PredictionsPage() {
|
||||
const { t, locale, kickoffDay, kickoffTime } = useFormat();
|
||||
const { t, locale } = useFormat();
|
||||
const model = useTournamentStore((s) => s.model);
|
||||
const snapshot = useTournamentStore((s) => s.snapshot);
|
||||
const [range, setRange] = useState<Range>('next3');
|
||||
const [stage, setStage] = useState<StageFilter>('all');
|
||||
|
||||
const upcoming = useMemo(() => {
|
||||
if (!model || !snapshot) return [];
|
||||
const fxByNum = new Map(snapshot.fixtures.map((f) => [f.num, f]));
|
||||
return model.matches
|
||||
.map((m) => ({ m, f: fxByNum.get(m.num)! }))
|
||||
.filter(({ f }) => f && f.status !== 'finished')
|
||||
.sort((a, b) => a.f.kickoff.localeCompare(b.f.kickoff))
|
||||
.slice(0, 8);
|
||||
}, [model, snapshot]);
|
||||
const { days, shown } = useMemo(() => {
|
||||
if (!model || !snapshot) return { days: [] as PredictionDay[], shown: 0 };
|
||||
const preds = new Map(model.matches.map((m) => [m.num, m]));
|
||||
const fixtures = snapshot.fixtures.filter(
|
||||
(f) =>
|
||||
f.status !== 'finished' &&
|
||||
preds.has(f.num) &&
|
||||
(stage === 'all' || (stage === 'group' ? f.stage === 'group' : f.stage !== 'group')),
|
||||
);
|
||||
const grouped = groupByDay(fixtures, range === 'next3' ? 3 : Infinity);
|
||||
const days = grouped.map((g) => ({
|
||||
key: g.key,
|
||||
firstKickoff: g.firstKickoff,
|
||||
items: g.fixtures.map((f) => ({ f, m: preds.get(f.num)! })),
|
||||
}));
|
||||
return { days, shown: days.reduce((s, d) => s + d.items.length, 0) };
|
||||
}, [model, snapshot, range, stage]);
|
||||
|
||||
if (!model) {
|
||||
return (
|
||||
@@ -108,24 +147,29 @@ export function PredictionsPage() {
|
||||
<OddsTable odds={model.odds} />
|
||||
</div>
|
||||
|
||||
{upcoming.length > 0 && (
|
||||
<>
|
||||
<h2 className="smallcaps mb-2.5">{t.predict.nextMatchesHeading}</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{upcoming.map(({ m, f }) => (
|
||||
<Card key={m.num}>
|
||||
<CardBody className="space-y-2">
|
||||
<div className="flex items-center justify-between text-[11px] text-faint">
|
||||
<span>{f.group ? fmt(t.common.group, { group: f.group }) : f.round}</span>
|
||||
<span>{kickoffDay(f.kickoff)} · {kickoffTime(f.kickoff)}</span>
|
||||
<div className="mb-2.5 flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="smallcaps">{t.predict.nextMatchesHeading} <span className="tnum normal-case text-faint">({shown})</span></h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Chips
|
||||
value={range}
|
||||
onChange={setRange}
|
||||
options={[
|
||||
{ v: 'next3', label: t.predict.filters.next3Days },
|
||||
{ v: 'all', label: t.predict.filters.allUpcoming },
|
||||
]}
|
||||
/>
|
||||
<Chips
|
||||
value={stage}
|
||||
onChange={setStage}
|
||||
options={[
|
||||
{ v: 'all', label: t.predict.filters.allStages },
|
||||
{ v: 'group', label: t.predict.filters.groupStage },
|
||||
{ v: 'ko', label: t.predict.filters.knockout },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<WinProbBar home={m.home} away={m.away} probs={m.probs} topScore={m.topScore} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<PredictionList days={days} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user