diff --git a/server/src/db/db.ts b/server/src/db/db.ts index 61b9ed2..fe9faa0 100644 --- a/server/src/db/db.ts +++ b/server/src/db/db.ts @@ -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; diff --git a/server/src/ics.test.ts b/server/src/ics.test.ts new file mode 100644 index 0000000..4d058a1 --- /dev/null +++ b/server/src/ics.test.ts @@ -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 => ({ + 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@')); + }); +}); diff --git a/server/src/ics.ts b/server/src/ics.ts new file mode 100644 index 0000000..e92e876 --- /dev/null +++ b/server/src/ics.ts @@ -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`; +} diff --git a/server/src/index.ts b/server/src/index.ts index 85ce7c7..d6f3c34 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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 ---- diff --git a/server/src/ingest/apiFootball.ts b/server/src/ingest/apiFootball.ts new file mode 100644 index 0000000..a4f2097 --- /dev/null +++ b/server/src/ingest/apiFootball.ts @@ -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 { + if (!KEY) return []; + const pages: RawInjuriesPage[] = []; + for (let page = 1; page <= 3; page++) { + const d = await cachedFetch( + 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); +} diff --git a/server/src/ingest/apiFootballNormalize.test.ts b/server/src/ingest/apiFootballNormalize.test.ts new file mode 100644 index 0000000..358e97b --- /dev/null +++ b/server/src/ingest/apiFootballNormalize.test.ts @@ -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([]); + }); +}); diff --git a/server/src/ingest/apiFootballNormalize.ts b/server/src/ingest/apiFootballNormalize.ts new file mode 100644 index 0000000..750d77f --- /dev/null +++ b/server/src/ingest/apiFootballNormalize.ts @@ -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(); + 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)); +} diff --git a/server/src/ingest/espnNormalize.ts b/server/src/ingest/espnNormalize.ts index a2dd156..7750ee9 100644 --- a/server/src/ingest/espnNormalize.ts +++ b/server/src/ingest/espnNormalize.ts @@ -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 }; } diff --git a/server/src/ingest/scheduler.ts b/server/src/ingest/scheduler.ts index dab035b..53de6ae 100644 --- a/server/src/ingest/scheduler.ts +++ b/server/src/ingest/scheduler.ts @@ -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 | undefined; let enrichTimer: ReturnType | 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 => { + 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 => { 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); }; } diff --git a/server/src/preview.ts b/server/src/preview.ts index 2ceee09..71d0a26 100644 --- a/server/src/preview.ts +++ b/server/src/preview.ts @@ -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), }; } diff --git a/src/features/match/MatchPreviewPage.tsx b/src/features/match/MatchPreviewPage.tsx index 1daac9d..96a0a33 100644 --- a/src/features/match/MatchPreviewPage.tsx +++ b/src/features/match/MatchPreviewPage.tsx @@ -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
{t.match.loadError} {t.match.backToLive}
; if (!preview || !f) return
; @@ -347,6 +348,40 @@ export function MatchPreviewPage() { )}
+ {kicks.length > 0 && ( + + + + {t.match.shootoutTitle} + + + {(['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 ( +
+ + {team && {teamFlag(team)}} + {side === 'home' ? homeName : awayName} + + + {sideKicks.map((k, i) => ( + + ))} + + {sideKicks.filter((k) => k.scored).length} +
+ ); + })} +

{t.match.shootoutNote}

+
+
+ )} {/* 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() { )} + {preview.videos.length > 0 && ( + + + + {t.match.videos} + + + +

{t.match.videosNote}

+
+
+ )} )} @@ -430,7 +497,8 @@ export function MatchPreviewPage() { {/* ── Lineups ── */} {tab === 'lineups' && (
- {(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) && ( {([['home', preview.suspensions.home], ['away', preview.suspensions.away]] as const) @@ -442,6 +510,19 @@ export function MatchPreviewPage() { {fmt(t.match.suspended, { players: players.join(', ') })}
))} + {(['home', 'away'] as const) + .filter((side) => preview.injuries[side].length > 0) + .map((side) => ( +
+ + {(side === 'home' ? f.home.team : f.away.team) ? teamFlag((side === 'home' ? f.home.team : f.away.team)!) : ''} + + {fmt(t.match.unavailable, { + players: preview.injuries[side].map((i) => (i.reason ? `${i.player} (${i.reason})` : i.player)).join(', '), + })} + +
+ ))} )} diff --git a/src/features/team/TeamProfilePage.tsx b/src/features/team/TeamProfilePage.tsx index bd554ee..31237d3 100644 --- a/src/features/team/TeamProfilePage.tsx +++ b/src/features/team/TeamProfilePage.tsx @@ -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(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> = { + r32: odds.qualify, r16: odds.reachR16, qf: odds.reachQF, sf: odds.reachSF, final: odds.reachFinal, + }; + + return ( + + + + {t.team.road.title} + + +
    + {path.map((p) => ( +
  • + {t.stage[p.stage]} + {t.common.vs} + {p.opponent ? ( + <> + {teamFlag(p.opponent)} + {p.opponent} + + ) : ( + {t.team.road.tbd} + )} + {pct(reach[p.stage] ?? null)} +
  • + ))} +
+
+ {fmt(t.team.road.winItAll, { pct: pct(odds.champion) })} +
+

{t.team.road.note}

+
+
+ ); +} + 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() { {fmt(t.team.eloBadge, { rating: profile.elo })} {fmt(t.team.titleOddsBadge, { pct: pct(profile.championOdds) })} {fmt(t.team.advanceOddsBadge, { pct: pct(profile.qualifyOdds) })} + + {t.team.addToCalendar} + {s && ( @@ -172,6 +257,8 @@ export function TeamProfilePage() { + +
{t.team.fixtures} @@ -181,6 +268,24 @@ export function TeamProfilePage() {
+ {profile.injuries.length > 0 && ( + + + + {t.team.injuriesTitle} + + +
    + {profile.injuries.map((i) => ( +
  • + {i.player} + {i.reason ?? i.status ?? ''} +
  • + ))} +
+
+
+ )} {profile.discipline.length > 0 && ( {t.team.discipline} diff --git a/src/lib/i18n/de.ts b/src/lib/i18n/de.ts index e6ee017..4f2b2a9 100644 --- a/src/lib/i18n/de.ts +++ b/src/lib/i18n/de.ts @@ -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}", diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index d6b64e9..eb63d44 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -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}", diff --git a/src/lib/matchEvents.test.ts b/src/lib/matchEvents.test.ts index 50ba168..f2200fe 100644 --- a/src/lib/matchEvents.test.ts +++ b/src/lib/matchEvents.test.ts @@ -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]); diff --git a/src/lib/matchEvents.ts b/src/lib/matchEvents.ts index f50c97d..3d82632 100644 --- a/src/lib/matchEvents.ts +++ b/src/lib/matchEvents.ts @@ -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]; diff --git a/src/lib/types.ts b/src/lib/types.ts index c8ca284..9bcf94a 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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) ---- diff --git a/src/lib/whatif.test.ts b/src/lib/whatif.test.ts index 45e9240..372a146 100644 --- a/src/lib/whatif.test.ts +++ b/src/lib/whatif.test.ts @@ -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 diff --git a/src/lib/whatif.ts b/src/lib/whatif.ts index 5ab2aaa..95b50fa 100644 --- a/src/lib/whatif.ts +++ b/src/lib/whatif.ts @@ -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[],