import { GroupId, Match, MatchStatus, Team } from "./types"; // ---------------------------------------------------------------------------- // Caching: einfacher In-Memory-Cache mit TTL. Verhindert, dass jede Browser- // Anfrage einen Upstream-Call auslöst (football-data: 10 req/min Limit). // ---------------------------------------------------------------------------- interface CacheEntry { value: T; expires: number; } const cache = new Map>(); export async function cached(key: string, ttlMs: number, fn: () => Promise): Promise { const hit = cache.get(key) as CacheEntry | undefined; const now = Date.now(); if (hit && hit.expires > now) return hit.value; try { const value = await fn(); cache.set(key, { value, expires: now + ttlMs }); return value; } catch (err) { // Bei Upstream-Fehler abgelaufenen Cache weiterverwenden, statt hart zu failen. if (hit) return hit.value; throw err; } } // ---------------------------------------------------------------------------- // 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; homeTeam: { id: number | null; name: string | null; tla?: string | null }; awayTeam: { id: number | null; name: string | null; tla?: 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"; } } // 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 }); } } } return { id: String(m.id), group, stage: stageFor(m.stage, group), matchNumber: m.matchday ?? 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, }; }); return { matches, teams: [...teamMap.values()] }; }); } // ---------------------------------------------------------------------------- // Polymarket Gamma API: Wahrscheinlichkeiten pro Spiel // ---------------------------------------------------------------------------- const PM_BASE = "https://gamma-api.polymarket.com"; interface PmMarket { question: string; outcomes: string; // JSON-String, z.B. "[\"Germany\",\"Draw\",\"Mexico\"]" outcomePrices: string; // JSON-String, z.B. "[\"0.55\",\"0.25\",\"0.20\"]" slug: string; closed: boolean; } // Map: normalisierter Teamname-Schlüssel -> Wahrscheinlichkeit. export interface OddsEntry { outcomes: string[]; prices: number[]; question: string; } function safeParse(s: string, fallback: T): T { try { return JSON.parse(s) as T; } catch { return fallback; } } // Holt WM-bezogene Märkte über das Tag/Slug der Polymarket-WM-Kollektion. // Hinweis: Der exakte Slug kann sich ändern; per ENV überschreibbar. export async function fetchOdds(): Promise { const slug = process.env.POLYMARKET_WC_SLUG || "world-cup-2026"; return cached("pm:odds", 120_000, async () => { const res = await fetch( `${PM_BASE}/events?slug=${encodeURIComponent(slug)}`, { cache: "no-store", headers: { "User-Agent": "wm2026-board/1.0" } }, ); if (!res.ok) throw new Error(`polymarket ${res.status}`); const events = (await res.json()) as Array<{ markets?: PmMarket[] }>; const entries: OddsEntry[] = []; for (const ev of events) { for (const mk of ev.markets ?? []) { if (mk.closed) continue; const outcomes = safeParse(mk.outcomes, []); const prices = safeParse(mk.outcomePrices, []).map(Number); if (outcomes.length && outcomes.length === prices.length) { entries.push({ outcomes, prices, question: mk.question }); } } } return entries; }); } // Verknüpft Polymarket-Märkte mit Spielen anhand der Teamnamen im Markttitel. export function attachOdds(matches: Match[], teams: Team[], odds: OddsEntry[]): Match[] { const nameById = new Map(teams.map((t) => [t.id, t.name.toLowerCase()])); return matches.map((m) => { if (!m.homeTeamId || !m.awayTeamId) return m; const hn = nameById.get(m.homeTeamId); const an = nameById.get(m.awayTeamId); if (!hn || !an) return m; const hit = odds.find( (o) => o.question.toLowerCase().includes(hn) && o.question.toLowerCase().includes(an), ); if (!hit) return m; // Heuristik: 3 Outcomes = Heim/Unentschieden/Auswärts; 2 = Heim/Auswärts. const idxHome = hit.outcomes.findIndex((o) => o.toLowerCase().includes(hn)); const idxAway = hit.outcomes.findIndex((o) => o.toLowerCase().includes(an)); const idxDraw = hit.outcomes.findIndex((o) => /draw|unentschieden|tie/i.test(o)); if (idxHome < 0 || idxAway < 0) return m; return { ...m, prob: { home: hit.prices[idxHome] ?? 0, draw: idxDraw >= 0 ? (hit.prices[idxDraw] ?? null) : null, away: hit.prices[idxAway] ?? 0, }, }; }); }