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
+20 -1
View File
@@ -9,8 +9,11 @@ import { TournamentState } from './tournament';
import { startScheduler } from './ingest/scheduler';
import { ModelEngine } from './model';
import { buildPreview, buildTeamProfile } from './preview';
import { buildPlayerProfile, playersIndex } from './player';
import { buildScoreboard, snapshotCheck } from './scoreboard';
import { buildStatsHub, fixtureEvents, goldenBootRows } from './stats';
import { normalizeMatchStats } from './ingest/fotmobStatsNormalize';
import { confirmedXI } from './ingest/fifaLineupNormalize';
import { buildIcs } from './ics';
import { db, healthAll, oddsFor, latestOddsAll, dbCounts, inplayHistory, recordInplay, getMatchProvider, allFixtureResults, saveFixtureResult, allModelHistory, saveModelHistory } from './db/db';
import { inPlayProbs } from '../../src/lib/model/inplay';
@@ -139,11 +142,13 @@ export function buildServer() {
interface FmDetails {
homeTeamId?: number | null;
shots?: FmShot[];
stats?: unknown;
momentum?: { main?: { data?: { minute: number; value: number }[] } } | null;
matchFacts?: { playerOfTheMatch?: { name?: { fullName?: string }; rating?: { num?: string }; teamName?: string } } | null;
lineup?: { homeTeam?: FmLineupTeam; awayTeam?: FmLineupTeam } | null;
lineup?: { lineupType?: string; homeTeam?: FmLineupTeam; awayTeam?: FmLineupTeam } | null;
}
const fm = getMatchProvider<FmDetails>(num, 'fotmob');
const fifa = getMatchProvider<{ live?: unknown }>(num, 'fifa');
const fifaInfo = getMatchProvider<{ attendance: number | null; officials: { Name?: { Description?: string }[]; TypeLocalized?: { Description?: string }[] }[]; stadium: string }>(num, 'fifa-info');
const weather = getMatchProvider<{ tempC: number | null; precipProbPct: number | null; windKmh: number | null; elevationM: number }>(num, 'weather');
@@ -196,6 +201,7 @@ export function buildServer() {
};
const lineupHome = trimTeam(fm?.data.lineup?.homeTeam);
const lineupAway = trimTeam(fm?.data.lineup?.awayTeam);
const fullStats = normalizeMatchStats(fm?.data.stats);
return {
shots,
@@ -211,6 +217,10 @@ export function buildServer() {
}
: null,
weather: weather ? weather.data : null,
fullStats: fullStats.length ? fullStats : null,
// Official XI published by FIFA, or FotMob no longer calling its
// lineup "predicted" — either way, stop labeling them as projections.
lineupConfirmed: confirmedXI(fifa?.data.live) != null || fm?.data.lineup?.lineupType === 'standard',
};
});
@@ -252,6 +262,15 @@ export function buildServer() {
const profile = buildTeamProfile(name, state, model);
return profile ?? reply.code(404).send({ error: 'no such team' });
});
// Player profiles, assembled from stored boards, lineups, shots and events.
// Either source's spelling of a name resolves ("Hwang In-Beom" = "In-Beom Hwang").
app.get('/api/player/:name', async (req, reply) => {
const name = decodeURIComponent((req.params as { name: string }).name);
const profile = buildPlayerProfile(name, state);
return profile ?? reply.code(404).send({ error: 'unknown player' });
});
// The search palette's player list.
app.get('/api/players', async () => ({ players: playersIndex(state) }));
// Calendar export: one team's schedule (?team=X) or the whole tournament.
app.get('/api/calendar.ics', async (req, reply) => {
const raw = (req.query as { team?: string }).team;
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { confirmedXI } from './fifaLineupNormalize';
const squad = (starters: number, bench = 15, prefix = 'P') => ({
Players: [
...Array.from({ length: starters }, (_, i) => ({ Status: 1, ShortName: [{ Locale: 'en-GB', Description: `${prefix}${i + 1}` }] })),
...Array.from({ length: bench }, (_, i) => ({ Status: 2, ShortName: [{ Locale: 'en-GB', Description: `${prefix}B${i + 1}` }] })),
],
});
describe('confirmedXI', () => {
it('returns both XIs when each side has exactly 11 Status===1 starters', () => {
const xi = confirmedXI({ HomeTeam: squad(11, 15, 'H'), AwayTeam: squad(11, 15, 'A') });
expect(xi?.home).toHaveLength(11);
expect(xi?.away).toHaveLength(11);
expect(xi?.home[0]).toBe('H1');
expect(xi?.away[10]).toBe('A11');
});
it('rejects sides without exactly 11 starters', () => {
expect(confirmedXI({ HomeTeam: squad(10), AwayTeam: squad(11) })).toBeNull();
expect(confirmedXI({ HomeTeam: squad(12), AwayTeam: squad(11) })).toBeNull();
expect(confirmedXI({ HomeTeam: squad(0, 26), AwayTeam: squad(11) })).toBeNull();
});
it('rejects unnamed starters and shape surprises', () => {
const broken = squad(11);
broken.Players[3] = { Status: 1, ShortName: [] };
expect(confirmedXI({ HomeTeam: broken, AwayTeam: squad(11) })).toBeNull();
expect(confirmedXI(null)).toBeNull();
expect(confirmedXI({})).toBeNull();
expect(confirmedXI({ HomeTeam: { Players: 'nope' }, AwayTeam: squad(11) })).toBeNull();
});
});
+29
View File
@@ -0,0 +1,29 @@
// Pure normalizer for the FIFA live payload's lineups — no network or DB.
// FIFA publishes the official XI ~7590 min before kickoff; Status === 1
// marks starters within the 26-man squad list.
interface RawFifaPlayer { Status?: number; ShortName?: { Description?: string }[] }
interface RawFifaTeam { Players?: RawFifaPlayer[] }
/** The `live` value stored under match_provider provider='fifa'. */
export interface RawFifaLive { HomeTeam?: RawFifaTeam; AwayTeam?: RawFifaTeam }
/** The officially confirmed XIs, or null unless BOTH sides have exactly 11
* named starters — validation doubles as the feature flag, so a payload
* drift silently keeps lineups marked "predicted". */
export function confirmedXI(raw: unknown): { home: string[]; away: string[] } | null {
const live = raw as RawFifaLive | null | undefined;
const side = (t?: RawFifaTeam): string[] | null => {
if (!t || !Array.isArray(t.Players)) return null;
const names: string[] = [];
for (const p of t.Players) {
if (!p || p.Status !== 1) continue;
const n = p.ShortName?.[0]?.Description;
if (typeof n !== 'string' || !n.trim()) return null;
names.push(n.trim());
}
return names.length === 11 ? names : null;
};
const home = side(live?.HomeTeam);
const away = side(live?.AwayTeam);
return home && away ? { home, away } : null;
}
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest';
import { normalizeMatchStats } from './fotmobStatsNormalize';
// Trimmed from the real match-1 capture (Mexico 2-0 South Africa).
const sample = {
Periods: {
All: {
stats: [
{
title: 'Top stats',
key: 'top_stats',
stats: [
{ title: 'Ball possession', key: 'BallPossesion', stats: [60, 40], format: 'integer', type: 'graph' },
{ title: 'Expected goals (xG)', key: 'expected_goals', stats: ['1.46', '0.07'], format: 'double', type: 'text' },
{ title: 'Big chances', key: 'big_chance', stats: [2, 0], format: 'integer', type: 'text' },
{ title: 'Touches in opposition box', key: 'touches_opp_box', stats: [20, 2], format: null, type: 'text' },
],
},
{
title: 'Expected goals (xG)',
key: 'expected_goals',
stats: [
{ title: 'Expected goals (xG)', key: 'expected_goals', stats: [null, null], format: null, type: 'title' },
{ title: 'Expected goals (xG)', key: 'expected_goals', stats: ['9.99', '9.99'], format: 'double', type: 'text' },
{ title: 'xG on target (xGOT)', key: 'expected_goals_on_target', stats: ['1.28', '0.13'], format: 'double', type: 'text' },
],
},
{
title: 'Passes',
key: 'passes',
stats: [
{ title: 'Accurate passes', key: 'accurate_passes', stats: ['467 (90%)', '272 (81%)'], format: 'integerWithPercentage', type: 'text' },
{ title: 'Accurate long balls', key: 'long_balls_accurate', stats: ['31 (65%)', '17 (37%)'], format: 'integerWithPercentage', type: 'text' },
],
},
{
title: 'Duels',
key: 'duels',
stats: [
{ title: 'Duels won', key: 'duel_won', stats: [47, 33], format: 'integer', type: 'text' },
{ title: 'Aerial duels won', key: 'aerials_won', stats: ['15 (68%)', '7 (32%)'], format: 'integerWithPercentage', type: 'text' },
],
},
],
},
},
};
describe('normalizeMatchStats', () => {
it('extracts whitelisted rows in display order, first occurrence wins', () => {
const rows = normalizeMatchStats(sample);
expect(rows.map((r) => r.key)).toEqual([
'xg', 'xgot', 'bigChances', 'accuratePasses', 'longBalls', 'touchesInBox', 'duelsWon', 'aerialDuels',
]);
// 1.46 from "Top stats" beats the 9.99 repeat in the xG group.
expect(rows[0]).toEqual({ key: 'xg', home: '1.46', away: '0.07' });
// provider formatting passes through verbatim
expect(rows[3]).toEqual({ key: 'accuratePasses', home: '467 (90%)', away: '272 (81%)' });
});
it('drops non-whitelisted keys and title separators', () => {
const rows = normalizeMatchStats(sample);
expect(rows.find((r) => r.key === 'BallPossesion')).toBeUndefined();
expect(rows.every((r) => r.home !== '' && r.away !== '')).toBe(true);
});
it('degrades to [] on null, garbage and pre-match payloads', () => {
expect(normalizeMatchStats(null)).toEqual([]);
expect(normalizeMatchStats(undefined)).toEqual([]);
expect(normalizeMatchStats('nope')).toEqual([]);
expect(normalizeMatchStats({ Periods: { All: { stats: 'nope' } } })).toEqual([]);
expect(normalizeMatchStats({ Periods: {} })).toEqual([]);
});
it('skips rows with missing or empty values', () => {
const rows = normalizeMatchStats({
Periods: { All: { stats: [{ stats: [
{ key: 'fouls', stats: [12, null], type: 'text' },
{ key: 'corners', stats: ['', 3], type: 'text' },
{ key: 'interceptions', stats: [8, 7], type: 'text' },
] }] } },
});
expect(rows).toEqual([{ key: 'interceptions', home: '8', away: '7' }]);
});
});
+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;
}
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { whoScores } from './propsNormalize';
const row = (athlete: string, odds: number | null, market = 'Anytime Goalscorer', captured_at = 1) =>
({ athlete, market, odds, captured_at });
describe('whoScores', () => {
it('keeps only anytime lines with odds, favorites first', () => {
const out = whoScores([
row('Longshot', 400),
row('Favorite', -150),
row('Mid', 120),
row('No Line', null),
row('First Only', -300, 'First Goalscorer'),
row('Two Plus', 200, 'To Score 2+ Goals'),
]);
expect(out).toEqual([
{ player: 'Favorite', odds: -150 },
{ player: 'Mid', odds: 120 },
{ player: 'Longshot', odds: 400 },
]);
});
it('the latest capture per athlete wins', () => {
const out = whoScores([
row('Mover', 150, 'Anytime Goalscorer', 1),
row('Mover', 110, 'Anytime Goalscorer', 2),
]);
expect(out).toEqual([{ player: 'Mover', odds: 110 }]);
});
it('caps the board', () => {
const rows = Array.from({ length: 20 }, (_, i) => row(`P${i}`, 100 + i));
expect(whoScores(rows)).toHaveLength(8);
expect(whoScores(rows)[0]?.player).toBe('P0');
});
it('handles empty input', () => {
expect(whoScores([])).toEqual([]);
});
});
+27
View File
@@ -0,0 +1,27 @@
// Pure scorer-props folds — no network or DB (same pattern as the other
// *Normalize modules). Odds are a display-only benchmark, never a model input.
export interface PropRow {
athlete: string;
market: string;
odds: number | null;
captured_at: number;
}
const implied = (american: number): number =>
american < 0 ? -american / (-american + 100) : 100 / (american + 100);
/** The bookmaker's anytime-scorer board for one fixture: latest capture per
* athlete, favorites first. */
export function whoScores(props: PropRow[], max = 8): { player: string; odds: number }[] {
const latest = new Map<string, { odds: number; at: number }>();
for (const p of props) {
if (p.odds == null || !/anytime/i.test(p.market)) continue;
const cur = latest.get(p.athlete);
if (!cur || p.captured_at >= cur.at) latest.set(p.athlete, { odds: p.odds, at: p.captured_at });
}
return [...latest.entries()]
.map(([player, v]) => ({ player, odds: v.odds }))
.sort((a, b) => implied(b.odds) - implied(a.odds) || a.player.localeCompare(b.player))
.slice(0, max);
}
+31 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { PLAYER_BOARDS, TEAM_BOARDS, boardSlug, normalizeStatBoard } from './statsNormalize';
import { PLAYER_BOARDS, TEAM_BOARDS, boardName, boardSlug, normalizeStatBoard } from './statsNormalize';
import { nameMatch } from '../../../src/lib/matchEvents';
// Real shape from data.fotmob.com/stats/77/season/24254/goals.json (WC 2026).
const realBoard = {
@@ -47,7 +48,36 @@ describe('board slugs', () => {
it('maps the CDN url to our category keys', () => {
expect(PLAYER_BOARDS[boardSlug('https://data.fotmob.com/stats/77/season/24254/goals.json')]).toBe('goals');
expect(PLAYER_BOARDS[boardSlug('https://data.fotmob.com/stats/77/season/24254/expected_goals.json')]).toBe('xg');
expect(PLAYER_BOARDS[boardSlug('https://data.fotmob.com/stats/77/season/24254/_save_percentage.json')]).toBe('savePct');
expect(TEAM_BOARDS[boardSlug('https://data.fotmob.com/stats/77/season/24254/_xg_diff_team.json')]).toBe('xgDiff');
expect(PLAYER_BOARDS[boardSlug(undefined)]).toBeUndefined();
});
});
describe('boardName', () => {
const boards = {
goals: [
{ name: 'In-Beom Hwang', team: 'South Korea', value: 1, matches: 1, minutes: 90 },
{ name: 'Cyle Larin', team: 'Canada', value: 1, matches: 1, minutes: 80 },
],
rating: [{ name: 'Thiago Silva', team: 'Brazil', value: 8.1, matches: 1, minutes: 90 }],
};
it('prefers the board form of the name when a same-team row matches', () => {
expect(boardName('Hwang In-Beom', 'South Korea', boards, nameMatch)).toBe('In-Beom Hwang');
});
it('keeps the ESPN form when nothing matches', () => {
expect(boardName('Oh Hyeon-Gyu', 'South Korea', boards, nameMatch)).toBe('Oh Hyeon-Gyu');
expect(boardName('Cyle Larin', 'Canada', undefined, nameMatch)).toBe('Cyle Larin');
});
it('never merges across teams', () => {
// a hypothetical second Thiago Silva playing elsewhere stays untouched
expect(boardName('Thiago Silva', 'Portugal', boards, nameMatch)).toBe('Thiago Silva');
});
it('falls back through rating/minutes boards for non-scorers', () => {
expect(boardName('Silva Thiago', 'Brazil', boards, nameMatch)).toBe('Thiago Silva');
});
});
+21
View File
@@ -13,6 +13,10 @@ export const PLAYER_BOARDS: Record<string, string> = {
'rating.json': 'rating',
'expected_goals.json': 'xg',
'expected_goals_per_90.json': 'xgPer90',
'expected_assists.json': 'xa',
'total_att_assist.json': 'chancesCreated',
'_save_percentage.json': 'savePct',
'saves.json': 'savesPer90',
'mins_played.json': 'minutes',
};
@@ -67,3 +71,20 @@ export function normalizeStatBoard(board: RawStatBoard, isTeam: boolean, max = 3
export function boardSlug(url: string | undefined): string {
return url?.split('/').pop() ?? '';
}
/** The FotMob display form of a player name, when a board row for the same
* team matches. ESPN spells the same player differently ("Hwang In-Beom" vs
* "In-Beom Hwang"), and the boards are what the rest of the page shows. */
export function boardName(
player: string,
team: string,
boards: Record<string, StatRow[]> | undefined,
match: (a: string, b: string) => boolean,
): string {
if (!boards) return player;
for (const cat of ['goals', 'rating', 'minutes']) {
const hit = boards[cat]?.find((r) => r.team === team && match(r.name, player));
if (hit) return hit.name;
}
return player;
}
+167
View File
@@ -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;
}
+4 -1
View File
@@ -1,5 +1,6 @@
import { readFileSync, existsSync } from 'node:fs';
import { getMatchExt, injuriesFor, snapshotFor } from './db/db';
import { getMatchExt, injuriesFor, scorerPropsFor, snapshotFor } from './db/db';
import { whoScores } from './ingest/propsNormalize';
import { disciplineFor } from './stats';
import type { TournamentState } from './tournament';
import type { ModelEngine } from './model';
@@ -149,6 +150,8 @@ export function buildPreview(num: number, state: TournamentState, model: ModelEn
away: away ? injuriesFor(away) : [],
},
videos: summary?.videos ?? [],
// The board disappears at kickoff — in-play lines drift too fast to mirror.
scorerProps: fixture.status === 'scheduled' ? whoScores(scorerPropsFor(num)) : [],
dataNote: !home || !away
? 'Knockout participants are decided once the bracket resolves.'
: ext
+13 -2
View File
@@ -1,7 +1,9 @@
import { getMatchExt, scorerPropsFor } from './db/db';
import { getTournamentStats } from './ingest/tournamentStats';
import { boardName } from './ingest/statsNormalize';
import { teamDiscipline, type DisciplineFixture } from '../../src/lib/discipline';
import { nameMatch } from '../../src/lib/matchEvents';
import { aliasKey } from '../../src/lib/playerProfile';
import type { TournamentState } from './tournament';
import type { EspnSummary } from './ingest/espnNormalize';
import type { GoldenBootRow, MatchLiveEvent, PlayerDiscipline, StatsHub } from '../../src/lib/types';
@@ -73,14 +75,23 @@ export function goldenBootRows(state: TournamentState, max = 25): GoldenBootRow[
tally.set(key, cur);
}
}
const boards = getTournamentStats()?.players;
// Token-set match plus the romanization fold ("Oh Hyeon-Gyu" = "Hyun-Gyu Oh").
const same = (a: string, b: string) => aliasKey(a) === aliasKey(b) || nameMatch(a, b);
return [...tally.values()]
.sort((a, b) => b.goals - a.goals || a.player.localeCompare(b.player))
.slice(0, max)
.map((p) => ({ ...p, nextOdds: nextAnytimeOdds(p.player, p.team, state) }));
// Display the FotMob form of the name (what the boards beside this list
// show); the ESPN form still drives the props lookup.
.map((p) => ({
...p,
player: boardName(p.player, p.team, boards, same),
nextOdds: nextAnytimeOdds(p.player, p.team, state),
}));
}
/** Latest captured anytime-scorer line for the player's next fixture. */
function nextAnytimeOdds(player: string, team: string, state: TournamentState): number | null {
export function nextAnytimeOdds(player: string, team: string, state: TournamentState): number | null {
if (!team) return null;
const next = state
.allFixtures()