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" });
|
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 "– : –";
|
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) {
|
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 {
|
function isLive(m: Match): boolean {
|
||||||
@@ -82,22 +85,31 @@ export default function KoFixtures({ teams, matches, dict, locale }: { teams: Te
|
|||||||
return upcoming?.id ?? null;
|
return upcoming?.id ?? null;
|
||||||
}, [koMatches]);
|
}, [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);
|
const hasScrolledRef = useRef<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mounted || !targetRef.current) return;
|
if (!mounted || !targetDateRef.current) return;
|
||||||
if (targetMatchId === hasScrolledRef.current) return;
|
if (targetDateKey === hasScrolledRef.current) return;
|
||||||
const el = targetRef.current;
|
const el = targetDateRef.current;
|
||||||
const raf = requestAnimationFrame(() => {
|
const raf = requestAnimationFrame(() => {
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
const absoluteTop = rect.top + window.scrollY;
|
const absoluteTop = rect.top + window.scrollY;
|
||||||
const offset = 180;
|
const offset = 180;
|
||||||
window.scrollTo({ top: Math.max(0, absoluteTop - offset), behavior: "smooth" });
|
window.scrollTo({ top: Math.max(0, absoluteTop - offset), behavior: "smooth" });
|
||||||
});
|
});
|
||||||
hasScrolledRef.current = targetMatchId;
|
hasScrolledRef.current = targetDateKey;
|
||||||
return () => cancelAnimationFrame(raf);
|
return () => cancelAnimationFrame(raf);
|
||||||
}, [mounted, targetMatchId, koMatches.length]);
|
}, [mounted, targetDateKey, koMatches.length]);
|
||||||
|
|
||||||
const [showScrollTop, setShowScrollTop] = useState(false);
|
const [showScrollTop, setShowScrollTop] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -116,7 +128,7 @@ export default function KoFixtures({ teams, matches, dict, locale }: { teams: Te
|
|||||||
<div>
|
<div>
|
||||||
{groups.map(([date, groupMatches]) => (
|
{groups.map(([date, groupMatches]) => (
|
||||||
<div key={date} style={{ marginBottom: 24 }}>
|
<div key={date} style={{ marginBottom: 24 }}>
|
||||||
<h3 style={{
|
<h3 ref={date === targetDateKey ? targetDateRef : undefined} style={{
|
||||||
fontFamily: "var(--font-display)", fontSize: 13,
|
fontFamily: "var(--font-display)", fontSize: 13,
|
||||||
textTransform: "uppercase", letterSpacing: "0.06em",
|
textTransform: "uppercase", letterSpacing: "0.06em",
|
||||||
color: "var(--ink-dim)", margin: "0 0 10px",
|
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 city = STADIUMS[MATCH_STADIUMS[m.matchNumber]]?.city;
|
||||||
const isTarget = String(m.id) === String(targetMatchId);
|
const isTarget = String(m.id) === String(targetMatchId);
|
||||||
const finished = m.status === "FINISHED";
|
const finished = m.status === "FINISHED";
|
||||||
|
const penalty = scorePenalty(m, dict);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={m.id}
|
key={m.id}
|
||||||
ref={isTarget ? targetRef : undefined}
|
|
||||||
style={{
|
style={{
|
||||||
background: "var(--bg-card)",
|
background: "var(--bg-card)",
|
||||||
border: `${isTarget ? 2 : 1}px solid ${live ? "var(--turf)" : isTarget ? "var(--turf)" : "var(--line-soft)"}`,
|
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 */}
|
{/* Teams + Score */}
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||||
<TeamLabel team={home} locale={locale} />
|
<TeamLabel team={home} locale={locale} />
|
||||||
<span style={{
|
<div style={{
|
||||||
fontFamily: "var(--font-mono)", fontSize: 22, fontWeight: 700,
|
position: "relative",
|
||||||
color: live ? "var(--turf)" : "var(--ink)",
|
display: "flex", flexDirection: "column", alignItems: "center",
|
||||||
padding: "0 16px",
|
padding: "0 16px",
|
||||||
}}>
|
}}>
|
||||||
{scoreDisplay(m, dict)}
|
<span style={{
|
||||||
</span>
|
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} />
|
<TeamLabel team={away} reverse locale={locale} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,30 @@ import type { Metadata } from "next";
|
|||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
import { Locale, getDictionary } from "@/lib/i18n";
|
import { Locale, getDictionary } from "@/lib/i18n";
|
||||||
|
|
||||||
|
const BASE_URL = "https://soccer-2026.info";
|
||||||
|
|
||||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
const dict = getDictionary(locale as Locale);
|
const dict = getDictionary(locale as Locale);
|
||||||
return {
|
return {
|
||||||
|
metadataBase: new URL(BASE_URL),
|
||||||
title: dict.meta.title,
|
title: dict.meta.title,
|
||||||
description: dict.meta.description,
|
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" }];
|
return [{ locale: "en" }, { locale: "de" }];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LocaleLayout({
|
export default async function LocaleLayout({
|
||||||
children,
|
children,
|
||||||
|
params,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
}) {
|
}) {
|
||||||
|
const { locale } = await params;
|
||||||
return (
|
return (
|
||||||
<>
|
<html lang={locale}>
|
||||||
{children}
|
<head>
|
||||||
{process.env.NEXT_PUBLIC_UMAMI_SRC && process.env.NEXT_PUBLIC_UMAMI_ID && (
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<Script
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
|
||||||
defer
|
<link
|
||||||
src={process.env.NEXT_PUBLIC_UMAMI_SRC}
|
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"
|
||||||
data-website-id={process.env.NEXT_PUBLIC_UMAMI_ID}
|
rel="stylesheet"
|
||||||
strategy="afterInteractive"
|
|
||||||
/>
|
/>
|
||||||
)}
|
</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 () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
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) {
|
if (!res.ok) {
|
||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
throw new Error(body.detail || `Fehler ${res.status}`);
|
throw new Error(body.detail || `Fehler ${res.status}`);
|
||||||
@@ -60,6 +60,11 @@ export default function Home({ params }: { params: Promise<{ locale: string }> }
|
|||||||
setTab("groupfixtures");
|
setTab("groupfixtures");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Tab-Wechsel: nach ganz oben scrollen
|
||||||
|
useEffect(() => {
|
||||||
|
window.scrollTo({ top: 0, behavior: "auto" });
|
||||||
|
}, [tab]);
|
||||||
|
|
||||||
const anyLive = data?.matches.some(
|
const anyLive = data?.matches.some(
|
||||||
(m) => m.status === "LIVE" || m.status === "IN_PLAY" || m.status === "PAUSED",
|
(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 { NextResponse } from "next/server";
|
||||||
import type { NextRequest } 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 { computeGroupTables, computeThirdPlaceTable } from "@/lib/standings";
|
||||||
import { qualifiedThirdGroups, resolveAnnexC } from "@/lib/bracket";
|
import { qualifiedThirdGroups, resolveAnnexC } from "@/lib/bracket";
|
||||||
import { securelyQualifiedThirdTeams } from "@/lib/third-place-security";
|
import { securelyQualifiedThirdTeams } from "@/lib/third-place-security";
|
||||||
@@ -27,17 +27,11 @@ export async function GET(request: NextRequest) {
|
|||||||
matches = attachKOOdds(matches, teams, odds);
|
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 {
|
try {
|
||||||
const fifaData = await fetchFifaScores(locale);
|
matches = await attachFifaLiveData(matches, teams, 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);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} 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);
|
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; }
|
.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)
|
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; }
|
.bracket-banner { padding: 10px 12px; font-size: 13px; }
|
||||||
.legend { font-size: 10px; gap: 12px; }
|
.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 ---------- */
|
/* ---------- mobil: Simulation ---------- */
|
||||||
.sim-row { flex-wrap: wrap; gap: 6px; padding: 8px 10px; }
|
.sim-row { flex-wrap: wrap; gap: 6px; padding: 8px 10px; }
|
||||||
.sim-row-phase { min-width: 100%; font-size: 9px; }
|
.sim-row-phase { min-width: 100%; font-size: 9px; }
|
||||||
|
|||||||
@@ -1,17 +1,5 @@
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return children;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
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";
|
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>>();
|
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> {
|
export async function cached<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T> {
|
||||||
const hit = cache.get(key) as CacheEntry<T> | undefined;
|
const hit = cache.get(key) as CacheEntry<T> | undefined;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Frischer Cache → direkt zurück
|
||||||
if (hit && hit.expires > now) return hit.value;
|
if (hit && hit.expires > now) return hit.value;
|
||||||
try {
|
|
||||||
const value = await fn();
|
// Abgelaufen, aber vorhanden → sofort alten Wert liefern, im Hintergrund erneuern
|
||||||
cache.set(key, { value, expires: now + ttlMs });
|
if (hit) {
|
||||||
return value;
|
if (!hit.refreshing) {
|
||||||
} catch (err) {
|
hit.refreshing = true;
|
||||||
// Bei Upstream-Fehler abgelaufenen Cache weiterverwenden, statt hart zu failen.
|
fn()
|
||||||
if (hit) return hit.value;
|
.then((value) => {
|
||||||
throw err;
|
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}`);
|
if (!res.ok) throw new Error(`fifa-calendar ${res.status}`);
|
||||||
const data = (await res.json()) as { Results: FifaCalendarMatch[] };
|
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>();
|
const teamMap = new Map<string, Team>();
|
||||||
|
|
||||||
@@ -531,18 +569,19 @@ export async function fetchMatchesAndTeamsFifa(locale: string = "de"): Promise<{
|
|||||||
});
|
});
|
||||||
|
|
||||||
const teams = [...teamMap.values()];
|
const teams = [...teamMap.values()];
|
||||||
|
console.log("[fifa-fetch] Teams:", teams.length, "Matches:", matches.length);
|
||||||
return { matches, teams };
|
return { matches, teams };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Holt Live-Scores von der FIFA-API. Gibt Map MatchNumber → Scores + FIFA-ID→App-Code-Map zurück.
|
// 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>;
|
scores: Map<number, FifaScores>;
|
||||||
fifaIdToAppCode: Map<string, string>;
|
fifaIdToAppCode: Map<string, string>;
|
||||||
}> {
|
}> {
|
||||||
return cached(`fifa:scores:${locale}`, 45_000, async () => {
|
return cached("fifa:scores", 45_000, async () => {
|
||||||
const lang = locale === "en" ? "en" : "de";
|
const url = `${FIFA_BASE}/calendar/matches?language=en&count=500&idSeason=${FIFA_SEASON}`;
|
||||||
const url = `${FIFA_BASE}/calendar/matches?language=${lang}&count=500&idSeason=${FIFA_SEASON}`;
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
headers: { "User-Agent": "wm2026-board/1.0" },
|
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}`);
|
if (!res.ok) throw new Error(`fifa ${res.status}`);
|
||||||
const data = (await res.json()) as { Results: FifaMatch[] };
|
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 scores = new Map<number, FifaScores>();
|
||||||
const fifaIdToAppCode = new Map<string, string>();
|
const fifaIdToAppCode = new Map<string, string>();
|
||||||
|
|
||||||
@@ -581,6 +621,7 @@ export async function fetchFifaScores(locale: string = "de"): Promise<{
|
|||||||
idStage: fm.IdStage ?? null,
|
idStage: fm.IdStage ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
console.log("[fifa-fetch] Score-Einträge:", scores.size, "Team-Mappings:", fifaIdToAppCode.size);
|
||||||
return { scores, fifaIdToAppCode };
|
return { scores, fifaIdToAppCode };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -629,8 +670,11 @@ function normMinuteStr(min: string | null | undefined): string {
|
|||||||
return (min ?? "").replace(/'/g, "").replace(/\s+/g, "").trim();
|
return (min ?? "").replace(/'/g, "").replace(/\s+/g, "").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchFifaGoals(idStage: string, idMatch: string, locale: string = "de"): Promise<FifaGoalRaw[]> {
|
async function fetchFifaGoals(
|
||||||
return cached(`fifa:detail:${locale}:${idMatch}`, 60_000, async () => {
|
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 lang = locale === "en" ? "en" : "de";
|
||||||
const url = `${FIFA_BASE}/live/football/17/${FIFA_SEASON}/${idStage}/${idMatch}?language=${lang}`;
|
const url = `${FIFA_BASE}/live/football/17/${FIFA_SEASON}/${idStage}/${idMatch}?language=${lang}`;
|
||||||
const res = await fetch(url, {
|
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(
|
export async function attachFifaGoals(
|
||||||
matches: Match[],
|
matches: Match[],
|
||||||
fifaData: { scores: Map<number, FifaScores>; fifaIdToAppCode: Map<string, string> },
|
fifaData: { scores: Map<number, FifaScores>; fifaIdToAppCode: Map<string, string> },
|
||||||
locale: string = "de",
|
locale: string = "de",
|
||||||
): Promise<Match[]> {
|
): Promise<Match[]> {
|
||||||
const targets = matches.filter(m =>
|
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,
|
((m.homeScore ?? 0) + (m.awayScore ?? 0)) > 0,
|
||||||
);
|
);
|
||||||
|
if (targets.length === 0) return matches;
|
||||||
|
|
||||||
const goalsByMatchId = new Map<string, GoalEvent[]>();
|
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);
|
const fs = fifaData.scores.get(m.matchNumber);
|
||||||
if (!fs?.idMatch || !fs?.idStage) return;
|
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);
|
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;
|
if (goalsByMatchId.size === 0) return matches;
|
||||||
|
|
||||||
return matches.map(m => {
|
return matches.map(m => {
|
||||||
const g = goalsByMatchId.get(m.id);
|
const g = goalsByMatchId.get(m.id);
|
||||||
return g ? { ...m, goals: g.map(({ scorer, minute, team }) => ({ scorer, minute, team })) } : m;
|
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 = {
|
const de: Dictionary = {
|
||||||
meta: {
|
meta: {
|
||||||
title: "WM 26 — Gruppen & K.o.-Baum",
|
title: "WM 2026 Live-Ticker, Ergebnisse & K.o.-Baum",
|
||||||
description:
|
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: {
|
header: {
|
||||||
hostCountries: "USA · Kanada · Mexiko",
|
hostCountries: "USA · Kanada · Mexiko",
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ function ordinal(n: number): string {
|
|||||||
|
|
||||||
const en: Dictionary = {
|
const en: Dictionary = {
|
||||||
meta: {
|
meta: {
|
||||||
title: "WC 26 — Groups & KO Bracket",
|
title: "World Cup 2026 Live Scores, Bracket & Standings",
|
||||||
description:
|
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: {
|
header: {
|
||||||
hostCountries: "USA · Canada · Mexico",
|
hostCountries: "USA · Canada · Mexico",
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function saveOverrides(overrides: SimOverrides): void {
|
|||||||
// Plausibles Standardergebnis aus Polymarket-3-Wege-Wahrscheinlichkeiten.
|
// Plausibles Standardergebnis aus Polymarket-3-Wege-Wahrscheinlichkeiten.
|
||||||
//
|
//
|
||||||
// Schwellen (zentral):
|
// 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.40–0.60 → 1:0
|
||||||
// Favorit 0.60–0.78 → 2:0
|
// Favorit 0.60–0.78 → 2:0
|
||||||
// Favorit > 0.78 → 3: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 (isDraw || maxFA < 0.40) {
|
||||||
if (isGroup) return { homeScore: 0, awayScore: 0 };
|
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
|
// Favorit bestimmen
|
||||||
|
|||||||
Reference in New Issue
Block a user