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,167 @@
|
||||
import { getMatchProvider } from './db/db';
|
||||
import { getTournamentStats } from './ingest/tournamentStats';
|
||||
import { disciplineFor, fixtureEvents, goldenBootRows, nextAnytimeOdds } from './stats';
|
||||
import {
|
||||
aliasKey, buildPlayerIndex, findBoardRow, playerMatchLine, resolvePlayer,
|
||||
type LineupPerfPlayer, type PlayerFixtureData, type PlayerRef,
|
||||
} from '../../src/lib/playerProfile';
|
||||
import { nameMatch } from '../../src/lib/matchEvents';
|
||||
import type { TournamentState } from './tournament';
|
||||
import type { Fixture, MatchLiveEvent, PlayerMatchLine, PlayerProfile, RichShot } from '../../src/lib/types';
|
||||
|
||||
// Assembles /api/player + /api/players entirely from stored data: tournament
|
||||
// boards, the per-fixture FotMob lineups/shots and our ESPN event feeds.
|
||||
// Pattern: thin orchestration here, the fold logic lives in src/lib/playerProfile.
|
||||
|
||||
interface FmPerfPlayer {
|
||||
name?: string;
|
||||
performance?: { rating?: number; substitutionEvents?: { time?: number; type?: string }[] };
|
||||
}
|
||||
interface FmLineupTeam { starters?: FmPerfPlayer[]; subs?: FmPerfPlayer[] }
|
||||
interface FmDetails {
|
||||
homeTeamId?: number | null;
|
||||
awayTeamId?: number | null;
|
||||
shots?: {
|
||||
teamId?: number; playerName?: string; x?: number; y?: number; min?: number;
|
||||
expectedGoals?: number; expectedGoalsOnTarget?: number; eventType?: string; situation?: string;
|
||||
}[];
|
||||
lineup?: { homeTeam?: FmLineupTeam; awayTeam?: FmLineupTeam } | null;
|
||||
}
|
||||
|
||||
const subTime = (p: FmPerfPlayer, type: string): number | null => {
|
||||
const e = p.performance?.substitutionEvents?.find((s) => s.type === type);
|
||||
return typeof e?.time === 'number' ? e.time : null;
|
||||
};
|
||||
|
||||
const perf = (p: FmPerfPlayer): LineupPerfPlayer => ({
|
||||
name: p.name ?? '',
|
||||
rating: typeof p.performance?.rating === 'number' ? p.performance.rating : null,
|
||||
subIn: subTime(p, 'subIn'),
|
||||
subOut: subTime(p, 'subOut'),
|
||||
});
|
||||
|
||||
/** Cross-source same-player check: alias-key equality (bridges romanization)
|
||||
* or token-set match (bridges name order). */
|
||||
const samePlayer = (a: string, b: string): boolean => aliasKey(a) === aliasKey(b) || nameMatch(a, b);
|
||||
|
||||
const opponentOf = (f: Fixture, side: 'home' | 'away'): string =>
|
||||
side === 'home' ? f.away.team ?? f.away.label : f.home.team ?? f.home.label;
|
||||
|
||||
/** Everyone the stored data knows: boards (FotMob display forms first), every
|
||||
* lineup that was captured, then the ESPN-derived scorers dedupe onto them. */
|
||||
function playerIndex(state: TournamentState): Map<string, PlayerRef> {
|
||||
const sources: PlayerRef[] = [];
|
||||
const boards = getTournamentStats()?.players;
|
||||
if (boards) {
|
||||
for (const cat of ['rating', 'minutes', 'goals', 'assists', 'xg']) {
|
||||
for (const r of boards[cat] ?? []) sources.push({ name: r.name, team: r.team });
|
||||
}
|
||||
}
|
||||
for (const f of state.allFixtures()) {
|
||||
if (f.status === 'scheduled' || !f.home.team || !f.away.team) continue;
|
||||
const fm = getMatchProvider<FmDetails>(f.num, 'fotmob');
|
||||
if (!fm) continue;
|
||||
for (const [key, team] of [['homeTeam', f.home.team], ['awayTeam', f.away.team]] as const) {
|
||||
const tm = fm.data.lineup?.[key];
|
||||
for (const p of [...(tm?.starters ?? []), ...(tm?.subs ?? [])]) {
|
||||
if (p.name) sources.push({ name: p.name, team });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const b of goldenBootRows(state)) sources.push({ name: b.player, team: b.team });
|
||||
return buildPlayerIndex(sources);
|
||||
}
|
||||
|
||||
function assemble(nameParam: string, state: TournamentState): PlayerProfile | null {
|
||||
const ref = resolvePlayer(nameParam, playerIndex(state));
|
||||
if (!ref) return null;
|
||||
const { name, team } = ref;
|
||||
|
||||
const cache = new Map<number, MatchLiveEvent[]>();
|
||||
const fixtures = state
|
||||
.allFixtures()
|
||||
.filter((f) => f.home.team === team || f.away.team === team)
|
||||
.sort((a, b) => a.kickoff.localeCompare(b.kickoff));
|
||||
|
||||
const matchLog: PlayerMatchLine[] = [];
|
||||
const shots: RichShot[] = [];
|
||||
for (const f of fixtures) {
|
||||
if (f.status === 'scheduled') continue;
|
||||
const side: 'home' | 'away' = f.home.team === team ? 'home' : 'away';
|
||||
const fm = getMatchProvider<FmDetails>(f.num, 'fotmob');
|
||||
const tm = fm?.data.lineup?.[side === 'home' ? 'homeTeam' : 'awayTeam'];
|
||||
const line = playerMatchLine(name, {
|
||||
num: f.num,
|
||||
opponent: opponentOf(f, side),
|
||||
side,
|
||||
finished: f.status === 'finished',
|
||||
kickoff: f.kickoff,
|
||||
homeScore: f.homeScore,
|
||||
awayScore: f.awayScore,
|
||||
starters: (tm?.starters ?? []).map(perf),
|
||||
bench: (tm?.subs ?? []).map(perf),
|
||||
events: fixtureEvents(f.num, f.home.team, f.away.team, cache),
|
||||
} satisfies PlayerFixtureData);
|
||||
if (line) matchLog.push(line);
|
||||
|
||||
// His shots, forced onto the home orientation so one aggregate ShotMap
|
||||
// can draw every match attacking right.
|
||||
const teamId = side === 'home' ? fm?.data.homeTeamId : fm?.data.awayTeamId;
|
||||
for (const s of fm?.data.shots ?? []) {
|
||||
if (!s.playerName || !samePlayer(s.playerName, name)) continue;
|
||||
if (teamId != null && s.teamId !== teamId) continue;
|
||||
shots.push({
|
||||
isHome: true, player: name,
|
||||
x: s.x ?? null, y: s.y ?? null, min: s.min ?? null,
|
||||
xg: s.expectedGoals ?? null, xgot: s.expectedGoalsOnTarget ?? null,
|
||||
type: s.eventType ?? '', situation: s.situation ?? '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const totals: PlayerProfile['totals'] = {};
|
||||
const boards = getTournamentStats()?.players;
|
||||
if (boards) {
|
||||
for (const [cat, rows] of Object.entries(boards)) {
|
||||
const row = findBoardRow(rows, name);
|
||||
if (row) totals[cat] = { value: row.value, matches: row.matches, minutes: row.minutes };
|
||||
}
|
||||
}
|
||||
const bootGoals = goldenBootRows(state).find((b) => samePlayer(b.player, name))?.goals ?? 0;
|
||||
const discipline = disciplineFor(team, state, cache).find((p) => samePlayer(p.player, name)) ?? null;
|
||||
|
||||
const next = state
|
||||
.allFixtures()
|
||||
.filter((f) => f.status !== 'finished' && (f.home.team === team || f.away.team === team))
|
||||
.sort((a, b) => a.kickoff.localeCompare(b.kickoff))[0];
|
||||
const odds = nextAnytimeOdds(name, team, state);
|
||||
const nextProp = next && odds != null
|
||||
? { num: next.num, opponent: opponentOf(next, next.home.team === team ? 'home' : 'away'), odds }
|
||||
: null;
|
||||
|
||||
return { name, team, totals, bootGoals, matchLog, shots, discipline, nextProp, updatedAt: Date.now() };
|
||||
}
|
||||
|
||||
// A 60s memo guards the 10s polling bursts of an open player page; negative
|
||||
// results are cached too (retired all-time scorers get linked).
|
||||
const TTL = 60_000;
|
||||
const memo = new Map<string, { at: number; profile: PlayerProfile | null }>();
|
||||
let listMemo: { at: number; players: PlayerRef[] } | null = null;
|
||||
|
||||
export function buildPlayerProfile(nameParam: string, state: TournamentState): PlayerProfile | null {
|
||||
const k = aliasKey(nameParam);
|
||||
const hit = memo.get(k);
|
||||
if (hit && Date.now() - hit.at < TTL) return hit.profile;
|
||||
const profile = assemble(nameParam, state);
|
||||
if (memo.size > 200) memo.clear();
|
||||
memo.set(k, { at: Date.now(), profile });
|
||||
return profile;
|
||||
}
|
||||
|
||||
/** The search palette's lightweight player list. */
|
||||
export function playersIndex(state: TournamentState): PlayerRef[] {
|
||||
if (listMemo && Date.now() - listMemo.at < TTL) return listMemo.players;
|
||||
const players = [...playerIndex(state).values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
listMemo = { at: Date.now(), players };
|
||||
return players;
|
||||
}
|
||||
Reference in New Issue
Block a user