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:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user