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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user