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
+61
View File
@@ -0,0 +1,61 @@
import type { MatchFullStat } from '../../../src/lib/types';
// Pure normalizer for the FotMob matchDetails `stats` payload — no network or
// DB, unit-testable in isolation (same pattern as statsNormalize).
/** Curated full-time stats, in display order: FotMob key → our i18n key.
* Unknown keys are dropped so the client's label dict stays exhaustive. */
const FULL_STAT_KEYS: [string, string][] = [
['expected_goals', 'xg'],
['expected_goals_on_target', 'xgot'],
['big_chance', 'bigChances'],
['accurate_passes', 'accuratePasses'],
['long_balls_accurate', 'longBalls'],
['accurate_crosses', 'crosses'],
['touches_opp_box', 'touchesInBox'],
['corners', 'corners'],
['Offsides', 'offsides'],
['fouls', 'fouls'],
['matchstats.headers.tackles', 'tackles'],
['interceptions', 'interceptions'],
['shot_blocks', 'blocks'],
['clearances', 'clearances'],
['keeper_saves', 'saves'],
['duel_won', 'duelsWon'],
['ground_duels_won', 'groundDuels'],
['aerials_won', 'aerialDuels'],
['dribbles_succeeded', 'dribbles'],
];
interface RawStatEntry { key?: string; stats?: unknown[]; type?: string }
interface RawStatGroup { stats?: RawStatEntry[] }
/** matchDetails `stats`: Periods.{All,FirstHalf,SecondHalf}.stats = groups. */
export interface RawMatchStats { Periods?: { All?: { stats?: RawStatGroup[] } } }
const usable = (v: unknown): v is string | number =>
(typeof v === 'number' && Number.isFinite(v)) || (typeof v === 'string' && v.trim() !== '');
/** Full-match stat rows from a raw FotMob `stats` payload. Any shape
* surprise degrades to fewer rows (or none) — never a throw. */
export function normalizeMatchStats(raw: unknown): MatchFullStat[] {
const groups = (raw as RawMatchStats | null | undefined)?.Periods?.All?.stats;
if (!Array.isArray(groups)) return [];
// Categories repeat across groups ("Top stats" previews the rest) — first
// occurrence wins, output follows the whitelist order.
const seen = new Map<string, { home: string; away: string }>();
for (const g of groups) {
if (!g || !Array.isArray(g.stats)) continue;
for (const s of g.stats) {
if (!s || typeof s.key !== 'string' || s.type === 'title' || !Array.isArray(s.stats)) continue;
const [h, a] = s.stats;
if (!usable(h) || !usable(a) || seen.has(s.key)) continue;
seen.set(s.key, { home: String(h), away: String(a) });
}
}
const out: MatchFullStat[] = [];
for (const [fmKey, key] of FULL_STAT_KEYS) {
const v = seen.get(fmKey);
if (v) out.push({ key, ...v });
}
return out;
}