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>
168 lines
7.1 KiB
TypeScript
168 lines
7.1 KiB
TypeScript
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;
|
|
}
|