diff --git a/server/src/index.ts b/server/src/index.ts index d6f3c34..2617046 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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(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; diff --git a/server/src/ingest/fifaLineupNormalize.test.ts b/server/src/ingest/fifaLineupNormalize.test.ts new file mode 100644 index 0000000..6d8bca9 --- /dev/null +++ b/server/src/ingest/fifaLineupNormalize.test.ts @@ -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(); + }); +}); diff --git a/server/src/ingest/fifaLineupNormalize.ts b/server/src/ingest/fifaLineupNormalize.ts new file mode 100644 index 0000000..405449e --- /dev/null +++ b/server/src/ingest/fifaLineupNormalize.ts @@ -0,0 +1,29 @@ +// Pure normalizer for the FIFA live payload's lineups — no network or DB. +// FIFA publishes the official XI ~75–90 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; +} diff --git a/server/src/ingest/fotmobStatsNormalize.test.ts b/server/src/ingest/fotmobStatsNormalize.test.ts new file mode 100644 index 0000000..ef546eb --- /dev/null +++ b/server/src/ingest/fotmobStatsNormalize.test.ts @@ -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' }]); + }); +}); diff --git a/server/src/ingest/fotmobStatsNormalize.ts b/server/src/ingest/fotmobStatsNormalize.ts new file mode 100644 index 0000000..7414a69 --- /dev/null +++ b/server/src/ingest/fotmobStatsNormalize.ts @@ -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(); + 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; +} diff --git a/server/src/ingest/propsNormalize.test.ts b/server/src/ingest/propsNormalize.test.ts new file mode 100644 index 0000000..e55ee51 --- /dev/null +++ b/server/src/ingest/propsNormalize.test.ts @@ -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([]); + }); +}); diff --git a/server/src/ingest/propsNormalize.ts b/server/src/ingest/propsNormalize.ts new file mode 100644 index 0000000..590097d --- /dev/null +++ b/server/src/ingest/propsNormalize.ts @@ -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(); + 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); +} diff --git a/server/src/ingest/statsNormalize.test.ts b/server/src/ingest/statsNormalize.test.ts index db97c1d..71f1f51 100644 --- a/server/src/ingest/statsNormalize.test.ts +++ b/server/src/ingest/statsNormalize.test.ts @@ -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'); + }); +}); diff --git a/server/src/ingest/statsNormalize.ts b/server/src/ingest/statsNormalize.ts index 4e61723..1486047 100644 --- a/server/src/ingest/statsNormalize.ts +++ b/server/src/ingest/statsNormalize.ts @@ -13,6 +13,10 @@ export const PLAYER_BOARDS: Record = { '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 | 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; +} diff --git a/server/src/player.ts b/server/src/player.ts new file mode 100644 index 0000000..8a55d5f --- /dev/null +++ b/server/src/player.ts @@ -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 { + 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(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(); + 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(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(); +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; +} diff --git a/server/src/preview.ts b/server/src/preview.ts index 71d0a26..f135982 100644 --- a/server/src/preview.ts +++ b/server/src/preview.ts @@ -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 diff --git a/server/src/stats.ts b/server/src/stats.ts index d81a04c..3b03583 100644 --- a/server/src/stats.ts +++ b/server/src/stats.ts @@ -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() diff --git a/src/components/LineupPitch.tsx b/src/components/LineupPitch.tsx index 5cf5a7a..4a6ff8c 100644 --- a/src/components/LineupPitch.tsx +++ b/src/components/LineupPitch.tsx @@ -1,5 +1,7 @@ import { useMemo } from 'react'; +import { useNavigate } from '@tanstack/react-router'; import { fmt, useT } from '@/lib/i18n'; +import { PlayerLink } from '@/components/PlayerLink'; import { nameMatch, parseEvents } from '@/lib/matchEvents'; import type { LineupTeam, MatchLineup, MatchLiveEvent, PitchPlayer } from '@/lib/types'; @@ -86,9 +88,11 @@ function shortName(name: string): string { } function PlayerDot({ slot, side, ny }: { slot: Slot; side: 'home' | 'away'; ny: number }) { + const navigate = useNavigate(); const p = slot.player; const st = slot.starter; if (st.x == null) return null; + const open = () => void navigate({ to: '/player/$name', params: { name: p.name } }); const cx = side === 'home' ? st.x * W : (1 - st.x) * W; const cy = side === 'home' ? ny * (H / 2) : H - ny * (H / 2); // Rating chip points toward the pitch center, sub markers outward — so @@ -101,7 +105,16 @@ function PlayerDot({ slot, side, ny }: { slot: Slot; side: 'home' | 'away'; ny: const gk = ny <= 0.1; const stroke = side === 'home' ? 'var(--app-accent)' : 'var(--app-info)'; return ( - + { if (e.key === 'Enter') open(); }} + > + {p.name} {p.num ?? ''} @@ -209,7 +222,7 @@ function BenchStrip({ players, tone, label, dir }: { players: PitchPlayer[]; ton return (
  • {dir === 'on' ? '▲' : '▼'} - {p.name} + {minute != null && {fmt(t.live.minute, { n: minute })}} {p.rating != null && {p.rating.toFixed(1)}}
  • diff --git a/src/components/MatchTimeline.tsx b/src/components/MatchTimeline.tsx index e9c578d..02f04d0 100644 --- a/src/components/MatchTimeline.tsx +++ b/src/components/MatchTimeline.tsx @@ -1,9 +1,16 @@ import { useMemo, useState, type KeyboardEvent } from 'react'; import { ArrowRightLeft, ChevronDown, CircleDot, Volleyball } from 'lucide-react'; -import { fmt, useT } from '@/lib/i18n'; +import { fmt, fmtSplit, useT } from '@/lib/i18n'; +import { PlayerLink } from '@/components/PlayerLink'; import { eventContext, goalShot, momentumVerdict, parseEvents, halfTimeScore, reportSentences, varLines, type CommentaryLine, type EventKind, type ParsedEvent } from '@/lib/matchEvents'; import type { MatchLiveEvent, RichShot } from '@/lib/types'; +/** Only properly-parsed player names become links — an unparseable line keeps + * its raw text as the title (the same guard the discipline fold uses). */ +const linkableName = (e: ParsedEvent): boolean => + (e.kind === 'goal' || e.kind === 'yellow' || e.kind === 'red' || (e.kind === 'sub' && !!e.subOut)) && + e.raw.trim().length > 0 && e.title !== e.raw.trim() && e.title.length <= 40; + /** Crisp event markers instead of emoji: ball icon for goals, card-shaped * color chips for bookings, arrows for substitutions. */ export function EventIcon({ kind }: { kind: EventKind }) { @@ -114,9 +121,9 @@ function GoalCard({ e, mobile, ctx, rich, open, onToggle }: { e: ParsedEvent; mo ? away ? 'border-l-[3px] border-l-info' : 'border-l-[3px] border-l-accent' : 'border-r-[3px] border-r-accent'; const own = e.detail?.type === 'ownGoal'; + const assistName = e.detail?.type === 'assist' ? e.detail.name : null; const detailText = - e.detail?.type === 'assist' ? fmt(t.match.assist, { name: e.detail.name }) - : e.detail?.type === 'penalty' ? t.match.penalty + e.detail?.type === 'penalty' ? t.match.penalty : e.detail?.type === 'raw' ? e.detail.text : null; return ( @@ -138,8 +145,18 @@ function GoalCard({ e, mobile, ctx, rich, open, onToggle }: { e: ParsedEvent; mo {ctx && } -
    {e.title}
    - {detailText &&
    {detailText}
    } +
    + {linkableName(e) ? : e.title} +
    + {assistName ? ( +
    + {fmtSplit(t.match.assist).map((part, j) => + typeof part === 'string' ? part : , + )} +
    + ) : detailText ? ( +
    {detailText}
    + ) : null} {ctx && open && } ); @@ -157,8 +174,12 @@ function MinorChip({ e, mobile, ctx, rich, open, onToggle }: { e: ParsedEvent; m > - {e.title} - {e.subOut && {e.subOut}} + {linkableName(e) ? ( + + ) : ( + {e.title} + )} + {e.subOut && } {ctx && } {ctx && open && } diff --git a/src/components/PlayerLink.tsx b/src/components/PlayerLink.tsx new file mode 100644 index 0000000..37056dc --- /dev/null +++ b/src/components/PlayerLink.tsx @@ -0,0 +1,27 @@ +import { Link } from '@tanstack/react-router'; +import type { MouseEvent, ReactNode } from 'react'; + +/** A name that navigates to the player page. `stop` keeps the click out of + * clickable parents (expandable timeline rows, fixture rows). */ +export function PlayerLink({ + name, + className = 'transition-colors hover:text-accent', + stop = false, + children, +}: { + name: string; + className?: string; + stop?: boolean; + children?: ReactNode; +}) { + return ( + e.stopPropagation() : undefined} + > + {children ?? name} + + ); +} diff --git a/src/components/SearchPalette.tsx b/src/components/SearchPalette.tsx index 014a929..51bdb15 100644 --- a/src/components/SearchPalette.tsx +++ b/src/components/SearchPalette.tsx @@ -2,24 +2,27 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from '@tanstack/react-router'; import { Search } from 'lucide-react'; import { teamFlag } from '@/lib/teams'; +import { norm } from '@/lib/matchEvents'; import { useFormat } from '@/lib/i18n'; import { useTournamentStore } from '@/stores/tournamentStore'; interface Hit { - kind: 'team' | 'match'; + kind: 'team' | 'match' | 'player'; label: string; sub: string; flags: string[]; go: () => void; } -/** Cmd/Ctrl-K palette: jump to any team or match. */ +/** Cmd/Ctrl-K palette: jump to any team, match or player. */ export function SearchPalette({ open, onClose }: { open: boolean; onClose: () => void }) { const { t, kickoffDay } = useFormat(); const navigate = useNavigate(); const snapshot = useTournamentStore((s) => s.snapshot); const [q, setQ] = useState(''); const [sel, setSel] = useState(0); + // Component state, not the store — a fresh-array zustand selector would loop. + const [players, setPlayers] = useState<{ name: string; team: string }[]>([]); const inputRef = useRef(null); useEffect(() => { @@ -30,6 +33,17 @@ export function SearchPalette({ open, onClose }: { open: boolean; onClose: () => } }, [open]); + // Fetch the player list once, on first open (it's small and cached server-side). + const fetched = useRef(false); + useEffect(() => { + if (!open || fetched.current) return; + fetched.current = true; + fetch('/api/players') + .then((r) => (r.ok ? r.json() : Promise.reject())) + .then((d: { players: { name: string; team: string }[] }) => setPlayers(d.players)) + .catch(() => { fetched.current = false; }); + }, [open]); + const hits = useMemo(() => { if (!snapshot || q.trim().length < 2) return []; const needle = q.trim().toLowerCase(); @@ -57,8 +71,19 @@ export function SearchPalette({ open, onClose }: { open: boolean; onClose: () => } if (out.length > 14) break; } + // Players rank below teams/matches; diacritic-folded so "quinones" hits "Quiñones". + let playerHits = 0; + for (const p of players) { + if (playerHits >= 5 || out.length > 14) break; + if (!norm(p.name).includes(norm(needle))) continue; + playerHits++; + out.push({ + kind: 'player', label: p.name, sub: p.team, flags: [teamFlag(p.team)], + go: () => void navigate({ to: '/player/$name', params: { name: p.name } }), + }); + } return out.slice(0, 12); - }, [snapshot, q, navigate, t, kickoffDay]); + }, [snapshot, players, q, navigate, t, kickoffDay]); useEffect(() => setSel(0), [q]); diff --git a/src/components/ShotMap.tsx b/src/components/ShotMap.tsx index a0d7344..58538ee 100644 --- a/src/components/ShotMap.tsx +++ b/src/components/ShotMap.tsx @@ -60,7 +60,8 @@ export function ShotMap({ shots, home, away }: { shots: RichShot[]; home: string ))}
    - ← {away} + {/* single-player aggregate maps pass an empty away side */} + {away ? `← ${away}` : ''} {home} →
    diff --git a/src/features/match/MatchPreviewPage.tsx b/src/features/match/MatchPreviewPage.tsx index 96a0a33..d9c5980 100644 --- a/src/features/match/MatchPreviewPage.tsx +++ b/src/features/match/MatchPreviewPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { Link, useParams } from '@tanstack/react-router'; -import { ArrowLeft, ArrowRight, Ban, Clapperboard, Goal, Shield, Shirt, Stethoscope } from 'lucide-react'; +import { ArrowLeft, ArrowRight, BadgeCheck, Ban, Clapperboard, Goal, Shield, Shirt, Stethoscope } from 'lucide-react'; import { Badge } from '@/components/ui/Badge'; import { Card, CardBody, CardHeader } from '@/components/ui/Card'; import { WinProbBar } from '@/components/WinProbBar'; @@ -18,10 +18,14 @@ import { goalEvents, shootoutKicks } from '@/lib/matchEvents'; import { redCards } from '@/lib/discipline'; import { inPlayProbs } from '@/lib/model/inplay'; import { useTournamentStore } from '@/stores/tournamentStore'; +import { PlayerLink } from '@/components/PlayerLink'; import type { Fixture, KeyPlayer, MatchPreview, MatchRich, OddsPoint } from '@/lib/types'; const num = (s: string | undefined) => (s ? Number(s.replace('%', '')) || 0 : 0); +/** Label dict for the detailed-stats card, widened for lookup by stat key. */ +const fullStatLabels = (d: Record): Record => d; + type TabKey = 'summary' | 'timeline' | 'stats' | 'lineups'; function LiveStatRow({ label, home, away }: { label: string; home: number; away: number }) { @@ -50,7 +54,7 @@ function ScorerList({ team, players }: { team: string; players: KeyPlayer[] }) {
      {players.slice(0, 6).map((p) => (
    • - {p.name} + {p.goals}{p.pens ? ` (${fmt(t.common.pensShort, { n: p.pens })})` : ''}
    • ))} @@ -70,7 +74,7 @@ function LineupCol({ team, players }: { team: string; players: { name: string; p {list.map((p) => (
    • {p.number ?? ''} - {p.name} + {p.position}
    • ))} @@ -173,6 +177,10 @@ export function MatchPreviewPage() { const finished = f.status === 'finished'; const koT = new Date(f.kickoff).getTime(); const hasOddsChart = oddsHistory.filter((o) => o.home_ml != null && o.draw_ml != null && o.away_ml != null && o.captured_at <= koT).length >= 2; + // Latest pre-kickoff totals/handicap line (display only, like all odds here). + const closingLine = oddsHistory + .filter((o) => o.captured_at <= koT && (o.over_under != null || o.spread != null)) + .sort((a, b) => b.captured_at - a.captured_at)[0] ?? null; // Hero extras from the rich capture: kickoff-hour weather (pre-match only) // and post-match official chips (attendance, referee, player of the match). @@ -207,7 +215,7 @@ export function MatchPreviewPage() { // Tabs only appear when their data exists (e.g. no Timeline before kickoff). const tabs: TabKey[] = ['summary']; if (preview.events.length > 0) tabs.push('timeline'); - if (preview.liveStats || (rich && (rich.shots.length >= 3 || rich.momentum.length >= 10))) tabs.push('stats'); + if (preview.liveStats || (rich && ((rich.fullStats?.length ?? 0) > 0 || rich.shots.length >= 3 || rich.momentum.length >= 10))) tabs.push('stats'); tabs.push('lineups'); const tab: TabKey = tabs.includes(activeTab) ? activeTab : 'summary'; @@ -322,6 +330,27 @@ export function MatchPreviewPage() { ) : modelCard} + {f.status === 'scheduled' && preview.scorerProps.length > 0 && ( + + + + {t.match.whoScores.title} + + +
        + {preview.scorerProps.map((p) => ( +
      • + + + {p.odds > 0 ? `+${p.odds}` : p.odds} + +
      • + ))} +
      +

      {t.match.whoScores.note}

      +
      +
      + )} {preview.events.length > 0 && ( {t.match.keyMoments} @@ -392,11 +421,19 @@ export function MatchPreviewPage() { )} - {preview.prediction && hasOddsChart && ( + {preview.prediction && (hasOddsChart || closingLine) && ( {t.match.oddsMovement} - + {hasOddsChart && } + {closingLine && ( +

      + {[ + closingLine.over_under != null ? fmt(t.match.oddsExtras.ou, { n: closingLine.over_under }) : null, + closingLine.spread != null ? fmt(t.match.oddsExtras.spread, { n: `${closingLine.spread > 0 ? '+' : ''}${closingLine.spread}` }) : null, + ].filter(Boolean).join(' · ')} · {closingLine.provider} +

      + )}
      )} @@ -457,6 +494,23 @@ export function MatchPreviewPage() { )} + {rich?.fullStats && rich.fullStats.length > 0 && ( + + {t.match.fullStatsTitle} + +
      + {rich.fullStats.map((r) => ( +
      + {r.home} + {fullStatLabels(t.match.fullStats)[r.key] ?? r.key} + {r.away} +
      + ))} +
      +

      {t.match.xgAttribution}

      +
      +
      + )} {rich && rich.momentum.length >= 10 && ( {t.match.momentumTitle} @@ -530,10 +584,18 @@ export function MatchPreviewPage() { labeled as predicted; from kickoff it's the confirmed lineup. */} {rich?.lineup ? ( - {hasScore ? t.match.lineups : t.match.predictedLineups} + + + {hasScore || rich.lineupConfirmed ? t.match.lineups : t.match.predictedLineups} + {rich.lineupConfirmed && !finished && ( + + {t.match.confirmedLineups} + + )} + -

      {hasScore ? t.match.xgAttribution : t.match.predictedLineupsNote}

      +

      {hasScore || rich.lineupConfirmed ? t.match.xgAttribution : t.match.predictedLineupsNote}

      ) : ( diff --git a/src/features/player/PlayerPage.tsx b/src/features/player/PlayerPage.tsx new file mode 100644 index 0000000..5f832d7 --- /dev/null +++ b/src/features/player/PlayerPage.tsx @@ -0,0 +1,198 @@ +import { useEffect, useState } from 'react'; +import { Link, useParams } from '@tanstack/react-router'; +import { ArrowLeft, Ban, Crosshair, ListOrdered, Medal } from 'lucide-react'; +import { Card, CardBody, CardHeader } from '@/components/ui/Card'; +import { ShotMap } from '@/components/ShotMap'; +import { teamFlag } from '@/lib/teams'; +import { fmt, useFormat, type Dict } from '@/lib/i18n'; +import type { PlayerProfile } from '@/lib/types'; + +/** Totals chips, in display order; values format like the stats-hub boards. */ +const TOTAL_CATS: (keyof Dict['stats']['cat'])[] = [ + 'goals', 'assists', 'rating', 'xg', 'xa', 'chancesCreated', 'savePct', 'savesPer90', 'minutes', +]; +const INT_CATS = new Set(['goals', 'assists', 'minutes', 'chancesCreated']); +const fmtTotal = (key: string, v: number): string => + key === 'savePct' ? `${v.toFixed(1)}%` : INT_CATS.has(key) ? String(Math.round(v)) : v.toFixed(2); + +const american = (o: number) => (o > 0 ? `+${o}` : String(o)); + +function CardSquares({ yellows, reds }: { yellows: number; reds: number }) { + if (yellows + reds === 0) return null; + return ( + + {Array.from({ length: yellows }).map((_, i) => )} + {Array.from({ length: reds }).map((_, i) => )} + + ); +} + +export function PlayerPage() { + const { name: nameParam } = useParams({ strict: false }) as { name: string }; + const { t, kickoffDay } = useFormat(); + const [profile, setProfile] = useState(null); + const [failed, setFailed] = useState(false); + + useEffect(() => { + let alive = true; + setProfile(null); + setFailed(false); + fetch(`/api/player/${encodeURIComponent(nameParam)}`) + .then((r) => (r.ok ? r.json() : Promise.reject())) + .then((d: PlayerProfile) => alive && setProfile(d)) + .catch(() => alive && setFailed(true)); + return () => { alive = false; }; + }, [nameParam]); + + if (failed) { + return ( +
      + {t.player.unknown}{' '} + {t.player.backToStats} +
      + ); + } + if (!profile) return
      ; + + const totals = TOTAL_CATS + .map((cat) => ({ cat, row: profile.totals[cat] })) + .filter((x): x is { cat: (typeof TOTAL_CATS)[number]; row: { value: number; matches: number | null; minutes: number | null } } => x.row != null); + // The boards only list leaders — our own event tally covers everyone else. + const showBootGoals = profile.totals['goals'] == null && profile.bootGoals > 0; + const d = profile.discipline; + + return ( +
      + + {t.player.backToStats} + + +
      + {teamFlag(profile.team)} +
      +

      {profile.name}

      + + {profile.team} + +
      + {d && (d.suspendedNext || d.pending === 1) && ( + + + {d.suspendedNext + ? d.suspendedFor != null ? fmt(t.team.suspChipFor, { num: d.suspendedFor }) : t.team.suspChipNext + : t.team.atRiskChip} + + )} +
      + + {(totals.length > 0 || showBootGoals || d) && ( + + + + {t.player.totalsTitle} + + + {showBootGoals && ( + + {t.player.goalsLabel} + {profile.bootGoals} + + )} + {totals.map(({ cat, row }) => ( + + {t.stats.cat[cat]} + {fmtTotal(cat, row.value)} + + ))} + {d && (d.yellows > 0 || d.reds > 0) && ( + + + + )} + + + )} + + {profile.matchLog.length > 0 && ( + + + + {t.player.matchLogTitle} + + +
      + + + + + + + + + + + + + + {profile.matchLog.map((m) => { + const us = m.side === 'home' ? m.homeScore : m.awayScore; + const them = m.side === 'home' ? m.awayScore : m.homeScore; + const res = us != null && them != null ? (us > them ? 'win' : us < them ? 'loss' : 'draw') : null; + return ( + + + + + + + + + + ); + })} + +
      {t.player.log.opponent}{t.player.log.min}{t.player.log.rating}{t.player.log.goals}{t.player.log.assists}
      + + {teamFlag(m.opponent)} + {m.opponent} + + {kickoffDay(m.kickoff)} · {m.started ? t.player.started : t.player.cameOn} + + {us != null && them != null && ( + + {us}–{them} + + )} + {m.minutes ?? '—'}{m.rating != null ? m.rating.toFixed(1) : '—'}{m.goals || '—'}{m.assists || '—'}
      +
      +

      {t.player.minutesNote}

      +
      +
      + )} + + {profile.shots.length > 0 && ( + + + + {t.player.shotMapTitle} + + + +

      {t.player.shotMapNote} {t.match.xgAttribution}

      +
      +
      + )} + + {profile.nextProp && ( + + + + {fmt(t.player.nextOdds, { opponent: profile.nextProp.opponent, odds: american(profile.nextProp.odds) })} + +

      {t.player.oddsNote}

      +
      +
      + )} +
      + ); +} diff --git a/src/features/stats/StatsPage.tsx b/src/features/stats/StatsPage.tsx index a05646d..fa28f3b 100644 --- a/src/features/stats/StatsPage.tsx +++ b/src/features/stats/StatsPage.tsx @@ -3,6 +3,7 @@ import { Link } from '@tanstack/react-router'; import { Award, Ban, ChartScatter, Shirt, Users } from 'lucide-react'; import { PageHeader } from '@/components/ui/PageHeader'; import { Card, CardBody, CardHeader } from '@/components/ui/Card'; +import { PlayerLink } from '@/components/PlayerLink'; import { teamFlag } from '@/lib/teams'; import { fmt, plural, useFormat, useT, type Dict } from '@/lib/i18n'; import { cn } from '@/lib/cn'; @@ -10,12 +11,12 @@ import type { StatRow, StatsHub } from '@/lib/types'; type CatKey = keyof Dict['stats']['cat']; -const PLAYER_CATS: CatKey[] = ['goals', 'assists', 'goalsAssists', 'rating', 'xg', 'xgPer90', 'minutes']; +const PLAYER_CATS: CatKey[] = ['goals', 'assists', 'goalsAssists', 'rating', 'xg', 'xgPer90', 'xa', 'chancesCreated', 'savePct', 'savesPer90', 'minutes']; const TEAM_CATS: CatKey[] = ['xg', 'xgDiff', 'possession', 'cleanSheets', 'bigChances', 'goalsPerMatch', 'concededPerMatch', 'rating']; -const INT_CATS = new Set(['goals', 'assists', 'goalsAssists', 'minutes', 'cleanSheets', 'bigChances']); +const INT_CATS = new Set(['goals', 'assists', 'goalsAssists', 'minutes', 'cleanSheets', 'bigChances', 'chancesCreated']); function fmtVal(key: CatKey, v: number): string { - if (key === 'possession') return `${v.toFixed(1)}%`; + if (key === 'possession' || key === 'savePct') return `${v.toFixed(1)}%`; if (key === 'xgDiff') return `${v > 0 ? '+' : ''}${v.toFixed(2)}`; if (INT_CATS.has(key)) return String(Math.round(v)); return v.toFixed(2); @@ -55,7 +56,7 @@ function BoardList({ rows, cat, linkTeams }: { rows: StatRow[]; cat: CatKey; lin {linkTeams && r.team ? ( {r.name} ) : ( - {r.name} + )} {r.matches != null && {plural(t.stats.matchesPlayed, r.matches)}} {fmtVal(cat, r.value)} @@ -137,7 +138,7 @@ function GoldenBoot({ hub }: { hub: StatsHub }) {
    • {i + 1} {p.team ? teamFlag(p.team) : ''} - {p.player} + {p.nextOdds != null && ( {fmt(t.stats.boot.oddsChip, { odds: american(p.nextOdds) })} @@ -172,7 +173,7 @@ function Discipline({ hub }: { hub: StatsHub }) { {hub.suspensions.map((p) => (
    • {teamFlag(p.team)} - {p.player} + {p.suspendedFor != null ? fmt(t.stats.discipline.suspendedFor, { num: p.suspendedFor }) : t.stats.discipline.suspendedNext} @@ -186,7 +187,7 @@ function Discipline({ hub }: { hub: StatsHub }) { {hub.cards.slice(0, 15).map((p) => (
    • {teamFlag(p.team)} - {p.player} + {Array.from({ length: p.yellows }).map((_, i) => )} {Array.from({ length: p.reds }).map((_, i) => )} diff --git a/src/features/team/TeamProfilePage.tsx b/src/features/team/TeamProfilePage.tsx index 31237d3..a1ebf85 100644 --- a/src/features/team/TeamProfilePage.tsx +++ b/src/features/team/TeamProfilePage.tsx @@ -12,6 +12,7 @@ import { fmt, useFormat, useT } from '@/lib/i18n'; import { hostAdvantage } from '@/lib/model/hosts'; import { knockoutAdvanceProb, predictMatch, type RatingsModel } from '@/lib/model/predict'; import { modelBracket, projectedSeeds, teamPath } from '@/lib/whatif'; +import { PlayerLink } from '@/components/PlayerLink'; import type { Fixture, TeamProfile } from '@/lib/types'; const pct = (x: number | null) => (x == null ? '—' : `${(x * 100).toFixed(1)}%`); @@ -278,7 +279,7 @@ export function TeamProfilePage() {
        {profile.injuries.map((i) => (
      • - {i.player} + {i.reason ?? i.status ?? ''}
      • ))} @@ -293,7 +294,7 @@ export function TeamProfilePage() {
          {profile.discipline.map((p) => (
        • - {p.player} + {Array.from({ length: p.yellows }).map((_, i) => )} {Array.from({ length: p.reds }).map((_, i) => )} @@ -320,7 +321,7 @@ export function TeamProfilePage() { {profile.keyPlayers.map((p, i) => (
        • {i + 1} - {p.name} + {p.goals} {p.pens ? {fmt(t.common.pensShort, { n: p.pens })} : null}
        • diff --git a/src/features/teams/TeamsPage.tsx b/src/features/teams/TeamsPage.tsx index 5d8f0fd..ff83e58 100644 --- a/src/features/teams/TeamsPage.tsx +++ b/src/features/teams/TeamsPage.tsx @@ -3,6 +3,7 @@ import { Link } from '@tanstack/react-router'; import { Award } from 'lucide-react'; import { PageHeader } from '@/components/ui/PageHeader'; import { Card, CardBody, CardHeader } from '@/components/ui/Card'; +import { PlayerLink } from '@/components/PlayerLink'; import { teamFlag } from '@/lib/teams'; import { useTournamentStore } from '@/stores/tournamentStore'; import { fmt, useT } from '@/lib/i18n'; @@ -41,7 +42,7 @@ function GoldenBoot() {
        • {i + 1} {p.team ? teamFlag(p.team) : ''} - {p.player} +
          diff --git a/src/lib/i18n/de.ts b/src/lib/i18n/de.ts index 4f2b2a9..132a211 100644 --- a/src/lib/i18n/de.ts +++ b/src/lib/i18n/de.ts @@ -121,6 +121,37 @@ export const de: Dict = { attendanceLine: "{stadium} · {n} Zuschauer · Schiedsrichter {ref}", potm: "Spieler des Spiels", xgAttribution: "xG und Bewertungen sind FotMob-Schätzungen.", + fullStatsTitle: "Detaillierte Statistik", + fullStats: { + xg: "Expected Goals (xG)", + xgot: "xG auf das Tor", + bigChances: "Großchancen", + accuratePasses: "Angekommene Pässe", + longBalls: "Angekommene lange Bälle", + crosses: "Angekommene Flanken", + touchesInBox: "Ballkontakte im Strafraum", + corners: "Ecken", + offsides: "Abseitsstellungen", + fouls: "Fouls", + tackles: "Tacklings", + interceptions: "Abgefangene Bälle", + blocks: "Geblockte Schüsse", + clearances: "Klärungsaktionen", + saves: "Paraden", + duelsWon: "Gewonnene Duelle", + groundDuels: "Gewonnene Bodenduelle", + aerialDuels: "Gewonnene Kopfballduelle", + dribbles: "Erfolgreiche Dribblings", + }, + oddsExtras: { + ou: "Über/Unter {n} Tore", + spread: "Handicap {n}", + }, + confirmedLineups: "Bestätigt", + whoScores: { + title: "Wer trifft?", + note: "DraftKings-Anytime-Quoten, Favoriten zuerst — nur Vergleichswert, nie Modell-Input.", + }, tabs: { summary: "Übersicht", timeline: "Spielverlauf", @@ -291,6 +322,10 @@ export const de: Dict = { bigChances: "Großchancen", goalsPerMatch: "Tore pro Spiel", concededPerMatch: "Gegentore pro Spiel", + xa: "Expected Assists (xA)", + chancesCreated: "Kreierte Chancen", + savePct: "Paradenquote", + savesPer90: "Paraden pro 90", }, scatter: { title: "Angriff vs. Abwehr", @@ -569,4 +604,26 @@ export const de: Dict = { final: "F", }, }, + player: { + unknown: "Keine Turnierdaten zu diesem Spieler.", + backToStats: "Zur Statistik", + totalsTitle: "Turnierbilanz", + matchLogTitle: "Spielprotokoll", + log: { + opponent: "Gegner", + min: "Min", + rating: "Note", + goals: "T", + assists: "V", + }, + started: "In der Startelf", + cameOn: "Eingewechselt", + benched: "Nicht eingesetzt", + minutesNote: "Minuten sind aus den Wechselzeiten geschätzt; Verlängerung zählt nicht mit.", + shotMapTitle: "Alle Schüsse in diesem Turnier", + shotMapNote: "Alle Schüsse zeigen nach rechts. Punktgröße entspricht dem xG; gefüllte Punkte sind Tore.", + nextOdds: "Trifft gegen {opponent} (anytime): {odds}", + oddsNote: "DraftKings-Quote — nur Vergleichswert, nie Modell-Input.", + goalsLabel: "Tore", + }, }; diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index eb63d44..43f6618 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -120,6 +120,37 @@ export const en = { attendanceLine: "{stadium} · {n} spectators · referee {ref}", potm: "Player of the match", xgAttribution: "xG and ratings are FotMob estimates.", + fullStatsTitle: "Detailed stats", + fullStats: { + xg: "Expected goals (xG)", + xgot: "xG on target", + bigChances: "Big chances", + accuratePasses: "Accurate passes", + longBalls: "Accurate long balls", + crosses: "Accurate crosses", + touchesInBox: "Touches in the box", + corners: "Corners", + offsides: "Offsides", + fouls: "Fouls", + tackles: "Tackles", + interceptions: "Interceptions", + blocks: "Blocked shots", + clearances: "Clearances", + saves: "Keeper saves", + duelsWon: "Duels won", + groundDuels: "Ground duels won", + aerialDuels: "Aerial duels won", + dribbles: "Dribbles completed", + }, + oddsExtras: { + ou: "Over/under {n} goals", + spread: "spread {n}", + }, + confirmedLineups: "Confirmed", + whoScores: { + title: "Who scores?", + note: "DraftKings anytime-scorer lines, favorites first — a benchmark, never a model input.", + }, tabs: { summary: "Summary", timeline: "Timeline", @@ -290,6 +321,10 @@ export const en = { bigChances: "Big chances", goalsPerMatch: "Goals per match", concededPerMatch: "Conceded per match", + xa: "Expected assists (xA)", + chancesCreated: "Chances created", + savePct: "Save percentage", + savesPer90: "Saves per 90", }, scatter: { title: "Attack vs defence", @@ -568,6 +603,28 @@ export const en = { final: "F", }, }, + player: { + unknown: "No tournament data for this player.", + backToStats: "Back to stats", + totalsTitle: "Tournament totals", + matchLogTitle: "Match log", + log: { + opponent: "Opponent", + min: "Min", + rating: "Rating", + goals: "G", + assists: "A", + }, + started: "Started", + cameOn: "Came on", + benched: "Unused sub", + minutesNote: "Minutes are estimated from substitution times; extra time isn't counted.", + shotMapTitle: "Every shot this tournament", + shotMapNote: "All shots drawn attacking to the right. Dot size scales with xG; filled dots are goals.", + nextOdds: "Anytime scorer vs {opponent}: {odds}", + oddsNote: "DraftKings line — a benchmark, never a model input.", + goalsLabel: "Goals", + }, }; export type Dict = typeof en; diff --git a/src/lib/playerProfile.test.ts b/src/lib/playerProfile.test.ts new file mode 100644 index 0000000..12239d6 --- /dev/null +++ b/src/lib/playerProfile.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; +import { aliasKey, buildPlayerIndex, findBoardRow, playerMatchLine, resolvePlayer, type PlayerFixtureData } from './playerProfile'; +import type { MatchLiveEvent } from './types'; + +const ev = (type: string, text: string, team: 'home' | 'away' | null, minute = 30): MatchLiveEvent => + ({ minute, type, text, team }); + +const fx = (over: Partial = {}): PlayerFixtureData => ({ + num: 1, opponent: 'South Africa', side: 'home', finished: true, + kickoff: '2026-06-11T19:00:00Z', homeScore: 2, awayScore: 0, + starters: [], bench: [], events: [], + ...over, +}); + +describe('name resolution', () => { + const idx = buildPlayerIndex([ + { name: 'In-Beom Hwang', team: 'South Korea' }, // FotMob form first = display winner + { name: 'Hwang In-Beom', team: 'South Korea' }, // ESPN form dedupes onto it + { name: 'Hyun-Gyu Oh', team: 'South Korea' }, + { name: 'Cyle Larin', team: 'Canada' }, + ]); + + it('collapses both token orders onto one entry, first spelling wins', () => { + expect(idx.size).toBe(3); + expect(resolvePlayer('Hwang In-Beom', idx)?.name).toBe('In-Beom Hwang'); + expect(resolvePlayer('In-Beom Hwang', idx)?.name).toBe('In-Beom Hwang'); + }); + + it('bridges the Hyeon/Hyun romanization gap via the alias fold', () => { + expect(aliasKey('Oh Hyeon-Gyu')).toBe(aliasKey('Hyun-Gyu Oh')); + expect(resolvePlayer('Oh Hyeon-Gyu', idx)?.name).toBe('Hyun-Gyu Oh'); + }); + + it('handles diacritics and unknowns', () => { + expect(resolvePlayer('CYLE LARÍN', idx)?.team).toBe('Canada'); + expect(resolvePlayer('Cuauhtemoc Blanco', idx)).toBeNull(); + }); + + it('findBoardRow tolerates cross-source spellings', () => { + const rows = [{ name: 'In-Beom Hwang', team: 'South Korea', value: 1, matches: 1, minutes: 90 }]; + expect(findBoardRow(rows, 'Hwang In-Beom')?.value).toBe(1); + expect(findBoardRow(rows, 'Nobody')).toBeNull(); + expect(findBoardRow(undefined, 'In-Beom Hwang')).toBeNull(); + }); +}); + +describe('playerMatchLine', () => { + it('starter who plays the full match gets 90 minutes and his rating', () => { + const line = playerMatchLine('Raul Jimenez', fx({ + starters: [{ name: 'Raúl Jiménez', rating: 7.8, subIn: null, subOut: null }], + })); + expect(line).toMatchObject({ started: true, minutes: 90, rating: 7.8, goals: 0 }); + }); + + it('substitution times bound the minutes on both ends', () => { + const off = playerMatchLine('A', fx({ starters: [{ name: 'A', rating: 6.9, subIn: null, subOut: 75 }] })); + expect(off?.minutes).toBe(75); + const on = playerMatchLine('B', fx({ bench: [{ name: 'B', rating: 7.1, subIn: 60, subOut: null }] })); + expect(on).toMatchObject({ started: false, minutes: 30, rating: 7.1 }); + }); + + it('a live match leaves an unsubbed starter\'s minutes open', () => { + const line = playerMatchLine('A', fx({ finished: false, starters: [{ name: 'A', rating: null, subIn: null, subOut: null }] })); + expect(line?.minutes).toBeNull(); + }); + + it('an unused sub with no events is no row at all', () => { + expect(playerMatchLine('C', fx({ bench: [{ name: 'C', rating: null, subIn: null, subOut: null }] }))).toBeNull(); + }); + + it('attributes goals, assists and cards from the event feed', () => { + const events = [ + ev('Goal', 'Goal! Mexico 1, South Africa 0. Raul Jimenez (Mexico) right footed shot from the centre of the box. Assisted by Hirving Lozano.', 'home', 28), + ev('Yellow Card', 'Hirving Lozano (Mexico) is shown the yellow card for a bad foul.', 'home', 55), + ev('Goal', 'Goal! Mexico 2, South Africa 0. Raul Jimenez (Mexico) header. Assisted by Cesar Huerta with a cross.', 'home', 70), + ]; + const jimenez = playerMatchLine('Raúl Jiménez', fx({ events, starters: [{ name: 'Raul Jimenez', rating: 8.4, subIn: null, subOut: null }] })); + expect(jimenez).toMatchObject({ goals: 2, assists: 0, yellows: 0 }); + const lozano = playerMatchLine('Hirving Lozano', fx({ events, starters: [{ name: 'Hirving Lozano', rating: 7.5, subIn: null, subOut: null }] })); + expect(lozano).toMatchObject({ goals: 0, assists: 1, yellows: 1 }); + }); + + it('events alone earn a row (no lineup captured), own goals never count', () => { + const events = [ + ev('Goal', 'Goal! Mexico 1, South Africa 0. Raul Jimenez (Mexico) header.', 'home', 28), + ev('Goal - Own Goal', 'Own Goal by Sipho Mbule, South Africa. Mexico 2, South Africa 0.', 'home', 60), + ]; + const line = playerMatchLine('Raul Jimenez', fx({ events })); + expect(line).toMatchObject({ started: false, minutes: null, rating: null, goals: 1 }); + // the own-goal scorer plays for the OTHER side; no goal credited either way + expect(playerMatchLine('Sipho Mbule', fx({ events, side: 'away' }))).toBeNull(); + }); + + it('opposition events never bleed in', () => { + const events = [ev('Goal', 'Goal! Mexico 1, South Africa 0. Raul Jimenez (Mexico) header.', 'home', 28)]; + expect(playerMatchLine('Raul Jimenez', fx({ events, side: 'away', opponent: 'Mexico' }))).toBeNull(); + }); +}); diff --git a/src/lib/playerProfile.ts b/src/lib/playerProfile.ts new file mode 100644 index 0000000..9553761 --- /dev/null +++ b/src/lib/playerProfile.ts @@ -0,0 +1,139 @@ +import { nameMatch, norm, parseEvents } from './matchEvents'; +import type { MatchLiveEvent, PlayerMatchLine, StatRow } from './types'; + +/** + * Pure logic behind /api/player: name resolution across the sources' spelling + * habits, and the per-fixture fold that turns stored lineups + event feeds + * into a player's match line. No DB or network — the server injects the data + * (same pattern as discipline.ts). + */ + +// ---- name resolution ---- + +/** Romanization variants token-set matching can't bridge (ESPN "Hyeon-Gyu" + * vs FotMob "Hyun-Gyu"). Applied to norm'd tokens, resolver-local only — + * nameMatch itself stays untouched (discipline depends on it). */ +const ROMANIZATION_ALIASES: Record = { + hyeon: 'hyun', +}; + +/** Order-insensitive key: "Hwang In-Beom" and "In-Beom Hwang" collide. */ +export const aliasKey = (s: string): string => + norm(s) + .split(' ') + .map((tk) => ROMANIZATION_ALIASES[tk] ?? tk) + .sort() + .join(' '); + +export interface PlayerRef { + /** Display form — first source to register a spelling wins. */ + name: string; + team: string; +} + +/** Dedup an ordered source list into a resolution index. Feed the preferred + * display forms (FotMob boards/lineups) before the ESPN-derived ones. */ +export function buildPlayerIndex(sources: PlayerRef[]): Map { + const idx = new Map(); + for (const p of sources) { + if (!p.name.trim() || !p.team) continue; + const key = aliasKey(p.name); + if (!idx.has(key)) idx.set(key, { name: p.name.trim(), team: p.team }); + } + return idx; +} + +/** Either spelling of a player lands on the same entry: exact alias-key hit + * first, then a token-set scan. */ +export function resolvePlayer(nameParam: string, idx: Map): PlayerRef | null { + const hit = idx.get(aliasKey(nameParam)); + if (hit) return hit; + for (const p of idx.values()) if (nameMatch(p.name, nameParam)) return p; + return null; +} + +/** A board row for this player, tolerant of cross-source spellings. */ +export function findBoardRow(rows: StatRow[] | undefined, name: string): StatRow | null { + if (!rows) return null; + return rows.find((r) => aliasKey(r.name) === aliasKey(name)) ?? rows.find((r) => nameMatch(r.name, name)) ?? null; +} + +// ---- per-fixture match line ---- + +/** One lineup entry with the performance bits the fold needs. */ +export interface LineupPerfPlayer { + name: string; + rating: number | null; + subIn: number | null; + subOut: number | null; +} + +/** Everything stored about one of the player's team's fixtures. */ +export interface PlayerFixtureData { + num: number; + opponent: string; + side: 'home' | 'away'; + finished: boolean; + kickoff: string; + homeScore: number | null; + awayScore: number | null; + starters: LineupPerfPlayer[]; + bench: LineupPerfPlayer[]; + events: MatchLiveEvent[]; +} + +/** The same phantom-name guard the discipline fold uses: only properly + * parsed titles may attribute an event to a player. */ +const parsedName = (e: { raw: string; title: string }): boolean => + e.raw.trim().length > 0 && e.title !== e.raw.trim() && e.title.length <= 40; + +/** + * Fold one fixture into the player's match line, or null when he neither + * appeared nor shows up in the event feed. Minutes are 90-based (no + * extra-time awareness) — labeled as approximate in the UI. + */ +export function playerMatchLine(name: string, fx: PlayerFixtureData): PlayerMatchLine | null { + const starter = fx.starters.find((p) => nameMatch(p.name, name)) ?? null; + const sub = starter ? null : fx.bench.find((p) => nameMatch(p.name, name)) ?? null; + + let minutes: number | null = null; + if (starter) minutes = starter.subOut ?? (fx.finished ? 90 : null); + else if (sub?.subIn != null) minutes = Math.max(0, 90 - sub.subIn); + + let goals = 0; + let assists = 0; + let yellows = 0; + let reds = 0; + for (const e of parseEvents(fx.events)) { + if (e.side !== fx.side) continue; + if (e.kind === 'goal') { + if (e.detail?.type === 'ownGoal') continue; + if (parsedName(e) && nameMatch(e.title, name)) goals++; + if (e.detail?.type === 'assist' && nameMatch(e.detail.name, name)) assists++; + } else if ((e.kind === 'yellow' || e.kind === 'red') && parsedName(e) && nameMatch(e.title, name)) { + if (e.kind === 'yellow') yellows++; + else reds++; + } + } + + const appeared = starter != null || sub?.subIn != null; + const involved = goals + assists + yellows + reds > 0; + if (!appeared && !involved) return null; + + const perf = starter ?? sub; + return { + num: fx.num, + opponent: fx.opponent, + side: fx.side, + kickoff: fx.kickoff, + homeScore: fx.homeScore, + awayScore: fx.awayScore, + started: starter != null, + minutes, + rating: perf?.rating ?? null, + goals, + assists, + yellows, + reds, + }; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 9bcf94a..3ee40e2 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -108,6 +108,10 @@ export interface OddsPoint { home_ml: number | null; draw_ml: number | null; away_ml: number | null; + /** Total-goals line (display only — never a model input). */ + over_under: number | null; + /** Goal handicap from the home side (display only). */ + spread: number | null; } /** A point on the championship-odds-over-time chart. */ @@ -182,6 +186,9 @@ export interface MatchPreview { injuries: { home: InjuryInfo[]; away: InjuryInfo[] }; /** ESPN video clips for this match (open on ESPN). */ videos: MatchVideo[]; + /** Bookmaker anytime-scorer board, favorites first — pre-match only, + * display only (never a model input). */ + scorerProps: { player: string; odds: number }[]; /** Honest note on which data layers are populated yet. */ dataNote: string; updatedAt: number | null; @@ -227,6 +234,10 @@ export interface LineupTeam { export interface MatchLineup { home: LineupTeam; away: LineupTeam } +/** One full-time stat row from the FotMob match report. Values keep the + * provider's formatting ("467 (90%)"), so they render as-is. */ +export interface MatchFullStat { key: string; home: string; away: string } + export interface MatchRich { shots: RichShot[]; xg: { home: number; away: number } | null; @@ -236,6 +247,10 @@ export interface MatchRich { potm: { name: string; rating: string | null } | null; info: { attendance: number | null; stadium: string; referee: string | null } | null; weather: { tempC: number | null; precipProbPct: number | null; windKmh: number | null; elevationM: number; tz: string } | null; + /** Curated full-time stats (passes, duels, xGOT…); null until captured. */ + fullStats: MatchFullStat[] | null; + /** Lineups are official (FIFA XI published / FotMob no longer "predicted"). */ + lineupConfirmed: boolean; } export interface TeamProfile { @@ -253,6 +268,43 @@ export interface TeamProfile { injuries: InjuryInfo[]; } +// ---- player profiles (/api/player) ---- + +/** One fixture from the player's perspective — a row in his match log. */ +export interface PlayerMatchLine { + num: number; + opponent: string; + side: 'home' | 'away'; + kickoff: string; + homeScore: number | null; + awayScore: number | null; + started: boolean; + /** 90-based approximation from substitution times (no extra-time awareness). */ + minutes: number | null; + rating: number | null; + goals: number; + assists: number; + yellows: number; + reds: number; +} + +export interface PlayerProfile { + /** Canonical display form (FotMob's, when the boards know the player). */ + name: string; + team: string; + /** Tournament totals by stats-hub category ('goals', 'rating', 'xg', …). */ + totals: Record; + /** Golden Boot goals from our own event feeds (covers board gaps). */ + bootGoals: number; + matchLog: PlayerMatchLine[]; + /** Every captured shot by this player, oriented attacking right. */ + shots: RichShot[]; + discipline: PlayerDiscipline | null; + /** Latest anytime-scorer line for the next match (display only). */ + nextProp: { num: number; opponent: string; odds: number } | null; + updatedAt: number; +} + // ---- tournament stats hub (/api/stats) ---- /** One row on a stat leaderboard (player or team board). */ diff --git a/src/router.tsx b/src/router.tsx index dfb413b..a23adf0 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -23,13 +23,14 @@ const statsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/stats' const storyRoute = createRoute({ getParentRoute: () => rootRoute, path: '/story', component: lazyRouteComponent(() => import('./features/story/StoryPage'), 'StoryPage') }); const matchRoute = createRoute({ getParentRoute: () => rootRoute, path: '/match/$num', component: MatchPreviewPage }); const teamRoute = createRoute({ getParentRoute: () => rootRoute, path: '/team/$name', component: TeamProfilePage }); +const playerRoute = createRoute({ getParentRoute: () => rootRoute, path: '/player/$name', component: lazyRouteComponent(() => import('./features/player/PlayerPage'), 'PlayerPage') }); const methodologyRoute = createRoute({ getParentRoute: () => rootRoute, path: '/methodology', component: lazyRouteComponent(() => import('./features/methodology/MethodologyPage'), 'MethodologyPage') }); const teamsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/teams', component: TeamsPage }); const scoreboardRoute = createRoute({ getParentRoute: () => rootRoute, path: '/scoreboard', component: ScoreboardPage }); const dataRoute = createRoute({ getParentRoute: () => rootRoute, path: '/data', component: lazyRouteComponent(() => import('./features/data/DataPage'), 'DataPage') }); const compareRoute = createRoute({ getParentRoute: () => rootRoute, path: '/compare', component: lazyRouteComponent(() => import('./features/compare/ComparePage'), 'ComparePage') }); -const routeTree = rootRoute.addChildren([indexRoute, groupsRoute, bracketRoute, predictRoute, statsRoute, storyRoute, matchRoute, teamRoute, methodologyRoute, teamsRoute, scoreboardRoute, dataRoute, compareRoute]); +const routeTree = rootRoute.addChildren([indexRoute, groupsRoute, bracketRoute, predictRoute, statsRoute, storyRoute, matchRoute, teamRoute, playerRoute, methodologyRoute, teamsRoute, scoreboardRoute, dataRoute, compareRoute]); export const router = createRouter({ routeTree, defaultPreload: 'intent' });