diff --git a/src/components/PredictionList.tsx b/src/components/PredictionList.tsx new file mode 100644 index 0000000..c430ef4 --- /dev/null +++ b/src/components/PredictionList.tsx @@ -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 ( +
+ {days.map((d) => ( +
+

{kickoffDay(d.firstKickoff)}

+
+ {d.items.map(({ m, f }) => ( + + +
+ {f.group ? fmt(t.common.group, { group: f.group }) : t.stage[f.stage]} + {kickoffTime(f.kickoff)} +
+ +
+
+ ))} +
+
+ ))} +
+ ); +} diff --git a/src/features/predictions/PredictionsPage.tsx b/src/features/predictions/PredictionsPage.tsx index 74c4cf1..e279738 100644 --- a/src/features/predictions/PredictionsPage.tsx +++ b/src/features/predictions/PredictionsPage.tsx @@ -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({ value, onChange, options }: { + value: T; + onChange: (v: T) => void; + options: { v: T; label: string }[]; +}) { + return ( +
+ {options.map((o) => ( + + ))} +
+ ); +} + 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('next3'); + const [stage, setStage] = useState('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() { - {upcoming.length > 0 && ( - <> -

{t.predict.nextMatchesHeading}

-
- {upcoming.map(({ m, f }) => ( - - -
- {f.group ? fmt(t.common.group, { group: f.group }) : f.round} - {kickoffDay(f.kickoff)} · {kickoffTime(f.kickoff)} -
- -
-
- ))} -
- - )} +
+

{t.predict.nextMatchesHeading} ({shown})

+
+ + +
+
+ ); }