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>
This commit is contained in:
2026-06-13 00:54:58 +02:00
parent f0e0f7390d
commit 0fd13d4c24
28 changed files with 1320 additions and 38 deletions
+4 -3
View File
@@ -12,6 +12,7 @@ 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)}%`);
@@ -278,7 +279,7 @@ export function TeamProfilePage() {
<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>
<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>
))}
@@ -293,7 +294,7 @@ export function TeamProfilePage() {
<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>
<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" />)}
@@ -320,7 +321,7 @@ export function TeamProfilePage() {
{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>
<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>