Files
cup26/src/features/team/TeamProfilePage.tsx
T
NilsBriggen 0fd13d4c24 Players become first-class: profile pages, unified names, deeper match data
- /player/:name pages assembled entirely from stored data: tournament
  totals, per-match log (rating, minutes, G/A, cards), aggregated shot
  map, suspension status, next-match scorer line. Either source's
  spelling resolves to the same page (token-set + romanization fold).
- Player names are now links everywhere: stats boards, golden boot,
  discipline, injuries, all-time scorers, lineups, the pitch dots,
  timeline events and assists, and the search palette (diacritic-folded).
- Golden Boot displays the FotMob form of each name — no more
  "Hwang In-Beom" and "In-Beom Hwang" on the same page.
- Match pages: detailed full-time stats card (passes, duels, xGOT… from
  the stored FotMob report), O/U + spread closing line, "Who scores?"
  pre-match props board, and a FIFA-confirmed-lineups badge.
- Stats hub: new boards — xA, chances created, save percentage, saves
  per 90.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 00:54:58 +02:00

338 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 { PlayerLink } from '@/components/PlayerLink';
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">
<PlayerLink name={i.player} className="truncate text-ink-soft transition-colors hover:text-accent" />
<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">
<PlayerLink name={p.player} className="truncate text-ink-soft transition-colors hover:text-accent" />
<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>
<PlayerLink name={p.name} className="truncate text-ink-soft transition-colors hover:text-accent" />
<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>
);
}