Tier 2: shootout panel, road to the final, ESPN videos, ICS export, injuries plumbing

- Penalty shootout panel: shootout-flagged events get a kicker-by-kicker
  dot strip on the Summary tab (and stay OFF the timeline/running score).
  Parsing is defensive — validate against the first real shootout June 28.
- Road to the final on team profiles: the model's projected knockout run
  (teamPath in whatif.ts forces the team through the model bracket) with
  per-round reach probabilities and a title-chance chip. Teams the model
  doesn't project through still get a hypothetical runner-up road.
- Match videos: ESPN summaries carry per-match clips (SUMMARY_V=5 keeps
  headline/duration/thumbnail/link) — a Videos card on the Summary tab,
  clips open on ESPN. Tokenless; replaces the parked ScoreBat idea.
- ICS calendar export: /api/calendar.ics[?team=X] (RFC 5545, escaped,
  folded, CRLF) + an Add-to-calendar chip on team pages.
- Injuries plumbing (dormant until API_FOOTBALL_KEY is set): twice-daily
  API-Football sweep into the injuries table, surfaced on team profiles
  and the match availability banner next to suspensions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 15:57:17 +02:00
parent f2203ed4a4
commit f0e0f7390d
19 changed files with 636 additions and 13 deletions
+107 -2
View File
@@ -1,17 +1,95 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Link, useParams } from '@tanstack/react-router';
import { ArrowLeft, Bell, BellRing, Shield, Star } from 'lucide-react';
import { ArrowLeft, Bell, BellRing, CalendarPlus, Route, Shield, Star, Stethoscope } from 'lucide-react';
import { EloHistoryChart } from '@/components/EloHistoryChart';
import { pushSupported, serverPushKey, subscribeTeam, unsubscribePush } from '@/lib/push';
import { useFavoriteStore } from '@/stores/favoriteStore';
import { useTournamentStore } from '@/stores/tournamentStore';
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { teamFlag } from '@/lib/teams';
import { fmt, useFormat, useT } from '@/lib/i18n';
import { hostAdvantage } from '@/lib/model/hosts';
import { knockoutAdvanceProb, predictMatch, type RatingsModel } from '@/lib/model/predict';
import { modelBracket, projectedSeeds, teamPath } from '@/lib/whatif';
import type { Fixture, TeamProfile } from '@/lib/types';
const pct = (x: number | null) => (x == null ? '—' : `${(x * 100).toFixed(1)}%`);
/** The model's most likely knockout run for this team, opponent by opponent. */
function RoadToFinal({ team }: { team: string }) {
const t = useT();
const snapshot = useTournamentStore((s) => s.snapshot);
const model = useTournamentStore((s) => s.model);
const [ratings, setRatings] = useState<RatingsModel | null>(null);
useEffect(() => {
let alive = true;
fetch('/data/ratings.json')
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then((d: RatingsModel) => alive && setRatings(d))
.catch(() => {});
return () => { alive = false; };
}, []);
const path = useMemo(() => {
if (!snapshot || !model || !ratings) return null;
const fixtures = snapshot.fixtures;
const seeds = projectedSeeds(fixtures, model.odds);
// Every team gets a road: if the model doesn't project this side out of
// the group, slot them hypothetically as their group's runner-up.
if (!Object.values(seeds).includes(team)) {
const g = fixtures.find((f) => f.stage === 'group' && (f.home.team === team || f.away.team === team))?.group;
if (!g) return null;
seeds[`2${g}`] = team;
}
const advFor = (f: Fixture, home: string, away: string): number | null => {
const adv = hostAdvantage(home, away, f.venue, ratings.params.homeAdvElo);
const p = predictMatch(home, away, ratings, adv);
return knockoutAdvanceProb(p.probs, p.eloHome, p.eloAway);
};
return teamPath(fixtures, seeds, modelBracket(fixtures, seeds, advFor), team);
}, [snapshot, model, ratings, team]);
const odds = model?.odds.find((o) => o.team === team);
if (!path?.length || !odds) return null;
const reach: Partial<Record<Fixture['stage'], number>> = {
r32: odds.qualify, r16: odds.reachR16, qf: odds.reachQF, sf: odds.reachSF, final: odds.reachFinal,
};
return (
<Card>
<CardHeader className="flex items-center gap-2">
<Route size={15} className="text-accent" />
<span className="font-display font-bold text-ink">{t.team.road.title}</span>
</CardHeader>
<CardBody>
<ul className="space-y-1.5">
{path.map((p) => (
<li key={p.num} className="flex items-center gap-2 text-sm">
<span className="w-32 shrink-0 text-xs text-faint">{t.stage[p.stage]}</span>
<span className="text-faint">{t.common.vs}</span>
{p.opponent ? (
<>
<span aria-hidden>{teamFlag(p.opponent)}</span>
<Link to="/team/$name" params={{ name: p.opponent }} className="truncate font-medium text-ink hover:text-accent">{p.opponent}</Link>
</>
) : (
<span className="italic text-faint">{t.team.road.tbd}</span>
)}
<span className="tnum ml-auto shrink-0 text-xs font-semibold text-ink">{pct(reach[p.stage] ?? null)}</span>
</li>
))}
</ul>
<div className="mt-2 inline-flex rounded-full bg-gold/15 px-2.5 py-0.5 text-xs font-semibold text-gold">
{fmt(t.team.road.winItAll, { pct: pct(odds.champion) })}
</div>
<p className="mt-2 text-[11px] text-faint">{t.team.road.note}</p>
</CardBody>
</Card>
);
}
function FixtureRow({ f, team }: { f: Fixture; team: string }) {
const { t, kickoffDay, kickoffTime } = useFormat();
const isHome = f.home.team === team;
@@ -154,6 +232,13 @@ export function TeamProfilePage() {
<Badge tone="muted">{fmt(t.team.eloBadge, { rating: profile.elo })}</Badge>
<Badge tone="gold">{fmt(t.team.titleOddsBadge, { pct: pct(profile.championOdds) })}</Badge>
<Badge tone="accent">{fmt(t.team.advanceOddsBadge, { pct: pct(profile.qualifyOdds) })}</Badge>
<a
href={`/api/calendar.ics?team=${encodeURIComponent(profile.team)}`}
download
className="inline-flex items-center gap-1 rounded-full border border-line px-2 py-0.5 text-xs font-semibold text-muted transition-colors hover:border-line-strong hover:text-ink"
>
<CalendarPlus size={13} /> {t.team.addToCalendar}
</a>
</div>
</div>
{s && (
@@ -172,6 +257,8 @@ export function TeamProfilePage() {
</CardBody>
</Card>
<RoadToFinal team={profile.team} />
<div className="grid gap-5 md:grid-cols-[1.3fr_1fr]">
<Card>
<CardHeader><span className="font-display font-bold text-ink">{t.team.fixtures}</span></CardHeader>
@@ -181,6 +268,24 @@ export function TeamProfilePage() {
</Card>
<div className="space-y-5">
{profile.injuries.length > 0 && (
<Card>
<CardHeader className="flex items-center gap-2">
<Stethoscope size={15} className="text-gold" />
<span className="font-display font-bold text-ink">{t.team.injuriesTitle}</span>
</CardHeader>
<CardBody>
<ul className="space-y-1.5">
{profile.injuries.map((i) => (
<li key={i.player} className="flex items-center gap-2 text-sm">
<span className="truncate text-ink-soft">{i.player}</span>
<span className="ml-auto shrink-0 text-[11px] text-faint">{i.reason ?? i.status ?? ''}</span>
</li>
))}
</ul>
</CardBody>
</Card>
)}
{profile.discipline.length > 0 && (
<Card>
<CardHeader><span className="font-display font-bold text-ink">{t.team.discipline}</span></CardHeader>