import { GroupId, GoalEvent, Match, MatchStatus, Team } from "./types"; 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: In-Memory-Cache mit TTL + stale-while-revalidate. // Bei abgelaufenem Cache wird der alte Wert sofort zurückgegeben und die // Erneuerung im Hintergrund angestoßen (keine Wartezeit für den Nutzer). // ---------------------------------------------------------------------------- interface CacheEntry { value: T; expires: number; refreshing?: boolean; } const cache = new Map>(); // Subscription-System: Benachrichtigt Listener (z.B. API-Routen) bei // erfolgreichem Hintergrund-Refresh, sodass das Frontend nach dem // nächsten Poll die aktuellen Daten erhält. type CacheListener = (value: unknown) => void; const cacheSubscriptions = new Map>(); function notifyCacheListeners(key: string, value: unknown): void { const subs = cacheSubscriptions.get(key); if (!subs) return; for (const cb of subs) { try { cb(value); } catch { /* silent */ } } } export function onCacheRefresh(key: string, cb: (value: T) => void): () => void { if (!cacheSubscriptions.has(key)) cacheSubscriptions.set(key, new Set()); const set = cacheSubscriptions.get(key)!; const wrapped = cb as CacheListener; set.add(wrapped); return () => { set.delete(wrapped); if (set.size === 0) cacheSubscriptions.delete(key); }; } export async function cached(key: string, ttlMs: number, fn: () => Promise): Promise { const hit = cache.get(key) as CacheEntry | undefined; const now = Date.now(); // Frischer Cache → direkt zurück if (hit && hit.expires > now) return hit.value; // Abgelaufen, aber vorhanden → sofort alten Wert liefern, im Hintergrund erneuern if (hit) { if (!hit.refreshing) { hit.refreshing = true; fn() .then((value) => { cache.set(key, { value, expires: Date.now() + ttlMs }); notifyCacheListeners(key, value); }) .catch((err) => { console.error(`[cache] Hintergrund-Refresh fehlgeschlagen (${key}):`, err); }) .finally(() => { const e = cache.get(key) as CacheEntry | undefined; if (e) e.refreshing = false; }); } return hit.value; } // Kaltstart: kein Cache → synchron laden const value = await fn(); cache.set(key, { value, expires: now + ttlMs }); return value; } // ---------------------------------------------------------------------------- // Polymarket Gamma API: Wahrscheinlichkeiten pro Spiel (Series soccer-fifwc) // ---------------------------------------------------------------------------- const PM_BASE = "https://gamma-api.polymarket.com"; const PM_SERIES = "11433"; // WM 2026 Serie interface PmEvent { id: string; slug: string; title: string; markets?: PmMarket[]; } interface PmMarket { slug: string; groupItemTitle: string; outcomes: string; // JSON-String ["Yes","No"] outcomePrices: string; // JSON-String ["0.385","0.615"] sportsMarketType?: string; closed: boolean; } export interface ParsedOdds { slug: string; homeCode: string; awayCode: string; homeName: string; awayName: string; pHome: number; pDraw: number; pAway: number; startTime: string | null; } // Normalisiert Teamcodes zwischen Polymarket-Slug und Feed (3-Buchstaben). // Bekannte Abweichungen: kr/kor (Südkorea), nld/ned (Niederlande), cdr/cod (DR Kongo). const CODE_NORM: Record = { kor: "kr", nld: "ned", cdr: "cod", cvi: "cpv", rsa: "rsa", // ok swe: "swe", // ok }; function normCode(c: string): string { return (CODE_NORM[c] ?? c).toLowerCase(); } function safeParse(s: string, fallback: T): T { try { return JSON.parse(s) as T; } catch { return fallback; } } // Holt alle WM-Spiele über den Series-Endpoint mit Pagination. // Parst pro Event die drei Moneyline-Märkte (Heim/Draw/Auswärts). export async function fetchOdds(): Promise { return cached("pm:odds", 300_000, async () => { const allEvents: PmEvent[] = []; for (let offset = 0; ; offset += 100) { const url = `${PM_BASE}/events?series_id=${PM_SERIES}&active=true&closed=false&limit=100&offset=${offset}`; const res = await fetch(url, { cache: "no-store", headers: { "User-Agent": "wm2026-board/1.0" }, }); if (!res.ok) throw new Error(`polymarket ${res.status}`); const page = (await res.json()) as PmEvent[]; allEvents.push(...page); if (page.length < 100) break; } const parsed: ParsedOdds[] = []; for (const ev of allEvents) { // Slug: fifwc-{home}-{away}-{yyyy}-{mm}-{dd} const slugParts = ev.slug.replace("fifwc-", "").split("-"); if (slugParts.length < 5) continue; const homeCode = normCode(slugParts[0]); const awayCode = normCode(slugParts[1]); let pHome = 0, pDraw = 0, pAway = 0; let homeName = "", awayName = ""; let marketGameStartTime: string | null = null; for (const mk of ev.markets ?? []) { if (mk.sportsMarketType && mk.sportsMarketType !== "moneyline") continue; if (mk.closed) continue; if (!marketGameStartTime) marketGameStartTime = (mk as any).gameStartTime ?? null; const prices = safeParse(mk.outcomePrices, []).map(Number); if (prices.length < 2) continue; const yes = prices[0]; // erster Preis = "Yes"-Wahrscheinlichkeit const gti = mk.groupItemTitle.toLowerCase(); if (mk.slug.endsWith("-draw") || gti.startsWith("draw")) { pDraw = yes; } else { // Heuristik: match gegen groupItemTitle (Teamname) // Prüfe ob der Titel den Home-Code oder Away-Code enthält // Besser: erster Markt = Home, dritter = Away; Draw in der Mitte. // Da die Reihenfolge nicht garantiert ist: über slug-Suffix matchen. if (mk.slug.endsWith("-" + slugParts[0].toLowerCase())) { pHome = yes; homeName = mk.groupItemTitle; } else if (mk.slug.endsWith("-" + slugParts[1].toLowerCase())) { pAway = yes; awayName = mk.groupItemTitle; } } } if (pHome > 0 || pDraw > 0 || pAway > 0) { const startTime = marketGameStartTime ?? (ev as any).gameStartTime ?? (ev as any).endDate ?? (ev as any).startDate; parsed.push({ slug: ev.slug, homeCode, awayCode, homeName, awayName, pHome, pDraw, pAway, startTime }); } } console.log("[polymarket] spiele:", parsed.length); return parsed; }); } // Normalisiert Teamnamen für Matching (lowercase, diakritische Zeichen, aliase). function normName(n: string): string { return n .toLowerCase() .normalize("NFD").replace(/[\u0300-\u036f]/g, "") // diakritische Zeichen entfernen .replace(/[^a-z0-9 ]/g, "") .replace(/\s+/g, " ") .trim(); } // Verknüpft Polymarket-Odds mit Feed-Matches über Teamnamen (primär) oder Codes. export function attachOdds(matches: Match[], teams: Team[], odds: ParsedOdds[]): Match[] { // Map: Team-ID → normierter Name const nameById = new Map(teams.map((t) => [t.id, normName(t.name)])); // Map: normierter Name → Team-ID (für Lookup) const idByName = new Map(); for (const t of teams) { const nn = normName(t.name); if (!idByName.has(nn)) idByName.set(nn, t.id); } // Map: normierter Code → Team-ID const idByCode = new Map(teams.map((t) => [t.code.toLowerCase(), t.id])); // Map: Polymarket ParsedOdds → { homeTeamId, awayTeamId } const oddsMatch = new Map(); for (const o of odds) { const hn = normName(o.homeName); const an = normName(o.awayName); const homeId = idByName.get(hn) ?? idByCode.get(o.homeCode); const awayId = idByName.get(an) ?? idByCode.get(o.awayCode); if (homeId && awayId) { oddsMatch.set(o, { homeId, awayId }); } else { oddsMatch.set(o, null); } } const attachedOdds = new Set(); const result = matches.map((m) => { if (!m.homeTeamId || !m.awayTeamId) return m; // Suche in oddsMatch nach einer Kombination die beide Team-IDs matcht for (const [o, ids] of oddsMatch) { if (!ids) continue; // Beide Richtungen prüfen if ((ids.homeId === m.homeTeamId && ids.awayId === m.awayTeamId) || (ids.homeId === m.awayTeamId && ids.awayId === m.homeTeamId)) { const swapped = ids.homeId === m.awayTeamId; attachedOdds.add(o); return { ...m, prob: { home: swapped ? o.pAway : o.pHome, draw: o.pDraw, away: swapped ? o.pHome : o.pAway, }, }; } } return m; }); return result; } // ---------------------------------------------------------------------------- // KO-Odds-Fallback: löst R32-Paarungen auf und verknüpft Polymarket-Events // mit K.o.-Feed-Matches, die noch keine Team-IDs im Feed haben. // ---------------------------------------------------------------------------- // Löst für jeden R32-Slot (73-88) die aktuellen Team-Paarungen auf Basis der // Gruppentabellen und Annex-C-Zuordnung. Gibt eine Map matchNumber → Paarung // zurück, NUR wenn beide Team-IDs eindeutig bekannt sind (kein Raten). function resolveR32Pairings(matches: Match[], teams: Team[]): Map { const tables = computeGroupTables(teams, matches); const thirds = computeThirdPlaceTable(tables); const qGroups = qualifiedThirdGroups(thirds); const annex = qGroups.length === 8 ? resolveAnnexC(qGroups) : null; const pairings = new Map(); 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) { pairings.set(slot.matchNumber, { homeTeamId: hid, awayTeamId: aid }); } } return pairings; } // Extrahiert das Datum (YYYY-MM-DD) aus einem Polymarket-Slug. // Slug-Format: fifwc-{home}-{away}-{yyyy}-{mm}-{dd} function slugToDate(slug: string): string | null { const parts = slug.split("-"); if (parts.length < 3) return null; const y = parts[parts.length - 3]; const m = parts[parts.length - 2]; const d = parts[parts.length - 1]; if (/^\d{4}$/.test(y) && /^\d{2}$/.test(m) && /^\d{2}$/.test(d)) { return `${y}-${m}-${d}`; } return null; } // Zweiter Durchlauf für attachOdds: ordnet Polymarket-Events K.o.-Feed-Matches // zu, die (noch) keine Team-IDs im Feed haben. Arbeitet direkt auf den // aufgelösten R32-Paarungen (resolveR32Pairings). Identifiziert das Ziel- // Feed-Match über die Slot-matchNumber (Stufe 1) oder Datums-Lookup aus dem // Slug (Stufe 2) — KEINE chronologische Nummernvergabe. // Mutiert matches in-place, setzt nur bei Matches OHNE bestehende prob. export function attachKOOdds(matches: Match[], teams: Team[], odds: ParsedOdds[]): Match[] { const pairings = resolveR32Pairings(matches, teams); if (pairings.size === 0) return matches; const idByName = new Map(); for (const t of teams) { const nn = normName(t.name); if (!idByName.has(nn)) idByName.set(nn, t.id); } const idByCode = new Map(teams.map((t) => [t.code.toLowerCase(), t.id])); for (const [matchNum, pairing] of pairings) { if (!pairing.homeTeamId || !pairing.awayTeamId) continue; // Finde Polymarket-Event für dieses Team-Paar let matchedEvent: ParsedOdds | null = null; for (const o of odds) { const hn = normName(o.homeName); const an = normName(o.awayName); const homeId = idByName.get(hn) ?? idByCode.get(o.homeCode); const awayId = idByName.get(an) ?? idByCode.get(o.awayCode); if (!homeId || !awayId) continue; if ((homeId === pairing.homeTeamId && awayId === pairing.awayTeamId) || (homeId === pairing.awayTeamId && awayId === pairing.homeTeamId)) { matchedEvent = o; break; } } if (!matchedEvent) continue; // Ziel-Feed-Match identifizieren let targetMatch: Match | undefined; // Stufe 1: bereits nummeriertes R32-Match (via assignKONumbersBySlots Pass 1) targetMatch = matches.find(m => m.stage === "R32" && m.group == null && m.matchNumber === matchNum, ); // Stufe 2: Datums-Lookup aus Polymarket-Slug für unnummerierte Matches if (!targetMatch) { const slugDate = slugToDate(matchedEvent.slug); if (slugDate) { const candidates = matches.filter(m => m.stage === "R32" && m.group == null && m.matchNumber === 0 && m.utcDate.slice(0, 10) === slugDate, ); if (candidates.length === 1) { targetMatch = candidates[0]; targetMatch.matchNumber = matchNum; } else if (candidates.length > 1) { // Tie-breaker: falls Polymarket-Event eine Startzeit hat, nächstgelegenes Feed-Match if (matchedEvent.startTime) { const eventTime = new Date(matchedEvent.startTime).getTime(); let best: Match | undefined; let bestDiff = Infinity; for (const c of candidates) { const diff = Math.abs(new Date(c.utcDate).getTime() - eventTime); if (diff < bestDiff) { bestDiff = diff; best = c; } } // Nur zuordnen, wenn Zeitdifferenz plausibel (<4h) if (best && bestDiff < 4 * 60 * 60 * 1000) { targetMatch = best; targetMatch.matchNumber = matchNum; } } } } } if (!targetMatch) continue; // prob nicht überschreiben, falls schon gesetzt if (targetMatch.prob) continue; const homeIdFromEvent = idByName.get(normName(matchedEvent.homeName)) ?? idByCode.get(matchedEvent.homeCode); const swapped = homeIdFromEvent === pairing.awayTeamId; targetMatch.prob = { home: swapped ? matchedEvent.pAway : matchedEvent.pHome, draw: matchedEvent.pDraw, away: swapped ? matchedEvent.pHome : matchedEvent.pAway, }; } return matches; } // ---------------------------------------------------------------------------- // FIFA-API: Live-Scores, Elfmeterschießen, Spielminute // ---------------------------------------------------------------------------- const FIFA_BASE = "https://api.fifa.com/api/v3"; const FIFA_SEASON = "285023"; // FIFA-Code → App-Code (nur Abweichungen; sonst identisch) const FIFA_CODE_OVERRIDE: Record = { CRO: "HRV", // Kroatien POR: "PRT", // Portugal SUI: "CHE", // Schweiz }; function fifaCodeToAppCode(fifaCode: string): string { return FIFA_CODE_OVERRIDE[fifaCode] ?? fifaCode; } interface FifaTeamBlock { IdTeam: string; // FIFA-Team-ID Abbreviation: string; // FIFA-Code (z.B. PAR, CRO) } interface FifaMatch { MatchNumber: number; IdMatch?: string | null; IdStage?: string | null; Home: FifaTeamBlock | null; Away: FifaTeamBlock | null; HomeTeamScore: number | null; AwayTeamScore: number | null; HomeTeamPenaltyScore: number | null; AwayTeamPenaltyScore: number | null; MatchStatus: number; // 0=finished, 1=scheduled, else=live MatchTime: string | null; // z.B. "132'" ResultType: number | null; // 1=regular, 2=penalties Winner?: string | null; // Team-ID des Siegers } interface FifaScores { homeScore: number | null; awayScore: number | null; homePenalty: number | null; awayPenalty: number | null; resultType: number | null; status: MatchStatus; matchTime: string | null; winnerTeamId: string | null; idMatch: string | null; idStage: string | null; } // ---------------------------------------------------------------------------- // FIFA-API Calendar-Endpoint: Primärquelle für Spiele + Teams // ---------------------------------------------------------------------------- interface FifaCalendarTeamBlock { IdTeam: string; Abbreviation: string; TeamName: Array<{ Locale: string; Description: string }>; } interface FifaCalendarMatch { IdMatch: string; IdStage: string; IdGroup: string | null; MatchNumber: number; Date: string; Home: FifaCalendarTeamBlock | null; Away: FifaCalendarTeamBlock | null; HomeTeamScore: number | null; AwayTeamScore: number | null; HomeTeamPenaltyScore: number | null; AwayTeamPenaltyScore: number | null; MatchStatus: number; MatchTime: string | null; ResultType: number | null; Winner?: string | null; Attendance: string | null; Stadium?: { Name: Array<{ Locale: string; Description: string }>; CityName: Array<{ Locale: string; Description: string }>; } | null; } // Holt alle Spiele + Teams von der FIFA-API (parallele Quelle, noch nicht aktiv). export async function fetchMatchesAndTeamsFifa(locale: string = "de"): Promise<{ matches: Match[]; teams: Team[] }> { return cached(`fifa:matches:${locale}`, 60_000, async () => { const lang = locale === "en" ? "en" : "de"; const url = `${FIFA_BASE}/calendar/matches?language=${lang}&count=500&idSeason=${FIFA_SEASON}`; const res = await fetch(url, { cache: "no-store", headers: { "User-Agent": "wm2026-board/1.0" }, }); if (!res.ok) throw new Error(`fifa-calendar ${res.status}`); const data = (await res.json()) as { Results: FifaCalendarMatch[] }; console.log("[fifa-fetch] Kalender geladen, Result-Array Länge:", (data.Results ?? []).length); const teamMap = new Map(); function teamFromBlock(tb: FifaCalendarTeamBlock, idGroup: string | null): Team | null { const id = tb.IdTeam; if (teamMap.has(id)) return teamMap.get(id)!; const code = fifaCodeToAppCode(tb.Abbreviation); const group = idGroup ? FIFA_GROUP_MAP[idGroup] : null; if (!group) return null; const loc = TEAM_LOCALIZATION[code.toUpperCase()]; const name = tb.TeamName?.[0]?.Description ?? ""; const t: Team = { id, name, code, group, localisedName: loc ? loc[lang === "en" ? "en" : "de"] : name, localisedNames: loc ? { de: loc.de, en: loc.en } : { de: name, en: name }, }; teamMap.set(id, t); return t; } function mapFifaStatus(ms: number): MatchStatus { switch (ms) { case 0: return "FINISHED"; case 1: return "SCHEDULED"; case 10: return "POSTPONED"; default: return "LIVE"; } } const matches: Match[] = (data.Results ?? []).map((fm) => { const group = fm.IdGroup ? FIFA_GROUP_MAP[fm.IdGroup] ?? null : null; let homeTeam: Team | null = null; let awayTeam: Team | null = null; if (fm.Home) homeTeam = teamFromBlock(fm.Home, fm.IdGroup); if (fm.Away) awayTeam = teamFromBlock(fm.Away, fm.IdGroup); const attendance = fm.Attendance ? parseInt(fm.Attendance, 10) || null : null; const minute = fm.MatchTime ? parseInt(fm.MatchTime, 10) || null : null; const pref = lang === "en" ? "en-GB" : "de-DE"; const stadiumName = fm.Stadium?.Name?.find(n => n.Locale === pref)?.Description ?? fm.Stadium?.Name?.[0]?.Description ?? null; const stadiumCity = fm.Stadium?.CityName?.find(n => n.Locale === pref)?.Description ?? fm.Stadium?.CityName?.[0]?.Description ?? null; return { id: fm.IdMatch, group, stage: FIFA_STAGE_MAP[fm.IdStage] ?? "GROUP", matchNumber: fm.MatchNumber, utcDate: fm.Date, status: mapFifaStatus(fm.MatchStatus), minute, homeTeamId: fm.Home?.IdTeam ?? null, awayTeamId: fm.Away?.IdTeam ?? null, homeScore: fm.HomeTeamScore, awayScore: fm.AwayTeamScore, homePenalty: fm.HomeTeamPenaltyScore, awayPenalty: fm.AwayTeamPenaltyScore, winnerTeamId: fm.Winner ?? null, venue: null, stadiumName, stadiumCity, attendance, }; }); const teams = [...teamMap.values()]; console.log("[fifa-fetch] Teams:", teams.length, "Matches:", matches.length); return { matches, teams }; }); } // Holt Live-Scores von der FIFA-API. Gibt Map MatchNumber → Scores + FIFA-ID→App-Code-Map zurück. // Scores/Status/Penalties/Winner sind sprachunabhängig → locale-freier Cache-Key. export async function fetchFifaScores(): Promise<{ scores: Map; fifaIdToAppCode: Map; }> { return cached("fifa:scores", 45_000, async () => { const url = `${FIFA_BASE}/calendar/matches?language=en&count=500&idSeason=${FIFA_SEASON}`; const res = await fetch(url, { cache: "no-store", headers: { "User-Agent": "wm2026-board/1.0" }, signal: AbortSignal.timeout(5000), }); if (!res.ok) throw new Error(`fifa ${res.status}`); const data = (await res.json()) as { Results: FifaMatch[] }; console.log("[fifa-fetch] Scores geladen, Result-Array Länge:", (data.Results ?? []).length); const scores = new Map(); const fifaIdToAppCode = new Map(); for (const fm of data.Results ?? []) { // Team-Mapping sammeln for (const tb of [fm.Home, fm.Away]) { if (tb && !fifaIdToAppCode.has(tb.IdTeam)) { fifaIdToAppCode.set(tb.IdTeam, fifaCodeToAppCode(tb.Abbreviation)); } } let status: MatchStatus; switch (fm.MatchStatus) { case 0: status = "FINISHED"; break; case 1: status = "SCHEDULED"; break; case 10: status = "POSTPONED"; break; default: status = "LIVE"; break; } scores.set(fm.MatchNumber, { homeScore: fm.HomeTeamScore, awayScore: fm.AwayTeamScore, homePenalty: fm.HomeTeamPenaltyScore, awayPenalty: fm.AwayTeamPenaltyScore, resultType: fm.ResultType, status, matchTime: fm.MatchTime, winnerTeamId: fm.Winner ?? null, idMatch: fm.IdMatch ?? null, idStage: fm.IdStage ?? null, }); } console.log("[fifa-fetch] Score-Einträge:", scores.size, "Team-Mappings:", fifaIdToAppCode.size); return { scores, fifaIdToAppCode }; }); } // Wendet FIFA-Live-Scores auf Matches an (MatchNumber als exakter Schlüssel). // Team-IDs sind jetzt FIFA-IDs → winnerTeamId kann direkt gesetzt werden. export function applyFifaScores( matches: Match[], teams: Team[], fifaData: { scores: Map; fifaIdToAppCode: Map }, ): Match[] { const { scores: fifaMap } = fifaData; if (fifaMap.size === 0) return matches; let applied = 0; const result = matches.map((m) => { const fs = fifaMap.get(m.matchNumber); if (!fs) return m; const r = { ...m }; if (fs.homeScore != null) r.homeScore = fs.homeScore; if (fs.awayScore != null) r.awayScore = fs.awayScore; if (fs.homePenalty != null) r.homePenalty = fs.homePenalty; if (fs.awayPenalty != null) r.awayPenalty = fs.awayPenalty; if (fs.winnerTeamId) r.winnerTeamId = fs.winnerTeamId; if (fs.matchTime) { const min = parseInt(fs.matchTime, 10); if (!isNaN(min)) r.minute = min; } r.status = fs.status; applied++; return r; }); if (applied > 0) console.log("[fifa] scores angewandt:", applied); return result; } // Holt Tor-Details pro Spiel vom FIFA-Detail-Endpoint. interface FifaGoalRaw { scorer: string; minute: string; team: "home" | "away"; type: number | null; } function locName(arr: Array<{ Locale: string; Description: string }> | undefined): string { if (!arr || arr.length === 0) return ""; return (arr.find(x => x.Locale === "de-DE") ?? arr.find(x => x.Locale === "en-GB") ?? arr[0])?.Description ?? ""; } function normMinuteStr(min: string | null | undefined): string { return (min ?? "").replace(/'/g, "").replace(/\s+/g, "").trim(); } async function fetchFifaGoals( idStage: string, idMatch: string, locale: string = "de", ttlMs: number = 60_000, ): Promise { return cached(`fifa:detail:${locale}:${idMatch}`, ttlMs, async () => { const lang = locale === "en" ? "en" : "de"; const url = `${FIFA_BASE}/live/football/17/${FIFA_SEASON}/${idStage}/${idMatch}?language=${lang}`; const res = await fetch(url, { cache: "no-store", headers: { "User-Agent": "wm2026-board/1.0" }, signal: AbortSignal.timeout(5000), }); if (!res.ok) { console.warn("[fifa-detail] status", res.status, idMatch); return []; } const dj: any = await res.json(); const buildPlayerMap = (teamBlock: any): Map => { const map = new Map(); for (const p of teamBlock?.Players ?? []) { map.set(String(p.IdPlayer), locName(p.PlayerName) || locName(p.ShortName)); } return map; }; const homeBlock = dj.HomeTeam, awayBlock = dj.AwayTeam; const homePlayers = buildPlayerMap(homeBlock); const awayPlayers = buildPlayerMap(awayBlock); const goals: FifaGoalRaw[] = []; for (const [block, players, side] of [ [homeBlock, homePlayers, "home"] as const, [awayBlock, awayPlayers, "away"] as const, ]) { for (const g of block?.Goals ?? []) { const min = normMinuteStr(g.Minute); if (min === "" || isNaN(parseInt(min, 10))) continue; goals.push({ scorer: players.get(String(g.IdPlayer)) || "?", minute: normMinuteStr(g.Minute), team: side, type: g.Type ?? null, }); } } goals.sort((a, b) => parseInt(a.minute, 10) - parseInt(b.minute, 10)); return goals; }); } // Lädt Tor-Details für laufende und alle beendeten Spiele mit Toren. // Beendete Spiele werden über langlebiges Caching (24h) gespart, nicht über Ausschluss. // Tore eines beendeten Spiels ändern sich nie mehr → einmal fetchen genügt. export async function attachFifaGoals( matches: Match[], fifaData: { scores: Map; fifaIdToAppCode: Map }, locale: string = "de", ): Promise { const targets = matches.filter(m => (m.status === "LIVE" || m.status === "IN_PLAY" || m.status === "PAUSED" || m.status === "FINISHED") && ((m.homeScore ?? 0) + (m.awayScore ?? 0)) > 0, ); if (targets.length === 0) return matches; const goalsByMatchId = new Map(); const results = await Promise.allSettled(targets.map(async (m) => { const fs = fifaData.scores.get(m.matchNumber); if (!fs?.idMatch || !fs?.idStage) return; const isFinished = m.status === "FINISHED"; const ttl = isFinished ? 24 * 60 * 60 * 1000 : 60_000; const goals = await fetchFifaGoals(fs.idStage, fs.idMatch, locale, ttl); if (goals.length) goalsByMatchId.set(m.id, goals); })); const fetched = results.filter(r => r.status === "fulfilled").length; const failed = results.filter(r => r.status === "rejected").length; if (goalsByMatchId.size > 0 || failed > 0) { console.log("[fifa-fetch] Goals: geladen für", goalsByMatchId.size, "Matches,", fetched, "OK,", failed, "Fehler"); } if (goalsByMatchId.size === 0) return matches; return matches.map(m => { const g = goalsByMatchId.get(m.id); return g ? { ...m, goals: g.map(({ scorer, minute, team }) => ({ scorer, minute, team })) } : m; }); } // ---------------------------------------------------------------------------- // FIFA Live-Daten Pipeline: kombiniert Scores + Goals in gestufter Abfolge. // Step 1: Scores von der FIFA-API holen (enthält idMatch/idStage-Mappings). // Step 2: Scores auf die Matches anwenden (MatchNumber als Schlüssel). // Step 3: Goal-Details NUR für live/beendete Matches mit Toren nachladen. // // Die Schritte werden sequentiell ausgeführt (kein Promise.all wie zuvor), // damit fehlende ID-Mappings nicht zu sinnlosen Requests führen. // Jeder Schritt loggt die Array-/Map-Länge, sodass auf der Vercel-Konsole // sofort ersichtlich ist, ob der Upstream leer ist oder der Fehler im Mapping liegt. // ---------------------------------------------------------------------------- export async function attachFifaLiveData( matches: Match[], teams: Team[], locale: string = "de", ): Promise { // --- Step 1: Scores von FIFA holen --- let fifaData: Awaited>; try { fifaData = await fetchFifaScores(); } catch (err) { console.warn("[fifa-fetch] Scores fehlgeschlagen:", err instanceof Error ? err.message : err); return matches; } // --- Step 2: Scores auf Matches anwenden --- const updated = applyFifaScores(matches, teams, fifaData); // --- Step 3: Goal-Details für alle Spiele mit Toren (live + beendet) --- // Requests werden durch differenzierte Cache-TTL gespart: // beendete Spiele → 24h (Tore ändern sich nie) // laufende Spiele → 60s (neue Tore erscheinen) const targets = updated.filter(m => { const fs = fifaData.scores.get(m.matchNumber); return ( (m.status === "LIVE" || m.status === "IN_PLAY" || m.status === "PAUSED" || m.status === "FINISHED") && ((m.homeScore ?? 0) + (m.awayScore ?? 0)) > 0 && fs?.idMatch != null && fs?.idStage != null ); }); if (targets.length === 0) { console.log("[fifa-fetch] Pipeline: Scores aktualisiert, keine Goal-Requests nötig"); return updated; } const goalsByMatchId = new Map(); let fetched = 0; let failed = 0; for (const m of targets) { const fs = fifaData.scores.get(m.matchNumber)!; if (!fs.idMatch || !fs.idStage) continue; const isFinished = m.status === "FINISHED"; const ttl = isFinished ? 24 * 60 * 60 * 1000 : 60_000; // beendet: 24h, live: 60s try { const goals = await fetchFifaGoals(fs.idStage, fs.idMatch, locale, ttl); fetched++; if (goals.length) goalsByMatchId.set(m.id, goals); } catch { failed++; } } console.log("[fifa-fetch] Pipeline: Goals für", goalsByMatchId.size, "Matches geladen (", fetched, "OK,", failed, "Fehler )"); if (goalsByMatchId.size === 0) return updated; return updated.map(m => { const g = goalsByMatchId.get(m.id); return g ? { ...m, goals: g.map(({ scorer, minute, team }) => ({ scorer, minute, team })) } : m; }); }