7f4838d032
- 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>
95 lines
3.9 KiB
TypeScript
95 lines
3.9 KiB
TypeScript
// 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();
|