0fd13d4c24
- /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>
62 lines
2.5 KiB
TypeScript
62 lines
2.5 KiB
TypeScript
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;
|
|
}
|