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 { startScheduler } from './ingest/scheduler';
import { ModelEngine } from './model'; import { ModelEngine } from './model';
import { buildPreview, buildTeamProfile } from './preview'; import { buildPreview, buildTeamProfile } from './preview';
import { buildPlayerProfile, playersIndex } from './player';
import { buildScoreboard, snapshotCheck } from './scoreboard'; import { buildScoreboard, snapshotCheck } from './scoreboard';
import { buildStatsHub, fixtureEvents, goldenBootRows } from './stats'; import { buildStatsHub, fixtureEvents, goldenBootRows } from './stats';
import { normalizeMatchStats } from './ingest/fotmobStatsNormalize';
import { confirmedXI } from './ingest/fifaLineupNormalize';
import { buildIcs } from './ics'; import { buildIcs } from './ics';
import { db, healthAll, oddsFor, latestOddsAll, dbCounts, inplayHistory, recordInplay, getMatchProvider, allFixtureResults, saveFixtureResult, allModelHistory, saveModelHistory } from './db/db'; import { db, healthAll, oddsFor, latestOddsAll, dbCounts, inplayHistory, recordInplay, getMatchProvider, allFixtureResults, saveFixtureResult, allModelHistory, saveModelHistory } from './db/db';
import { inPlayProbs } from '../../src/lib/model/inplay'; import { inPlayProbs } from '../../src/lib/model/inplay';
@@ -139,11 +142,13 @@ export function buildServer() {
interface FmDetails { interface FmDetails {
homeTeamId?: number | null; homeTeamId?: number | null;
shots?: FmShot[]; shots?: FmShot[];
stats?: unknown;
momentum?: { main?: { data?: { minute: number; value: number }[] } } | null; momentum?: { main?: { data?: { minute: number; value: number }[] } } | null;
matchFacts?: { playerOfTheMatch?: { name?: { fullName?: string }; rating?: { num?: string }; teamName?: string } } | 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 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 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'); 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 lineupHome = trimTeam(fm?.data.lineup?.homeTeam);
const lineupAway = trimTeam(fm?.data.lineup?.awayTeam); const lineupAway = trimTeam(fm?.data.lineup?.awayTeam);
const fullStats = normalizeMatchStats(fm?.data.stats);
return { return {
shots, shots,
@@ -211,6 +217,10 @@ export function buildServer() {
} }
: null, : null,
weather: weather ? weather.data : 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); const profile = buildTeamProfile(name, state, model);
return profile ?? reply.code(404).send({ error: 'no such team' }); 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. // Calendar export: one team's schedule (?team=X) or the whole tournament.
app.get('/api/calendar.ics', async (req, reply) => { app.get('/api/calendar.ics', async (req, reply) => {
const raw = (req.query as { team?: string }).team; 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 { 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). // Real shape from data.fotmob.com/stats/77/season/24254/goals.json (WC 2026).
const realBoard = { const realBoard = {
@@ -47,7 +48,36 @@ describe('board slugs', () => {
it('maps the CDN url to our category keys', () => { 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/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/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(TEAM_BOARDS[boardSlug('https://data.fotmob.com/stats/77/season/24254/_xg_diff_team.json')]).toBe('xgDiff');
expect(PLAYER_BOARDS[boardSlug(undefined)]).toBeUndefined(); 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', 'rating.json': 'rating',
'expected_goals.json': 'xg', 'expected_goals.json': 'xg',
'expected_goals_per_90.json': 'xgPer90', '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', 'mins_played.json': 'minutes',
}; };
@@ -67,3 +71,20 @@ export function normalizeStatBoard(board: RawStatBoard, isTeam: boolean, max = 3
export function boardSlug(url: string | undefined): string { export function boardSlug(url: string | undefined): string {
return url?.split('/').pop() ?? ''; 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 { 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 { disciplineFor } from './stats';
import type { TournamentState } from './tournament'; import type { TournamentState } from './tournament';
import type { ModelEngine } from './model'; import type { ModelEngine } from './model';
@@ -149,6 +150,8 @@ export function buildPreview(num: number, state: TournamentState, model: ModelEn
away: away ? injuriesFor(away) : [], away: away ? injuriesFor(away) : [],
}, },
videos: summary?.videos ?? [], 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 dataNote: !home || !away
? 'Knockout participants are decided once the bracket resolves.' ? 'Knockout participants are decided once the bracket resolves.'
: ext : ext
+13 -2
View File
@@ -1,7 +1,9 @@
import { getMatchExt, scorerPropsFor } from './db/db'; import { getMatchExt, scorerPropsFor } from './db/db';
import { getTournamentStats } from './ingest/tournamentStats'; import { getTournamentStats } from './ingest/tournamentStats';
import { boardName } from './ingest/statsNormalize';
import { teamDiscipline, type DisciplineFixture } from '../../src/lib/discipline'; import { teamDiscipline, type DisciplineFixture } from '../../src/lib/discipline';
import { nameMatch } from '../../src/lib/matchEvents'; import { nameMatch } from '../../src/lib/matchEvents';
import { aliasKey } from '../../src/lib/playerProfile';
import type { TournamentState } from './tournament'; import type { TournamentState } from './tournament';
import type { EspnSummary } from './ingest/espnNormalize'; import type { EspnSummary } from './ingest/espnNormalize';
import type { GoldenBootRow, MatchLiveEvent, PlayerDiscipline, StatsHub } from '../../src/lib/types'; 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); 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()] return [...tally.values()]
.sort((a, b) => b.goals - a.goals || a.player.localeCompare(b.player)) .sort((a, b) => b.goals - a.goals || a.player.localeCompare(b.player))
.slice(0, max) .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. */ /** 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; if (!team) return null;
const next = state const next = state
.allFixtures() .allFixtures()
+15 -2
View File
@@ -1,5 +1,7 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { fmt, useT } from '@/lib/i18n'; import { fmt, useT } from '@/lib/i18n';
import { PlayerLink } from '@/components/PlayerLink';
import { nameMatch, parseEvents } from '@/lib/matchEvents'; import { nameMatch, parseEvents } from '@/lib/matchEvents';
import type { LineupTeam, MatchLineup, MatchLiveEvent, PitchPlayer } from '@/lib/types'; 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 }) { function PlayerDot({ slot, side, ny }: { slot: Slot; side: 'home' | 'away'; ny: number }) {
const navigate = useNavigate();
const p = slot.player; const p = slot.player;
const st = slot.starter; const st = slot.starter;
if (st.x == null) return null; 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 cx = side === 'home' ? st.x * W : (1 - st.x) * W;
const cy = side === 'home' ? ny * (H / 2) : H - ny * (H / 2); const cy = side === 'home' ? ny * (H / 2) : H - ny * (H / 2);
// Rating chip points toward the pitch center, sub markers outward — so // 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 gk = ny <= 0.1;
const stroke = side === 'home' ? 'var(--app-accent)' : 'var(--app-info)'; const stroke = side === 'home' ? 'var(--app-accent)' : 'var(--app-info)';
return ( return (
<g opacity={slot.dim ? 0.55 : 1}> <g
opacity={slot.dim ? 0.55 : 1}
className="cursor-pointer focus:outline-none"
role="link"
tabIndex={0}
aria-label={p.name}
onClick={open}
onKeyDown={(e) => { if (e.key === 'Enter') open(); }}
>
<title>{p.name}</title>
<circle cx={cx} cy={cy} r={3.3} fill="var(--app-panel-2)" stroke={stroke} strokeWidth={0.6} /> <circle cx={cx} cy={cy} r={3.3} fill="var(--app-panel-2)" stroke={stroke} strokeWidth={0.6} />
<text x={cx} y={cy + 1.05} textAnchor="middle" fontSize={3} fontWeight={700} fill="var(--app-ink)" className="tnum"> <text x={cx} y={cy + 1.05} textAnchor="middle" fontSize={3} fontWeight={700} fill="var(--app-ink)" className="tnum">
{p.num ?? ''} {p.num ?? ''}
@@ -209,7 +222,7 @@ function BenchStrip({ players, tone, label, dir }: { players: PitchPlayer[]; ton
return ( return (
<li key={p.name} className="flex items-center gap-1.5"> <li key={p.name} className="flex items-center gap-1.5">
<span aria-hidden className={dir === 'on' ? 'text-success' : 'text-loss'}>{dir === 'on' ? '▲' : '▼'}</span> <span aria-hidden className={dir === 'on' ? 'text-success' : 'text-loss'}>{dir === 'on' ? '▲' : '▼'}</span>
<span className="truncate">{p.name}</span> <PlayerLink name={p.name} className="truncate transition-colors hover:text-accent" />
{minute != null && <span className="tnum shrink-0 text-faint">{fmt(t.live.minute, { n: minute })}</span>} {minute != null && <span className="tnum shrink-0 text-faint">{fmt(t.live.minute, { n: minute })}</span>}
{p.rating != null && <span className="tnum ml-auto shrink-0 text-faint">{p.rating.toFixed(1)}</span>} {p.rating != null && <span className="tnum ml-auto shrink-0 text-faint">{p.rating.toFixed(1)}</span>}
</li> </li>
+27 -6
View File
@@ -1,9 +1,16 @@
import { useMemo, useState, type KeyboardEvent } from 'react'; import { useMemo, useState, type KeyboardEvent } from 'react';
import { ArrowRightLeft, ChevronDown, CircleDot, Volleyball } from 'lucide-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 { eventContext, goalShot, momentumVerdict, parseEvents, halfTimeScore, reportSentences, varLines, type CommentaryLine, type EventKind, type ParsedEvent } from '@/lib/matchEvents';
import type { MatchLiveEvent, RichShot } from '@/lib/types'; 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 /** Crisp event markers instead of emoji: ball icon for goals, card-shaped
* color chips for bookings, arrows for substitutions. */ * color chips for bookings, arrows for substitutions. */
export function EventIcon({ kind }: { kind: EventKind }) { 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' ? away ? 'border-l-[3px] border-l-info' : 'border-l-[3px] border-l-accent'
: 'border-r-[3px] border-r-accent'; : 'border-r-[3px] border-r-accent';
const own = e.detail?.type === 'ownGoal'; const own = e.detail?.type === 'ownGoal';
const assistName = e.detail?.type === 'assist' ? e.detail.name : null;
const detailText = 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 : e.detail?.type === 'raw' ? e.detail.text
: null; : null;
return ( return (
@@ -138,8 +145,18 @@ function GoalCard({ e, mobile, ctx, rich, open, onToggle }: { e: ParsedEvent; mo
{ctx && <ChevronDown size={13} className={`text-faint transition-transform ${open ? 'rotate-180' : ''}`} />} {ctx && <ChevronDown size={13} className={`text-faint transition-transform ${open ? 'rotate-180' : ''}`} />}
</span> </span>
</div> </div>
<div className={`mt-1 text-base font-bold text-ink ${mobile ? '' : 'truncate'}`}>{e.title}</div> <div className={`mt-1 text-base font-bold text-ink ${mobile ? '' : 'truncate'}`}>
{detailText && <div className={`mt-0.5 text-xs text-faint ${mobile ? '' : 'truncate'}`}>{detailText}</div>} {linkableName(e) ? <PlayerLink name={e.title} stop /> : e.title}
</div>
{assistName ? (
<div className={`mt-0.5 text-xs text-faint ${mobile ? '' : 'truncate'}`}>
{fmtSplit(t.match.assist).map((part, j) =>
typeof part === 'string' ? part : <PlayerLink key={j} name={assistName} stop className="transition-colors hover:text-accent" />,
)}
</div>
) : detailText ? (
<div className={`mt-0.5 text-xs text-faint ${mobile ? '' : 'truncate'}`}>{detailText}</div>
) : null}
{ctx && open && <ContextPanel e={e} ctx={ctx} {...rich} />} {ctx && open && <ContextPanel e={e} ctx={ctx} {...rich} />}
</div> </div>
); );
@@ -157,8 +174,12 @@ function MinorChip({ e, mobile, ctx, rich, open, onToggle }: { e: ParsedEvent; m
> >
<span className="flex min-w-0 items-center gap-2"> <span className="flex min-w-0 items-center gap-2">
<EventIcon kind={e.kind} /> <EventIcon kind={e.kind} />
{linkableName(e) ? (
<PlayerLink name={e.title} stop className="truncate text-[13.5px] text-ink-soft transition-colors hover:text-accent" />
) : (
<span className="truncate text-[13.5px] text-ink-soft">{e.title}</span> <span className="truncate text-[13.5px] text-ink-soft">{e.title}</span>
{e.subOut && <span className="truncate text-[13.5px] text-faint">{e.subOut}</span>} )}
{e.subOut && <PlayerLink name={e.subOut} stop className="truncate text-[13.5px] text-faint transition-colors hover:text-accent" />}
{ctx && <ChevronDown size={12} className={`shrink-0 text-faint transition-transform ${open ? 'rotate-180' : ''}`} />} {ctx && <ChevronDown size={12} className={`shrink-0 text-faint transition-transform ${open ? 'rotate-180' : ''}`} />}
</span> </span>
{ctx && open && <ContextPanel e={e} ctx={ctx} {...rich} />} {ctx && open && <ContextPanel e={e} ctx={ctx} {...rich} />}
+27
View File
@@ -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 (
<Link
to="/player/$name"
params={{ name }}
className={className}
onClick={stop ? (e: MouseEvent) => e.stopPropagation() : undefined}
>
{children ?? name}
</Link>
);
}
+28 -3
View File
@@ -2,24 +2,27 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
import { Search } from 'lucide-react'; import { Search } from 'lucide-react';
import { teamFlag } from '@/lib/teams'; import { teamFlag } from '@/lib/teams';
import { norm } from '@/lib/matchEvents';
import { useFormat } from '@/lib/i18n'; import { useFormat } from '@/lib/i18n';
import { useTournamentStore } from '@/stores/tournamentStore'; import { useTournamentStore } from '@/stores/tournamentStore';
interface Hit { interface Hit {
kind: 'team' | 'match'; kind: 'team' | 'match' | 'player';
label: string; label: string;
sub: string; sub: string;
flags: string[]; flags: string[];
go: () => void; 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 }) { export function SearchPalette({ open, onClose }: { open: boolean; onClose: () => void }) {
const { t, kickoffDay } = useFormat(); const { t, kickoffDay } = useFormat();
const navigate = useNavigate(); const navigate = useNavigate();
const snapshot = useTournamentStore((s) => s.snapshot); const snapshot = useTournamentStore((s) => s.snapshot);
const [q, setQ] = useState(''); const [q, setQ] = useState('');
const [sel, setSel] = useState(0); 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<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
@@ -30,6 +33,17 @@ export function SearchPalette({ open, onClose }: { open: boolean; onClose: () =>
} }
}, [open]); }, [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<Hit[]>(() => { const hits = useMemo<Hit[]>(() => {
if (!snapshot || q.trim().length < 2) return []; if (!snapshot || q.trim().length < 2) return [];
const needle = q.trim().toLowerCase(); const needle = q.trim().toLowerCase();
@@ -57,8 +71,19 @@ export function SearchPalette({ open, onClose }: { open: boolean; onClose: () =>
} }
if (out.length > 14) break; 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); return out.slice(0, 12);
}, [snapshot, q, navigate, t, kickoffDay]); }, [snapshot, players, q, navigate, t, kickoffDay]);
useEffect(() => setSel(0), [q]); useEffect(() => setSel(0), [q]);
+2 -1
View File
@@ -60,7 +60,8 @@ export function ShotMap({ shots, home, away }: { shots: RichShot[]; home: string
))} ))}
</svg> </svg>
<div className="mt-1.5 flex items-center justify-between text-[11px]"> <div className="mt-1.5 flex items-center justify-between text-[11px]">
<span className="font-semibold text-info"> {away}</span> {/* single-player aggregate maps pass an empty away side */}
<span className="font-semibold text-info">{away ? `${away}` : ''}</span>
<span className="font-semibold text-accent">{home} </span> <span className="font-semibold text-accent">{home} </span>
</div> </div>
</div> </div>
+70 -8
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Link, useParams } from '@tanstack/react-router'; 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 { Badge } from '@/components/ui/Badge';
import { Card, CardBody, CardHeader } from '@/components/ui/Card'; import { Card, CardBody, CardHeader } from '@/components/ui/Card';
import { WinProbBar } from '@/components/WinProbBar'; import { WinProbBar } from '@/components/WinProbBar';
@@ -18,10 +18,14 @@ import { goalEvents, shootoutKicks } from '@/lib/matchEvents';
import { redCards } from '@/lib/discipline'; import { redCards } from '@/lib/discipline';
import { inPlayProbs } from '@/lib/model/inplay'; import { inPlayProbs } from '@/lib/model/inplay';
import { useTournamentStore } from '@/stores/tournamentStore'; import { useTournamentStore } from '@/stores/tournamentStore';
import { PlayerLink } from '@/components/PlayerLink';
import type { Fixture, KeyPlayer, MatchPreview, MatchRich, OddsPoint } from '@/lib/types'; import type { Fixture, KeyPlayer, MatchPreview, MatchRich, OddsPoint } from '@/lib/types';
const num = (s: string | undefined) => (s ? Number(s.replace('%', '')) || 0 : 0); 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<string, string>): Record<string, string> => d;
type TabKey = 'summary' | 'timeline' | 'stats' | 'lineups'; type TabKey = 'summary' | 'timeline' | 'stats' | 'lineups';
function LiveStatRow({ label, home, away }: { label: string; home: number; away: number }) { function LiveStatRow({ label, home, away }: { label: string; home: number; away: number }) {
@@ -50,7 +54,7 @@ function ScorerList({ team, players }: { team: string; players: KeyPlayer[] }) {
<ul className="space-y-1"> <ul className="space-y-1">
{players.slice(0, 6).map((p) => ( {players.slice(0, 6).map((p) => (
<li key={p.name} className="flex items-center justify-between text-sm"> <li key={p.name} className="flex items-center justify-between text-sm">
<span className="truncate text-ink-soft">{p.name}</span> <PlayerLink name={p.name} className="truncate text-ink-soft transition-colors hover:text-accent" />
<span className="tnum shrink-0 text-faint">{p.goals}{p.pens ? ` (${fmt(t.common.pensShort, { n: p.pens })})` : ''}</span> <span className="tnum shrink-0 text-faint">{p.goals}{p.pens ? ` (${fmt(t.common.pensShort, { n: p.pens })})` : ''}</span>
</li> </li>
))} ))}
@@ -70,7 +74,7 @@ function LineupCol({ team, players }: { team: string; players: { name: string; p
{list.map((p) => ( {list.map((p) => (
<li key={p.name} className="flex items-center gap-2 text-ink-soft"> <li key={p.name} className="flex items-center gap-2 text-ink-soft">
<span className="tnum w-5 text-right text-xs text-faint">{p.number ?? ''}</span> <span className="tnum w-5 text-right text-xs text-faint">{p.number ?? ''}</span>
<span className="truncate">{p.name}</span> <PlayerLink name={p.name} className="truncate transition-colors hover:text-accent" />
<span className="ml-auto text-[11px] text-faint">{p.position}</span> <span className="ml-auto text-[11px] text-faint">{p.position}</span>
</li> </li>
))} ))}
@@ -173,6 +177,10 @@ export function MatchPreviewPage() {
const finished = f.status === 'finished'; const finished = f.status === 'finished';
const koT = new Date(f.kickoff).getTime(); 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; 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) // Hero extras from the rich capture: kickoff-hour weather (pre-match only)
// and post-match official chips (attendance, referee, player of the match). // 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). // Tabs only appear when their data exists (e.g. no Timeline before kickoff).
const tabs: TabKey[] = ['summary']; const tabs: TabKey[] = ['summary'];
if (preview.events.length > 0) tabs.push('timeline'); 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'); tabs.push('lineups');
const tab: TabKey = tabs.includes(activeTab) ? activeTab : 'summary'; const tab: TabKey = tabs.includes(activeTab) ? activeTab : 'summary';
@@ -322,6 +330,27 @@ export function MatchPreviewPage() {
</CardBody> </CardBody>
</Card> </Card>
) : modelCard} ) : modelCard}
{f.status === 'scheduled' && preview.scorerProps.length > 0 && (
<Card>
<CardHeader className="flex items-center gap-2">
<Goal size={16} className="text-accent" />
<span className="font-display font-bold text-ink">{t.match.whoScores.title}</span>
</CardHeader>
<CardBody>
<ul className="space-y-1.5">
{preview.scorerProps.map((p) => (
<li key={p.player} className="flex items-center gap-2 text-sm">
<PlayerLink name={p.player} className="truncate font-medium text-ink transition-colors hover:text-accent" />
<span className="tnum ml-auto shrink-0 rounded-full bg-elevated px-2 py-0.5 text-xs font-semibold text-ink-soft">
{p.odds > 0 ? `+${p.odds}` : p.odds}
</span>
</li>
))}
</ul>
<p className="mt-2 text-xs text-faint">{t.match.whoScores.note}</p>
</CardBody>
</Card>
)}
{preview.events.length > 0 && ( {preview.events.length > 0 && (
<Card> <Card>
<CardHeader><span className="font-display font-bold text-ink">{t.match.keyMoments}</span></CardHeader> <CardHeader><span className="font-display font-bold text-ink">{t.match.keyMoments}</span></CardHeader>
@@ -392,11 +421,19 @@ export function MatchPreviewPage() {
</CardBody> </CardBody>
</Card> </Card>
)} )}
{preview.prediction && hasOddsChart && ( {preview.prediction && (hasOddsChart || closingLine) && (
<Card> <Card>
<CardHeader><span className="font-display font-bold text-ink">{t.match.oddsMovement}</span></CardHeader> <CardHeader><span className="font-display font-bold text-ink">{t.match.oddsMovement}</span></CardHeader>
<CardBody> <CardBody>
<OddsMovement history={oddsHistory} model={preview.prediction.probs} kickoff={f.kickoff} /> {hasOddsChart && <OddsMovement history={oddsHistory} model={preview.prediction.probs} kickoff={f.kickoff} />}
{closingLine && (
<p className={`text-xs text-faint${hasOddsChart ? ' mt-2' : ''}`}>
{[
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}
</p>
)}
</CardBody> </CardBody>
</Card> </Card>
)} )}
@@ -457,6 +494,23 @@ export function MatchPreviewPage() {
</CardBody> </CardBody>
</Card> </Card>
)} )}
{rich?.fullStats && rich.fullStats.length > 0 && (
<Card>
<CardHeader><span className="font-display font-bold text-ink">{t.match.fullStatsTitle}</span></CardHeader>
<CardBody>
<div className="space-y-2">
{rich.fullStats.map((r) => (
<div key={r.key} className="grid grid-cols-[1fr_auto_1fr] items-center gap-2 text-sm">
<span className="tnum font-semibold text-ink">{r.home}</span>
<span className="text-center text-xs text-muted">{fullStatLabels(t.match.fullStats)[r.key] ?? r.key}</span>
<span className="tnum text-right font-semibold text-ink">{r.away}</span>
</div>
))}
</div>
<p className="mt-2 text-xs text-faint">{t.match.xgAttribution}</p>
</CardBody>
</Card>
)}
{rich && rich.momentum.length >= 10 && ( {rich && rich.momentum.length >= 10 && (
<Card> <Card>
<CardHeader><span className="font-display font-bold text-ink">{t.match.momentumTitle}</span></CardHeader> <CardHeader><span className="font-display font-bold text-ink">{t.match.momentumTitle}</span></CardHeader>
@@ -530,10 +584,18 @@ export function MatchPreviewPage() {
labeled as predicted; from kickoff it's the confirmed lineup. */} labeled as predicted; from kickoff it's the confirmed lineup. */}
{rich?.lineup ? ( {rich?.lineup ? (
<Card> <Card>
<CardHeader className="flex items-center gap-2"><Shirt size={16} className="text-accent" /><span className="font-display font-bold text-ink">{hasScore ? t.match.lineups : t.match.predictedLineups}</span></CardHeader> <CardHeader className="flex items-center gap-2">
<Shirt size={16} className="text-accent" />
<span className="font-display font-bold text-ink">{hasScore || rich.lineupConfirmed ? t.match.lineups : t.match.predictedLineups}</span>
{rich.lineupConfirmed && !finished && (
<span className="ml-auto flex items-center gap-1 rounded-full bg-accent/10 px-2 py-0.5 text-[11px] font-semibold text-accent">
<BadgeCheck size={12} /> {t.match.confirmedLineups}
</span>
)}
</CardHeader>
<CardBody> <CardBody>
<LineupPitch lineup={rich.lineup} home={homeName} away={awayName} events={preview.events} mode={isLive ? 'current' : 'starters'} /> <LineupPitch lineup={rich.lineup} home={homeName} away={awayName} events={preview.events} mode={isLive ? 'current' : 'starters'} />
<p className="mt-2 text-xs text-faint">{hasScore ? t.match.xgAttribution : t.match.predictedLineupsNote}</p> <p className="mt-2 text-xs text-faint">{hasScore || rich.lineupConfirmed ? t.match.xgAttribution : t.match.predictedLineupsNote}</p>
</CardBody> </CardBody>
</Card> </Card>
) : ( ) : (
+198
View File
@@ -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<string>(['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 (
<span className="inline-flex items-center gap-0.5">
{Array.from({ length: yellows }).map((_, i) => <span key={`y${i}`} className="inline-block h-3.5 w-2.5 rounded-[2px] bg-gold" />)}
{Array.from({ length: reds }).map((_, i) => <span key={`r${i}`} className="inline-block h-3.5 w-2.5 rounded-[2px] bg-loss" />)}
</span>
);
}
export function PlayerPage() {
const { name: nameParam } = useParams({ strict: false }) as { name: string };
const { t, kickoffDay } = useFormat();
const [profile, setProfile] = useState<PlayerProfile | null>(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 (
<div className="py-16 text-center text-muted">
{t.player.unknown}{' '}
<Link to="/stats" className="text-accent">{t.player.backToStats}</Link>
</div>
);
}
if (!profile) return <div className="h-96 animate-pulse rounded-2xl border border-line bg-panel" />;
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 (
<div className="space-y-4">
<Link to="/stats" className="inline-flex items-center gap-1 text-sm text-muted transition-colors hover:text-ink">
<ArrowLeft size={15} /> {t.player.backToStats}
</Link>
<div className="flex flex-wrap items-center gap-3">
<span aria-hidden className="text-3xl">{teamFlag(profile.team)}</span>
<div>
<h1 className="font-display text-2xl font-extrabold text-ink">{profile.name}</h1>
<Link to="/team/$name" params={{ name: profile.team }} className="text-sm font-medium text-muted transition-colors hover:text-accent">
{profile.team}
</Link>
</div>
{d && (d.suspendedNext || d.pending === 1) && (
<span className={`ml-auto inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold ${d.suspendedNext ? 'bg-loss/15 text-loss' : 'bg-gold/15 text-gold'}`}>
<Ban size={12} />
{d.suspendedNext
? d.suspendedFor != null ? fmt(t.team.suspChipFor, { num: d.suspendedFor }) : t.team.suspChipNext
: t.team.atRiskChip}
</span>
)}
</div>
{(totals.length > 0 || showBootGoals || d) && (
<Card>
<CardHeader className="flex items-center gap-2">
<Medal size={15} className="text-accent" />
<span className="font-display font-bold text-ink">{t.player.totalsTitle}</span>
</CardHeader>
<CardBody className="flex flex-wrap gap-2">
{showBootGoals && (
<span className="rounded-lg border border-line bg-elevated px-2.5 py-1.5 text-sm">
<span className="text-xs text-faint">{t.player.goalsLabel} </span>
<span className="tnum font-bold text-ink">{profile.bootGoals}</span>
</span>
)}
{totals.map(({ cat, row }) => (
<span key={cat} className="rounded-lg border border-line bg-elevated px-2.5 py-1.5 text-sm">
<span className="text-xs text-faint">{t.stats.cat[cat]} </span>
<span className="tnum font-bold text-ink">{fmtTotal(cat, row.value)}</span>
</span>
))}
{d && (d.yellows > 0 || d.reds > 0) && (
<span className="inline-flex items-center gap-1.5 rounded-lg border border-line bg-elevated px-2.5 py-1.5 text-sm">
<CardSquares yellows={d.yellows} reds={d.reds} />
</span>
)}
</CardBody>
</Card>
)}
{profile.matchLog.length > 0 && (
<Card>
<CardHeader className="flex items-center gap-2">
<ListOrdered size={15} className="text-accent" />
<span className="font-display font-bold text-ink">{t.player.matchLogTitle}</span>
</CardHeader>
<CardBody>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-[11px] uppercase tracking-wide text-faint">
<th className="py-1 pr-2 font-medium">{t.player.log.opponent}</th>
<th className="px-2 py-1 font-medium"></th>
<th className="px-2 py-1 text-right font-medium">{t.player.log.min}</th>
<th className="px-2 py-1 text-right font-medium">{t.player.log.rating}</th>
<th className="px-2 py-1 text-right font-medium">{t.player.log.goals}</th>
<th className="px-2 py-1 text-right font-medium">{t.player.log.assists}</th>
<th className="py-1 pl-2 text-right font-medium"></th>
</tr>
</thead>
<tbody>
{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 (
<tr key={m.num} className="border-t border-line">
<td className="py-1.5 pr-2">
<Link to="/match/$num" params={{ num: String(m.num) }} className="flex items-center gap-1.5 font-medium text-ink transition-colors hover:text-accent">
<span aria-hidden>{teamFlag(m.opponent)}</span>
<span className="truncate">{m.opponent}</span>
</Link>
<span className="block text-[11px] text-faint">{kickoffDay(m.kickoff)} · {m.started ? t.player.started : t.player.cameOn}</span>
</td>
<td className="px-2 py-1.5">
{us != null && them != null && (
<span className={`tnum rounded px-1.5 py-0.5 text-xs font-bold ${res === 'win' ? 'bg-win/20 text-win' : res === 'loss' ? 'bg-loss/20 text-loss' : 'bg-draw/20 text-draw'}`}>
{us}{them}
</span>
)}
</td>
<td className="tnum px-2 py-1.5 text-right text-ink-soft">{m.minutes ?? '—'}</td>
<td className="tnum px-2 py-1.5 text-right font-semibold text-ink">{m.rating != null ? m.rating.toFixed(1) : '—'}</td>
<td className="tnum px-2 py-1.5 text-right text-ink-soft">{m.goals || '—'}</td>
<td className="tnum px-2 py-1.5 text-right text-ink-soft">{m.assists || '—'}</td>
<td className="py-1.5 pl-2 text-right"><CardSquares yellows={m.yellows} reds={m.reds} /></td>
</tr>
);
})}
</tbody>
</table>
</div>
<p className="mt-2 text-xs text-faint">{t.player.minutesNote}</p>
</CardBody>
</Card>
)}
{profile.shots.length > 0 && (
<Card>
<CardHeader className="flex items-center gap-2">
<Crosshair size={15} className="text-accent" />
<span className="font-display font-bold text-ink">{t.player.shotMapTitle}</span>
</CardHeader>
<CardBody>
<ShotMap shots={profile.shots} home={profile.name} away="" />
<p className="mt-2 text-xs text-faint">{t.player.shotMapNote} {t.match.xgAttribution}</p>
</CardBody>
</Card>
)}
{profile.nextProp && (
<Card>
<CardBody>
<span className="text-sm text-ink-soft">
{fmt(t.player.nextOdds, { opponent: profile.nextProp.opponent, odds: american(profile.nextProp.odds) })}
</span>
<p className="mt-1 text-xs text-faint">{t.player.oddsNote}</p>
</CardBody>
</Card>
)}
</div>
);
}
+8 -7
View File
@@ -3,6 +3,7 @@ import { Link } from '@tanstack/react-router';
import { Award, Ban, ChartScatter, Shirt, Users } from 'lucide-react'; import { Award, Ban, ChartScatter, Shirt, Users } from 'lucide-react';
import { PageHeader } from '@/components/ui/PageHeader'; import { PageHeader } from '@/components/ui/PageHeader';
import { Card, CardBody, CardHeader } from '@/components/ui/Card'; import { Card, CardBody, CardHeader } from '@/components/ui/Card';
import { PlayerLink } from '@/components/PlayerLink';
import { teamFlag } from '@/lib/teams'; import { teamFlag } from '@/lib/teams';
import { fmt, plural, useFormat, useT, type Dict } from '@/lib/i18n'; import { fmt, plural, useFormat, useT, type Dict } from '@/lib/i18n';
import { cn } from '@/lib/cn'; import { cn } from '@/lib/cn';
@@ -10,12 +11,12 @@ import type { StatRow, StatsHub } from '@/lib/types';
type CatKey = keyof Dict['stats']['cat']; 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 TEAM_CATS: CatKey[] = ['xg', 'xgDiff', 'possession', 'cleanSheets', 'bigChances', 'goalsPerMatch', 'concededPerMatch', 'rating'];
const INT_CATS = new Set<CatKey>(['goals', 'assists', 'goalsAssists', 'minutes', 'cleanSheets', 'bigChances']); const INT_CATS = new Set<CatKey>(['goals', 'assists', 'goalsAssists', 'minutes', 'cleanSheets', 'bigChances', 'chancesCreated']);
function fmtVal(key: CatKey, v: number): string { 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 (key === 'xgDiff') return `${v > 0 ? '+' : ''}${v.toFixed(2)}`;
if (INT_CATS.has(key)) return String(Math.round(v)); if (INT_CATS.has(key)) return String(Math.round(v));
return v.toFixed(2); return v.toFixed(2);
@@ -55,7 +56,7 @@ function BoardList({ rows, cat, linkTeams }: { rows: StatRow[]; cat: CatKey; lin
{linkTeams && r.team ? ( {linkTeams && r.team ? (
<Link to="/team/$name" params={{ name: r.team }} className="truncate font-medium text-ink hover:text-accent">{r.name}</Link> <Link to="/team/$name" params={{ name: r.team }} className="truncate font-medium text-ink hover:text-accent">{r.name}</Link>
) : ( ) : (
<span className="truncate font-medium text-ink" title={r.team}>{r.name}</span> <PlayerLink name={r.name} className="truncate font-medium text-ink transition-colors hover:text-accent" />
)} )}
{r.matches != null && <span className="hidden shrink-0 text-[11px] text-faint sm:inline">{plural(t.stats.matchesPlayed, r.matches)}</span>} {r.matches != null && <span className="hidden shrink-0 text-[11px] text-faint sm:inline">{plural(t.stats.matchesPlayed, r.matches)}</span>}
<span className="tnum ml-auto shrink-0 font-bold text-ink">{fmtVal(cat, r.value)}</span> <span className="tnum ml-auto shrink-0 font-bold text-ink">{fmtVal(cat, r.value)}</span>
@@ -137,7 +138,7 @@ function GoldenBoot({ hub }: { hub: StatsHub }) {
<li key={`${p.player}|${p.team}`} className="flex items-center gap-2.5 text-sm"> <li key={`${p.player}|${p.team}`} className="flex items-center gap-2.5 text-sm">
<span className="w-4 shrink-0 text-right text-xs font-bold tabular-nums text-faint">{i + 1}</span> <span className="w-4 shrink-0 text-right text-xs font-bold tabular-nums text-faint">{i + 1}</span>
<span aria-hidden className="shrink-0" title={p.team}>{p.team ? teamFlag(p.team) : ''}</span> <span aria-hidden className="shrink-0" title={p.team}>{p.team ? teamFlag(p.team) : ''}</span>
<span className="truncate font-medium text-ink">{p.player}</span> <PlayerLink name={p.player} className="truncate font-medium text-ink transition-colors hover:text-accent" />
{p.nextOdds != null && ( {p.nextOdds != null && (
<span className="hidden shrink-0 rounded-full bg-elevated px-2 py-0.5 text-[10px] text-faint sm:inline" title={t.stats.boot.oddsNote}> <span className="hidden shrink-0 rounded-full bg-elevated px-2 py-0.5 text-[10px] text-faint sm:inline" title={t.stats.boot.oddsNote}>
{fmt(t.stats.boot.oddsChip, { odds: american(p.nextOdds) })} {fmt(t.stats.boot.oddsChip, { odds: american(p.nextOdds) })}
@@ -172,7 +173,7 @@ function Discipline({ hub }: { hub: StatsHub }) {
{hub.suspensions.map((p) => ( {hub.suspensions.map((p) => (
<li key={`${p.player}|${p.team}`} className="flex items-center gap-2 text-ink-soft"> <li key={`${p.player}|${p.team}`} className="flex items-center gap-2 text-ink-soft">
<span aria-hidden title={p.team}>{teamFlag(p.team)}</span> <span aria-hidden title={p.team}>{teamFlag(p.team)}</span>
<span className="truncate font-medium text-ink">{p.player}</span> <PlayerLink name={p.player} className="truncate font-medium text-ink transition-colors hover:text-accent" />
<span className="ml-auto shrink-0 text-[11px] text-faint"> <span className="ml-auto shrink-0 text-[11px] text-faint">
{p.suspendedFor != null ? fmt(t.stats.discipline.suspendedFor, { num: p.suspendedFor }) : t.stats.discipline.suspendedNext} {p.suspendedFor != null ? fmt(t.stats.discipline.suspendedFor, { num: p.suspendedFor }) : t.stats.discipline.suspendedNext}
</span> </span>
@@ -186,7 +187,7 @@ function Discipline({ hub }: { hub: StatsHub }) {
{hub.cards.slice(0, 15).map((p) => ( {hub.cards.slice(0, 15).map((p) => (
<li key={`${p.player}|${p.team}`} className="flex flex-wrap items-center gap-2 text-sm"> <li key={`${p.player}|${p.team}`} className="flex flex-wrap items-center gap-2 text-sm">
<span aria-hidden className="shrink-0" title={p.team}>{teamFlag(p.team)}</span> <span aria-hidden className="shrink-0" title={p.team}>{teamFlag(p.team)}</span>
<span className="truncate text-ink-soft">{p.player}</span> <PlayerLink name={p.player} className="truncate text-ink-soft transition-colors hover:text-accent" />
<span className="flex items-center gap-0.5" aria-hidden> <span className="flex items-center gap-0.5" aria-hidden>
{Array.from({ length: p.yellows }).map((_, i) => <span key={`y${i}`} className="inline-block h-3.5 w-2.5 rounded-[2px] bg-gold" />)} {Array.from({ length: p.yellows }).map((_, i) => <span key={`y${i}`} className="inline-block h-3.5 w-2.5 rounded-[2px] bg-gold" />)}
{Array.from({ length: p.reds }).map((_, i) => <span key={`r${i}`} className="inline-block h-3.5 w-2.5 rounded-[2px] bg-loss" />)} {Array.from({ length: p.reds }).map((_, i) => <span key={`r${i}`} className="inline-block h-3.5 w-2.5 rounded-[2px] bg-loss" />)}
+4 -3
View File
@@ -12,6 +12,7 @@ import { fmt, useFormat, useT } from '@/lib/i18n';
import { hostAdvantage } from '@/lib/model/hosts'; import { hostAdvantage } from '@/lib/model/hosts';
import { knockoutAdvanceProb, predictMatch, type RatingsModel } from '@/lib/model/predict'; import { knockoutAdvanceProb, predictMatch, type RatingsModel } from '@/lib/model/predict';
import { modelBracket, projectedSeeds, teamPath } from '@/lib/whatif'; import { modelBracket, projectedSeeds, teamPath } from '@/lib/whatif';
import { PlayerLink } from '@/components/PlayerLink';
import type { Fixture, TeamProfile } from '@/lib/types'; import type { Fixture, TeamProfile } from '@/lib/types';
const pct = (x: number | null) => (x == null ? '—' : `${(x * 100).toFixed(1)}%`); const pct = (x: number | null) => (x == null ? '—' : `${(x * 100).toFixed(1)}%`);
@@ -278,7 +279,7 @@ export function TeamProfilePage() {
<ul className="space-y-1.5"> <ul className="space-y-1.5">
{profile.injuries.map((i) => ( {profile.injuries.map((i) => (
<li key={i.player} className="flex items-center gap-2 text-sm"> <li key={i.player} className="flex items-center gap-2 text-sm">
<span className="truncate text-ink-soft">{i.player}</span> <PlayerLink name={i.player} className="truncate text-ink-soft transition-colors hover:text-accent" />
<span className="ml-auto shrink-0 text-[11px] text-faint">{i.reason ?? i.status ?? ''}</span> <span className="ml-auto shrink-0 text-[11px] text-faint">{i.reason ?? i.status ?? ''}</span>
</li> </li>
))} ))}
@@ -293,7 +294,7 @@ export function TeamProfilePage() {
<ul className="space-y-1.5"> <ul className="space-y-1.5">
{profile.discipline.map((p) => ( {profile.discipline.map((p) => (
<li key={p.player} className="flex flex-wrap items-center gap-2 text-sm"> <li key={p.player} className="flex flex-wrap items-center gap-2 text-sm">
<span className="truncate text-ink-soft">{p.player}</span> <PlayerLink name={p.player} className="truncate text-ink-soft transition-colors hover:text-accent" />
<span className="flex items-center gap-0.5" aria-hidden> <span className="flex items-center gap-0.5" aria-hidden>
{Array.from({ length: p.yellows }).map((_, i) => <span key={`y${i}`} className="inline-block h-3.5 w-2.5 rounded-[2px] bg-gold" />)} {Array.from({ length: p.yellows }).map((_, i) => <span key={`y${i}`} className="inline-block h-3.5 w-2.5 rounded-[2px] bg-gold" />)}
{Array.from({ length: p.reds }).map((_, i) => <span key={`r${i}`} className="inline-block h-3.5 w-2.5 rounded-[2px] bg-loss" />)} {Array.from({ length: p.reds }).map((_, i) => <span key={`r${i}`} className="inline-block h-3.5 w-2.5 rounded-[2px] bg-loss" />)}
@@ -320,7 +321,7 @@ export function TeamProfilePage() {
{profile.keyPlayers.map((p, i) => ( {profile.keyPlayers.map((p, i) => (
<li key={p.name} className="flex items-center gap-2 text-sm"> <li key={p.name} className="flex items-center gap-2 text-sm">
<span className="w-4 text-right text-xs font-bold text-faint">{i + 1}</span> <span className="w-4 text-right text-xs font-bold text-faint">{i + 1}</span>
<span className="truncate text-ink-soft">{p.name}</span> <PlayerLink name={p.name} className="truncate text-ink-soft transition-colors hover:text-accent" />
<span className="tnum ml-auto font-semibold text-ink">{p.goals}</span> <span className="tnum ml-auto font-semibold text-ink">{p.goals}</span>
{p.pens ? <span className="text-[11px] text-faint">{fmt(t.common.pensShort, { n: p.pens })}</span> : null} {p.pens ? <span className="text-[11px] text-faint">{fmt(t.common.pensShort, { n: p.pens })}</span> : null}
</li> </li>
+2 -1
View File
@@ -3,6 +3,7 @@ import { Link } from '@tanstack/react-router';
import { Award } from 'lucide-react'; import { Award } from 'lucide-react';
import { PageHeader } from '@/components/ui/PageHeader'; import { PageHeader } from '@/components/ui/PageHeader';
import { Card, CardBody, CardHeader } from '@/components/ui/Card'; import { Card, CardBody, CardHeader } from '@/components/ui/Card';
import { PlayerLink } from '@/components/PlayerLink';
import { teamFlag } from '@/lib/teams'; import { teamFlag } from '@/lib/teams';
import { useTournamentStore } from '@/stores/tournamentStore'; import { useTournamentStore } from '@/stores/tournamentStore';
import { fmt, useT } from '@/lib/i18n'; import { fmt, useT } from '@/lib/i18n';
@@ -41,7 +42,7 @@ function GoldenBoot() {
<li key={`${p.player}|${p.team}`} className="flex items-center gap-2.5 text-sm"> <li key={`${p.player}|${p.team}`} className="flex items-center gap-2.5 text-sm">
<span className="w-4 shrink-0 text-right text-xs font-bold tabular-nums text-faint">{i + 1}</span> <span className="w-4 shrink-0 text-right text-xs font-bold tabular-nums text-faint">{i + 1}</span>
<span aria-hidden className="shrink-0">{p.team ? teamFlag(p.team) : ''}</span> <span aria-hidden className="shrink-0">{p.team ? teamFlag(p.team) : ''}</span>
<span className="truncate font-medium text-ink">{p.player}</span> <PlayerLink name={p.player} className="truncate font-medium text-ink transition-colors hover:text-accent" />
<div className="ml-auto flex h-2 w-24 shrink-0 overflow-hidden rounded-full bg-elevated sm:w-40"> <div className="ml-auto flex h-2 w-24 shrink-0 overflow-hidden rounded-full bg-elevated sm:w-40">
<div className="bg-gradient-to-r from-gold/60 to-gold" style={{ width: `${(p.goals / max) * 100}%` }} /> <div className="bg-gradient-to-r from-gold/60 to-gold" style={{ width: `${(p.goals / max) * 100}%` }} />
</div> </div>
+57
View File
@@ -121,6 +121,37 @@ export const de: Dict = {
attendanceLine: "{stadium} · {n} Zuschauer · Schiedsrichter {ref}", attendanceLine: "{stadium} · {n} Zuschauer · Schiedsrichter {ref}",
potm: "Spieler des Spiels", potm: "Spieler des Spiels",
xgAttribution: "xG und Bewertungen sind FotMob-Schätzungen.", 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: { tabs: {
summary: "Übersicht", summary: "Übersicht",
timeline: "Spielverlauf", timeline: "Spielverlauf",
@@ -291,6 +322,10 @@ export const de: Dict = {
bigChances: "Großchancen", bigChances: "Großchancen",
goalsPerMatch: "Tore pro Spiel", goalsPerMatch: "Tore pro Spiel",
concededPerMatch: "Gegentore pro Spiel", concededPerMatch: "Gegentore pro Spiel",
xa: "Expected Assists (xA)",
chancesCreated: "Kreierte Chancen",
savePct: "Paradenquote",
savesPer90: "Paraden pro 90",
}, },
scatter: { scatter: {
title: "Angriff vs. Abwehr", title: "Angriff vs. Abwehr",
@@ -569,4 +604,26 @@ export const de: Dict = {
final: "F", 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",
},
}; };
+57
View File
@@ -120,6 +120,37 @@ export const en = {
attendanceLine: "{stadium} · {n} spectators · referee {ref}", attendanceLine: "{stadium} · {n} spectators · referee {ref}",
potm: "Player of the match", potm: "Player of the match",
xgAttribution: "xG and ratings are FotMob estimates.", 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: { tabs: {
summary: "Summary", summary: "Summary",
timeline: "Timeline", timeline: "Timeline",
@@ -290,6 +321,10 @@ export const en = {
bigChances: "Big chances", bigChances: "Big chances",
goalsPerMatch: "Goals per match", goalsPerMatch: "Goals per match",
concededPerMatch: "Conceded per match", concededPerMatch: "Conceded per match",
xa: "Expected assists (xA)",
chancesCreated: "Chances created",
savePct: "Save percentage",
savesPer90: "Saves per 90",
}, },
scatter: { scatter: {
title: "Attack vs defence", title: "Attack vs defence",
@@ -568,6 +603,28 @@ export const en = {
final: "F", 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; export type Dict = typeof en;
+98
View File
@@ -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> = {}): 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();
});
});
+139
View File
@@ -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<string, string> = {
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<string, PlayerRef> {
const idx = new Map<string, PlayerRef>();
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<string, PlayerRef>): 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,
};
}
+52
View File
@@ -108,6 +108,10 @@ export interface OddsPoint {
home_ml: number | null; home_ml: number | null;
draw_ml: number | null; draw_ml: number | null;
away_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. */ /** A point on the championship-odds-over-time chart. */
@@ -182,6 +186,9 @@ export interface MatchPreview {
injuries: { home: InjuryInfo[]; away: InjuryInfo[] }; injuries: { home: InjuryInfo[]; away: InjuryInfo[] };
/** ESPN video clips for this match (open on ESPN). */ /** ESPN video clips for this match (open on ESPN). */
videos: MatchVideo[]; 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. */ /** Honest note on which data layers are populated yet. */
dataNote: string; dataNote: string;
updatedAt: number | null; updatedAt: number | null;
@@ -227,6 +234,10 @@ export interface LineupTeam {
export interface MatchLineup { home: LineupTeam; away: 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 { export interface MatchRich {
shots: RichShot[]; shots: RichShot[];
xg: { home: number; away: number } | null; xg: { home: number; away: number } | null;
@@ -236,6 +247,10 @@ export interface MatchRich {
potm: { name: string; rating: string | null } | null; potm: { name: string; rating: string | null } | null;
info: { attendance: number | null; stadium: string; referee: 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; 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 { export interface TeamProfile {
@@ -253,6 +268,43 @@ export interface TeamProfile {
injuries: InjuryInfo[]; 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<string, { value: number; matches: number | null; minutes: number | null }>;
/** 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) ---- // ---- tournament stats hub (/api/stats) ----
/** One row on a stat leaderboard (player or team board). */ /** One row on a stat leaderboard (player or team board). */
+2 -1
View File
@@ -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 storyRoute = createRoute({ getParentRoute: () => rootRoute, path: '/story', component: lazyRouteComponent(() => import('./features/story/StoryPage'), 'StoryPage') });
const matchRoute = createRoute({ getParentRoute: () => rootRoute, path: '/match/$num', component: MatchPreviewPage }); const matchRoute = createRoute({ getParentRoute: () => rootRoute, path: '/match/$num', component: MatchPreviewPage });
const teamRoute = createRoute({ getParentRoute: () => rootRoute, path: '/team/$name', component: TeamProfilePage }); 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 methodologyRoute = createRoute({ getParentRoute: () => rootRoute, path: '/methodology', component: lazyRouteComponent(() => import('./features/methodology/MethodologyPage'), 'MethodologyPage') });
const teamsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/teams', component: TeamsPage }); const teamsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/teams', component: TeamsPage });
const scoreboardRoute = createRoute({ getParentRoute: () => rootRoute, path: '/scoreboard', component: ScoreboardPage }); const scoreboardRoute = createRoute({ getParentRoute: () => rootRoute, path: '/scoreboard', component: ScoreboardPage });
const dataRoute = createRoute({ getParentRoute: () => rootRoute, path: '/data', component: lazyRouteComponent(() => import('./features/data/DataPage'), 'DataPage') }); 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 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' }); export const router = createRouter({ routeTree, defaultPreload: 'intent' });