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
+45 -17
View File
@@ -180,23 +180,51 @@ export function TeamProfilePage() {
</CardBody>
</Card>
<Card>
<CardHeader><span className="font-display font-bold text-ink">{t.team.allTimeTopScorers}</span></CardHeader>
<CardBody>
{profile.keyPlayers.length ? (
<ol className="space-y-1.5">
{profile.keyPlayers.map((p, i) => (
<li key={p.name} className="flex items-center gap-2 text-sm">
<span className="w-4 text-right text-xs font-bold text-faint">{i + 1}</span>
<span className="truncate text-ink-soft">{p.name}</span>
<span className="tnum ml-auto font-semibold text-ink">{p.goals}</span>
{p.pens ? <span className="text-[11px] text-faint">{fmt(t.common.pensShort, { n: p.pens })}</span> : null}
</li>
))}
</ol>
) : <p className="text-sm text-faint">{t.common.noData}</p>}
</CardBody>
</Card>
<div className="space-y-5">
{profile.discipline.length > 0 && (
<Card>
<CardHeader><span className="font-display font-bold text-ink">{t.team.discipline}</span></CardHeader>
<CardBody>
<ul className="space-y-1.5">
{profile.discipline.map((p) => (
<li key={p.player} className="flex flex-wrap items-center gap-2 text-sm">
<span className="truncate text-ink-soft">{p.player}</span>
<span className="flex items-center gap-0.5" aria-hidden>
{Array.from({ length: p.yellows }).map((_, i) => <span key={`y${i}`} className="inline-block h-3.5 w-2.5 rounded-[2px] bg-gold" />)}
{Array.from({ length: p.reds }).map((_, i) => <span key={`r${i}`} className="inline-block h-3.5 w-2.5 rounded-[2px] bg-loss" />)}
</span>
{p.suspendedNext && (
<span className="ml-auto rounded-full bg-loss/15 px-2 py-0.5 text-[11px] font-semibold text-loss">
{p.suspendedFor != null ? fmt(t.team.suspChipFor, { num: p.suspendedFor }) : t.team.suspChipNext}
</span>
)}
{!p.suspendedNext && p.pending === 1 && (
<span className="ml-auto rounded-full bg-gold/15 px-2 py-0.5 text-[11px] font-semibold text-gold">{t.team.atRiskChip}</span>
)}
</li>
))}
</ul>
</CardBody>
</Card>
)}
<Card>
<CardHeader><span className="font-display font-bold text-ink">{t.team.allTimeTopScorers}</span></CardHeader>
<CardBody>
{profile.keyPlayers.length ? (
<ol className="space-y-1.5">
{profile.keyPlayers.map((p, i) => (
<li key={p.name} className="flex items-center gap-2 text-sm">
<span className="w-4 text-right text-xs font-bold text-faint">{i + 1}</span>
<span className="truncate text-ink-soft">{p.name}</span>
<span className="tnum ml-auto font-semibold text-ink">{p.goals}</span>
{p.pens ? <span className="text-[11px] text-faint">{fmt(t.common.pensShort, { n: p.pens })}</span> : null}
</li>
))}
</ol>
) : <p className="text-sm text-faint">{t.common.noData}</p>}
</CardBody>
</Card>
</div>
</div>
</div>
);