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:
2026-06-11 15:34:54 +02:00
parent b6f62679c9
commit 7f4838d032
12 changed files with 48216 additions and 5 deletions
+94
View File
@@ -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();