Tournament stats hub, suspension tracker, card-aware live probabilities

- /stats: FotMob tournament boards (goals/assists/rating/xG player+team
  categories from the data.fotmob.com CDN, capture-first via scheduler),
  Golden Boot race with DraftKings anytime-line benchmark from the
  already-captured scorer props, and an attack-vs-defence xG scatter.
- Discipline engine (src/lib/discipline.ts): FIFA suspension rules folded
  from stored event feeds — red/second-yellow bans, two-yellow accumulation,
  post-QF wipe. Surfaces on /stats, team profiles and match lineups tabs.
- In-play win probability now accounts for red cards (10 men: own rate
  x0.67, opponent x1.15 per card) on the server tick and the match page.
- Fix ModelDiary infinite render loop (unstable zustand snapshot while the
  model loads crashed the Methodology route under React 19).
- Drop dead tables squad + goalscorers; scorer_props is now read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 15:42:00 +02:00
parent 9e1f6e5cee
commit f2203ed4a4
22 changed files with 1148 additions and 75 deletions
+19 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useParams } from '@tanstack/react-router';
import { ArrowLeft, ArrowRight, Shield, Shirt } from 'lucide-react';
import { ArrowLeft, ArrowRight, Ban, Shield, Shirt } from 'lucide-react';
import { Badge } from '@/components/ui/Badge';
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
import { WinProbBar } from '@/components/WinProbBar';
@@ -15,6 +15,7 @@ import { LineupPitch } from '@/components/LineupPitch';
import { teamFlag } from '@/lib/teams';
import { fmt, useFormat, useT } from '@/lib/i18n';
import { goalEvents } from '@/lib/matchEvents';
import { redCards } from '@/lib/discipline';
import { inPlayProbs } from '@/lib/model/inplay';
import { useTournamentStore } from '@/stores/tournamentStore';
import type { Fixture, KeyPlayer, MatchPreview, MatchRich, OddsPoint } from '@/lib/types';
@@ -147,7 +148,8 @@ export function MatchPreviewPage() {
const inPlay = useMemo(() => {
if (!preview?.prediction || !f || f.status !== 'live' || f.homeScore == null || f.awayScore == null) return null;
return inPlayProbs(preview.prediction.lambdaHome, preview.prediction.lambdaAway, f.minute ?? 0, f.homeScore, f.awayScore);
const reds = redCards(preview.events);
return inPlayProbs(preview.prediction.lambdaHome, preview.prediction.lambdaAway, f.minute ?? 0, f.homeScore, f.awayScore, reds.home, reds.away);
}, [preview, f]);
const statRows = useMemo<[string, string][]>(() => [
@@ -428,6 +430,21 @@ export function MatchPreviewPage() {
{/* ── Lineups ── */}
{tab === 'lineups' && (
<div className="space-y-4">
{(preview.suspensions.home.length > 0 || preview.suspensions.away.length > 0) && (
<Card className="border-loss/30">
<CardBody className="space-y-1.5 text-sm">
{([['home', preview.suspensions.home], ['away', preview.suspensions.away]] as const)
.filter(([, players]) => players.length > 0)
.map(([side, players]) => (
<div key={side} className="flex items-center gap-2 text-ink-soft">
<Ban size={14} className="shrink-0 text-loss" />
<span aria-hidden>{(side === 'home' ? f.home.team : f.away.team) ? teamFlag((side === 'home' ? f.home.team : f.away.team)!) : ''}</span>
<span>{fmt(t.match.suspended, { players: players.join(', ') })}</span>
</div>
))}
</CardBody>
</Card>
)}
{/* Before kickoff the pitch shows FotMob's projected XIs, clearly
labeled as predicted; from kickoff it's the confirmed lineup. */}
{rich?.lineup ? (