In-play win-probability timeline: the momentum curve on match pages
While a match runs, every poll records the in-play model probability per game state (insert-if-new on minute+score) into inplay_history. The match page draws it as a stacked area chart — home fills from the bottom, away from the top, the gap is the draw — with minute ticks and a 50% guide. Shows live (under the win-probability bar) and after full time as its own card. EN/DE strings included. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { Link, useParams } from '@tanstack/react-router';
|
||||
import { ArrowLeft, ArrowRightLeft, CircleDot, Shield, Shirt, Volleyball } from 'lucide-react';
|
||||
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
||||
import { WinProbBar } from '@/components/WinProbBar';
|
||||
import { WinProbTimeline, type TimelinePoint } from '@/components/WinProbTimeline';
|
||||
import { FormChips } from '@/components/FormChips';
|
||||
import { teamFlag } from '@/lib/teams';
|
||||
import { fmt, useFormat, useT } from '@/lib/i18n';
|
||||
@@ -82,6 +83,7 @@ export function MatchPreviewPage() {
|
||||
const { num: numParam } = useParams({ strict: false }) as { num: string };
|
||||
const { t, kickoffDay, kickoffTime } = useFormat();
|
||||
const [preview, setPreview] = useState<MatchPreview | null>(null);
|
||||
const [timeline, setTimeline] = useState<TimelinePoint[]>([]);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
// Live score/minute arrive over the WebSocket snapshot — overlay them on the
|
||||
@@ -92,14 +94,26 @@ export function MatchPreviewPage() {
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setPreview(null);
|
||||
setTimeline([]);
|
||||
setFailed(false);
|
||||
const load = () =>
|
||||
fetch(`/api/preview/${numParam}`)
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject()))
|
||||
.then((d: MatchPreview) => alive && setPreview(d))
|
||||
.catch(() => alive && setFailed(true));
|
||||
const loadTimeline = () =>
|
||||
fetch(`/api/inplay/${numParam}`)
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject()))
|
||||
.then((d: { points: TimelinePoint[] }) => alive && setTimeline(d.points))
|
||||
.catch(() => {});
|
||||
void load();
|
||||
const id = setInterval(() => { if (useTournamentStore.getState().snapshot?.fixtures.find((f) => f.num === Number(numParam))?.status === 'live') void load(); }, 10_000);
|
||||
void loadTimeline();
|
||||
const id = setInterval(() => {
|
||||
if (useTournamentStore.getState().snapshot?.fixtures.find((f) => f.num === Number(numParam))?.status === 'live') {
|
||||
void load();
|
||||
void loadTimeline();
|
||||
}
|
||||
}, 10_000);
|
||||
return () => { alive = false; clearInterval(id); };
|
||||
}, [numParam]);
|
||||
|
||||
@@ -164,11 +178,26 @@ export function MatchPreviewPage() {
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<WinProbBar home={homeName} away={awayName} probs={inPlay} />
|
||||
{timeline.length >= 2 && (
|
||||
<div className="mt-4">
|
||||
<WinProbTimeline points={timeline} home={homeName} away={awayName} />
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-faint">{t.match.inPlayHelp}</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* FT: how the win probability swung over the match */}
|
||||
{finished && timeline.length >= 2 && (
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">{t.match.momentum}</span></CardHeader>
|
||||
<CardBody>
|
||||
<WinProbTimeline points={timeline} home={homeName} away={awayName} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* LIVE/FT: stats */}
|
||||
{preview.liveStats && (
|
||||
<Card>
|
||||
|
||||
Reference in New Issue
Block a user