Tier 2: shootout panel, road to the final, ESPN videos, ICS export, injuries plumbing

- Penalty shootout panel: shootout-flagged events get a kicker-by-kicker
  dot strip on the Summary tab (and stay OFF the timeline/running score).
  Parsing is defensive — validate against the first real shootout June 28.
- Road to the final on team profiles: the model's projected knockout run
  (teamPath in whatif.ts forces the team through the model bracket) with
  per-round reach probabilities and a title-chance chip. Teams the model
  doesn't project through still get a hypothetical runner-up road.
- Match videos: ESPN summaries carry per-match clips (SUMMARY_V=5 keeps
  headline/duration/thumbnail/link) — a Videos card on the Summary tab,
  clips open on ESPN. Tokenless; replaces the parked ScoreBat idea.
- ICS calendar export: /api/calendar.ics[?team=X] (RFC 5545, escaped,
  folded, CRLF) + an Add-to-calendar chip on team pages.
- Injuries plumbing (dormant until API_FOOTBALL_KEY is set): twice-daily
  API-Football sweep into the injuries table, surfaced on team profiles
  and the match availability banner next to suspensions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 15:57:17 +02:00
parent f2203ed4a4
commit f0e0f7390d
19 changed files with 636 additions and 13 deletions
+24
View File
@@ -214,6 +214,30 @@ export function scorerPropsFor(fixtureNum: number): { provider: string; market:
.all(fixtureNum) as unknown as { provider: string; market: string; athlete: string; odds: number | null; captured_at: number }[];
}
// ---- injuries (API-Football daily sweep; table is the single source) ----
export interface InjuryDbRow { team: string; player: string; status: string | null; reason: string | null }
export function replaceInjuries(rows: InjuryDbRow[]): void {
const d = db();
d.exec('BEGIN');
try {
d.prepare('DELETE FROM injuries').run();
const ins = d.prepare('INSERT INTO injuries (team, player, status, reason, return_date, updated_at) VALUES (?, ?, ?, ?, NULL, ?)');
const now = Date.now();
for (const r of rows) ins.run(r.team, r.player, r.status, r.reason, now);
d.exec('COMMIT');
} catch (e) {
d.exec('ROLLBACK');
throw e;
}
}
export function injuriesFor(team: string): { player: string; status: string | null; reason: string | null }[] {
return db()
.prepare('SELECT player, status, reason FROM injuries WHERE team = ? ORDER BY player')
.all(team) as unknown as { player: string; status: string | null; reason: string | null }[];
}
// ---- Web Push subscriptions (goal/kickoff notifications, opt-in per team) ----
export interface PushSub {
endpoint: string;
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { buildIcs } from './ics';
import type { Fixture } from '../../src/lib/types';
const fixture = (over: Partial<Fixture> = {}): Fixture => ({
num: 1, stage: 'group', group: 'A', round: 'Matchday 1', groupRound: 1,
kickoff: '2026-06-11T19:00:00Z', venue: 'Estadio Azteca',
home: { team: 'Mexico', placeholder: null, label: 'Mexico' },
away: { team: 'South Africa', placeholder: null, label: 'South Africa' },
status: 'scheduled', homeScore: null, awayScore: null, minute: null,
...over,
});
describe('buildIcs', () => {
it('builds a calendar with stable UIDs, UTC times and CRLF endings', () => {
const ics = buildIcs([fixture()], 'Cup26 — Mexico', 1765000000000);
expect(ics).toContain('BEGIN:VCALENDAR');
expect(ics).toContain('X-WR-CALNAME:Cup26 — Mexico');
expect(ics).toContain('UID:cup26-m1@cup26.briggen.dev');
expect(ics).toContain('DTSTART:20260611T190000Z');
expect(ics).toContain('DTEND:20260611T210000Z');
expect(ics).toContain('SUMMARY:Mexico vs South Africa — Matchday 1');
expect(ics).toContain('URL:https://cup26.briggen.dev/match/1');
expect(ics.endsWith('END:VCALENDAR\r\n')).toBe(true);
expect(ics.includes('\n') && !ics.includes('\r\n')).toBe(false); // CRLF only
});
it('escapes commas and semicolons in text fields', () => {
const ics = buildIcs([fixture({ venue: 'Estadio; Azteca, CDMX' })], 'X', 0);
expect(ics).toContain('LOCATION:Estadio\\; Azteca\\, CDMX');
});
it('folds long lines with a leading-space continuation', () => {
const long = fixture({ round: 'A very long round label that pushes the summary line well past the seventy-four octet folding limit' });
const ics = buildIcs([long], 'X', 0);
const folded = ics.split('\r\n').find((l) => l.startsWith(' '));
expect(folded).toBeDefined();
expect(ics.split('\r\n').every((l) => l.length <= 74)).toBe(true);
});
it('sorts events by kickoff', () => {
const ics = buildIcs(
[fixture({ num: 2, kickoff: '2026-06-13T01:00:00Z' }), fixture({ num: 1 })],
'X', 0,
);
expect(ics.indexOf('UID:cup26-m1@')).toBeLessThan(ics.indexOf('UID:cup26-m2@'));
});
});
+54
View File
@@ -0,0 +1,54 @@
import type { Fixture } from '../../src/lib/types';
// RFC 5545 calendar export — pure string building, no deps, vitest-safe.
const SITE = 'https://cup26.briggen.dev';
const MATCH_MS = 2 * 60 * 60 * 1000; // block out two hours per match
/** TEXT escaping per RFC 5545 §3.3.11. */
function esc(s: string): string {
return s.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n');
}
/** Fold long content lines at 74 octets (continuation lines start with a space). */
function fold(line: string): string {
const out: string[] = [];
let rest = line;
while (rest.length > 74) {
out.push(rest.slice(0, 74));
rest = ` ${rest.slice(74)}`;
}
out.push(rest);
return out.join('\r\n');
}
const stamp = (ms: number): string => new Date(ms).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
/** A VCALENDAR of the given fixtures (a team's schedule, or the whole cup). */
export function buildIcs(fixtures: Fixture[], calName: string, now: number): string {
const lines: string[] = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//cup26//worldcup 2026//EN',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
fold(`X-WR-CALNAME:${esc(calName)}`),
];
for (const f of [...fixtures].sort((a, b) => a.kickoff.localeCompare(b.kickoff))) {
const ko = new Date(f.kickoff).getTime();
if (!Number.isFinite(ko)) continue;
lines.push(
'BEGIN:VEVENT',
`UID:cup26-m${f.num}@cup26.briggen.dev`,
`DTSTAMP:${stamp(now)}`,
`DTSTART:${stamp(ko)}`,
`DTEND:${stamp(ko + MATCH_MS)}`,
fold(`SUMMARY:${esc(`${f.home.label} vs ${f.away.label}${f.round}`)}`),
fold(`LOCATION:${esc(f.venue)}`),
fold(`URL:${SITE}/match/${f.num}`),
'END:VEVENT',
);
}
lines.push('END:VCALENDAR');
return `${lines.join('\r\n')}\r\n`;
}
+13
View File
@@ -11,6 +11,7 @@ import { ModelEngine } from './model';
import { buildPreview, buildTeamProfile } from './preview';
import { buildScoreboard, snapshotCheck } from './scoreboard';
import { buildStatsHub, fixtureEvents, goldenBootRows } from './stats';
import { buildIcs } from './ics';
import { db, healthAll, oddsFor, latestOddsAll, dbCounts, inplayHistory, recordInplay, getMatchProvider, allFixtureResults, saveFixtureResult, allModelHistory, saveModelHistory } from './db/db';
import { inPlayProbs } from '../../src/lib/model/inplay';
import { redCards } from '../../src/lib/discipline';
@@ -251,6 +252,18 @@ export function buildServer() {
const profile = buildTeamProfile(name, state, model);
return profile ?? reply.code(404).send({ error: 'no such team' });
});
// Calendar export: one team's schedule (?team=X) or the whole tournament.
app.get('/api/calendar.ics', async (req, reply) => {
const raw = (req.query as { team?: string }).team;
const team = raw ? canonicalTeam(raw) : null;
const fixtures = state.allFixtures().filter((f) => !team || f.home.team === team || f.away.team === team);
if (team && !fixtures.length) return reply.code(404).send({ error: 'no such team' });
const slug = team ? team.toLowerCase().replace(/[^a-z0-9]+/g, '-') : 'world-cup-2026';
return reply
.type('text/calendar; charset=utf-8')
.header('Content-Disposition', `attachment; filename="cup26-${slug}.ics"`)
.send(buildIcs(fixtures, team ? `Cup26 — ${team}` : 'Cup26 — World Cup 2026', Date.now()));
});
app.get('/healthz', async () => ({ ok: true }));
// ---- dev-only: inject a score to exercise the live → standings → WS loop ----
+32
View File
@@ -0,0 +1,32 @@
import { cachedFetch } from './fetcher';
import { normalizeInjuries, type InjuryRow, type RawInjuriesPage } from './apiFootballNormalize';
// API-Football (api-sports.io) injuries feed — the one source with structured
// availability data for the World Cup. Key-gated: the free tier (100 req/day)
// is plenty for a twice-daily sweep, but the user must create the key, so
// everything here stays dormant until API_FOOTBALL_KEY is set in the env.
const KEY = process.env.API_FOOTBALL_KEY?.trim();
const SOURCE = 'api-football';
const BASE = 'https://v3.football.api-sports.io';
const WC_LEAGUE = 1; // API-Football league id for the FIFA World Cup
const SEASON = 2026;
export const apiFootballEnabled = !!KEY;
/** Current injury/unavailability list for the whole tournament. */
export async function fetchWcInjuries(): Promise<InjuryRow[]> {
if (!KEY) return [];
const pages: RawInjuriesPage[] = [];
for (let page = 1; page <= 3; page++) {
const d = await cachedFetch<RawInjuriesPage>(
SOURCE,
`apifb:injuries:${page}`,
`${BASE}/injuries?league=${WC_LEAGUE}&season=${SEASON}&page=${page}`,
{ ttl: 11 * 60 * 60_000, minInterval: 6_000, headers: { 'x-apisports-key': KEY } },
);
pages.push(d);
if ((d.paging?.total ?? 1) <= page) break;
}
return normalizeInjuries(pages);
}
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { normalizeInjuries } from './apiFootballNormalize';
describe('normalizeInjuries', () => {
it('flattens, canonicalizes the team and dedupes per-fixture repeats', () => {
const rows = normalizeInjuries([
{
response: [
{ player: { name: 'Kim Min-Jae', type: 'Missing Fixture', reason: 'Calf Injury' }, team: { name: 'Korea Republic' } },
{ player: { name: 'Kim Min-Jae', type: 'Questionable', reason: 'Calf Injury' }, team: { name: 'Korea Republic' } },
],
},
{ response: [{ player: { name: 'Hirving Lozano', reason: 'Knock' }, team: { name: 'Mexico' } }] },
]);
expect(rows).toEqual([
{ team: 'Mexico', player: 'Hirving Lozano', status: null, reason: 'Knock' },
// the later entry wins; FotMob/API-Football "Korea Republic" is our "South Korea"
{ team: 'South Korea', player: 'Kim Min-Jae', status: 'Questionable', reason: 'Calf Injury' },
]);
});
it('drops items without a player or team and survives empty pages', () => {
expect(normalizeInjuries([{ response: [{ player: { name: ' ' } }, {}] }, {}])).toEqual([]);
});
});
+34
View File
@@ -0,0 +1,34 @@
import { canonicalTeam } from '../../../src/lib/teams';
import type { InjuryInfo } from '../../../src/lib/types';
// Pure API-Football injuries normalizer — no network or DB (espnNormalize pattern).
export interface RawInjuryItem {
player?: { name?: string; type?: string; reason?: string };
team?: { name?: string };
}
export interface RawInjuriesPage {
response?: RawInjuryItem[];
paging?: { current?: number; total?: number };
}
export interface InjuryRow extends InjuryInfo { team: string }
/** Flatten + dedupe (the feed repeats players across their team's fixtures). */
export function normalizeInjuries(pages: RawInjuriesPage[]): InjuryRow[] {
const byKey = new Map<string, InjuryRow>();
for (const page of pages) {
for (const item of page.response ?? []) {
const player = item.player?.name?.trim();
const team = canonicalTeam(item.team?.name ?? '');
if (!player || !team) continue;
byKey.set(`${team}|${player.toLowerCase()}`, {
team,
player,
status: item.player?.type?.trim() || null,
reason: item.player?.reason?.trim() || null,
});
}
}
return [...byKey.values()].sort((a, b) => a.team.localeCompare(b.team) || a.player.localeCompare(b.player));
}
+15 -2
View File
@@ -75,10 +75,12 @@ export interface EspnTimelineEvent {
shootout?: boolean;
}
/** Current normalizer schema — bump to make the boot backfill re-store. */
export const SUMMARY_V = 4;
export const SUMMARY_V = 5;
export interface EspnCommentaryLine { minute: number | null; text: string }
export interface EspnVideo { headline: string; duration: number | null; thumbnail: string | null; href: string }
export interface EspnSummary {
/** Normalizer schema version — bump to trigger the boot backfill. */
v?: number;
@@ -98,12 +100,15 @@ export interface EspnSummary {
/** Post-match report article (the narrative — why cards were shown, how
* goals came about). Plain text, appears around full time. */
report?: { headline: string; story: string } | null;
/** Match video clips (highlights, reactions) — links open on ESPN. */
videos?: EspnVideo[];
}
export interface RawSummary {
gameInfo?: { venue?: { fullName?: string } };
commentary?: { sequence?: number | string; time?: { displayValue?: string }; text?: string }[];
article?: { headline?: string; story?: string };
videos?: { headline?: string; duration?: number; thumbnail?: string; links?: { web?: { href?: string } } }[];
header?: { competitions?: { competitors?: { id: string; team?: { displayName?: string }; homeAway: 'home' | 'away' }[] }[] };
headToHeadGames?: { events?: { gameDate: string; homeTeamId: string; awayTeamId: string; homeTeamScore: string; awayTeamScore: string }[] }[];
boxscore?: {
@@ -193,5 +198,13 @@ export function normalizeSummary(raw: RawSummary): EspnSummary {
.slice(0, 8000);
const report = story ? { headline: raw.article?.headline ?? '', story } : null;
return { v: SUMMARY_V, fetchedAt: Date.now(), venue: raw.gameInfo?.venue?.fullName ?? null, teams, h2h, form, lineups, stats, events, commentary, report };
const videos: EspnVideo[] = (raw.videos ?? [])
.flatMap((v) => {
const href = v.links?.web?.href;
if (!href || !v.headline) return [];
return [{ headline: v.headline, duration: typeof v.duration === 'number' ? v.duration : null, thumbnail: v.thumbnail ?? null, href }];
})
.slice(0, 6);
return { v: SUMMARY_V, fetchedAt: Date.now(), venue: raw.gameInfo?.venue?.fullName ?? null, teams, h2h, form, lineups, stats, events, commentary, report, videos };
}
+20 -2
View File
@@ -4,8 +4,9 @@ import { fetchEspnScoreboard, fetchEspnSummary } from './espn';
import { fetchEspnOdds } from './espnOdds';
import { fetchFootballData } from '../footballData';
import { fetchSofascore } from '../sofascore';
import { setSourceMap, getSourceMap, getMatchExt, setMatchExt, setMatchProvider, getMatchProvider, recordOdds, recordScorerProp, logIngest, prune, backupDb, saveFixtureResult } from '../db/db';
import { setSourceMap, getSourceMap, getMatchExt, setMatchExt, setMatchProvider, getMatchProvider, recordOdds, recordScorerProp, logIngest, prune, backupDb, saveFixtureResult, replaceInjuries } from '../db/db';
import { fetchFotmobDetails, fetchFotmobMatches } from './fotmob';
import { apiFootballEnabled, fetchWcInjuries } from './apiFootball';
import { refreshTournamentStats } from './tournamentStats';
import { fetchFifaCalendar, fetchFifaLive, fetchFifaTimeline } from './fifa';
import { fetchMatchWeather } from './weather';
@@ -40,7 +41,7 @@ export function startScheduler(
let liveTimer: ReturnType<typeof setTimeout> | undefined;
let enrichTimer: ReturnType<typeof setTimeout> | undefined;
console.log(`[ingest] scheduler — espn:on football-data:${token ? 'on' : 'off'} sofascore:${sofa ? 'on' : 'off'}`);
console.log(`[ingest] scheduler — espn:on football-data:${token ? 'on' : 'off'} sofascore:${sofa ? 'on' : 'off'} api-football:${apiFootballEnabled ? 'on' : 'off'}`);
// ---- one-time: map every fixture to its ESPN event id (across all dates).
// Mappings persist in source_map, so on restarts we only fetch dates that
@@ -267,6 +268,21 @@ export function startScheduler(
statsTimer = setTimeout(async () => { await statsTick().catch(() => {}); scheduleStats(); }, state.hasLive() ? 20 * 60_000 : 60 * 60_000);
};
// ---- injuries (API-Football, key-gated): a twice-daily sweep is plenty
// and stays far inside the free tier's 100 requests/day.
const injuriesTick = async (): Promise<void> => {
if (!apiFootballEnabled) return;
const t0 = Date.now();
try {
const rows = await fetchWcInjuries();
replaceInjuries(rows);
logIngest('api-football', 'injuries', true, Date.now() - t0, `${rows.length} players`);
} catch (e) {
logIngest('api-football', 'injuries', false, Date.now() - t0, e instanceof Error ? e.message : String(e));
}
};
const injuriesTimer = apiFootballEnabled ? setInterval(() => { void injuriesTick(); }, 12 * 60 * 60 * 1000) : undefined;
// ---- FIFA official: confirmed lineups, XY timelines, attendance/officials.
const fifaTick = async (): Promise<void> => {
const now = Date.now();
@@ -403,6 +419,7 @@ export function startScheduler(
scheduleFotmob();
await statsTick().catch(() => {});
scheduleStats();
await injuriesTick().catch(() => {});
await fifaTick().catch(() => {});
scheduleFifa();
await oddsTick().catch(() => {});
@@ -421,6 +438,7 @@ export function startScheduler(
if (fotmobTimer) clearTimeout(fotmobTimer);
if (statsTimer) clearTimeout(statsTimer);
if (fifaTimer) clearTimeout(fifaTimer);
if (injuriesTimer) clearInterval(injuriesTimer);
clearInterval(pruneTimer);
};
}
+8 -1
View File
@@ -1,5 +1,5 @@
import { readFileSync, existsSync } from 'node:fs';
import { getMatchExt, snapshotFor } from './db/db';
import { getMatchExt, injuriesFor, snapshotFor } from './db/db';
import { disciplineFor } from './stats';
import type { TournamentState } from './tournament';
import type { ModelEngine } from './model';
@@ -138,11 +138,17 @@ export function buildPreview(num: number, state: TournamentState, model: ModelEn
type: e.type,
text: e.text,
team: e.teamId === eh?.id ? 'home' : e.teamId === ea?.id ? 'away' : null,
...(e.shootout ? { shootout: true } : {}),
})),
suspensions: {
home: home ? disciplineFor(home, state).filter((p) => p.suspendedFor === num).map((p) => p.player) : [],
away: away ? disciplineFor(away, state).filter((p) => p.suspendedFor === num).map((p) => p.player) : [],
},
injuries: {
home: home ? injuriesFor(home) : [],
away: away ? injuriesFor(away) : [],
},
videos: summary?.videos ?? [],
dataNote: !home || !away
? 'Knockout participants are decided once the bracket resolves.'
: ext
@@ -172,5 +178,6 @@ export function buildTeamProfile(teamRaw: string, state: TournamentState, model:
championOdds: odds?.champion ?? null,
qualifyOdds: odds?.qualify ?? null,
discipline: disciplineFor(teamRaw, state).filter((p) => p.yellows + p.reds > 0),
injuries: injuriesFor(teamRaw),
};
}