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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user