diff --git a/app/api/matches/route.ts b/app/api/matches/route.ts index ae7c9fb..c2157cd 100644 --- a/app/api/matches/route.ts +++ b/app/api/matches/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { fetchMatchesAndTeams, fetchOdds, attachOdds, attachKOOdds, fetchLiveScores, applyLiveScores, assignKONumbersBySlots } from "@/lib/feeds"; +import { fetchMatchesAndTeams, fetchOdds, attachOdds, attachKOOdds, fetchLiveScores, applyLiveScores, assignKONumbersBySlots, fetchKOLiveData, attachKOLiveData } from "@/lib/feeds"; import { computeGroupTables, computeThirdPlaceTable } from "@/lib/standings"; import { qualifiedThirdGroups, resolveAnnexC } from "@/lib/bracket"; import { securelyQualifiedThirdTeams } from "@/lib/third-place-security"; @@ -39,6 +39,14 @@ export async function GET() { matches = attachKOOdds(matches, teams, odds); } + // KO-Live-Daten (Tore, Minute) von worldcup26 + try { + const koGames = await fetchKOLiveData(); + matches = attachKOLiveData(matches, teams, koGames); + } catch (err) { + console.warn("[worldcup26] KO-Live-Daten fehlgeschlagen:", err instanceof Error ? err.message : err); + } + const normalizedMatches = matches.map((m) => ({ ...m, prob: m.prob ?? null })); const thirdTable = computeThirdPlaceTable(groupTablesLive); diff --git a/app/components/KoFixtures.tsx b/app/components/KoFixtures.tsx new file mode 100644 index 0000000..0981e55 --- /dev/null +++ b/app/components/KoFixtures.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { useMemo, useState, useEffect } from "react"; +import { Match, Team } from "@/lib/types"; +import Flag from "./Flag"; + +function fmtShortDate(iso: string): string { + const d = new Date(iso); + return d.toLocaleDateString("de-DE", { weekday: "short", day: "2-digit", month: "2-digit" }); +} + +function fmtTime(iso: string): string { + const d = new Date(iso); + return d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }); +} + +function scoreDisplay(m: Match): string { + if (m.status === "SCHEDULED" || (m.homeScore == null && m.awayScore == null)) return "– : –"; + return `${m.homeScore ?? 0} : ${m.awayScore ?? 0}`; +} + +function statusLabel(m: Match): string { + switch (m.status) { + case "LIVE": case "IN_PLAY": return m.minute != null ? `${m.minute}'` : "Live"; + case "PAUSED": return "Halbzeit"; + case "FINISHED": return "Beendet"; + default: return fmtTime(m.utcDate); + } +} + +function isLive(m: Match): boolean { + return m.status === "LIVE" || m.status === "IN_PLAY" || m.status === "PAUSED"; +} + +export default function KoFixtures({ teams, matches }: { teams: Team[]; matches: Match[] }) { + const [mounted, setMounted] = useState(false); + useEffect(() => { setMounted(true); }, []); + + const koMatches = useMemo(() => { + return matches + .filter((m) => + m.group == null && + m.homeTeamId != null && m.awayTeamId != null && + // Nur Spiele mit bekannten Teams (keine TBD) + teams.some(t => t.id === m.homeTeamId) && + teams.some(t => t.id === m.awayTeamId), + ) + .sort((a, b) => { + // Live zuerst + const aLive = isLive(a) ? 1 : 0; + const bLive = isLive(b) ? 1 : 0; + if (aLive !== bLive) return bLive - aLive; + // Innerhalb Live/Upcoming/Finished: Datum aufsteigend + return +new Date(a.utcDate) - +new Date(b.utcDate); + }); + }, [matches, teams]); + + // Lokale Datums-Extraktion (vermeidet UTC-Tagesverschiebung bei US-Spielen) + function localDateKey(iso: string): string { + const d = new Date(iso); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; + } + + // Nach Datum gruppieren (lokale Zeitzone nach Hydration, sonst UTC) + const groups = useMemo(() => { + const map = new Map(); + for (const m of koMatches) { + const key = mounted ? localDateKey(m.utcDate) : m.utcDate.slice(0, 10); + if (!map.has(key)) map.set(key, []); + map.get(key)!.push(m); + } + return [...map.entries()]; + }, [koMatches, mounted]); + + if (koMatches.length === 0) { + return
Keine K.o.-Spiele mit bekannten Teams verfügbar.
; + } + + return ( +
+ {groups.map(([date, groupMatches]) => ( +
+

+ {fmtShortDate(groupMatches[0].utcDate)} +

+
+ {groupMatches.map((m) => { + const home = m.homeTeamId ? teams.find(t => t.id === m.homeTeamId) : undefined; + const away = m.awayTeamId ? teams.find(t => t.id === m.awayTeamId) : undefined; + const live = isLive(m); + const stageMap: Record = { + R32: "R32", R16: "Achtelfinale", QF: "Viertelfinale", + SF: "Halbfinale", "3RD": "Platz 3", FINAL: "Finale", + }; + + return ( +
+ {/* Kopfzeile */} +
+ {stageMap[m.stage] ?? m.stage} · Spiel {m.matchNumber || "—"} + + {statusLabel(m)} + +
+ + {/* Teams + Score */} +
+ + + {scoreDisplay(m)} + + +
+ + {/* Torabfolge */} + {m.goals && m.goals.length > 0 && ( +
+
+ Tore +
+
+ {m.goals.map((g, i) => ( + + {g.team === "home" ? "⬆" : "⬇"} + + {g.scorer} + + + {g.minute}' + + + ))} +
+
+ )} + + {/* Prob (falls vorhanden) */} + {m.prob && m.prob.home > 0 && ( +
+ Polymarket: {Math.round(m.prob.home * 100)}% / {Math.round(m.prob.draw * 100)}% / {Math.round(m.prob.away * 100)}% +
+ )} +
+ ); + })} +
+
+ ))} +
+ ); +} + +function TeamLabel({ team, reverse }: { team?: Team; reverse?: boolean }) { + return ( +
+ + + {team?.localisedName ?? team?.name ?? "—"} + +
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index 4893333..d778c7e 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -8,6 +8,7 @@ import ThirdPlace from "./components/ThirdPlace"; import Bracket from "./components/Bracket"; import Fixtures from "./components/Fixtures"; import Simulation from "./components/Simulation"; +import KoFixtures from "./components/KoFixtures"; interface ApiData { updatedAt: string; @@ -21,12 +22,12 @@ interface ApiData { annexResolved: boolean; } -type Tab = "groups" | "fixtures" | "thirds" | "bracket" | "sim"; +type Tab = "groups" | "groupfixtures" | "kofixtures" | "thirds" | "bracket" | "sim"; export default function Home() { const [data, setData] = useState(null); const [error, setError] = useState(null); - const [tab, setTab] = useState("groups"); + const [tab, setTab] = useState("kofixtures"); // Zuletzt gewählte Gruppe für den Spiele-Tab. null = noch keine gewählt. const [fixturesGroup, setFixturesGroup] = useState(null); @@ -53,7 +54,7 @@ export default function Home() { // Klick auf einen Gruppen-Header: Gruppe merken und zum Spiele-Tab wechseln. const openGroupFixtures = useCallback((g: GroupId) => { setFixturesGroup(g); - setTab("fixtures"); + setTab("groupfixtures"); }, []); const anyLive = data?.matches.some( @@ -77,14 +78,17 @@ export default function Home() { : "Lade Daten…"}