v2 Phase 2: match previews + team profiles
- buildPreviewData.ts: precomputes from 150y of results → h2h.json (825 WC-team pairings, full records + recent meetings) + scorers.json (top-10 all-time scorers per team). Committed goalscorers.csv. - server/src/preview.ts: assembles MatchPreview / TeamProfile from three layers — historical (h2h, scorers), DB enrichment (ESPN form/lineups/venue), live model (W/D/L, xG, odds, Elo). /api/preview/:num + /api/team/:name. - Client: rich /match/:num page (model expectation, recent form chips, H2H record + recent meetings, lineups-or-key-players) and /team/:name profile (Elo, odds, standing, fixtures, all-time scorers). Match cards now link to previews; cross-linking between matches and teams. - Verified: Mexico-SA preview (81% W, 2-0, form, H2H 2-1-1, Borgetti 37) and Argentina profile (Messi 63) render; 22 tests pass; build clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
// international-results.csv + goalscorers.csv → public/data/{h2h.json,scorers.json}
|
||||
// Precomputes match-preview inputs that need the full historical corpus: every
|
||||
// World-Cup-team pairing's head-to-head record, and each team's top historical
|
||||
// scorers. ESPN supplies live form/lineups/venue at runtime; these are the deep
|
||||
// historical layers.
|
||||
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { canonicalTeam, ALL_TEAMS } from '../src/lib/teams';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const RESULTS = join(ROOT, 'data', 'raw', 'international-results.csv');
|
||||
const SCORERS = join(ROOT, 'data', 'raw', 'goalscorers.csv');
|
||||
const OUT_DIR = join(ROOT, 'public', 'data');
|
||||
|
||||
const WC = new Set(ALL_TEAMS);
|
||||
const pairKey = (a: string, b: string) => [a, b].sort().join('|');
|
||||
|
||||
interface H2HMeeting { date: string; home: string; away: string; hs: number; as: number }
|
||||
interface H2HRecord {
|
||||
a: string; b: string;
|
||||
games: number; aWins: number; bWins: number; draws: number; aGoals: number; bGoals: number;
|
||||
last: H2HMeeting[];
|
||||
}
|
||||
|
||||
function buildH2H(): Record<string, H2HRecord> {
|
||||
const lines = readFileSync(RESULTS, 'utf8').split('\n');
|
||||
const rec: Record<string, H2HRecord> = {};
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const c = lines[i]?.split(',');
|
||||
if (!c || c.length < 9) continue;
|
||||
const hs = Number(c[3]); const as = Number(c[4]);
|
||||
if (!Number.isFinite(hs) || !Number.isFinite(as)) continue;
|
||||
const home = canonicalTeam(c[1]!); const away = canonicalTeam(c[2]!);
|
||||
if (!WC.has(home) || !WC.has(away)) continue;
|
||||
const [a, b] = [home, away].sort() as [string, string];
|
||||
const key = pairKey(home, away);
|
||||
let r = rec[key];
|
||||
if (!r) { r = { a, b, games: 0, aWins: 0, bWins: 0, draws: 0, aGoals: 0, bGoals: 0, last: [] }; rec[key] = r; }
|
||||
// goals from a's/b's perspective
|
||||
const aIsHome = home === a;
|
||||
const aScore = aIsHome ? hs : as;
|
||||
const bScore = aIsHome ? as : hs;
|
||||
r.games++; r.aGoals += aScore; r.bGoals += bScore;
|
||||
if (aScore > bScore) r.aWins++; else if (aScore < bScore) r.bWins++; else r.draws++;
|
||||
r.last.push({ date: c[0]!, home, away, hs, as });
|
||||
}
|
||||
// keep only the 6 most recent meetings per pair
|
||||
for (const r of Object.values(rec)) {
|
||||
r.last.sort((x, y) => y.date.localeCompare(x.date));
|
||||
r.last = r.last.slice(0, 6);
|
||||
}
|
||||
return rec;
|
||||
}
|
||||
|
||||
interface Scorer { name: string; goals: number; pens: number }
|
||||
|
||||
function buildScorers(): Record<string, Scorer[]> {
|
||||
const lines = readFileSync(SCORERS, 'utf8').split('\n');
|
||||
const byTeam = new Map<string, Map<string, { goals: number; pens: number }>>();
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const c = lines[i]?.split(',');
|
||||
if (!c || c.length < 8) continue;
|
||||
const team = canonicalTeam(c[3]!);
|
||||
const scorer = c[4];
|
||||
if (!scorer || !WC.has(team)) continue;
|
||||
if ((c[6] ?? '').toUpperCase() === 'TRUE') continue; // own goal
|
||||
const pen = (c[7] ?? '').toUpperCase() === 'TRUE';
|
||||
if (!byTeam.has(team)) byTeam.set(team, new Map());
|
||||
const m = byTeam.get(team)!;
|
||||
const e = m.get(scorer) ?? { goals: 0, pens: 0 };
|
||||
e.goals++; if (pen) e.pens++;
|
||||
m.set(scorer, e);
|
||||
}
|
||||
const out: Record<string, Scorer[]> = {};
|
||||
for (const [team, m] of byTeam) {
|
||||
out[team] = [...m.entries()]
|
||||
.map(([name, v]) => ({ name, goals: v.goals, pens: v.pens }))
|
||||
.sort((x, y) => y.goals - x.goals)
|
||||
.slice(0, 10);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const h2h = buildH2H();
|
||||
const scorers = buildScorers();
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
writeFileSync(join(OUT_DIR, 'h2h.json'), JSON.stringify(h2h));
|
||||
writeFileSync(join(OUT_DIR, 'scorers.json'), JSON.stringify(scorers));
|
||||
console.log(`preview data → h2h.json (${Object.keys(h2h).length} pairings), scorers.json (${Object.keys(scorers).length} teams)`);
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user