10 Commits

Author SHA1 Message Date
63d4ed9470 fix locale cache 2026-07-08 22:49:24 -05:00
5138e61854 PSO Score 2026-07-04 10:32:31 -05:00
867d3c96b1 goals 2026-07-04 10:17:41 -05:00
b727c110a2 Cache-Busting 2026-07-03 17:04:43 -05:00
a10a876e0c reduce traffic 2026-07-03 15:55:10 -05:00
8c639a83a9 scrolling fixes 2026-07-02 17:51:59 -05:00
d6a026287a SEO 2026-07-02 10:55:19 -05:00
81d54e3e57 sitemap 2026-07-02 10:24:12 -05:00
7f49ca1626 cache 2026-07-02 10:21:27 -05:00
2c435472f5 fix simulation 2026-07-01 20:04:55 -05:00
13 changed files with 394 additions and 78 deletions

View File

@@ -16,13 +16,16 @@ function fmtTime(iso: string, locale: string): string {
return d.toLocaleTimeString(locale === "en" ? "en-US" : "de-DE", { hour: "2-digit", minute: "2-digit" });
}
function scoreDisplay(m: Match, dict: Dictionary): string {
function scoreMain(m: Match): string {
if (m.status === "SCHEDULED" || m.status === "POSTPONED" || (m.homeScore == null && m.awayScore == null)) return " : ";
const base = `${m.homeScore ?? 0} : ${m.awayScore ?? 0}`;
return `${m.homeScore ?? 0} : ${m.awayScore ?? 0}`;
}
function scorePenalty(m: Match, dict: Dictionary): string | null {
if (m.homePenalty != null && m.awayPenalty != null) {
return `${base} (${m.homePenalty}:${m.awayPenalty} ${dict.bracket.penaltyShootout})`;
return `${m.homePenalty}:${m.awayPenalty} ${dict.bracket.penaltyShootout}`;
}
return base;
return null;
}
function isLive(m: Match): boolean {
@@ -82,22 +85,31 @@ export default function KoFixtures({ teams, matches, dict, locale }: { teams: Te
return upcoming?.id ?? null;
}, [koMatches]);
const targetRef = useRef<HTMLDivElement>(null);
// Scroll-Ziel: aktueller Tag (heute, oder nächster Spieltag bei Ruhetag)
const targetDateKey = useMemo(() => {
const todayKey = localDateKey(new Date().toISOString());
const dayKeys = groups.map(([key]) => key);
if (dayKeys.includes(todayKey)) return todayKey;
const future = dayKeys.find(k => k >= todayKey);
return future ?? dayKeys[dayKeys.length - 1] ?? null;
}, [groups]);
const targetDateRef = useRef<HTMLHeadingElement>(null);
const hasScrolledRef = useRef<string | null>(null);
useEffect(() => {
if (!mounted || !targetRef.current) return;
if (targetMatchId === hasScrolledRef.current) return;
const el = targetRef.current;
if (!mounted || !targetDateRef.current) return;
if (targetDateKey === hasScrolledRef.current) return;
const el = targetDateRef.current;
const raf = requestAnimationFrame(() => {
const rect = el.getBoundingClientRect();
const absoluteTop = rect.top + window.scrollY;
const offset = 180;
window.scrollTo({ top: Math.max(0, absoluteTop - offset), behavior: "smooth" });
});
hasScrolledRef.current = targetMatchId;
hasScrolledRef.current = targetDateKey;
return () => cancelAnimationFrame(raf);
}, [mounted, targetMatchId, koMatches.length]);
}, [mounted, targetDateKey, koMatches.length]);
const [showScrollTop, setShowScrollTop] = useState(false);
useEffect(() => {
@@ -116,7 +128,7 @@ export default function KoFixtures({ teams, matches, dict, locale }: { teams: Te
<div>
{groups.map(([date, groupMatches]) => (
<div key={date} style={{ marginBottom: 24 }}>
<h3 style={{
<h3 ref={date === targetDateKey ? targetDateRef : undefined} style={{
fontFamily: "var(--font-display)", fontSize: 13,
textTransform: "uppercase", letterSpacing: "0.06em",
color: "var(--ink-dim)", margin: "0 0 10px",
@@ -136,11 +148,11 @@ export default function KoFixtures({ teams, matches, dict, locale }: { teams: Te
const city = STADIUMS[MATCH_STADIUMS[m.matchNumber]]?.city;
const isTarget = String(m.id) === String(targetMatchId);
const finished = m.status === "FINISHED";
const penalty = scorePenalty(m, dict);
return (
<div
key={m.id}
ref={isTarget ? targetRef : undefined}
style={{
background: "var(--bg-card)",
border: `${isTarget ? 2 : 1}px solid ${live ? "var(--turf)" : isTarget ? "var(--turf)" : "var(--line-soft)"}`,
@@ -187,13 +199,21 @@ export default function KoFixtures({ teams, matches, dict, locale }: { teams: Te
{/* Teams + Score */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<TeamLabel team={home} locale={locale} />
<span style={{
fontFamily: "var(--font-mono)", fontSize: 22, fontWeight: 700,
color: live ? "var(--turf)" : "var(--ink)",
<div style={{
position: "relative",
display: "flex", flexDirection: "column", alignItems: "center",
padding: "0 16px",
}}>
{scoreDisplay(m, dict)}
</span>
<span style={{
fontFamily: "var(--font-mono)", fontSize: 22, fontWeight: 700,
color: live ? "var(--turf)" : "var(--ink)",
lineHeight: 1,
}}>
{scoreMain(m)}
{penalty && <span className="pso-inline"> ({penalty})</span>}
</span>
{penalty && <span className="pso-badge">{penalty}</span>}
</div>
<TeamLabel team={away} reverse locale={locale} />
</div>

View File

@@ -2,12 +2,30 @@ import type { Metadata } from "next";
import Script from "next/script";
import { Locale, getDictionary } from "@/lib/i18n";
const BASE_URL = "https://soccer-2026.info";
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
const dict = getDictionary(locale as Locale);
return {
metadataBase: new URL(BASE_URL),
title: dict.meta.title,
description: dict.meta.description,
alternates: {
canonical: `${BASE_URL}/${locale}`,
languages: {
en: `${BASE_URL}/en`,
de: `${BASE_URL}/de`,
},
},
openGraph: {
title: dict.meta.title,
description: dict.meta.description,
url: `${BASE_URL}/${locale}`,
locale: locale === "en" ? "en_US" : "de_DE",
siteName: "WM 2026 Dashboard",
type: "website",
},
};
}
@@ -15,22 +33,35 @@ export async function generateStaticParams() {
return [{ locale: "en" }, { locale: "de" }];
}
export default function LocaleLayout({
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return (
<>
{children}
{process.env.NEXT_PUBLIC_UMAMI_SRC && process.env.NEXT_PUBLIC_UMAMI_ID && (
<Script
defer
src={process.env.NEXT_PUBLIC_UMAMI_SRC}
data-website-id={process.env.NEXT_PUBLIC_UMAMI_ID}
strategy="afterInteractive"
<html lang={locale}>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
<link
href="https://fonts.googleapis.com/css2?family=Archivo:wght@500;700;800&family=Archivo+Expanded:wght@700;800&family=Inter:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600&display=swap"
rel="stylesheet"
/>
)}
</>
</head>
<body>
{children}
{process.env.NEXT_PUBLIC_UMAMI_SRC && process.env.NEXT_PUBLIC_UMAMI_ID && (
<Script
defer
src={process.env.NEXT_PUBLIC_UMAMI_SRC}
data-website-id={process.env.NEXT_PUBLIC_UMAMI_ID}
strategy="afterInteractive"
/>
)}
</body>
</html>
);
}

View File

@@ -36,7 +36,7 @@ export default function Home({ params }: { params: Promise<{ locale: string }> }
const load = useCallback(async () => {
try {
const res = await fetch(`/api/matches?locale=${locale}`, { cache: "no-store" });
const res = await fetch(`/api/matches?locale=${locale}&t=${Date.now()}`, { cache: "no-store", headers: { "Pragma": "no-cache", "Cache-Control": "no-cache" } });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.detail || `Fehler ${res.status}`);
@@ -60,6 +60,11 @@ export default function Home({ params }: { params: Promise<{ locale: string }> }
setTab("groupfixtures");
}, []);
// Tab-Wechsel: nach ganz oben scrollen
useEffect(() => {
window.scrollTo({ top: 0, behavior: "auto" });
}, [tab]);
const anyLive = data?.matches.some(
(m) => m.status === "LIVE" || m.status === "IN_PLAY" || m.status === "PAUSED",
);

78
app/api/live/route.ts Normal file
View File

@@ -0,0 +1,78 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { fetchMatchesAndTeamsFifa, fetchFifaScores, applyFifaScores, attachFifaGoals } from "@/lib/feeds";
import type { Match, Team } from "@/lib/types";
export const dynamic = "force-dynamic";
const RECENT_MS = 60 * 60 * 1000;
const MATCH_DURATION = 2.5 * 60 * 60 * 1000;
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 > Date.now() - RECENT_MS;
}
export async function GET(request: NextRequest) {
const locale = request.nextUrl.searchParams.get("locale") || "de";
try {
const { matches: rawMatches, teams } = await fetchMatchesAndTeamsFifa(locale);
const relevant = rawMatches.filter(m =>
m.status === "LIVE" || m.status === "IN_PLAY" || m.status === "PAUSED" || isRecentlyFinished(m),
);
if (relevant.length === 0) {
return NextResponse.json({
updatedAt: new Date().toISOString(),
liveCount: 0,
recentCount: 0,
teams: [] as Team[],
matches: [] as Match[],
});
}
let fifaData: Awaited<ReturnType<typeof fetchFifaScores>>;
try {
fifaData = await fetchFifaScores();
} catch {
return NextResponse.json({
updatedAt: new Date().toISOString(),
liveCount: relevant.filter(m => m.status !== "FINISHED").length,
recentCount: relevant.filter(m => m.status === "FINISHED").length,
teams,
matches: relevant,
goalsFailed: true,
});
}
let matches = applyFifaScores(relevant, teams, fifaData);
let goalsFailed = false;
try {
matches = await attachFifaGoals(matches, fifaData, locale);
} catch {
goalsFailed = true;
}
const liveCount = matches.filter(m => m.status === "LIVE" || m.status === "IN_PLAY" || m.status === "PAUSED").length;
const recentCount = matches.filter(m => m.status === "FINISHED" && isRecentlyFinished(m)).length;
return NextResponse.json({
updatedAt: new Date().toISOString(),
liveCount,
recentCount,
teams,
matches,
goalsFailed,
});
} catch (err) {
const message = err instanceof Error ? err.message : "unbekannter Fehler";
return NextResponse.json(
{ error: "Live-Feed nicht erreichbar", detail: message },
{ status: 502 },
);
}
}

View File

@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { fetchMatchesAndTeamsFifa, fetchOdds, attachOdds, attachKOOdds, fetchFifaScores, applyFifaScores, attachFifaGoals } from "@/lib/feeds";
import { fetchMatchesAndTeamsFifa, fetchOdds, attachOdds, attachKOOdds, attachFifaLiveData } from "@/lib/feeds";
import { computeGroupTables, computeThirdPlaceTable } from "@/lib/standings";
import { qualifiedThirdGroups, resolveAnnexC } from "@/lib/bracket";
import { securelyQualifiedThirdTeams } from "@/lib/third-place-security";
@@ -27,17 +27,11 @@ export async function GET(request: NextRequest) {
matches = attachKOOdds(matches, teams, odds);
}
// FIFA-Live-Scores als häufiger gecachtes Overlay
// FIFA-Live-Scores als häufiger gecachtes Overlay (Pipeline: Scores → Goals)
try {
const fifaData = await fetchFifaScores(locale);
matches = applyFifaScores(matches, teams, fifaData);
try {
matches = await attachFifaGoals(matches, fifaData, locale);
} catch (err) {
console.warn("[fifa] Goals fehlgeschlagen:", err instanceof Error ? err.message : err);
}
matches = await attachFifaLiveData(matches, teams, locale);
} catch (err) {
console.warn("[fifa] Scores fehlgeschlagen:", err instanceof Error ? err.message : err);
console.warn("[fifa] Live-Overlay fehlgeschlagen:", err instanceof Error ? err.message : err);
}
const groupTables = computeGroupTables(teams, matches);

View File

@@ -285,6 +285,9 @@ table.standings { width: 100%; border-collapse: collapse; }
}
.foot a { color: var(--ink-dim); text-decoration: underline; text-underline-offset: 2px; }
/* PSO-Badge: auf Desktop inline im Score, auf Mobile absolut darunter schwebend */
.pso-badge { display: none; }
/* =====================================================================
MOBIL — Breakpoint 640px (iPhone ~375, Galaxy ~412, alle ≤640)
===================================================================== */
@@ -357,6 +360,22 @@ table.standings { width: 100%; border-collapse: collapse; }
.bracket-banner { padding: 10px 12px; font-size: 13px; }
.legend { font-size: 10px; gap: 12px; }
/* ---------- mobil: PSO-Badge (Elfmeterschießen unter dem Score) ---------- */
.pso-inline { display: none; }
.pso-badge {
display: block;
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
margin-top: 2px;
font-size: 10px;
font-family: var(--font-mono);
color: var(--ink);
white-space: nowrap;
pointer-events: none;
}
/* ---------- mobil: Simulation ---------- */
.sim-row { flex-wrap: wrap; gap: 6px; padding: 8px 10px; }
.sim-row-phase { min-width: 100%; font-size: 9px; }

View File

@@ -1,17 +1,5 @@
import "./globals.css";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
<link
href="https://fonts.googleapis.com/css2?family=Archivo:wght@500;700;800&family=Archivo+Expanded:wght@700;800&family=Inter:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600&display=swap"
rel="stylesheet"
/>
</head>
<body>{children}</body>
</html>
);
return children;
}

12
app/robots.ts Normal file
View File

@@ -0,0 +1,12 @@
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/api/"],
},
sitemap: "https://soccer-2026.info/sitemap.xml",
};
}

33
app/sitemap.ts Normal file
View File

@@ -0,0 +1,33 @@
import type { MetadataRoute } from "next";
const BASE = "https://soccer-2026.info";
export default function sitemap(): MetadataRoute.Sitemap {
const now = new Date();
return [
{
url: `${BASE}/en`,
lastModified: now,
changeFrequency: "hourly",
priority: 1.0,
alternates: {
languages: {
en: `${BASE}/en`,
de: `${BASE}/de`,
},
},
},
{
url: `${BASE}/de`,
lastModified: now,
changeFrequency: "hourly",
priority: 1.0,
alternates: {
languages: {
en: `${BASE}/en`,
de: `${BASE}/de`,
},
},
},
];
}

View File

@@ -5,24 +5,61 @@ import { computeGroupTables, computeThirdPlaceTable } from "./standings";
import { FIFA_GROUP_MAP, FIFA_STAGE_MAP } from "./fifa-constants";
// ----------------------------------------------------------------------------
// Caching: einfacher In-Memory-Cache mit TTL.
// 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<T> { value: T; expires: number; }
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();
// Frischer Cache → direkt zurück
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;
// 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<T> | 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;
}
// ----------------------------------------------------------------------------
@@ -463,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>();
@@ -531,18 +569,19 @@ 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 };
});
}
// Holt Live-Scores von der FIFA-API. Gibt Map MatchNumber → Scores + FIFA-ID→App-Code-Map zurück.
export async function fetchFifaScores(locale: string = "de"): Promise<{
// Scores/Status/Penalties/Winner sind sprachunabhängig → locale-freier Cache-Key.
export async function fetchFifaScores(): Promise<{
scores: Map<number, FifaScores>;
fifaIdToAppCode: Map<string, string>;
}> {
return cached(`fifa:scores:${locale}`, 45_000, async () => {
const lang = locale === "en" ? "en" : "de";
const url = `${FIFA_BASE}/calendar/matches?language=${lang}&count=500&idSeason=${FIFA_SEASON}`;
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" },
@@ -550,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>();
@@ -581,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 };
});
}
@@ -629,8 +670,11 @@ 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"): Promise<FifaGoalRaw[]> {
return cached(`fifa:detail:${locale}:${idMatch}`, 60_000, async () => {
async function fetchFifaGoals(
idStage: string, idMatch: string, locale: string = "de",
ttlMs: number = 60_000,
): Promise<FifaGoalRaw[]> {
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, {
@@ -673,26 +717,114 @@ 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 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<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" || m.status === "FINISHED") &&
((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);
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<Match[]> {
// --- Step 1: Scores von FIFA holen ---
let fifaData: Awaited<ReturnType<typeof fetchFifaScores>>;
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<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;
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;
});
}

View File

@@ -2,9 +2,9 @@ import { Dictionary } from "./types";
const de: Dictionary = {
meta: {
title: "WM 26 — Gruppen & K.o.-Baum",
title: "WM 2026 Live-Ticker, Ergebnisse & K.o.-Baum",
description:
"Live-Gruppentabellen, Drittplatzierten-Wertung und der vollständige K.o.-Baum der FIFA WM 2026 mit Annex-C-Zuordnung und Polymarket-Wahrscheinlichkeiten.",
"Live-Ergebnisse, Gruppentabellen, K.o.-Baum und Prognosen zur Fußball-WM 2026 in den USA, Kanada & Mexiko. Alle Spiele in Echtzeit.",
},
header: {
hostCountries: "USA · Kanada · Mexiko",

View File

@@ -8,9 +8,9 @@ function ordinal(n: number): string {
const en: Dictionary = {
meta: {
title: "WC 26 — Groups & KO Bracket",
title: "World Cup 2026 Live Scores, Bracket & Standings",
description:
"Live group tables, third-place ranking and the complete KO bracket for the FIFA World Cup 2026 with Annex C assignment and Polymarket probabilities.",
"Live scores, group standings, knockout bracket and match predictions for the 2026 FIFA World Cup in the USA, Canada & Mexico. Real-time results.",
},
header: {
hostCountries: "USA · Canada · Mexico",

View File

@@ -26,7 +26,7 @@ export function saveOverrides(overrides: SimOverrides): void {
// Plausibles Standardergebnis aus Polymarket-3-Wege-Wahrscheinlichkeiten.
//
// Schwellen (zentral):
// Draw am höchsten ODER max(home,away) < 0.40 → 0:0 (Gruppe) / 1:0 Heim (K.o.)
// Draw am höchsten ODER max(home,away) < 0.40 → 0:0 (Gruppe) / Favorit 1:0 (K.o.)
// Favorit 0.400.60 → 1:0
// Favorit 0.600.78 → 2:0
// Favorit > 0.78 → 3:0
@@ -48,7 +48,11 @@ export function defaultScore(match: Match): { homeScore: number; awayScore: numb
if (isDraw || maxFA < 0.40) {
if (isGroup) return { homeScore: 0, awayScore: 0 };
return { homeScore: 1, awayScore: 0 }; // K.o.: knapp Heim (kein Remis)
// K.o.: kein Remis möglich → der wahrscheinlichere von Heim/Auswärts gewinnt knapp,
// Draw wird ignoriert (kein gültiges K.o.-Ergebnis).
return prob.home >= prob.away
? { homeScore: 1, awayScore: 0 }
: { homeScore: 0, awayScore: 1 };
}
// Favorit bestimmen