Files
cup26/src/features/team/TeamProfilePage.tsx
T
NilsBriggen f0e0f7390d 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>
2026-06-12 15:57:17 +02:00

337 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}