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:
+20
-1
@@ -9,8 +9,11 @@ import { TournamentState } from './tournament';
|
||||
import { startScheduler } from './ingest/scheduler';
|
||||
import { ModelEngine } from './model';
|
||||
import { buildPreview, buildTeamProfile } from './preview';
|
||||
import { buildPlayerProfile, playersIndex } from './player';
|
||||
import { buildScoreboard, snapshotCheck } from './scoreboard';
|
||||
import { buildStatsHub, fixtureEvents, goldenBootRows } from './stats';
|
||||
import { normalizeMatchStats } from './ingest/fotmobStatsNormalize';
|
||||
import { confirmedXI } from './ingest/fifaLineupNormalize';
|
||||
import { buildIcs } from './ics';
|
||||
import { db, healthAll, oddsFor, latestOddsAll, dbCounts, inplayHistory, recordInplay, getMatchProvider, allFixtureResults, saveFixtureResult, allModelHistory, saveModelHistory } from './db/db';
|
||||
import { inPlayProbs } from '../../src/lib/model/inplay';
|
||||
@@ -139,11 +142,13 @@ export function buildServer() {
|
||||
interface FmDetails {
|
||||
homeTeamId?: number | null;
|
||||
shots?: FmShot[];
|
||||
stats?: unknown;
|
||||
momentum?: { main?: { data?: { minute: number; value: number }[] } } | null;
|
||||
matchFacts?: { playerOfTheMatch?: { name?: { fullName?: string }; rating?: { num?: string }; teamName?: string } } | null;
|
||||
lineup?: { homeTeam?: FmLineupTeam; awayTeam?: FmLineupTeam } | null;
|
||||
lineup?: { lineupType?: string; homeTeam?: FmLineupTeam; awayTeam?: FmLineupTeam } | null;
|
||||
}
|
||||
const fm = getMatchProvider<FmDetails>(num, 'fotmob');
|
||||
const fifa = getMatchProvider<{ live?: unknown }>(num, 'fifa');
|
||||
const fifaInfo = getMatchProvider<{ attendance: number | null; officials: { Name?: { Description?: string }[]; TypeLocalized?: { Description?: string }[] }[]; stadium: string }>(num, 'fifa-info');
|
||||
const weather = getMatchProvider<{ tempC: number | null; precipProbPct: number | null; windKmh: number | null; elevationM: number }>(num, 'weather');
|
||||
|
||||
@@ -196,6 +201,7 @@ export function buildServer() {
|
||||
};
|
||||
const lineupHome = trimTeam(fm?.data.lineup?.homeTeam);
|
||||
const lineupAway = trimTeam(fm?.data.lineup?.awayTeam);
|
||||
const fullStats = normalizeMatchStats(fm?.data.stats);
|
||||
|
||||
return {
|
||||
shots,
|
||||
@@ -211,6 +217,10 @@ export function buildServer() {
|
||||
}
|
||||
: null,
|
||||
weather: weather ? weather.data : null,
|
||||
fullStats: fullStats.length ? fullStats : null,
|
||||
// Official XI published by FIFA, or FotMob no longer calling its
|
||||
// lineup "predicted" — either way, stop labeling them as projections.
|
||||
lineupConfirmed: confirmedXI(fifa?.data.live) != null || fm?.data.lineup?.lineupType === 'standard',
|
||||
};
|
||||
});
|
||||
|
||||
@@ -252,6 +262,15 @@ export function buildServer() {
|
||||
const profile = buildTeamProfile(name, state, model);
|
||||
return profile ?? reply.code(404).send({ error: 'no such team' });
|
||||
});
|
||||
// Player profiles, assembled from stored boards, lineups, shots and events.
|
||||
// Either source's spelling of a name resolves ("Hwang In-Beom" = "In-Beom Hwang").
|
||||
app.get('/api/player/:name', async (req, reply) => {
|
||||
const name = decodeURIComponent((req.params as { name: string }).name);
|
||||
const profile = buildPlayerProfile(name, state);
|
||||
return profile ?? reply.code(404).send({ error: 'unknown player' });
|
||||
});
|
||||
// The search palette's player list.
|
||||
app.get('/api/players', async () => ({ players: playersIndex(state) }));
|
||||
// Calendar export: one team's schedule (?team=X) or the whole tournament.
|
||||
app.get('/api/calendar.ics', async (req, reply) => {
|
||||
const raw = (req.query as { team?: string }).team;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { confirmedXI } from './fifaLineupNormalize';
|
||||
|
||||
const squad = (starters: number, bench = 15, prefix = 'P') => ({
|
||||
Players: [
|
||||
...Array.from({ length: starters }, (_, i) => ({ Status: 1, ShortName: [{ Locale: 'en-GB', Description: `${prefix}${i + 1}` }] })),
|
||||
...Array.from({ length: bench }, (_, i) => ({ Status: 2, ShortName: [{ Locale: 'en-GB', Description: `${prefix}B${i + 1}` }] })),
|
||||
],
|
||||
});
|
||||
|
||||
describe('confirmedXI', () => {
|
||||
it('returns both XIs when each side has exactly 11 Status===1 starters', () => {
|
||||
const xi = confirmedXI({ HomeTeam: squad(11, 15, 'H'), AwayTeam: squad(11, 15, 'A') });
|
||||
expect(xi?.home).toHaveLength(11);
|
||||
expect(xi?.away).toHaveLength(11);
|
||||
expect(xi?.home[0]).toBe('H1');
|
||||
expect(xi?.away[10]).toBe('A11');
|
||||
});
|
||||
|
||||
it('rejects sides without exactly 11 starters', () => {
|
||||
expect(confirmedXI({ HomeTeam: squad(10), AwayTeam: squad(11) })).toBeNull();
|
||||
expect(confirmedXI({ HomeTeam: squad(12), AwayTeam: squad(11) })).toBeNull();
|
||||
expect(confirmedXI({ HomeTeam: squad(0, 26), AwayTeam: squad(11) })).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects unnamed starters and shape surprises', () => {
|
||||
const broken = squad(11);
|
||||
broken.Players[3] = { Status: 1, ShortName: [] };
|
||||
expect(confirmedXI({ HomeTeam: broken, AwayTeam: squad(11) })).toBeNull();
|
||||
expect(confirmedXI(null)).toBeNull();
|
||||
expect(confirmedXI({})).toBeNull();
|
||||
expect(confirmedXI({ HomeTeam: { Players: 'nope' }, AwayTeam: squad(11) })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
// Pure normalizer for the FIFA live payload's lineups — no network or DB.
|
||||
// FIFA publishes the official XI ~75–90 min before kickoff; Status === 1
|
||||
// marks starters within the 26-man squad list.
|
||||
|
||||
interface RawFifaPlayer { Status?: number; ShortName?: { Description?: string }[] }
|
||||
interface RawFifaTeam { Players?: RawFifaPlayer[] }
|
||||
/** The `live` value stored under match_provider provider='fifa'. */
|
||||
export interface RawFifaLive { HomeTeam?: RawFifaTeam; AwayTeam?: RawFifaTeam }
|
||||
|
||||
/** The officially confirmed XIs, or null unless BOTH sides have exactly 11
|
||||
* named starters — validation doubles as the feature flag, so a payload
|
||||
* drift silently keeps lineups marked "predicted". */
|
||||
export function confirmedXI(raw: unknown): { home: string[]; away: string[] } | null {
|
||||
const live = raw as RawFifaLive | null | undefined;
|
||||
const side = (t?: RawFifaTeam): string[] | null => {
|
||||
if (!t || !Array.isArray(t.Players)) return null;
|
||||
const names: string[] = [];
|
||||
for (const p of t.Players) {
|
||||
if (!p || p.Status !== 1) continue;
|
||||
const n = p.ShortName?.[0]?.Description;
|
||||
if (typeof n !== 'string' || !n.trim()) return null;
|
||||
names.push(n.trim());
|
||||
}
|
||||
return names.length === 11 ? names : null;
|
||||
};
|
||||
const home = side(live?.HomeTeam);
|
||||
const away = side(live?.AwayTeam);
|
||||
return home && away ? { home, away } : null;
|
||||
}
|
||||
@@ -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' }]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { PLAYER_BOARDS, TEAM_BOARDS, boardSlug, normalizeStatBoard } from './statsNormalize';
|
||||
import { PLAYER_BOARDS, TEAM_BOARDS, boardName, boardSlug, normalizeStatBoard } from './statsNormalize';
|
||||
import { nameMatch } from '../../../src/lib/matchEvents';
|
||||
|
||||
// Real shape from data.fotmob.com/stats/77/season/24254/goals.json (WC 2026).
|
||||
const realBoard = {
|
||||
@@ -47,7 +48,36 @@ describe('board slugs', () => {
|
||||
it('maps the CDN url to our category keys', () => {
|
||||
expect(PLAYER_BOARDS[boardSlug('https://data.fotmob.com/stats/77/season/24254/goals.json')]).toBe('goals');
|
||||
expect(PLAYER_BOARDS[boardSlug('https://data.fotmob.com/stats/77/season/24254/expected_goals.json')]).toBe('xg');
|
||||
expect(PLAYER_BOARDS[boardSlug('https://data.fotmob.com/stats/77/season/24254/_save_percentage.json')]).toBe('savePct');
|
||||
expect(TEAM_BOARDS[boardSlug('https://data.fotmob.com/stats/77/season/24254/_xg_diff_team.json')]).toBe('xgDiff');
|
||||
expect(PLAYER_BOARDS[boardSlug(undefined)]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('boardName', () => {
|
||||
const boards = {
|
||||
goals: [
|
||||
{ name: 'In-Beom Hwang', team: 'South Korea', value: 1, matches: 1, minutes: 90 },
|
||||
{ name: 'Cyle Larin', team: 'Canada', value: 1, matches: 1, minutes: 80 },
|
||||
],
|
||||
rating: [{ name: 'Thiago Silva', team: 'Brazil', value: 8.1, matches: 1, minutes: 90 }],
|
||||
};
|
||||
|
||||
it('prefers the board form of the name when a same-team row matches', () => {
|
||||
expect(boardName('Hwang In-Beom', 'South Korea', boards, nameMatch)).toBe('In-Beom Hwang');
|
||||
});
|
||||
|
||||
it('keeps the ESPN form when nothing matches', () => {
|
||||
expect(boardName('Oh Hyeon-Gyu', 'South Korea', boards, nameMatch)).toBe('Oh Hyeon-Gyu');
|
||||
expect(boardName('Cyle Larin', 'Canada', undefined, nameMatch)).toBe('Cyle Larin');
|
||||
});
|
||||
|
||||
it('never merges across teams', () => {
|
||||
// a hypothetical second Thiago Silva playing elsewhere stays untouched
|
||||
expect(boardName('Thiago Silva', 'Portugal', boards, nameMatch)).toBe('Thiago Silva');
|
||||
});
|
||||
|
||||
it('falls back through rating/minutes boards for non-scorers', () => {
|
||||
expect(boardName('Silva Thiago', 'Brazil', boards, nameMatch)).toBe('Thiago Silva');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,10 @@ export const PLAYER_BOARDS: Record<string, string> = {
|
||||
'rating.json': 'rating',
|
||||
'expected_goals.json': 'xg',
|
||||
'expected_goals_per_90.json': 'xgPer90',
|
||||
'expected_assists.json': 'xa',
|
||||
'total_att_assist.json': 'chancesCreated',
|
||||
'_save_percentage.json': 'savePct',
|
||||
'saves.json': 'savesPer90',
|
||||
'mins_played.json': 'minutes',
|
||||
};
|
||||
|
||||
@@ -67,3 +71,20 @@ export function normalizeStatBoard(board: RawStatBoard, isTeam: boolean, max = 3
|
||||
export function boardSlug(url: string | undefined): string {
|
||||
return url?.split('/').pop() ?? '';
|
||||
}
|
||||
|
||||
/** The FotMob display form of a player name, when a board row for the same
|
||||
* team matches. ESPN spells the same player differently ("Hwang In-Beom" vs
|
||||
* "In-Beom Hwang"), and the boards are what the rest of the page shows. */
|
||||
export function boardName(
|
||||
player: string,
|
||||
team: string,
|
||||
boards: Record<string, StatRow[]> | undefined,
|
||||
match: (a: string, b: string) => boolean,
|
||||
): string {
|
||||
if (!boards) return player;
|
||||
for (const cat of ['goals', 'rating', 'minutes']) {
|
||||
const hit = boards[cat]?.find((r) => r.team === team && match(r.name, player));
|
||||
if (hit) return hit.name;
|
||||
}
|
||||
return player;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { getMatchExt, injuriesFor, snapshotFor } from './db/db';
|
||||
import { getMatchExt, injuriesFor, scorerPropsFor, snapshotFor } from './db/db';
|
||||
import { whoScores } from './ingest/propsNormalize';
|
||||
import { disciplineFor } from './stats';
|
||||
import type { TournamentState } from './tournament';
|
||||
import type { ModelEngine } from './model';
|
||||
@@ -149,6 +150,8 @@ export function buildPreview(num: number, state: TournamentState, model: ModelEn
|
||||
away: away ? injuriesFor(away) : [],
|
||||
},
|
||||
videos: summary?.videos ?? [],
|
||||
// The board disappears at kickoff — in-play lines drift too fast to mirror.
|
||||
scorerProps: fixture.status === 'scheduled' ? whoScores(scorerPropsFor(num)) : [],
|
||||
dataNote: !home || !away
|
||||
? 'Knockout participants are decided once the bracket resolves.'
|
||||
: ext
|
||||
|
||||
+13
-2
@@ -1,7 +1,9 @@
|
||||
import { getMatchExt, scorerPropsFor } from './db/db';
|
||||
import { getTournamentStats } from './ingest/tournamentStats';
|
||||
import { boardName } from './ingest/statsNormalize';
|
||||
import { teamDiscipline, type DisciplineFixture } from '../../src/lib/discipline';
|
||||
import { nameMatch } from '../../src/lib/matchEvents';
|
||||
import { aliasKey } from '../../src/lib/playerProfile';
|
||||
import type { TournamentState } from './tournament';
|
||||
import type { EspnSummary } from './ingest/espnNormalize';
|
||||
import type { GoldenBootRow, MatchLiveEvent, PlayerDiscipline, StatsHub } from '../../src/lib/types';
|
||||
@@ -73,14 +75,23 @@ export function goldenBootRows(state: TournamentState, max = 25): GoldenBootRow[
|
||||
tally.set(key, cur);
|
||||
}
|
||||
}
|
||||
const boards = getTournamentStats()?.players;
|
||||
// Token-set match plus the romanization fold ("Oh Hyeon-Gyu" = "Hyun-Gyu Oh").
|
||||
const same = (a: string, b: string) => aliasKey(a) === aliasKey(b) || nameMatch(a, b);
|
||||
return [...tally.values()]
|
||||
.sort((a, b) => b.goals - a.goals || a.player.localeCompare(b.player))
|
||||
.slice(0, max)
|
||||
.map((p) => ({ ...p, nextOdds: nextAnytimeOdds(p.player, p.team, state) }));
|
||||
// Display the FotMob form of the name (what the boards beside this list
|
||||
// show); the ESPN form still drives the props lookup.
|
||||
.map((p) => ({
|
||||
...p,
|
||||
player: boardName(p.player, p.team, boards, same),
|
||||
nextOdds: nextAnytimeOdds(p.player, p.team, state),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Latest captured anytime-scorer line for the player's next fixture. */
|
||||
function nextAnytimeOdds(player: string, team: string, state: TournamentState): number | null {
|
||||
export function nextAnytimeOdds(player: string, team: string, state: TournamentState): number | null {
|
||||
if (!team) return null;
|
||||
const next = state
|
||||
.allFixtures()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { fmt, useT } from '@/lib/i18n';
|
||||
import { PlayerLink } from '@/components/PlayerLink';
|
||||
import { nameMatch, parseEvents } from '@/lib/matchEvents';
|
||||
import type { LineupTeam, MatchLineup, MatchLiveEvent, PitchPlayer } from '@/lib/types';
|
||||
|
||||
@@ -86,9 +88,11 @@ function shortName(name: string): string {
|
||||
}
|
||||
|
||||
function PlayerDot({ slot, side, ny }: { slot: Slot; side: 'home' | 'away'; ny: number }) {
|
||||
const navigate = useNavigate();
|
||||
const p = slot.player;
|
||||
const st = slot.starter;
|
||||
if (st.x == null) return null;
|
||||
const open = () => void navigate({ to: '/player/$name', params: { name: p.name } });
|
||||
const cx = side === 'home' ? st.x * W : (1 - st.x) * W;
|
||||
const cy = side === 'home' ? ny * (H / 2) : H - ny * (H / 2);
|
||||
// Rating chip points toward the pitch center, sub markers outward — so
|
||||
@@ -101,7 +105,16 @@ function PlayerDot({ slot, side, ny }: { slot: Slot; side: 'home' | 'away'; ny:
|
||||
const gk = ny <= 0.1;
|
||||
const stroke = side === 'home' ? 'var(--app-accent)' : 'var(--app-info)';
|
||||
return (
|
||||
<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} />
|
||||
<text x={cx} y={cy + 1.05} textAnchor="middle" fontSize={3} fontWeight={700} fill="var(--app-ink)" className="tnum">
|
||||
{p.num ?? ''}
|
||||
@@ -209,7 +222,7 @@ function BenchStrip({ players, tone, label, dir }: { players: PitchPlayer[]; ton
|
||||
return (
|
||||
<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 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>}
|
||||
{p.rating != null && <span className="tnum ml-auto shrink-0 text-faint">{p.rating.toFixed(1)}</span>}
|
||||
</li>
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { useMemo, useState, type KeyboardEvent } from 'react';
|
||||
import { ArrowRightLeft, ChevronDown, CircleDot, Volleyball } from 'lucide-react';
|
||||
import { fmt, useT } from '@/lib/i18n';
|
||||
import { fmt, fmtSplit, useT } from '@/lib/i18n';
|
||||
import { PlayerLink } from '@/components/PlayerLink';
|
||||
import { eventContext, goalShot, momentumVerdict, parseEvents, halfTimeScore, reportSentences, varLines, type CommentaryLine, type EventKind, type ParsedEvent } from '@/lib/matchEvents';
|
||||
import type { MatchLiveEvent, RichShot } from '@/lib/types';
|
||||
|
||||
/** Only properly-parsed player names become links — an unparseable line keeps
|
||||
* its raw text as the title (the same guard the discipline fold uses). */
|
||||
const linkableName = (e: ParsedEvent): boolean =>
|
||||
(e.kind === 'goal' || e.kind === 'yellow' || e.kind === 'red' || (e.kind === 'sub' && !!e.subOut)) &&
|
||||
e.raw.trim().length > 0 && e.title !== e.raw.trim() && e.title.length <= 40;
|
||||
|
||||
/** Crisp event markers instead of emoji: ball icon for goals, card-shaped
|
||||
* color chips for bookings, arrows for substitutions. */
|
||||
export function EventIcon({ kind }: { kind: EventKind }) {
|
||||
@@ -114,9 +121,9 @@ function GoalCard({ e, mobile, ctx, rich, open, onToggle }: { e: ParsedEvent; mo
|
||||
? away ? 'border-l-[3px] border-l-info' : 'border-l-[3px] border-l-accent'
|
||||
: 'border-r-[3px] border-r-accent';
|
||||
const own = e.detail?.type === 'ownGoal';
|
||||
const assistName = e.detail?.type === 'assist' ? e.detail.name : null;
|
||||
const detailText =
|
||||
e.detail?.type === 'assist' ? fmt(t.match.assist, { name: e.detail.name })
|
||||
: e.detail?.type === 'penalty' ? t.match.penalty
|
||||
e.detail?.type === 'penalty' ? t.match.penalty
|
||||
: e.detail?.type === 'raw' ? e.detail.text
|
||||
: null;
|
||||
return (
|
||||
@@ -138,8 +145,18 @@ function GoalCard({ e, mobile, ctx, rich, open, onToggle }: { e: ParsedEvent; mo
|
||||
{ctx && <ChevronDown size={13} className={`text-faint transition-transform ${open ? 'rotate-180' : ''}`} />}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`mt-1 text-base font-bold text-ink ${mobile ? '' : 'truncate'}`}>{e.title}</div>
|
||||
{detailText && <div className={`mt-0.5 text-xs text-faint ${mobile ? '' : 'truncate'}`}>{detailText}</div>}
|
||||
<div className={`mt-1 text-base font-bold text-ink ${mobile ? '' : 'truncate'}`}>
|
||||
{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} />}
|
||||
</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">
|
||||
<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>
|
||||
{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' : ''}`} />}
|
||||
</span>
|
||||
{ctx && open && <ContextPanel e={e} ctx={ctx} {...rich} />}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,24 +2,27 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { Search } from 'lucide-react';
|
||||
import { teamFlag } from '@/lib/teams';
|
||||
import { norm } from '@/lib/matchEvents';
|
||||
import { useFormat } from '@/lib/i18n';
|
||||
import { useTournamentStore } from '@/stores/tournamentStore';
|
||||
|
||||
interface Hit {
|
||||
kind: 'team' | 'match';
|
||||
kind: 'team' | 'match' | 'player';
|
||||
label: string;
|
||||
sub: string;
|
||||
flags: string[];
|
||||
go: () => void;
|
||||
}
|
||||
|
||||
/** Cmd/Ctrl-K palette: jump to any team or match. */
|
||||
/** Cmd/Ctrl-K palette: jump to any team, match or player. */
|
||||
export function SearchPalette({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const { t, kickoffDay } = useFormat();
|
||||
const navigate = useNavigate();
|
||||
const snapshot = useTournamentStore((s) => s.snapshot);
|
||||
const [q, setQ] = useState('');
|
||||
const [sel, setSel] = useState(0);
|
||||
// Component state, not the store — a fresh-array zustand selector would loop.
|
||||
const [players, setPlayers] = useState<{ name: string; team: string }[]>([]);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -30,6 +33,17 @@ export function SearchPalette({ open, onClose }: { open: boolean; onClose: () =>
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Fetch the player list once, on first open (it's small and cached server-side).
|
||||
const fetched = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!open || fetched.current) return;
|
||||
fetched.current = true;
|
||||
fetch('/api/players')
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject()))
|
||||
.then((d: { players: { name: string; team: string }[] }) => setPlayers(d.players))
|
||||
.catch(() => { fetched.current = false; });
|
||||
}, [open]);
|
||||
|
||||
const hits = useMemo<Hit[]>(() => {
|
||||
if (!snapshot || q.trim().length < 2) return [];
|
||||
const needle = q.trim().toLowerCase();
|
||||
@@ -57,8 +71,19 @@ export function SearchPalette({ open, onClose }: { open: boolean; onClose: () =>
|
||||
}
|
||||
if (out.length > 14) break;
|
||||
}
|
||||
// Players rank below teams/matches; diacritic-folded so "quinones" hits "Quiñones".
|
||||
let playerHits = 0;
|
||||
for (const p of players) {
|
||||
if (playerHits >= 5 || out.length > 14) break;
|
||||
if (!norm(p.name).includes(norm(needle))) continue;
|
||||
playerHits++;
|
||||
out.push({
|
||||
kind: 'player', label: p.name, sub: p.team, flags: [teamFlag(p.team)],
|
||||
go: () => void navigate({ to: '/player/$name', params: { name: p.name } }),
|
||||
});
|
||||
}
|
||||
return out.slice(0, 12);
|
||||
}, [snapshot, q, navigate, t, kickoffDay]);
|
||||
}, [snapshot, players, q, navigate, t, kickoffDay]);
|
||||
|
||||
useEffect(() => setSel(0), [q]);
|
||||
|
||||
|
||||
@@ -60,7 +60,8 @@ export function ShotMap({ shots, home, away }: { shots: RichShot[]; home: string
|
||||
))}
|
||||
</svg>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useParams } from '@tanstack/react-router';
|
||||
import { ArrowLeft, ArrowRight, Ban, Clapperboard, Goal, Shield, Shirt, Stethoscope } from 'lucide-react';
|
||||
import { ArrowLeft, ArrowRight, BadgeCheck, Ban, Clapperboard, Goal, Shield, Shirt, Stethoscope } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
||||
import { WinProbBar } from '@/components/WinProbBar';
|
||||
@@ -18,10 +18,14 @@ import { goalEvents, shootoutKicks } from '@/lib/matchEvents';
|
||||
import { redCards } from '@/lib/discipline';
|
||||
import { inPlayProbs } from '@/lib/model/inplay';
|
||||
import { useTournamentStore } from '@/stores/tournamentStore';
|
||||
import { PlayerLink } from '@/components/PlayerLink';
|
||||
import type { Fixture, KeyPlayer, MatchPreview, MatchRich, OddsPoint } from '@/lib/types';
|
||||
|
||||
const num = (s: string | undefined) => (s ? Number(s.replace('%', '')) || 0 : 0);
|
||||
|
||||
/** Label dict for the detailed-stats card, widened for lookup by stat key. */
|
||||
const fullStatLabels = (d: Record<string, string>): Record<string, string> => d;
|
||||
|
||||
type TabKey = 'summary' | 'timeline' | 'stats' | 'lineups';
|
||||
|
||||
function LiveStatRow({ label, home, away }: { label: string; home: number; away: number }) {
|
||||
@@ -50,7 +54,7 @@ function ScorerList({ team, players }: { team: string; players: KeyPlayer[] }) {
|
||||
<ul className="space-y-1">
|
||||
{players.slice(0, 6).map((p) => (
|
||||
<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>
|
||||
</li>
|
||||
))}
|
||||
@@ -70,7 +74,7 @@ function LineupCol({ team, players }: { team: string; players: { name: string; p
|
||||
{list.map((p) => (
|
||||
<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="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>
|
||||
</li>
|
||||
))}
|
||||
@@ -173,6 +177,10 @@ export function MatchPreviewPage() {
|
||||
const finished = f.status === 'finished';
|
||||
const koT = new Date(f.kickoff).getTime();
|
||||
const hasOddsChart = oddsHistory.filter((o) => o.home_ml != null && o.draw_ml != null && o.away_ml != null && o.captured_at <= koT).length >= 2;
|
||||
// Latest pre-kickoff totals/handicap line (display only, like all odds here).
|
||||
const closingLine = oddsHistory
|
||||
.filter((o) => o.captured_at <= koT && (o.over_under != null || o.spread != null))
|
||||
.sort((a, b) => b.captured_at - a.captured_at)[0] ?? null;
|
||||
|
||||
// Hero extras from the rich capture: kickoff-hour weather (pre-match only)
|
||||
// and post-match official chips (attendance, referee, player of the match).
|
||||
@@ -207,7 +215,7 @@ export function MatchPreviewPage() {
|
||||
// Tabs only appear when their data exists (e.g. no Timeline before kickoff).
|
||||
const tabs: TabKey[] = ['summary'];
|
||||
if (preview.events.length > 0) tabs.push('timeline');
|
||||
if (preview.liveStats || (rich && (rich.shots.length >= 3 || rich.momentum.length >= 10))) tabs.push('stats');
|
||||
if (preview.liveStats || (rich && ((rich.fullStats?.length ?? 0) > 0 || rich.shots.length >= 3 || rich.momentum.length >= 10))) tabs.push('stats');
|
||||
tabs.push('lineups');
|
||||
const tab: TabKey = tabs.includes(activeTab) ? activeTab : 'summary';
|
||||
|
||||
@@ -322,6 +330,27 @@ export function MatchPreviewPage() {
|
||||
</CardBody>
|
||||
</Card>
|
||||
) : 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 && (
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">{t.match.keyMoments}</span></CardHeader>
|
||||
@@ -392,11 +421,19 @@ export function MatchPreviewPage() {
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
{preview.prediction && hasOddsChart && (
|
||||
{preview.prediction && (hasOddsChart || closingLine) && (
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">{t.match.oddsMovement}</span></CardHeader>
|
||||
<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>
|
||||
</Card>
|
||||
)}
|
||||
@@ -457,6 +494,23 @@ export function MatchPreviewPage() {
|
||||
</CardBody>
|
||||
</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 && (
|
||||
<Card>
|
||||
<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. */}
|
||||
{rich?.lineup ? (
|
||||
<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>
|
||||
<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>
|
||||
</Card>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Link } from '@tanstack/react-router';
|
||||
import { Award, Ban, ChartScatter, Shirt, Users } from 'lucide-react';
|
||||
import { PageHeader } from '@/components/ui/PageHeader';
|
||||
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
||||
import { PlayerLink } from '@/components/PlayerLink';
|
||||
import { teamFlag } from '@/lib/teams';
|
||||
import { fmt, plural, useFormat, useT, type Dict } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/cn';
|
||||
@@ -10,12 +11,12 @@ import type { StatRow, StatsHub } from '@/lib/types';
|
||||
|
||||
type CatKey = keyof Dict['stats']['cat'];
|
||||
|
||||
const PLAYER_CATS: CatKey[] = ['goals', 'assists', 'goalsAssists', 'rating', 'xg', 'xgPer90', 'minutes'];
|
||||
const PLAYER_CATS: CatKey[] = ['goals', 'assists', 'goalsAssists', 'rating', 'xg', 'xgPer90', 'xa', 'chancesCreated', 'savePct', 'savesPer90', 'minutes'];
|
||||
const TEAM_CATS: CatKey[] = ['xg', 'xgDiff', 'possession', 'cleanSheets', 'bigChances', 'goalsPerMatch', 'concededPerMatch', 'rating'];
|
||||
const INT_CATS = new Set<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 {
|
||||
if (key === 'possession') return `${v.toFixed(1)}%`;
|
||||
if (key === 'possession' || key === 'savePct') return `${v.toFixed(1)}%`;
|
||||
if (key === 'xgDiff') return `${v > 0 ? '+' : ''}${v.toFixed(2)}`;
|
||||
if (INT_CATS.has(key)) return String(Math.round(v));
|
||||
return v.toFixed(2);
|
||||
@@ -55,7 +56,7 @@ function BoardList({ rows, cat, linkTeams }: { rows: StatRow[]; cat: CatKey; lin
|
||||
{linkTeams && r.team ? (
|
||||
<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>}
|
||||
<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">
|
||||
<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 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 && (
|
||||
<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) })}
|
||||
@@ -172,7 +173,7 @@ function Discipline({ hub }: { hub: StatsHub }) {
|
||||
{hub.suspensions.map((p) => (
|
||||
<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 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">
|
||||
{p.suspendedFor != null ? fmt(t.stats.discipline.suspendedFor, { num: p.suspendedFor }) : t.stats.discipline.suspendedNext}
|
||||
</span>
|
||||
@@ -186,7 +187,7 @@ function Discipline({ hub }: { hub: StatsHub }) {
|
||||
{hub.cards.slice(0, 15).map((p) => (
|
||||
<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 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>
|
||||
{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" />)}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { fmt, useFormat, useT } from '@/lib/i18n';
|
||||
import { hostAdvantage } from '@/lib/model/hosts';
|
||||
import { knockoutAdvanceProb, predictMatch, type RatingsModel } from '@/lib/model/predict';
|
||||
import { modelBracket, projectedSeeds, teamPath } from '@/lib/whatif';
|
||||
import { PlayerLink } from '@/components/PlayerLink';
|
||||
import type { Fixture, TeamProfile } from '@/lib/types';
|
||||
|
||||
const pct = (x: number | null) => (x == null ? '—' : `${(x * 100).toFixed(1)}%`);
|
||||
@@ -278,7 +279,7 @@ export function TeamProfilePage() {
|
||||
<ul className="space-y-1.5">
|
||||
{profile.injuries.map((i) => (
|
||||
<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>
|
||||
</li>
|
||||
))}
|
||||
@@ -293,7 +294,7 @@ export function TeamProfilePage() {
|
||||
<ul className="space-y-1.5">
|
||||
{profile.discipline.map((p) => (
|
||||
<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>
|
||||
{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" />)}
|
||||
@@ -320,7 +321,7 @@ export function TeamProfilePage() {
|
||||
{profile.keyPlayers.map((p, i) => (
|
||||
<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="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>
|
||||
{p.pens ? <span className="text-[11px] text-faint">{fmt(t.common.pensShort, { n: p.pens })}</span> : null}
|
||||
</li>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link } from '@tanstack/react-router';
|
||||
import { Award } from 'lucide-react';
|
||||
import { PageHeader } from '@/components/ui/PageHeader';
|
||||
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
||||
import { PlayerLink } from '@/components/PlayerLink';
|
||||
import { teamFlag } from '@/lib/teams';
|
||||
import { useTournamentStore } from '@/stores/tournamentStore';
|
||||
import { fmt, useT } from '@/lib/i18n';
|
||||
@@ -41,7 +42,7 @@ function GoldenBoot() {
|
||||
<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 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="bg-gradient-to-r from-gold/60 to-gold" style={{ width: `${(p.goals / max) * 100}%` }} />
|
||||
</div>
|
||||
|
||||
@@ -121,6 +121,37 @@ export const de: Dict = {
|
||||
attendanceLine: "{stadium} · {n} Zuschauer · Schiedsrichter {ref}",
|
||||
potm: "Spieler des Spiels",
|
||||
xgAttribution: "xG und Bewertungen sind FotMob-Schätzungen.",
|
||||
fullStatsTitle: "Detaillierte Statistik",
|
||||
fullStats: {
|
||||
xg: "Expected Goals (xG)",
|
||||
xgot: "xG auf das Tor",
|
||||
bigChances: "Großchancen",
|
||||
accuratePasses: "Angekommene Pässe",
|
||||
longBalls: "Angekommene lange Bälle",
|
||||
crosses: "Angekommene Flanken",
|
||||
touchesInBox: "Ballkontakte im Strafraum",
|
||||
corners: "Ecken",
|
||||
offsides: "Abseitsstellungen",
|
||||
fouls: "Fouls",
|
||||
tackles: "Tacklings",
|
||||
interceptions: "Abgefangene Bälle",
|
||||
blocks: "Geblockte Schüsse",
|
||||
clearances: "Klärungsaktionen",
|
||||
saves: "Paraden",
|
||||
duelsWon: "Gewonnene Duelle",
|
||||
groundDuels: "Gewonnene Bodenduelle",
|
||||
aerialDuels: "Gewonnene Kopfballduelle",
|
||||
dribbles: "Erfolgreiche Dribblings",
|
||||
},
|
||||
oddsExtras: {
|
||||
ou: "Über/Unter {n} Tore",
|
||||
spread: "Handicap {n}",
|
||||
},
|
||||
confirmedLineups: "Bestätigt",
|
||||
whoScores: {
|
||||
title: "Wer trifft?",
|
||||
note: "DraftKings-Anytime-Quoten, Favoriten zuerst — nur Vergleichswert, nie Modell-Input.",
|
||||
},
|
||||
tabs: {
|
||||
summary: "Übersicht",
|
||||
timeline: "Spielverlauf",
|
||||
@@ -291,6 +322,10 @@ export const de: Dict = {
|
||||
bigChances: "Großchancen",
|
||||
goalsPerMatch: "Tore pro Spiel",
|
||||
concededPerMatch: "Gegentore pro Spiel",
|
||||
xa: "Expected Assists (xA)",
|
||||
chancesCreated: "Kreierte Chancen",
|
||||
savePct: "Paradenquote",
|
||||
savesPer90: "Paraden pro 90",
|
||||
},
|
||||
scatter: {
|
||||
title: "Angriff vs. Abwehr",
|
||||
@@ -569,4 +604,26 @@ export const de: Dict = {
|
||||
final: "F",
|
||||
},
|
||||
},
|
||||
player: {
|
||||
unknown: "Keine Turnierdaten zu diesem Spieler.",
|
||||
backToStats: "Zur Statistik",
|
||||
totalsTitle: "Turnierbilanz",
|
||||
matchLogTitle: "Spielprotokoll",
|
||||
log: {
|
||||
opponent: "Gegner",
|
||||
min: "Min",
|
||||
rating: "Note",
|
||||
goals: "T",
|
||||
assists: "V",
|
||||
},
|
||||
started: "In der Startelf",
|
||||
cameOn: "Eingewechselt",
|
||||
benched: "Nicht eingesetzt",
|
||||
minutesNote: "Minuten sind aus den Wechselzeiten geschätzt; Verlängerung zählt nicht mit.",
|
||||
shotMapTitle: "Alle Schüsse in diesem Turnier",
|
||||
shotMapNote: "Alle Schüsse zeigen nach rechts. Punktgröße entspricht dem xG; gefüllte Punkte sind Tore.",
|
||||
nextOdds: "Trifft gegen {opponent} (anytime): {odds}",
|
||||
oddsNote: "DraftKings-Quote — nur Vergleichswert, nie Modell-Input.",
|
||||
goalsLabel: "Tore",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -120,6 +120,37 @@ export const en = {
|
||||
attendanceLine: "{stadium} · {n} spectators · referee {ref}",
|
||||
potm: "Player of the match",
|
||||
xgAttribution: "xG and ratings are FotMob estimates.",
|
||||
fullStatsTitle: "Detailed stats",
|
||||
fullStats: {
|
||||
xg: "Expected goals (xG)",
|
||||
xgot: "xG on target",
|
||||
bigChances: "Big chances",
|
||||
accuratePasses: "Accurate passes",
|
||||
longBalls: "Accurate long balls",
|
||||
crosses: "Accurate crosses",
|
||||
touchesInBox: "Touches in the box",
|
||||
corners: "Corners",
|
||||
offsides: "Offsides",
|
||||
fouls: "Fouls",
|
||||
tackles: "Tackles",
|
||||
interceptions: "Interceptions",
|
||||
blocks: "Blocked shots",
|
||||
clearances: "Clearances",
|
||||
saves: "Keeper saves",
|
||||
duelsWon: "Duels won",
|
||||
groundDuels: "Ground duels won",
|
||||
aerialDuels: "Aerial duels won",
|
||||
dribbles: "Dribbles completed",
|
||||
},
|
||||
oddsExtras: {
|
||||
ou: "Over/under {n} goals",
|
||||
spread: "spread {n}",
|
||||
},
|
||||
confirmedLineups: "Confirmed",
|
||||
whoScores: {
|
||||
title: "Who scores?",
|
||||
note: "DraftKings anytime-scorer lines, favorites first — a benchmark, never a model input.",
|
||||
},
|
||||
tabs: {
|
||||
summary: "Summary",
|
||||
timeline: "Timeline",
|
||||
@@ -290,6 +321,10 @@ export const en = {
|
||||
bigChances: "Big chances",
|
||||
goalsPerMatch: "Goals per match",
|
||||
concededPerMatch: "Conceded per match",
|
||||
xa: "Expected assists (xA)",
|
||||
chancesCreated: "Chances created",
|
||||
savePct: "Save percentage",
|
||||
savesPer90: "Saves per 90",
|
||||
},
|
||||
scatter: {
|
||||
title: "Attack vs defence",
|
||||
@@ -568,6 +603,28 @@ export const en = {
|
||||
final: "F",
|
||||
},
|
||||
},
|
||||
player: {
|
||||
unknown: "No tournament data for this player.",
|
||||
backToStats: "Back to stats",
|
||||
totalsTitle: "Tournament totals",
|
||||
matchLogTitle: "Match log",
|
||||
log: {
|
||||
opponent: "Opponent",
|
||||
min: "Min",
|
||||
rating: "Rating",
|
||||
goals: "G",
|
||||
assists: "A",
|
||||
},
|
||||
started: "Started",
|
||||
cameOn: "Came on",
|
||||
benched: "Unused sub",
|
||||
minutesNote: "Minutes are estimated from substitution times; extra time isn't counted.",
|
||||
shotMapTitle: "Every shot this tournament",
|
||||
shotMapNote: "All shots drawn attacking to the right. Dot size scales with xG; filled dots are goals.",
|
||||
nextOdds: "Anytime scorer vs {opponent}: {odds}",
|
||||
oddsNote: "DraftKings line — a benchmark, never a model input.",
|
||||
goalsLabel: "Goals",
|
||||
},
|
||||
};
|
||||
|
||||
export type Dict = typeof en;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -108,6 +108,10 @@ export interface OddsPoint {
|
||||
home_ml: number | null;
|
||||
draw_ml: number | null;
|
||||
away_ml: number | null;
|
||||
/** Total-goals line (display only — never a model input). */
|
||||
over_under: number | null;
|
||||
/** Goal handicap from the home side (display only). */
|
||||
spread: number | null;
|
||||
}
|
||||
|
||||
/** A point on the championship-odds-over-time chart. */
|
||||
@@ -182,6 +186,9 @@ export interface MatchPreview {
|
||||
injuries: { home: InjuryInfo[]; away: InjuryInfo[] };
|
||||
/** ESPN video clips for this match (open on ESPN). */
|
||||
videos: MatchVideo[];
|
||||
/** Bookmaker anytime-scorer board, favorites first — pre-match only,
|
||||
* display only (never a model input). */
|
||||
scorerProps: { player: string; odds: number }[];
|
||||
/** Honest note on which data layers are populated yet. */
|
||||
dataNote: string;
|
||||
updatedAt: number | null;
|
||||
@@ -227,6 +234,10 @@ export interface LineupTeam {
|
||||
|
||||
export interface MatchLineup { home: LineupTeam; away: LineupTeam }
|
||||
|
||||
/** One full-time stat row from the FotMob match report. Values keep the
|
||||
* provider's formatting ("467 (90%)"), so they render as-is. */
|
||||
export interface MatchFullStat { key: string; home: string; away: string }
|
||||
|
||||
export interface MatchRich {
|
||||
shots: RichShot[];
|
||||
xg: { home: number; away: number } | null;
|
||||
@@ -236,6 +247,10 @@ export interface MatchRich {
|
||||
potm: { name: string; rating: string | null } | null;
|
||||
info: { attendance: number | null; stadium: string; referee: string | null } | null;
|
||||
weather: { tempC: number | null; precipProbPct: number | null; windKmh: number | null; elevationM: number; tz: string } | null;
|
||||
/** Curated full-time stats (passes, duels, xGOT…); null until captured. */
|
||||
fullStats: MatchFullStat[] | null;
|
||||
/** Lineups are official (FIFA XI published / FotMob no longer "predicted"). */
|
||||
lineupConfirmed: boolean;
|
||||
}
|
||||
|
||||
export interface TeamProfile {
|
||||
@@ -253,6 +268,43 @@ export interface TeamProfile {
|
||||
injuries: InjuryInfo[];
|
||||
}
|
||||
|
||||
// ---- player profiles (/api/player) ----
|
||||
|
||||
/** One fixture from the player's perspective — a row in his match log. */
|
||||
export interface PlayerMatchLine {
|
||||
num: number;
|
||||
opponent: string;
|
||||
side: 'home' | 'away';
|
||||
kickoff: string;
|
||||
homeScore: number | null;
|
||||
awayScore: number | null;
|
||||
started: boolean;
|
||||
/** 90-based approximation from substitution times (no extra-time awareness). */
|
||||
minutes: number | null;
|
||||
rating: number | null;
|
||||
goals: number;
|
||||
assists: number;
|
||||
yellows: number;
|
||||
reds: number;
|
||||
}
|
||||
|
||||
export interface PlayerProfile {
|
||||
/** Canonical display form (FotMob's, when the boards know the player). */
|
||||
name: string;
|
||||
team: string;
|
||||
/** Tournament totals by stats-hub category ('goals', 'rating', 'xg', …). */
|
||||
totals: Record<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) ----
|
||||
|
||||
/** One row on a stat leaderboard (player or team board). */
|
||||
|
||||
+2
-1
@@ -23,13 +23,14 @@ const statsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/stats'
|
||||
const storyRoute = createRoute({ getParentRoute: () => rootRoute, path: '/story', component: lazyRouteComponent(() => import('./features/story/StoryPage'), 'StoryPage') });
|
||||
const matchRoute = createRoute({ getParentRoute: () => rootRoute, path: '/match/$num', component: MatchPreviewPage });
|
||||
const teamRoute = createRoute({ getParentRoute: () => rootRoute, path: '/team/$name', component: TeamProfilePage });
|
||||
const playerRoute = createRoute({ getParentRoute: () => rootRoute, path: '/player/$name', component: lazyRouteComponent(() => import('./features/player/PlayerPage'), 'PlayerPage') });
|
||||
const methodologyRoute = createRoute({ getParentRoute: () => rootRoute, path: '/methodology', component: lazyRouteComponent(() => import('./features/methodology/MethodologyPage'), 'MethodologyPage') });
|
||||
const teamsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/teams', component: TeamsPage });
|
||||
const scoreboardRoute = createRoute({ getParentRoute: () => rootRoute, path: '/scoreboard', component: ScoreboardPage });
|
||||
const dataRoute = createRoute({ getParentRoute: () => rootRoute, path: '/data', component: lazyRouteComponent(() => import('./features/data/DataPage'), 'DataPage') });
|
||||
const compareRoute = createRoute({ getParentRoute: () => rootRoute, path: '/compare', component: lazyRouteComponent(() => import('./features/compare/ComparePage'), 'ComparePage') });
|
||||
|
||||
const routeTree = rootRoute.addChildren([indexRoute, groupsRoute, bracketRoute, predictRoute, statsRoute, storyRoute, matchRoute, teamRoute, methodologyRoute, teamsRoute, scoreboardRoute, dataRoute, compareRoute]);
|
||||
const routeTree = rootRoute.addChildren([indexRoute, groupsRoute, bracketRoute, predictRoute, statsRoute, storyRoute, matchRoute, teamRoute, playerRoute, methodologyRoute, teamsRoute, scoreboardRoute, dataRoute, compareRoute]);
|
||||
|
||||
export const router = createRouter({ routeTree, defaultPreload: 'intent' });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user