@@ -1,13 +1,11 @@
import { GroupId , GoalEvent , Match , MatchStatus , Team } from "./types" ;
import { venueFor } from "./venue s" ;
import { localisedTeamName , TEAM_LOCALIZATION } from "./team-mappings" ;
import { TEAM_LOCALIZATION } from "./team-mapping s" ;
import { R32 , LATER_ROUNDS , resolveAnnexC , qualifiedThirdGroups } from "./bracket" ;
import { computeGroupTables , computeThirdPlaceTable } from "./standings" ;
import { FIFA_GROUP_MAP , FIFA_STAGE_MAP } from "./fifa-constants" ;
// ----------------------------------------------------------------------------
// Caching: einfacher In-Memory-Cache mit TTL. Verhindert, dass jede Browser-
// Anfrage einen Upstream-Call auslöst (football-data: 10 req/min Limit).
// Caching: einfacher In-Memory-Cache mit TTL.
// ----------------------------------------------------------------------------
interface CacheEntry < T > { value : T ; expires : number ; }
const cache = new Map < string , CacheEntry < unknown > > ( ) ;
@@ -27,195 +25,6 @@ export async function cached<T>(key: string, ttlMs: number, fn: () => Promise<T>
}
}
// ----------------------------------------------------------------------------
// football-data.org: Teams, Spiele, Status
// ----------------------------------------------------------------------------
const FD_BASE = "https://api.football-data.org/v4" ;
const FD_COMP = "WC" ; // FIFA World Cup
function fdHeaders ( ) : Record < string , string > {
const token = process . env . FOOTBALL_DATA_TOKEN ;
return token ? { "X-Auth-Token" : token } : { } ;
}
function mapStatus ( s : string ) : MatchStatus {
switch ( s ) {
case "LIVE" : return "LIVE" ;
case "IN_PLAY" : return "IN_PLAY" ;
case "PAUSED" : return "PAUSED" ;
case "FINISHED" : return "FINISHED" ;
default : return "SCHEDULED" ;
}
}
// Wandelt einen football-data-Gruppennamen ("GROUP_A") in unsere GroupId.
function parseGroup ( g : string | null | undefined ) : GroupId | null {
if ( ! g ) return null ;
const m = /GROUP_([A-L])/ . exec ( g ) ;
return m ? ( m [ 1 ] as GroupId ) : null ;
}
interface FdMatchesResponse {
matches : Array < {
id : number ;
utcDate : string ;
status : string ;
minute? : number | null ;
matchday? : number | null ;
stage : string ;
group? : string | null ;
venue? : string | null ;
attendance? : number | null ;
homeTeam : { id : number | null ; name : string | null ; tla? : string | null ; crest? : string | null } ;
awayTeam : { id : number | null ; name : string | null ; tla? : string | null ; crest? : string | null } ;
score : { fullTime : { home : number | null ; away : number | null } } ;
} > ;
}
function stageFor ( stage : string , group : GroupId | null ) : Match [ "stage" ] {
if ( group ) return "GROUP" ;
switch ( stage ) {
case "LAST_32" : return "R32" ;
case "LAST_16" : return "R16" ;
case "QUARTER_FINALS" : return "QF" ;
case "SEMI_FINALS" : return "SF" ;
case "THIRD_PLACE" : return "3RD" ;
case "FINAL" : return "FINAL" ;
default : return "GROUP" ;
}
}
// Phasen-Reihenfolge für die K.o.-Nummerierung.
const STAGE_ORDER : Record < Match [ "stage" ] , number > = {
GROUP : 0 , R32 : 1 , R16 : 2 , QF : 3 , SF : 4 , "3RD" : 5 , FINAL : 6 ,
} ;
// Setzt Spielnummern und Stadien.
// K.o.-Spiele: Nummerierung über Slot-Auflösung (assignKONumbersBySlots),
// NICHT mehr chronologisch — die FIFA-Nummern folgen der Bracket-Topologie.
// Venue: Gruppenspiele über die Teampaarung, K.o.-Spiele über die Spielnummer.
function assignNumbersAndVenues ( matches : Match [ ] , teams : Team [ ] ) : void {
// Stadien setzen (Gruppenphase per Paarung, K.o. per Nummer).
for ( const m of matches ) {
m . venue = venueFor ( m , teams ) ;
}
}
// Vergibt K.o.-Match-Nummern über Slot-Auflösung (FIFA-Topologie)
// statt chronologisch. Für R32-Matches mit Team-IDs wird der Slot gesucht,
// dessen aufgelöste Teams dem Feed-Paar entsprechen.
export function assignKONumbersBySlots ( matches : Match [ ] , teams : Team [ ] ) : void {
const tables = computeGroupTables ( teams , matches ) ;
const thirds = computeThirdPlaceTable ( tables ) ;
const qGroups = qualifiedThirdGroups ( thirds ) ;
const annex = qGroups . length === 8 ? resolveAnnexC ( qGroups ) : null ;
const byPair = new Map < string , Match > ( ) ;
for ( const m of matches ) {
if ( m . group != null || ! m . homeTeamId || ! m . awayTeamId ) continue ;
byPair . set ( ` ${ m . homeTeamId } :: ${ m . awayTeamId } ` , m ) ;
byPair . set ( ` ${ m . awayTeamId } :: ${ m . homeTeamId } ` , m ) ;
}
for ( const slot of R32 ) {
const homeGroup = slot . home . type === "W" ? slot.home.group : undefined ;
const awayGroup = slot . away . type === "W" ? slot.away.group : undefined ;
const wg = ( homeGroup ? ? awayGroup ) as GroupId | undefined ;
let hid : string | null = null ;
if ( slot . home . type === "W" && slot . home . group ) {
hid = tables . find ( t = > t . group === slot . home . group ) ? . rows . find ( r = > r . rank === 1 ) ? . teamId ? ? null ;
} else if ( slot . home . type === "R" && slot . home . group ) {
hid = tables . find ( t = > t . group === slot . home . group ) ? . rows . find ( r = > r . rank === 2 ) ? . teamId ? ? null ;
} else if ( slot . home . type === "3" && annex && wg ) {
const tg = annex [ wg ] ;
if ( tg ) hid = thirds . find ( r = > r . group === tg && r . qualifies ) ? . teamId ? ? null ;
}
let aid : string | null = null ;
if ( slot . away . type === "W" && slot . away . group ) {
aid = tables . find ( t = > t . group === slot . away . group ) ? . rows . find ( r = > r . rank === 1 ) ? . teamId ? ? null ;
} else if ( slot . away . type === "R" && slot . away . group ) {
aid = tables . find ( t = > t . group === slot . away . group ) ? . rows . find ( r = > r . rank === 2 ) ? . teamId ? ? null ;
} else if ( slot . away . type === "3" && annex && wg ) {
const tg = annex [ wg ] ;
if ( tg ) aid = thirds . find ( r = > r . group === tg && r . qualifies ) ? . teamId ? ? null ;
}
if ( hid && aid ) {
const fm = byPair . get ( ` ${ hid } :: ${ aid } ` ) ;
if ( fm ) fm . matchNumber = slot . matchNumber ;
}
}
// R16– Finale: per Stage + Datum den LATER_ROUNDS-Slots zuordnen
for ( const stage of [ "R16" , "QF" , "SF" , "3RD" , "FINAL" ] as const ) {
const sm = matches . filter ( m = > m . stage === stage && m . group == null && m . matchNumber === 0 )
. sort ( ( a , b ) = > + new Date ( a . utcDate ) - + new Date ( b . utcDate ) ) ;
const ls = LATER_ROUNDS . filter ( k = > k . stage === ( stage === "3RD" ? "3RD" : stage ) ) ;
for ( let i = 0 ; i < sm . length && i < ls . length ; i ++ ) {
sm [ i ] . matchNumber = ls [ i ] . matchNumber ;
}
}
}
// Holt alle Spiele + leitet die Teamliste daraus ab (spart einen Extra-Call).
export async function fetchMatchesAndTeams ( ) : Promise < { matches : Match [ ] ; teams : Team [ ] } > {
return cached ( "fd:matches" , 60 _000 , async ( ) = > {
const res = await fetch ( ` ${ FD_BASE } /competitions/ ${ FD_COMP } /matches ` , {
headers : fdHeaders ( ) ,
// Next.js: kein eigenes Caching, wir cachen selbst
cache : "no-store" ,
} ) ;
if ( ! res . ok ) throw new Error ( ` football-data ${ res . status } ` ) ;
const data = ( await res . json ( ) ) as FdMatchesResponse ;
const teamMap = new Map < string , Team > ( ) ;
const matches : Match [ ] = data . matches . map ( ( m ) = > {
const group = parseGroup ( m . group ) ;
// Teams registrieren (nur wenn Gruppenspiel und ID vorhanden)
for ( const side of [ m . homeTeam , m . awayTeam ] ) {
if ( side . id != null && side . name && group ) {
const id = String ( side . id ) ;
if ( ! teamMap . has ( id ) ) {
teamMap . set ( id , {
id , name : side.name , code : side.tla ? ? "" , group ,
crest : ` /crests/ ${ id } .svg ` ,
localisedName : localisedTeamName ( side . tla ? ? "" , side . name ? ? "" ) ,
localisedNames : {
de : localisedTeamName ( side . tla ? ? "" , side . name ? ? "" , "de" ) ,
en : localisedTeamName ( side . tla ? ? "" , side . name ? ? "" , "en" ) ,
} ,
} ) ;
}
}
}
return {
id : String ( m . id ) ,
group ,
stage : stageFor ( m . stage , group ) ,
// Vorläufig 0 — die echte FIFA-Spielnummer wird unten gesetzt.
matchNumber : 0 ,
utcDate : m.utcDate ,
status : mapStatus ( m . status ) ,
minute : m.minute ? ? null ,
homeTeamId : m.homeTeam.id != null ? String ( m . homeTeam . id ) : null ,
awayTeamId : m.awayTeam.id != null ? String ( m . awayTeam . id ) : null ,
homeScore : m.score.fullTime.home ,
awayScore : m.score.fullTime.away ,
venue : null , // wird unten aus der Map gesetzt
attendance : m.attendance ? ? null ,
} ;
} ) ;
const teams = [ . . . teamMap . values ( ) ] ;
assignNumbersAndVenues ( matches , teams ) ;
return { matches , teams } ;
} ) ;
}
// ----------------------------------------------------------------------------
// Polymarket Gamma API: Wahrscheinlichkeiten pro Spiel (Series soccer-fifwc)
// ----------------------------------------------------------------------------
@@ -611,7 +420,7 @@ interface FifaScores {
}
// ----------------------------------------------------------------------------
// FIFA-API Calendar-Endpoint: Spiele + Teams parallel zur football-data-Quelle
// FIFA-API Calendar-Endpoint: Primärquelle für Spiele + Teams
// ----------------------------------------------------------------------------
interface FifaCalendarTeamBlock {