reduce traffic

This commit is contained in:
2026-07-03 15:55:10 -05:00
parent 8c639a83a9
commit a10a876e0c
3 changed files with 206 additions and 14 deletions

View File

@@ -12,6 +12,28 @@ import { FIFA_GROUP_MAP, FIFA_STAGE_MAP } from "./fifa-constants";
interface CacheEntry<T> { value: T; expires: number; refreshing?: boolean; }
const cache = new Map<string, CacheEntry<unknown>>();
// 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<string, Set<CacheListener>>();
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<T>(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<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T> {
const hit = cache.get(key) as CacheEntry<T> | undefined;
const now = Date.now();
@@ -24,7 +46,10 @@ export async function cached<T>(key: string, ttlMs: number, fn: () => Promise<T>
if (!hit.refreshing) {
hit.refreshing = true;
fn()
.then((value) => { cache.set(key, { value, expires: Date.now() + ttlMs }); })
.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<T> | undefined; if (e) e.refreshing = false; });
}
@@ -475,6 +500,7 @@ export async function fetchMatchesAndTeamsFifa(locale: string = "de"): Promise<{
});
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<string, Team>();
@@ -543,6 +569,7 @@ export async function fetchMatchesAndTeamsFifa(locale: string = "de"): Promise<{
});
const teams = [...teamMap.values()];
console.log("[fifa-fetch] Teams:", teams.length, "Matches:", matches.length);
return { matches, teams };
});
}
@@ -562,6 +589,7 @@ export async function fetchFifaScores(locale: string = "de"): Promise<{
});
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<number, FifaScores>();
const fifaIdToAppCode = new Map<string, string>();
@@ -593,6 +621,7 @@ export async function fetchFifaScores(locale: string = "de"): Promise<{
idStage: fm.IdStage ?? null,
});
}
console.log("[fifa-fetch] Score-Einträge:", scores.size, "Team-Mappings:", fifaIdToAppCode.size);
return { scores, fifaIdToAppCode };
});
}
@@ -641,6 +670,16 @@ function normMinuteStr(min: string | null | undefined): string {
return (min ?? "").replace(/'/g, "").replace(/\s+/g, "").trim();
}
const RECENT_FINISHED_MS = 60 * 60 * 1000; // 60 Minuten
const MATCH_DURATION_ESTIMATE = 2.5 * 60 * 60 * 1000; // 2.5h Puffer für Spiel + Pause + Nachspielzeit
function isRecentlyFinished(m: Match): boolean {
if (m.status !== "FINISHED") return false;
const kickoff = new Date(m.utcDate).getTime();
if (isNaN(kickoff)) return false;
return kickoff + MATCH_DURATION_ESTIMATE > Date.now() - RECENT_FINISHED_MS;
}
async function fetchFifaGoals(idStage: string, idMatch: string, locale: string = "de"): Promise<FifaGoalRaw[]> {
return cached(`fifa:detail:${locale}:${idMatch}`, 60_000, async () => {
const lang = locale === "en" ? "en" : "de";
@@ -685,26 +724,107 @@ async function fetchFifaGoals(idStage: string, idMatch: string, locale: string =
});
}
// Lädt Tor-Details für beendete/laufende Spiele mit Toren und hängt sie an die Matches an.
// Lädt Tor-Details für laufende oder kürzlich beendete Spiele mit Toren.
// FINISHED-Matches werden nur innerhalb eines 60-Minuten-Fensters berücksichtigt,
// um die Request-Zahl bei 79+ Spielen im Turnierverlauf drastisch zu reduzieren.
export async function attachFifaGoals(
matches: Match[],
fifaData: { scores: Map<number, FifaScores>; fifaIdToAppCode: Map<string, string> },
locale: string = "de",
): Promise<Match[]> {
const targets = matches.filter(m =>
(m.status === "FINISHED" || m.status === "LIVE" || m.status === "IN_PLAY") &&
((m.status === "LIVE" || m.status === "IN_PLAY" || m.status === "PAUSED") || isRecentlyFinished(m)) &&
((m.homeScore ?? 0) + (m.awayScore ?? 0)) > 0,
);
if (targets.length === 0) return matches;
const goalsByMatchId = new Map<string, GoalEvent[]>();
await Promise.all(targets.map(async (m) => {
const results = await Promise.allSettled(targets.map(async (m) => {
const fs = fifaData.scores.get(m.matchNumber);
if (!fs?.idMatch || !fs?.idStage) return;
const goals = await fetchFifaGoals(fs.idStage, fs.idMatch, locale);
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<Match[]> {
// --- Step 1: Scores von FIFA holen ---
let fifaData: Awaited<ReturnType<typeof fetchFifaScores>>;
try {
fifaData = await fetchFifaScores(locale);
} 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 nur für live + kürzlich beendete Matches mit Toren ---
const targets = updated.filter(m => {
const fs = fifaData.scores.get(m.matchNumber);
return (
((m.status === "LIVE" || m.status === "IN_PLAY" || m.status === "PAUSED") || isRecentlyFinished(m)) &&
((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<string, GoalEvent[]>();
let fetched = 0;
let failed = 0;
for (const m of targets) {
const fs = fifaData.scores.get(m.matchNumber)!;
if (!fs.idMatch || !fs.idStage) continue;
try {
const goals = await fetchFifaGoals(fs.idStage, fs.idMatch, locale);
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;
});
}