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:
2026-06-12 00:22:52 +02:00
parent dc4079fe4b
commit 9b34941eb2
2 changed files with 111 additions and 30 deletions
+37
View File
@@ -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>
);
}
+72 -28
View File
@@ -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>
);
}