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 ----