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:
@@ -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;
|
||||
|
||||
@@ -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@'));
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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 ----
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useParams } from '@tanstack/react-router';
|
||||
import { ArrowLeft, ArrowRight, Ban, Shield, Shirt } from 'lucide-react';
|
||||
import { ArrowLeft, ArrowRight, Ban, Clapperboard, Goal, Shield, Shirt, Stethoscope } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
||||
import { WinProbBar } from '@/components/WinProbBar';
|
||||
@@ -14,7 +14,7 @@ import { EventIcon, MatchTimeline } from '@/components/MatchTimeline';
|
||||
import { LineupPitch } from '@/components/LineupPitch';
|
||||
import { teamFlag } from '@/lib/teams';
|
||||
import { fmt, useFormat, useT } from '@/lib/i18n';
|
||||
import { goalEvents } from '@/lib/matchEvents';
|
||||
import { goalEvents, shootoutKicks } from '@/lib/matchEvents';
|
||||
import { redCards } from '@/lib/discipline';
|
||||
import { inPlayProbs } from '@/lib/model/inplay';
|
||||
import { useTournamentStore } from '@/stores/tournamentStore';
|
||||
@@ -162,6 +162,7 @@ export function MatchPreviewPage() {
|
||||
], [t]);
|
||||
|
||||
const goals = useMemo(() => (preview ? goalEvents(preview.events) : []), [preview]);
|
||||
const kicks = useMemo(() => (preview ? shootoutKicks(preview.events) : []), [preview]);
|
||||
|
||||
if (failed) return <div className="py-16 text-center text-muted">{t.match.loadError} <Link to="/" className="text-accent">{t.match.backToLive}</Link></div>;
|
||||
if (!preview || !f) return <div className="h-96 animate-pulse rounded-2xl border border-line bg-panel" />;
|
||||
@@ -347,6 +348,40 @@ export function MatchPreviewPage() {
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
{kicks.length > 0 && (
|
||||
<Card className="border-gold/40">
|
||||
<CardHeader className="flex items-center gap-2">
|
||||
<Goal size={16} className="text-gold" />
|
||||
<span className="font-display font-bold text-ink">{t.match.shootoutTitle}</span>
|
||||
</CardHeader>
|
||||
<CardBody className="space-y-2">
|
||||
{(['home', 'away'] as const).map((side) => {
|
||||
const sideKicks = kicks.filter((k) => k.side === side);
|
||||
if (!sideKicks.length) return null;
|
||||
const team = side === 'home' ? f.home.team : f.away.team;
|
||||
return (
|
||||
<div key={side} className="flex items-center gap-2.5 text-sm">
|
||||
<span className="flex w-32 shrink-0 items-center gap-1.5 font-medium text-ink">
|
||||
{team && <span aria-hidden>{teamFlag(team)}</span>}
|
||||
<span className="truncate">{side === 'home' ? homeName : awayName}</span>
|
||||
</span>
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
{sideKicks.map((k, i) => (
|
||||
<span
|
||||
key={i}
|
||||
title={k.player ?? ''}
|
||||
className={`h-3 w-3 rounded-full ${k.scored ? 'bg-accent' : 'border-2 border-loss'}`}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<span className="tnum ml-auto shrink-0 font-bold text-ink">{sideKicks.filter((k) => k.scored).length}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<p className="text-xs text-faint">{t.match.shootoutNote}</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
{/* live matches keep the pre-match view alongside the in-play one */}
|
||||
{inPlay && preview.prediction && modelCard}
|
||||
{finished && timeline.length >= 2 && (
|
||||
@@ -365,6 +400,38 @@ export function MatchPreviewPage() {
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
{preview.videos.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="flex items-center gap-2">
|
||||
<Clapperboard size={16} className="text-accent" />
|
||||
<span className="font-display font-bold text-ink">{t.match.videos}</span>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{preview.videos.map((v) => (
|
||||
<a
|
||||
key={v.href}
|
||||
href={v.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="group flex gap-3 rounded-lg border border-line p-2 transition-colors hover:border-line-strong hover:bg-panel-2"
|
||||
>
|
||||
{v.thumbnail && <img src={v.thumbnail} alt="" loading="lazy" className="h-14 w-24 shrink-0 rounded object-cover" />}
|
||||
<span className="min-w-0">
|
||||
<span className="line-clamp-2 text-sm font-medium text-ink group-hover:text-accent">{v.headline}</span>
|
||||
{v.duration != null && (
|
||||
<span className="mt-0.5 block text-[11px] text-faint">
|
||||
{Math.floor(v.duration / 60)}:{String(v.duration % 60).padStart(2, '0')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-faint">{t.match.videosNote}</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -430,7 +497,8 @@ export function MatchPreviewPage() {
|
||||
{/* ── Lineups ── */}
|
||||
{tab === 'lineups' && (
|
||||
<div className="space-y-4">
|
||||
{(preview.suspensions.home.length > 0 || preview.suspensions.away.length > 0) && (
|
||||
{(preview.suspensions.home.length > 0 || preview.suspensions.away.length > 0 ||
|
||||
preview.injuries.home.length > 0 || preview.injuries.away.length > 0) && (
|
||||
<Card className="border-loss/30">
|
||||
<CardBody className="space-y-1.5 text-sm">
|
||||
{([['home', preview.suspensions.home], ['away', preview.suspensions.away]] as const)
|
||||
@@ -442,6 +510,19 @@ export function MatchPreviewPage() {
|
||||
<span>{fmt(t.match.suspended, { players: players.join(', ') })}</span>
|
||||
</div>
|
||||
))}
|
||||
{(['home', 'away'] as const)
|
||||
.filter((side) => preview.injuries[side].length > 0)
|
||||
.map((side) => (
|
||||
<div key={`inj-${side}`} className="flex items-center gap-2 text-ink-soft">
|
||||
<Stethoscope size={14} className="shrink-0 text-gold" />
|
||||
<span aria-hidden>{(side === 'home' ? f.home.team : f.away.team) ? teamFlag((side === 'home' ? f.home.team : f.away.team)!) : ''}</span>
|
||||
<span>
|
||||
{fmt(t.match.unavailable, {
|
||||
players: preview.injuries[side].map((i) => (i.reason ? `${i.player} (${i.reason})` : i.player)).join(', '),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -1,17 +1,95 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useParams } from '@tanstack/react-router';
|
||||
import { ArrowLeft, Bell, BellRing, Shield, Star } from 'lucide-react';
|
||||
import { ArrowLeft, Bell, BellRing, CalendarPlus, Route, Shield, Star, Stethoscope } from 'lucide-react';
|
||||
import { EloHistoryChart } from '@/components/EloHistoryChart';
|
||||
import { pushSupported, serverPushKey, subscribeTeam, unsubscribePush } from '@/lib/push';
|
||||
import { useFavoriteStore } from '@/stores/favoriteStore';
|
||||
import { useTournamentStore } from '@/stores/tournamentStore';
|
||||
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { teamFlag } from '@/lib/teams';
|
||||
import { fmt, useFormat, useT } from '@/lib/i18n';
|
||||
import { hostAdvantage } from '@/lib/model/hosts';
|
||||
import { knockoutAdvanceProb, predictMatch, type RatingsModel } from '@/lib/model/predict';
|
||||
import { modelBracket, projectedSeeds, teamPath } from '@/lib/whatif';
|
||||
import type { Fixture, TeamProfile } from '@/lib/types';
|
||||
|
||||
const pct = (x: number | null) => (x == null ? '—' : `${(x * 100).toFixed(1)}%`);
|
||||
|
||||
/** The model's most likely knockout run for this team, opponent by opponent. */
|
||||
function RoadToFinal({ team }: { team: string }) {
|
||||
const t = useT();
|
||||
const snapshot = useTournamentStore((s) => s.snapshot);
|
||||
const model = useTournamentStore((s) => s.model);
|
||||
const [ratings, setRatings] = useState<RatingsModel | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
fetch('/data/ratings.json')
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject()))
|
||||
.then((d: RatingsModel) => alive && setRatings(d))
|
||||
.catch(() => {});
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
const path = useMemo(() => {
|
||||
if (!snapshot || !model || !ratings) return null;
|
||||
const fixtures = snapshot.fixtures;
|
||||
const seeds = projectedSeeds(fixtures, model.odds);
|
||||
// Every team gets a road: if the model doesn't project this side out of
|
||||
// the group, slot them hypothetically as their group's runner-up.
|
||||
if (!Object.values(seeds).includes(team)) {
|
||||
const g = fixtures.find((f) => f.stage === 'group' && (f.home.team === team || f.away.team === team))?.group;
|
||||
if (!g) return null;
|
||||
seeds[`2${g}`] = team;
|
||||
}
|
||||
const advFor = (f: Fixture, home: string, away: string): number | null => {
|
||||
const adv = hostAdvantage(home, away, f.venue, ratings.params.homeAdvElo);
|
||||
const p = predictMatch(home, away, ratings, adv);
|
||||
return knockoutAdvanceProb(p.probs, p.eloHome, p.eloAway);
|
||||
};
|
||||
return teamPath(fixtures, seeds, modelBracket(fixtures, seeds, advFor), team);
|
||||
}, [snapshot, model, ratings, team]);
|
||||
|
||||
const odds = model?.odds.find((o) => o.team === team);
|
||||
if (!path?.length || !odds) return null;
|
||||
const reach: Partial<Record<Fixture['stage'], number>> = {
|
||||
r32: odds.qualify, r16: odds.reachR16, qf: odds.reachQF, sf: odds.reachSF, final: odds.reachFinal,
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex items-center gap-2">
|
||||
<Route size={15} className="text-accent" />
|
||||
<span className="font-display font-bold text-ink">{t.team.road.title}</span>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<ul className="space-y-1.5">
|
||||
{path.map((p) => (
|
||||
<li key={p.num} className="flex items-center gap-2 text-sm">
|
||||
<span className="w-32 shrink-0 text-xs text-faint">{t.stage[p.stage]}</span>
|
||||
<span className="text-faint">{t.common.vs}</span>
|
||||
{p.opponent ? (
|
||||
<>
|
||||
<span aria-hidden>{teamFlag(p.opponent)}</span>
|
||||
<Link to="/team/$name" params={{ name: p.opponent }} className="truncate font-medium text-ink hover:text-accent">{p.opponent}</Link>
|
||||
</>
|
||||
) : (
|
||||
<span className="italic text-faint">{t.team.road.tbd}</span>
|
||||
)}
|
||||
<span className="tnum ml-auto shrink-0 text-xs font-semibold text-ink">{pct(reach[p.stage] ?? null)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-2 inline-flex rounded-full bg-gold/15 px-2.5 py-0.5 text-xs font-semibold text-gold">
|
||||
{fmt(t.team.road.winItAll, { pct: pct(odds.champion) })}
|
||||
</div>
|
||||
<p className="mt-2 text-[11px] text-faint">{t.team.road.note}</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function FixtureRow({ f, team }: { f: Fixture; team: string }) {
|
||||
const { t, kickoffDay, kickoffTime } = useFormat();
|
||||
const isHome = f.home.team === team;
|
||||
@@ -154,6 +232,13 @@ export function TeamProfilePage() {
|
||||
<Badge tone="muted">{fmt(t.team.eloBadge, { rating: profile.elo })}</Badge>
|
||||
<Badge tone="gold">{fmt(t.team.titleOddsBadge, { pct: pct(profile.championOdds) })}</Badge>
|
||||
<Badge tone="accent">{fmt(t.team.advanceOddsBadge, { pct: pct(profile.qualifyOdds) })}</Badge>
|
||||
<a
|
||||
href={`/api/calendar.ics?team=${encodeURIComponent(profile.team)}`}
|
||||
download
|
||||
className="inline-flex items-center gap-1 rounded-full border border-line px-2 py-0.5 text-xs font-semibold text-muted transition-colors hover:border-line-strong hover:text-ink"
|
||||
>
|
||||
<CalendarPlus size={13} /> {t.team.addToCalendar}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{s && (
|
||||
@@ -172,6 +257,8 @@ export function TeamProfilePage() {
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<RoadToFinal team={profile.team} />
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-[1.3fr_1fr]">
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">{t.team.fixtures}</span></CardHeader>
|
||||
@@ -181,6 +268,24 @@ export function TeamProfilePage() {
|
||||
</Card>
|
||||
|
||||
<div className="space-y-5">
|
||||
{profile.injuries.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="flex items-center gap-2">
|
||||
<Stethoscope size={15} className="text-gold" />
|
||||
<span className="font-display font-bold text-ink">{t.team.injuriesTitle}</span>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<ul className="space-y-1.5">
|
||||
{profile.injuries.map((i) => (
|
||||
<li key={i.player} className="flex items-center gap-2 text-sm">
|
||||
<span className="truncate text-ink-soft">{i.player}</span>
|
||||
<span className="ml-auto shrink-0 text-[11px] text-faint">{i.reason ?? i.status ?? ''}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
{profile.discipline.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">{t.team.discipline}</span></CardHeader>
|
||||
|
||||
@@ -107,6 +107,11 @@ export const de: Dict = {
|
||||
},
|
||||
lineups: "Aufstellungen",
|
||||
suspended: "Für dieses Spiel gesperrt: {players}",
|
||||
unavailable: "Verletzt oder fraglich: {players}",
|
||||
shootoutTitle: "Elfmeterschießen",
|
||||
shootoutNote: "Schützen in Reihenfolge aus dem Live-Feed — Punkt antippen für den Namen.",
|
||||
videos: "Videos",
|
||||
videosNote: "Spiel-Clips — öffnen bei ESPN.",
|
||||
keyPlayers: "Schlüsselspieler (beste Torschützen aller Zeiten)",
|
||||
xgRace: "xG-Rennen",
|
||||
shotMap: "Schusskarte",
|
||||
@@ -531,7 +536,15 @@ export const de: Dict = {
|
||||
groupStanding: "Platz in der Gruppe",
|
||||
standingLine: "#{rank} · {points} Pkt. · {won}-{drawn}-{lost} · TD {gd}",
|
||||
fixtures: "Spielplan",
|
||||
addToCalendar: "In den Kalender",
|
||||
eloHistory: "Elo-Wertung im Zeitverlauf",
|
||||
injuriesTitle: "Verletzt / fraglich",
|
||||
road: {
|
||||
title: "Weg ins Finale",
|
||||
note: "Der wahrscheinlichste Weg laut Modell: projizierte Gegner aus der aktuellen Simulation. Die Prozente sind die Chance, diese Runde zu erreichen.",
|
||||
winItAll: "Titelchance: {pct}",
|
||||
tbd: "noch offen",
|
||||
},
|
||||
discipline: "Karten & Sperren",
|
||||
suspChipNext: "fehlt im nächsten Spiel",
|
||||
suspChipFor: "fehlt in Sp. {num}",
|
||||
|
||||
@@ -106,6 +106,11 @@ export const en = {
|
||||
},
|
||||
lineups: "Lineups",
|
||||
suspended: "Suspended for this match: {players}",
|
||||
unavailable: "Unavailable or doubtful: {players}",
|
||||
shootoutTitle: "Penalty shootout",
|
||||
shootoutNote: "Kicks in order from the live feed — hover a dot for the taker.",
|
||||
videos: "Videos",
|
||||
videosNote: "Match clips — they open on ESPN.",
|
||||
keyPlayers: "Key players (all-time top scorers)",
|
||||
xgRace: "xG race",
|
||||
shotMap: "Shot map",
|
||||
@@ -530,7 +535,15 @@ export const en = {
|
||||
groupStanding: "Group standing",
|
||||
standingLine: "#{rank} · {points} pts · {won}-{drawn}-{lost} · GD {gd}",
|
||||
fixtures: "Fixtures",
|
||||
addToCalendar: "Add to calendar",
|
||||
eloHistory: "Elo rating over time",
|
||||
injuriesTitle: "Unavailable / doubtful",
|
||||
road: {
|
||||
title: "Road to the final",
|
||||
note: "The model's most likely path: projected opponents from the current simulation. Percentages are the chance of reaching that round.",
|
||||
winItAll: "Win it all: {pct}",
|
||||
tbd: "to be decided",
|
||||
},
|
||||
discipline: "Cards & suspensions",
|
||||
suspChipNext: "out next match",
|
||||
suspChipFor: "out for M{num}",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { eventContext, goalEvents, goalShot, halfTimeScore, momentumVerdict, parseEvents, reportSentences, varLines } from './matchEvents';
|
||||
import { eventContext, goalEvents, goalShot, halfTimeScore, momentumVerdict, parseEvents, reportSentences, shootoutKicks, varLines } from './matchEvents';
|
||||
import type { MatchLiveEvent } from './types';
|
||||
|
||||
// Real shapes from the ESPN feed (WC 2026 match 2, Korea Republic – Czechia).
|
||||
@@ -209,6 +209,43 @@ describe('reportSentences', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('shootoutKicks', () => {
|
||||
const kick = (type: string, text: string, team: 'home' | 'away'): MatchLiveEvent =>
|
||||
({ minute: 120, type, text, team, shootout: true });
|
||||
|
||||
it('reads scored/missed from the event type and extracts the taker', () => {
|
||||
const kicks = shootoutKicks([
|
||||
kick('Penalty - Scored', 'Goal! Lionel Messi (Argentina) converts the penalty with a right footed shot.', 'home'),
|
||||
kick('Penalty - Missed', 'Kingsley Coman (France) misses to the left.', 'away'),
|
||||
kick('Penalty - Saved', 'Penalty saved! Aurelien Tchouameni (France) is denied.', 'away'),
|
||||
]);
|
||||
expect(kicks.map((k) => k.scored)).toEqual([true, false, false]);
|
||||
expect(kicks[0]).toMatchObject({ side: 'home', player: 'Lionel Messi' });
|
||||
expect(kicks[1]?.player).toBe('Kingsley Coman');
|
||||
});
|
||||
|
||||
it('falls back to the text when the type is generic', () => {
|
||||
const kicks = shootoutKicks([
|
||||
kick('Penalty', 'Goal! Paulo Dybala (Argentina) converts.', 'home'),
|
||||
kick('Penalty', 'Penalty missed! Kylian Mbappe (France) hits the crossbar.', 'away'),
|
||||
]);
|
||||
expect(kicks.map((k) => k.scored)).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it('only sees shootout-flagged events', () => {
|
||||
expect(shootoutKicks(realEvents)).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps shootout kicks off the parsed timeline and its running score', () => {
|
||||
const parsed = parseEvents([
|
||||
{ minute: 30, type: 'Goal', text: 'Goal! A 1, B 0. Someone (A) shot.', team: 'home' },
|
||||
kick('Penalty - Scored', 'Goal! X (A) converts.', 'home'),
|
||||
]);
|
||||
expect(parsed).toHaveLength(1);
|
||||
expect(parsed[0]?.score).toEqual([1, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('halfTimeScore', () => {
|
||||
it('is the last goal score at minute ≤ 45', () => {
|
||||
expect(halfTimeScore(parseEvents(realEvents))).toEqual([0, 0]);
|
||||
|
||||
@@ -108,6 +108,7 @@ export function parseEvents(events: MatchLiveEvent[]): ParsedEvent[] {
|
||||
const out: ParsedEvent[] = [];
|
||||
let running: [number, number] = [0, 0];
|
||||
for (const e of events) {
|
||||
if (e.shootout) continue; // shootout kicks get their own panel, not timeline rows
|
||||
const kind = eventKind(e.type);
|
||||
const type = e.type.toLowerCase();
|
||||
if (MARKER_TYPES.has(type)) continue;
|
||||
@@ -251,6 +252,36 @@ export function varLines(e: ParsedEvent, commentary: CommentaryLine[]): string[]
|
||||
.map((c) => c.text);
|
||||
}
|
||||
|
||||
export interface ShootoutKick {
|
||||
side: 'home' | 'away' | null;
|
||||
player: string | null;
|
||||
scored: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Penalty-shootout kicks in feed order. The exact ESPN payload shape is only
|
||||
* observable once the first knockout shootout happens, so detection is
|
||||
* defensive: the event type decides where it can ("Penalty - Scored" /
|
||||
* "- Missed" / "- Saved"), the text decides otherwise.
|
||||
*/
|
||||
export function shootoutKicks(events: MatchLiveEvent[]): ShootoutKick[] {
|
||||
return events
|
||||
.filter((e) => e.shootout)
|
||||
.map((e) => {
|
||||
const type = e.type.toLowerCase();
|
||||
let scored: boolean;
|
||||
if (/scored|goal/.test(type)) scored = true;
|
||||
else if (/miss|save/.test(type)) scored = false;
|
||||
else scored = /goal!|converts|scores\b/i.test(e.text) && !/miss|saved|wide|post|crossbar/i.test(e.text);
|
||||
// Kicker name: drop any "Goal! " / "Penalty saved! " lead-in, then take
|
||||
// the "Name (Team)" head; fall back to goal-style "…. Name (Team) …".
|
||||
const lead = e.text.replace(/^[^!.()]*!\s*/, '');
|
||||
const name = /^([^(]+?)\s*\(/.exec(lead)?.[1] ?? /\.\s*([^.()]+?)\s*\(/.exec(e.text)?.[1] ?? null;
|
||||
const player = name && name.trim().length <= 40 ? name.trim() : null;
|
||||
return { side: e.team, player, scored };
|
||||
});
|
||||
}
|
||||
|
||||
/** Score at half-time: last goal score at minute ≤ 45 (0–0 when none). */
|
||||
export function halfTimeScore(parsed: ParsedEvent[]): [number, number] {
|
||||
let ht: [number, number] = [0, 0];
|
||||
|
||||
+20
-1
@@ -143,7 +143,20 @@ export interface H2HSummary {
|
||||
|
||||
export interface KeyPlayer { name: string; goals: number; pens: number }
|
||||
export interface LineupPlayer { name: string; position: string | null; starter: boolean; number: number | null }
|
||||
export interface MatchLiveEvent { minute: number | null; type: string; text: string; team: 'home' | 'away' | null }
|
||||
export interface MatchLiveEvent {
|
||||
minute: number | null;
|
||||
type: string;
|
||||
text: string;
|
||||
team: 'home' | 'away' | null;
|
||||
/** Penalty-shootout kick (rendered by the shootout panel, not the timeline). */
|
||||
shootout?: boolean;
|
||||
}
|
||||
|
||||
/** A match-related ESPN video clip (highlights, reactions). */
|
||||
export interface MatchVideo { headline: string; duration: number | null; thumbnail: string | null; href: string }
|
||||
|
||||
/** One unavailable/doubtful player (API-Football injury feed, key-gated). */
|
||||
export interface InjuryInfo { player: string; status: string | null; reason: string | null }
|
||||
|
||||
export interface MatchPreview {
|
||||
num: number;
|
||||
@@ -165,6 +178,10 @@ export interface MatchPreview {
|
||||
report: { headline: string; story: string } | null;
|
||||
/** Players banned for THIS match (red card / yellow accumulation). */
|
||||
suspensions: { home: string[]; away: string[] };
|
||||
/** Unavailable/doubtful players (injury feed; empty until a key is set). */
|
||||
injuries: { home: InjuryInfo[]; away: InjuryInfo[] };
|
||||
/** ESPN video clips for this match (open on ESPN). */
|
||||
videos: MatchVideo[];
|
||||
/** Honest note on which data layers are populated yet. */
|
||||
dataNote: string;
|
||||
updatedAt: number | null;
|
||||
@@ -232,6 +249,8 @@ export interface TeamProfile {
|
||||
qualifyOdds: number | null;
|
||||
/** Card ledger + suspension status for this squad (carded players only). */
|
||||
discipline: PlayerDiscipline[];
|
||||
/** Unavailable/doubtful players (injury feed; empty until a key is set). */
|
||||
injuries: InjuryInfo[];
|
||||
}
|
||||
|
||||
// ---- tournament stats hub (/api/stats) ----
|
||||
|
||||
+24
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { championPath, effectiveBracket, modelBracket, projectedSeeds, prunePicks, type Picks } from './whatif';
|
||||
import { championPath, effectiveBracket, modelBracket, projectedSeeds, prunePicks, teamPath, type Picks } from './whatif';
|
||||
import type { Fixture } from './types';
|
||||
|
||||
const slot = (team: string | null, placeholder: string | null = null) =>
|
||||
@@ -41,6 +41,29 @@ describe('effectiveBracket', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('teamPath', () => {
|
||||
it('forces the team through the bracket and lists projected opponents', () => {
|
||||
// model bracket favors Spain + Brazil; Japan's road overrides only its own matches
|
||||
const path = teamPath(bracket(), {}, { 90: 'home', 91: 'home', 93: 'home' }, 'Japan');
|
||||
expect(path).toEqual([
|
||||
{ num: 91, stage: 'sf', opponent: 'Brazil' },
|
||||
{ num: 93, stage: 'final', opponent: 'Spain' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('a real elimination ends the road', () => {
|
||||
const fxs = bracket();
|
||||
fxs[1] = { ...fxs[1]!, status: 'finished', homeScore: 2, awayScore: 0 }; // Brazil beat Japan
|
||||
const path = teamPath(fxs, {}, {}, 'Japan');
|
||||
expect(path).toEqual([{ num: 91, stage: 'sf', opponent: 'Brazil' }]);
|
||||
});
|
||||
|
||||
it('excludes the third-place play-off', () => {
|
||||
const path = teamPath(bracket(), {}, { 90: 'home', 91: 'home' }, 'France');
|
||||
expect(path.map((p) => p.num)).toEqual([90, 93]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prunePicks', () => {
|
||||
it('drops downstream picks when an upstream pick changes the pairing', () => {
|
||||
const picks: Picks = { 90: 'home', 91: 'home', 93: 'away' }; // final pick: Brazil
|
||||
|
||||
@@ -163,6 +163,39 @@ export function modelBracket(
|
||||
return picks;
|
||||
}
|
||||
|
||||
/**
|
||||
* One team's projected knockout run: force the team through every round of
|
||||
* the (model-picked) bracket and list the opponent it would meet there.
|
||||
* Real eliminations end the path — decided matches can't be overridden.
|
||||
* Excludes the third-place play-off (the road points at the final).
|
||||
*/
|
||||
export function teamPath(
|
||||
fixtures: Fixture[],
|
||||
seeds: Seeds,
|
||||
basePicks: Picks,
|
||||
team: string,
|
||||
): { num: number; stage: Fixture['stage']; opponent: string | null }[] {
|
||||
const picks: Picks = { ...basePicks };
|
||||
const ko = fixtures.filter((f) => f.stage !== 'group' && f.stage !== 'third').sort((a, b) => a.num - b.num);
|
||||
for (let pass = 0; pass < 6; pass++) {
|
||||
const eff = effectiveBracket(fixtures, picks, seeds);
|
||||
let changed = false;
|
||||
for (const f of ko) {
|
||||
const m = eff.get(f.num);
|
||||
if (!m || m.decided) continue;
|
||||
const side: PickSide | null = m.home === team ? 'home' : m.away === team ? 'away' : null;
|
||||
if (side && picks[f.num] !== side) { picks[f.num] = side; changed = true; }
|
||||
}
|
||||
if (!changed) break;
|
||||
}
|
||||
const eff = effectiveBracket(fixtures, picks, seeds);
|
||||
return ko.flatMap((f) => {
|
||||
const m = eff.get(f.num);
|
||||
if (!m || (m.home !== team && m.away !== team)) return [];
|
||||
return [{ num: f.num, stage: f.stage, opponent: m.home === team ? m.away : m.home }];
|
||||
});
|
||||
}
|
||||
|
||||
/** The picked champion's path: the matches they were picked to win, in order. */
|
||||
export function championPath(
|
||||
fixtures: Fixture[],
|
||||
|
||||
Reference in New Issue
Block a user