f0e0f7390d
- 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>
337 lines
15 KiB
TypeScript
337 lines
15 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
||
import { Link, useParams } from '@tanstack/react-router';
|
||
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;
|
||
const opp = isHome ? f.away : f.home;
|
||
const done = f.status === 'finished' || f.status === 'live';
|
||
const us = isHome ? f.homeScore : f.awayScore;
|
||
const them = isHome ? f.awayScore : f.homeScore;
|
||
const res = done && us != null && them != null ? (us > them ? 'win' : us < them ? 'loss' : 'draw') : null;
|
||
return (
|
||
<Link to="/match/$num" params={{ num: String(f.num) }} className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-elevated">
|
||
<span className="w-10 text-xs text-faint">{f.group ? fmt(t.team.groupShort, { group: f.group }) : f.stage !== 'group' ? t.team.stageShort[f.stage] : f.round}</span>
|
||
<span className="text-faint">{t.common.vs}</span>
|
||
<span aria-hidden className="grid w-5 place-items-center">{opp.team ? teamFlag(opp.team) : <Shield size={14} className="text-faint" />}</span>
|
||
<span className="truncate text-ink">{opp.label}</span>
|
||
<span
|
||
className="shrink-0 rounded bg-elevated px-1 py-px text-[10px] font-bold uppercase text-faint"
|
||
title={isHome ? t.team.homeGame : t.team.awayGame}
|
||
>
|
||
{isHome ? t.team.homeShort : t.team.awayShort}
|
||
</span>
|
||
<span className="ml-auto tnum text-faint">
|
||
{done && us != null ? <span className="text-ink">{us}–{them}</span> : `${kickoffDay(f.kickoff)} ${kickoffTime(f.kickoff)}`}
|
||
</span>
|
||
{res && <span className={`grid h-5 w-5 place-items-center rounded text-[11px] font-bold ${res === 'win' ? 'bg-win/20 text-win' : res === 'loss' ? 'bg-loss/20 text-loss' : 'bg-draw/20 text-draw'}`}>{t.team.resultShort[res]}</span>}
|
||
</Link>
|
||
);
|
||
}
|
||
|
||
function FavoriteStar({ team }: { team: string }) {
|
||
const t = useT();
|
||
const favorite = useFavoriteStore((s) => s.favorite);
|
||
const toggle = useFavoriteStore((s) => s.toggleFavorite);
|
||
const active = favorite === team;
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={() => toggle(team)}
|
||
aria-label={active ? t.team.unfollow : t.team.follow}
|
||
aria-pressed={active}
|
||
className={`grid h-9 w-9 place-items-center rounded-md transition-colors ${active ? 'text-gold' : 'text-faint hover:text-ink'}`}
|
||
>
|
||
<Star size={20} fill={active ? 'currentColor' : 'none'} />
|
||
</button>
|
||
);
|
||
}
|
||
|
||
const PUSH_TEAM_KEY = 'cup26:push-team';
|
||
|
||
/** Goal/kickoff alerts for this team — shown only when the server has push
|
||
* enabled and the browser supports it. One followed team per device. */
|
||
function NotifyBell({ team }: { team: string }) {
|
||
const t = useT();
|
||
const [avail, setAvail] = useState(false);
|
||
const [denied, setDenied] = useState(false);
|
||
const [subTeam, setSubTeam] = useState<string | null>(() => {
|
||
try { return localStorage.getItem(PUSH_TEAM_KEY); } catch { return null; }
|
||
});
|
||
|
||
useEffect(() => {
|
||
let alive = true;
|
||
void serverPushKey().then((k) => alive && setAvail(Boolean(k) && pushSupported()));
|
||
return () => { alive = false; };
|
||
}, []);
|
||
|
||
if (!avail) return null;
|
||
const active = subTeam === team;
|
||
|
||
const toggle = async (): Promise<void> => {
|
||
if (active) {
|
||
await unsubscribePush();
|
||
try { localStorage.removeItem(PUSH_TEAM_KEY); } catch { /* ok */ }
|
||
setSubTeam(null);
|
||
return;
|
||
}
|
||
const res = await subscribeTeam(team);
|
||
if (res === 'subscribed') {
|
||
try { localStorage.setItem(PUSH_TEAM_KEY, team); } catch { /* ok */ }
|
||
setSubTeam(team);
|
||
setDenied(false);
|
||
} else if (res === 'denied') {
|
||
setDenied(true);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<span className="inline-flex items-center">
|
||
<button
|
||
type="button"
|
||
onClick={() => void toggle()}
|
||
aria-label={active ? t.team.notifyOff : t.team.notifyOn}
|
||
aria-pressed={active}
|
||
title={denied ? t.team.notifyDenied : active ? t.team.notifyOff : t.team.notifyOn}
|
||
className={`grid h-9 w-9 place-items-center rounded-md transition-colors ${active ? 'text-accent' : 'text-faint hover:text-ink'}`}
|
||
>
|
||
{active ? <BellRing size={18} /> : <Bell size={18} />}
|
||
</button>
|
||
{denied && <span className="text-[11px] text-faint">{t.team.notifyDenied}</span>}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
export function TeamProfilePage() {
|
||
const { name } = useParams({ strict: false }) as { name: string };
|
||
const t = useT();
|
||
const [profile, setProfile] = useState<TeamProfile | null>(null);
|
||
const [failed, setFailed] = useState(false);
|
||
|
||
useEffect(() => {
|
||
let alive = true;
|
||
setProfile(null);
|
||
setFailed(false);
|
||
fetch(`/api/team/${encodeURIComponent(name)}`)
|
||
.then((r) => (r.ok ? r.json() : Promise.reject()))
|
||
.then((d: TeamProfile) => alive && setProfile(d))
|
||
.catch(() => alive && setFailed(true));
|
||
return () => { alive = false; };
|
||
}, [name]);
|
||
|
||
if (failed) return <div className="py-16 text-center text-muted">{t.team.unknown} <Link to="/groups" className="text-accent">{t.team.allTeams}</Link></div>;
|
||
if (!profile) return <div className="h-80 animate-pulse rounded-xl border border-line bg-panel" />;
|
||
|
||
const s = profile.standing;
|
||
return (
|
||
<div className="space-y-5">
|
||
<Link to="/teams" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-ink">
|
||
<ArrowLeft size={15} /> {t.team.allTeams}
|
||
</Link>
|
||
|
||
<Card>
|
||
<CardBody className="flex flex-wrap items-center gap-4">
|
||
<span aria-hidden className="text-5xl">{teamFlag(profile.team)}</span>
|
||
<div>
|
||
<h1 className="flex items-center gap-1 font-display text-2xl font-extrabold text-ink">
|
||
<span className="mr-1">{profile.team}</span>
|
||
<FavoriteStar team={profile.team} />
|
||
<NotifyBell team={profile.team} />
|
||
</h1>
|
||
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm">
|
||
{profile.group && <Badge tone="neutral">{fmt(t.common.group, { group: profile.group })}</Badge>}
|
||
<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 && (
|
||
<div className="ml-auto text-right text-sm">
|
||
<div className="text-faint text-xs">{t.team.groupStanding}</div>
|
||
<div className="tnum text-ink">{fmt(t.team.standingLine, { rank: s.rank, points: s.points, won: s.won, drawn: s.drawn, lost: s.lost, gd: s.gd > 0 ? `+${s.gd}` : s.gd })}</div>
|
||
</div>
|
||
)}
|
||
</CardBody>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader><span className="font-display font-bold text-ink">{t.team.eloHistory}</span></CardHeader>
|
||
<CardBody>
|
||
<EloHistoryChart team={profile.team} />
|
||
</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>
|
||
<CardBody className="space-y-0.5">
|
||
{profile.fixtures.map((f) => <FixtureRow key={f.num} f={f} team={profile.team} />)}
|
||
</CardBody>
|
||
</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>
|
||
<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>
|
||
);
|
||
}
|