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
+12
View File
@@ -8,7 +8,9 @@ import type { WebSocket } from 'ws';
import { TournamentState } from './tournament';
import { startScheduler } from './ingest/scheduler';
import { ModelEngine } from './model';
import { buildPreview, buildTeamProfile } from './preview';
import { db, healthAll } from './db/db';
import { canonicalTeam } from '../../src/lib/teams';
import type { MatchStatus, ServerMessage } from '../../src/lib/types';
const PORT = Number(process.env.PORT ?? 8787);
@@ -55,6 +57,16 @@ export function buildServer() {
app.get('/api/snapshot', async () => state.snapshot());
app.get('/api/predictions', async () => model.current());
app.get('/api/sources/health', async () => ({ sources: healthAll() }));
app.get('/api/preview/:num', async (req, reply) => {
const num = Number((req.params as { num: string }).num);
const preview = Number.isFinite(num) ? buildPreview(num, state, model) : null;
return preview ?? reply.code(404).send({ error: 'no such fixture' });
});
app.get('/api/team/:name', async (req, reply) => {
const name = canonicalTeam(decodeURIComponent((req.params as { name: string }).name));
const profile = buildTeamProfile(name, state, model);
return profile ?? reply.code(404).send({ error: 'no such team' });
});
app.get('/healthz', async () => ({ ok: true }));
// ---- dev-only: inject a score to exercise the live → standings → WS loop ----
+117
View File
@@ -0,0 +1,117 @@
import { readFileSync, existsSync } from 'node:fs';
import { getMatchExt } from './db/db';
import type { TournamentState } from './tournament';
import type { ModelEngine } from './model';
import type { EspnSummary } from './ingest/espnNormalize';
import type {
H2HSummary, KeyPlayer, MatchPreview, StandingRow, TeamProfile,
} from '../../src/lib/types';
// Assembles a MatchPreview / TeamProfile from three layers: precomputed history
// (h2h.json, scorers.json), the DB's ESPN enrichment (form, lineups, venue), and
// the live model (prediction, odds, ratings).
interface H2HRecord {
a: string; b: string;
games: number; aWins: number; bWins: number; draws: number; aGoals: number; bGoals: number;
last: { date: string; home: string; away: string; hs: number; as: number }[];
}
function loadStatic<T>(name: string): T {
const candidates = [
`${process.env.STATIC_DIR ?? ''}/data/${name}`,
`${process.cwd()}/public/data/${name}`,
`${process.cwd()}/dist/data/${name}`,
];
for (const p of candidates) if (p && existsSync(p)) return JSON.parse(readFileSync(p, 'utf8')) as T;
throw new Error(`${name} not found — run "npm run data:preview". Looked in: ${candidates.join(', ')}`);
}
let _h2h: Record<string, H2HRecord> | null = null;
let _scorers: Record<string, KeyPlayer[]> | null = null;
let _ratings: { ratings: Record<string, number> } | null = null;
const h2hData = () => (_h2h ??= loadStatic<Record<string, H2HRecord>>('h2h.json'));
const scorersData = () => (_scorers ??= loadStatic<Record<string, KeyPlayer[]>>('scorers.json'));
const ratingsData = () => (_ratings ??= loadStatic<{ ratings: Record<string, number> }>('ratings.json'));
const pairKey = (a: string, b: string) => [a, b].sort().join('|');
function h2hSummary(home: string, away: string): H2HSummary | null {
const rec = h2hData()[pairKey(home, away)];
if (!rec) return null;
const homeIsA = home === rec.a;
return {
games: rec.games,
homeWins: homeIsA ? rec.aWins : rec.bWins,
awayWins: homeIsA ? rec.bWins : rec.aWins,
draws: rec.draws,
homeGoals: homeIsA ? rec.aGoals : rec.bGoals,
awayGoals: homeIsA ? rec.bGoals : rec.aGoals,
last: rec.last.map((m) => ({ date: m.date, home: m.home, away: m.away, homeScore: m.hs, awayScore: m.as })),
};
}
export function buildPreview(num: number, state: TournamentState, model: ModelEngine): MatchPreview | null {
const fixture = state.getFixture(num);
if (!fixture) return null;
const home = fixture.home.team;
const away = fixture.away.team;
const ext = getMatchExt<EspnSummary>(num);
const summary = ext?.data;
// map ESPN data by canonical team name (robust to home/away orientation)
const espnFor = (team: string | null) => (team && summary ? summary.teams.find((t) => t.name === team) : undefined);
const eh = espnFor(home);
const ea = espnFor(away);
const prediction = model.current().matches.find((m) => m.num === num) ?? null;
return {
num,
fixture,
venue: summary?.venue ?? fixture.venue,
prediction,
form: {
home: eh ? summary!.form[eh.id] ?? [] : [],
away: ea ? summary!.form[ea.id] ?? [] : [],
},
h2h: home && away ? h2hSummary(home, away) : null,
lineups:
eh && ea && (summary!.lineups[eh.id]?.length || summary!.lineups[ea.id]?.length)
? { home: summary!.lineups[eh.id] ?? [], away: summary!.lineups[ea.id] ?? [] }
: null,
keyPlayers: {
home: home ? scorersData()[home] ?? [] : [],
away: away ? scorersData()[away] ?? [] : [],
},
dataNote: !home || !away
? 'Knockout participants are decided once the bracket resolves.'
: ext
? 'Live form, lineups and stats from ESPN; head-to-head and key players from 150 years of results.'
: 'Head-to-head and key players from history; live form & lineups appear closer to kickoff.',
updatedAt: ext?.updatedAt ?? null,
};
}
export function buildTeamProfile(teamRaw: string, state: TournamentState, model: ModelEngine): TeamProfile | null {
const snap = state.snapshot();
const fixtures = snap.fixtures.filter((f) => f.home.team === teamRaw || f.away.team === teamRaw);
if (!fixtures.length) return null;
const group = fixtures.find((f) => f.group)?.group ?? null;
const standing: StandingRow | null =
group ? snap.tables[group]?.find((r) => r.team === teamRaw) ?? null : null;
const odds = model.current().odds.find((o) => o.team === teamRaw) ?? null;
return {
team: teamRaw,
elo: Math.round(ratingsData().ratings[teamRaw] ?? 1500),
group,
standing,
fixtures,
keyPlayers: scorersData()[teamRaw] ?? [],
championOdds: odds?.champion ?? null,
qualifyOdds: odds?.qualify ?? null,
};
}