diff --git a/.env.example b/.env.example index ac9a935..1883542 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,4 @@ -# football-data.org API-Token (kostenlos: https://www.football-data.org/client/register) -# Ohne Token liefert die API nur eingeschränkte Daten. -FOOTBALL_DATA_TOKEN=dein_token_hier +# FIFA-API als Primärquelle (kein Token nötig) # Polymarket-Slug des WM-Events (Standard: world-cup-2026). # Den genauen Slug findest du in der Polymarket-URL nach /event/. diff --git a/lib/feeds.ts b/lib/feeds.ts index d0596db..a597eb7 100644 --- a/lib/feeds.ts +++ b/lib/feeds.ts @@ -1,13 +1,11 @@ import { GroupId, GoalEvent, Match, MatchStatus, Team } from "./types"; -import { venueFor } from "./venues"; -import { localisedTeamName, TEAM_LOCALIZATION } from "./team-mappings"; +import { TEAM_LOCALIZATION } from "./team-mappings"; 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 { value: T; expires: number; } const cache = new Map>(); @@ -27,195 +25,6 @@ export async function cached(key: string, ttlMs: number, fn: () => Promise } } -// ---------------------------------------------------------------------------- -// 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 { - 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 = { - 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(); - 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(); - 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 { diff --git a/lib/i18n/de.ts b/lib/i18n/de.ts index 4ca68f8..ad6d305 100644 --- a/lib/i18n/de.ts +++ b/lib/i18n/de.ts @@ -35,7 +35,7 @@ const de: Dictionary = { }, footer: { dashboard: "WM 2026 Dashboard", - dataSources: "Daten: football-data.org · Polymarket Gamma API · FIFA Annex C", + dataSources: "Daten: FIFA · Polymarket · Annex C", }, sim: { notice: diff --git a/lib/i18n/en.ts b/lib/i18n/en.ts index 3f0af54..96e7297 100644 --- a/lib/i18n/en.ts +++ b/lib/i18n/en.ts @@ -41,7 +41,7 @@ const en: Dictionary = { }, footer: { dashboard: "WC 2026 Dashboard", - dataSources: "Data: football-data.org · Polymarket Gamma API · FIFA Annex C", + dataSources: "Data: FIFA · Polymarket · Annex C", }, sim: { notice: diff --git a/lib/team-mappings.ts b/lib/team-mappings.ts index f096250..a37e449 100644 --- a/lib/team-mappings.ts +++ b/lib/team-mappings.ts @@ -1,6 +1,5 @@ // Zentrale Lokalisierungstabelle für Teamnamen (TLA → Deutsch / Englisch). -// Schlüssel = FIFA-3-Buchstaben-Code (uppercase), wie er von football-data.org -// im Feld `tla` geliefert wird. +// Schlüssel = App-3-Buchstaben-Code (uppercase), gemappt von FIFA-Abbreviation. // // Erweiterung auf weitere Sprachen: einfach pro Sprache ein Feld ergänzen. diff --git a/lib/types.ts b/lib/types.ts index 82612a5..9dc4a51 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,5 +1,5 @@ // Datenmodell für die WM-Seite. Bewusst entkoppelt von den Feed-Formaten: -// Adapter (siehe lib/feeds.ts) übersetzen football-data.org & Polymarket in diese Typen. +// Adapter (siehe lib/feeds.ts) übersetzen FIFA & Polymarket in diese Typen. export type GroupId = | "A" | "B" | "C" | "D" | "E" | "F" @@ -14,7 +14,7 @@ export interface Team { name: string; // Originalname aus dem Feed (englisch) code: string; // 3-Buchstaben-Code, z.B. GER group: GroupId; - crest?: string | null; // URL zur Flagge / zum Wappen (football-data: crest) + crest?: string | null; // URL zur Flagge / zum Wappen (veraltet) localisedName: string; // Lokalisierter Anzeigename (z.B. "Deutschland") localisedNames?: { de: string; en: string }; } @@ -51,7 +51,7 @@ export interface Match { winnerTeamId?: string | null; // FIFA-Winner (auch bei Elfmeterschießen) // Wahrscheinlichkeiten 0..1 (Polymarket), falls vorhanden prob?: { home: number; draw: number; away: number } | null; - venue?: string | null; // Austragungsort (alt: football-data) + venue?: string | null; // Austragungsort (veraltet) stadiumName?: string | null; // FIFA-Stadionname stadiumCity?: string | null; // FIFA-Stadt attendance?: number | null; // Zuschauerzahl