Compare commits
10 Commits
fifa-prima
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 63d4ed9470 | |||
| 5138e61854 | |||
| 867d3c96b1 | |||
| b727c110a2 | |||
| a10a876e0c | |||
| 8c639a83a9 | |||
| d6a026287a | |||
| 81d54e3e57 | |||
| 7f49ca1626 | |||
| 2c435472f5 |
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
78
app/api/live/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
12
app/robots.ts
Normal 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
33
app/sitemap.ts
Normal 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`,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
172
lib/feeds.ts
172
lib/feeds.ts
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.40–0.60 → 1:0
|
||||
// Favorit 0.60–0.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
|
||||
|
||||
Reference in New Issue
Block a user