Compare commits

..

10 Commits

Author SHA1 Message Date
573bc74cfa colors 2026-06-24 16:24:44 -05:00
2f8e8b4a12 fix stage 2026-06-24 16:18:19 -05:00
7996ebc504 localized team names 2026-06-24 12:17:46 -05:00
aa19c16f93 favico 2026-06-24 12:13:52 -05:00
b25bcb85bd adds 2026-06-24 11:49:10 -05:00
ddaa1db876 polymarket slug 2026-06-24 11:28:26 -05:00
253b8cfbcd fix 2026-06-24 10:27:15 -05:00
4dc88f8e0f simulation 2026-06-24 09:52:03 -05:00
8dcf9d5e08 changes 2026-06-23 17:44:38 -05:00
e2bfb0a17d fixes 2026-06-23 16:45:36 -05:00
15 changed files with 888 additions and 105 deletions

View File

@@ -16,12 +16,16 @@ export async function GET() {
try {
const odds = await fetchOdds();
matches = attachOdds(rawMatches, teams, odds);
} catch {
// still ohne Wahrscheinlichkeiten
} catch (err) {
console.error("[polymarket] fetchOdds fehlgeschlagen:", err);
}
const groupTables = computeGroupTables(teams, matches);
const groupTablesLive = computeGroupTables(teams, matches, true);
// prob-Feld normalisieren: immer null statt undefined, damit JSON konsistent ist
const normalizedMatches = matches.map((m) => ({ ...m, prob: m.prob ?? null }));
const thirdTable = computeThirdPlaceTable(groupTablesLive);
const qGroups = qualifiedThirdGroups(thirdTable);
const annex = qGroups.length === 8 ? resolveAnnexC(qGroups) : null;
@@ -29,7 +33,7 @@ export async function GET() {
return NextResponse.json({
updatedAt: new Date().toISOString(),
teams,
matches,
matches: normalizedMatches,
groupTables,
groupTablesLive,
thirdTable,

View File

@@ -1,18 +1,33 @@
"use client";
import { useState, useCallback } from "react";
import { GroupTable, Match, Team, ThirdPlaceRow } from "@/lib/types";
import { ThirdAssignment, orderedRound } from "@/lib/bracket";
import { resolveBracket, ResolvedSide, ResolvedTie } from "@/lib/resolve-bracket";
import Flag from "./Flag";
interface TipState { x: number; y: number; text: string }
function Side({
s, prob, teams,
}: { s: ResolvedSide; prob?: number | null; teams: Team[] }) {
s, prob, teams, onShowTip, onHideTip,
}: {
s: ResolvedSide; prob?: number | null; teams: Team[];
onShowTip: (text: string, x: number, y: number) => void;
onHideTip: () => void;
}) {
const team = s.teamId ? teams.find((t) => t.id === s.teamId) : undefined;
// fix = ein echtes Team steht fest (nicht vorläufig). Eigene Klasse für klares Styling.
const fix = s.teamId != null && !s.provisional;
const hasTip = !!s.tooltip;
return (
<div className={`side ${s.isWinner ? "win" : ""} ${s.provisional ? "prov" : ""} ${fix ? "fix" : ""}`}>
<div
className={`side ${s.isWinner ? "win" : ""} ${s.provisional ? "prov" : ""} ${fix ? "fix" : ""}`}
{...(hasTip ? {
onMouseEnter: (e: React.MouseEvent<HTMLDivElement>) => onShowTip(s.tooltip!, e.clientX, e.clientY),
onMouseMove: (e: React.MouseEvent<HTMLDivElement>) => onShowTip(s.tooltip!, e.clientX, e.clientY),
onMouseLeave: onHideTip,
} : {})}
>
<span className="nm">
{s.teamId ? (
<>
@@ -32,30 +47,36 @@ function Side({
);
}
function Tie({ tie, teams, isFinal }: { tie: ResolvedTie; teams: Team[]; isFinal?: boolean }) {
function Tie({
tie, teams, isFinal, onShowTip, onHideTip,
}: {
tie: ResolvedTie; teams: Team[]; isFinal?: boolean;
onShowTip: (text: string, x: number, y: number) => void;
onHideTip: () => void;
}) {
return (
<div className={`tie ${isFinal ? "final-tie" : ""}`}>
<div className="tie-head">Spiel {tie.matchNumber}</div>
<Side s={tie.home} prob={tie.prob?.home} teams={teams} />
<Side s={tie.away} prob={tie.prob?.away} teams={teams} />
<Side s={tie.home} prob={tie.prob?.home} teams={teams} onShowTip={onShowTip} onHideTip={onHideTip} />
<Side s={tie.away} prob={tie.prob?.away} teams={teams} onShowTip={onShowTip} onHideTip={onHideTip} />
</div>
);
}
export default function Bracket({
matches, teams, tables, thirds, assignment, annexResolved,
preResolved,
}: {
matches: Match[]; teams: Team[]; tables: GroupTable[];
thirds: ThirdPlaceRow[]; assignment: ThirdAssignment | null; annexResolved: boolean;
preResolved?: { r32: ResolvedTie[]; later: Record<number, ResolvedTie> };
}) {
const { r32: r32Array, later } = resolveBracket(
const { r32: r32Array, later } = preResolved ?? resolveBracket(
matches, teams, tables, thirds, assignment, annexResolved,
);
const r32ByNum = new Map(r32Array.map((t) => [t.matchNumber, t]));
// Korrekte Baum-Reihenfolge: von der Wurzel (Finale) rekursiv die Quellen
// jeder Runde ermitteln, damit die Spalten vertikal ausgerichtet sind.
const sfOrder = orderedRound([104]);
const qfOrder = orderedRound(sfOrder);
const r16Order = orderedRound(qfOrder);
@@ -68,6 +89,10 @@ export default function Bracket({
const fin = later[104];
const third = later[103];
const [tip, setTip] = useState<TipState | null>(null);
const showTip = useCallback((text: string, x: number, y: number) => setTip({ x, y, text }), []);
const hideTip = useCallback(() => setTip(null), []);
return (
<div>
<div className="bracket-banner">
@@ -84,35 +109,35 @@ export default function Bracket({
<div className="round">
<div className="round-label">Letzte 32</div>
<div className="round-matches">
{r32.map((t) => <Tie key={t.matchNumber} tie={t} teams={teams} />)}
{r32.map((t) => <Tie key={t.matchNumber} tie={t} teams={teams} onShowTip={showTip} onHideTip={hideTip} />)}
</div>
</div>
<div className="round">
<div className="round-label">Achtelfinale</div>
<div className="round-matches">
{r16.map((t) => <Tie key={t.matchNumber} tie={t} teams={teams} />)}
{r16.map((t) => <Tie key={t.matchNumber} tie={t} teams={teams} onShowTip={showTip} onHideTip={hideTip} />)}
</div>
</div>
<div className="round">
<div className="round-label">Viertelfinale</div>
<div className="round-matches">
{qf.map((t) => <Tie key={t.matchNumber} tie={t} teams={teams} />)}
{qf.map((t) => <Tie key={t.matchNumber} tie={t} teams={teams} onShowTip={showTip} onHideTip={hideTip} />)}
</div>
</div>
<div className="round">
<div className="round-label">Halbfinale</div>
<div className="round-matches">
{sf.map((t) => <Tie key={t.matchNumber} tie={t} teams={teams} />)}
{sf.map((t) => <Tie key={t.matchNumber} tie={t} teams={teams} onShowTip={showTip} onHideTip={hideTip} />)}
</div>
</div>
<div className="round">
<div className="round-label">Finale</div>
<div className="round-matches">
{fin && <Tie tie={fin} teams={teams} isFinal />}
{fin && <Tie tie={fin} teams={teams} isFinal onShowTip={showTip} onHideTip={hideTip} />}
{third && (
<div style={{ marginTop: 20 }}>
<div className="round-label" style={{ marginBottom: 8 }}>Spiel um Platz 3</div>
<Tie tie={third} teams={teams} />
<Tie tie={third} teams={teams} onShowTip={showTip} onHideTip={hideTip} />
</div>
)}
</div>
@@ -128,6 +153,12 @@ export default function Bracket({
<span><i style={{ background: "var(--ink-faint)" }} />Platzhalter offen</span>
<span>%-Werte: Polymarket-Wahrscheinlichkeit (falls verfügbar)</span>
</div>
{tip && (
<div className="kobaum-tip" style={{ left: tip.x + 12, top: tip.y + 12 }}>
{tip.text}
</div>
)}
</div>
);
}
}

View File

@@ -101,7 +101,7 @@ function GroupStandings({
<td className="team">
<span className={`rankdot ${cls}`}>{r.rank}</span>
<Flag team={team} size={20} />
<span className="team-name">{team?.name ?? r.teamId}</span>
<span className="team-name">{team?.localisedName ?? team?.name ?? r.teamId}</span>
{isLive && <span className="row-live-dot" title="läuft gerade" />}
</td>
<td>{r.played}</td>
@@ -163,12 +163,12 @@ export default function Fixtures({
<div className="fx-teams">
<div className={`fx-team home ${homeWin ? "win" : ""}`}>
<Flag team={home} size={22} />
<span className="fx-name">{home?.name ?? "—"}</span>
<span className="fx-name">{home?.localisedName ?? home?.name ?? "—"}</span>
</div>
<ScoreCell m={m} />
<div className={`fx-team away ${awayWin ? "win" : ""}`}>
<Flag team={away} size={22} />
<span className="fx-name">{away?.name ?? "—"}</span>
<span className="fx-name">{away?.localisedName ?? away?.name ?? "—"}</span>
</div>
</div>
<div className="fx-meta">

View File

@@ -13,7 +13,7 @@ export default function Flag({
return (
<img
src={team.crest}
alt={team.code || team.name}
alt={team.code || team.localisedName || team.name}
className="flag"
style={style}
loading="lazy"

View File

@@ -36,6 +36,8 @@ export default function Groups({
{tables.map((t) => {
const live = liveMatchFor(t.group, matches);
const liveIds = liveTeamIdsFor(t.group, matches);
const groupMatches = matches.filter((m) => m.group === t.group);
const groupComplete = groupMatches.length > 0 && groupMatches.every((m) => m.status === "FINISHED");
return (
<div className="group-card" key={t.group}>
<button
@@ -43,7 +45,7 @@ export default function Groups({
onClick={() => onOpenGroup(t.group)}
title={`Spiele der Gruppe ${t.group} ansehen`}
>
<span className="group-name">Gruppe {t.group}</span>
<span className={`group-name${groupComplete ? " group-complete" : ""}`}>Gruppe {t.group}</span>
<span className="group-tag">
{live ? "● LIVE" : "Spiele ansehen →"}
</span>
@@ -66,7 +68,7 @@ export default function Groups({
<td className="team">
<span className={`rankdot ${cls}`}>{r.rank}</span>
<Flag team={team} size={20} />
<span className="team-name">{team?.name ?? r.teamId}</span>
<span className="team-name">{team?.localisedName ?? team?.name ?? r.teamId}</span>
{isLive && <span className="row-live-dot" title="läuft gerade" />}
</td>
<td>{r.played}</td>

View File

@@ -0,0 +1,350 @@
"use client";
import { useState, useMemo, useCallback, useEffect } from "react";
import { Match, Team } from "@/lib/types";
import { computeGroupTables, computeThirdPlaceTable } from "@/lib/standings";
import { qualifiedThirdGroups, resolveAnnexC, LATER_ROUNDS } from "@/lib/bracket";
import { resolveBracket, ResolvedTie } from "@/lib/resolve-bracket";
import {
loadOverrides, saveOverrides, buildSimMatches,
defaultScore, SimOverrides,
} from "@/lib/simulation";
import Bracket from "./Bracket";
import ThirdPlace from "./ThirdPlace";
import Flag from "./Flag";
function teamById(teams: Team[], id: string | null) {
return id ? teams.find((t) => t.id === id) : undefined;
}
function fmtDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleString("de-DE", {
weekday: "short", day: "2-digit", month: "2-digit",
hour: "2-digit", minute: "2-digit",
});
}
// Label für die Spielphase in der Liste
function phaseLabel(match: Match): string {
if (match.stage === "GROUP" && match.group) return `Gruppe ${match.group}`;
if (match.stage === "R32") return "Sechzehntelfinale";
if (match.stage === "R16") return "Achtelfinale";
if (match.stage === "QF") return "Viertelfinale";
if (match.stage === "SF") return "Halbfinale";
if (match.stage === "3RD") return "Spiel um Platz 3";
if (match.stage === "FINAL") return "Finale";
return match.stage;
}
export default function Simulation({ teams, matches }: { teams: Team[]; matches: Match[] }) {
const [overrides, setOverrides] = useState<SimOverrides>({});
useEffect(() => {
setOverrides(loadOverrides());
}, []);
const handleScore = useCallback((matchId: string, homeScore: number, awayScore: number) => {
setOverrides((prev) => {
const next = { ...prev, [matchId]: { homeScore, awayScore } };
saveOverrides(next);
return next;
});
}, []);
const handleReset = useCallback(() => {
setOverrides({});
saveOverrides({});
}, []);
const handleClear = useCallback((matchId: string) => {
setOverrides((prev) => {
const next = { ...prev };
delete next[matchId];
saveOverrides(next);
return next;
});
}, []);
// ---- Simulations-Pipeline ----
const simMatches = useMemo(() => buildSimMatches(matches, overrides), [matches, overrides]);
const simTables = useMemo(() => computeGroupTables(teams, simMatches), [teams, simMatches]);
const simThirds = useMemo(() => computeThirdPlaceTable(simTables), [simTables]);
const simQGroups = useMemo(() => qualifiedThirdGroups(simThirds), [simThirds]);
const simAnnex = useMemo(
() => (simQGroups.length === 8 ? resolveAnnexC(simQGroups) : null),
[simQGroups],
);
const simAnnexResolved = simAnnex != null;
const simBracket = useMemo(
() => resolveBracket(simMatches, teams, simTables, simThirds, simAnnex, simAnnexResolved, true),
[simMatches, teams, simTables, simThirds, simAnnex, simAnnexResolved],
);
// Echte Tabellen/Bracket für fix/provisional-Flags (nur tatsächlich FINISHED).
const realTables = useMemo(() => computeGroupTables(teams, matches), [teams, matches]);
const realThirds = useMemo(() => computeThirdPlaceTable(realTables), [realTables]);
const realQGroups = useMemo(() => qualifiedThirdGroups(realThirds), [realThirds]);
const realAnnex = useMemo(
() => (realQGroups.length === 8 ? resolveAnnexC(realQGroups) : null),
[realQGroups],
);
const realBracket = useMemo(
() => resolveBracket(matches, teams, realTables, realThirds, realAnnex, realAnnex != null),
[matches, teams, realTables, realThirds, realAnnex],
);
// R32: provisional-Flags aus dem echten Bracket übernehmen.
const realProvisional = useMemo(() => {
const m = new Map<number, { home: boolean; away: boolean }>();
for (const tie of realBracket.r32) {
m.set(tie.matchNumber, { home: tie.home.provisional, away: tie.away.provisional });
}
return m;
}, [realBracket]);
// Folgerunden: decided-Flags aus echten Matches (status === FINISHED).
const realDecided = useMemo(() => {
const m = new Map<number, boolean>();
for (const mt of matches) {
if (mt.group == null && mt.matchNumber >= 73) {
m.set(mt.matchNumber, mt.status === "FINISHED");
}
}
return m;
}, [matches]);
// Sim-Bracket mit echten provisional-Flags mergen (inkl. Tooltip-Korrektur).
const preResolved = useMemo(() => {
const fixTooltip = (t: string | null, prov: boolean): string | null => {
if (!t) return t;
if (prov) return t.startsWith("aktuell ") ? t : `aktuell ${t}`;
return t.startsWith("aktuell ") ? t.slice(8) : t;
};
const r32Fixed: ResolvedTie[] = simBracket.r32.map((tie) => {
const rp = realProvisional.get(tie.matchNumber);
if (!rp) return tie;
return {
...tie,
home: { ...tie.home, provisional: rp.home, tooltip: fixTooltip(tie.home.tooltip, rp.home) },
away: { ...tie.away, provisional: rp.away, tooltip: fixTooltip(tie.away.tooltip, rp.away) },
};
});
const laterFixed: Record<number, ResolvedTie> = {};
for (const km of LATER_ROUNDS) {
const tie = simBracket.later[km.matchNumber];
if (!tie) continue;
const homeProv = !(realDecided.get(km.fromHome) ?? false);
const awayProv = !(realDecided.get(km.fromAway) ?? false);
laterFixed[km.matchNumber] = {
...tie,
home: { ...tie.home, provisional: homeProv },
away: { ...tie.away, provisional: awayProv },
};
}
return { r32: r32Fixed, later: laterFixed };
}, [simBracket, realProvisional, realDecided]);
// ---- Eingabeliste: offene Spiele aus dem echten Feed ----
const openMatches = useMemo(
() =>
matches
.filter((m) => m.status !== "FINISHED")
.sort((a, b) => +new Date(a.utcDate) - +new Date(b.utcDate)),
[matches],
);
const groupPhase = openMatches.filter((m) => m.stage === "GROUP");
const koPhase = openMatches.filter((m) => m.stage !== "GROUP");
// KO-Eingaben erst freigeben, wenn alle Gruppenspiele beendet sind.
const groupPhaseComplete = matches
.filter((m) => m.group != null)
.every((m) => m.status === "FINISHED");
const hasChanges = Object.keys(overrides).length > 0;
return (
<div>
<div className="notice" style={{ marginBottom: 20 }}>
Simulation Ergebnisse frei wählbar, beeinflusst nicht die echten Daten.
Alle ungespielten Spiele sind vorbelegt mit dem Polymarket-Favoriten.
Eingaben werden im Browser gespeichert.
</div>
{/* ---- Eingabeliste ---- */}
<div style={{ marginBottom: 28 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 14 }}>
<h3 style={{ fontFamily: "var(--font-display)", fontSize: 15, textTransform: "uppercase", letterSpacing: "0.06em", color: "var(--ink-dim)", margin: 0 }}>
Offene Spiele ({openMatches.length})
</h3>
{hasChanges && (
<button
onClick={handleReset}
style={{
fontFamily: "var(--font-mono)", fontSize: 11,
color: "var(--ink-faint)", background: "var(--bg-card)",
border: "1px solid var(--line)", borderRadius: "var(--radius-sm)",
padding: "5px 12px", cursor: "pointer",
}}
>
Auf Polymarket-Defaults zurücksetzen
</button>
)}
</div>
{groupPhase.length > 0 && (
<div style={{ marginBottom: 16 }}>
<div style={{ fontFamily: "var(--font-display)", fontSize: 12, textTransform: "uppercase", letterSpacing: "0.06em", color: "var(--ink-faint)", marginBottom: 8 }}>
Gruppenphase
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{groupPhase.map((m) => (
<SimRow key={m.id} match={m} teams={teams} overrides={overrides} onScore={handleScore} onClear={handleClear} />
))}
</div>
</div>
)}
{koPhase.length > 0 && (
<div>
<div style={{ fontFamily: "var(--font-display)", fontSize: 12, textTransform: "uppercase", letterSpacing: "0.06em", color: "var(--ink-faint)", marginBottom: 8 }}>
K.o.-Phase
</div>
{groupPhaseComplete ? (
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{koPhase.map((m) => (
<SimRow key={m.id} match={m} teams={teams} overrides={overrides} onScore={handleScore} onClear={handleClear} />
))}
</div>
) : (
<div className="notice" style={{ fontSize: 13 }}>
K.o.-Spiele werden zur Eingabe freigegeben, sobald die Gruppenphase abgeschlossen ist.
</div>
)}
</div>
)}
</div>
{/* ---- Drittplatzierte (simuliert) ---- */}
<h3 style={{ fontFamily: "var(--font-display)", fontSize: 15, textTransform: "uppercase", letterSpacing: "0.06em", color: "var(--ink-dim)", margin: "0 0 12px" }}>
Drittplatzierte (simuliert)
</h3>
<ThirdPlace rows={simThirds} teams={teams} />
{/* ---- Simulierter K.o.-Baum ---- */}
<h3 style={{ fontFamily: "var(--font-display)", fontSize: 15, textTransform: "uppercase", letterSpacing: "0.06em", color: "var(--ink-dim)", margin: "28px 0 12px" }}>
Simulierter K.o.-Baum
</h3>
<Bracket
matches={simMatches}
teams={teams}
tables={simTables}
thirds={simThirds}
assignment={simAnnex}
annexResolved={simAnnexResolved}
preResolved={preResolved}
/>
</div>
);
}
// ---- Einzelne Eingabezeile ----
function SimRow({
match, teams, overrides, onScore, onClear,
}: {
match: Match; teams: Team[]; overrides: SimOverrides; onScore: (id: string, h: number, a: number) => void;
onClear: (id: string) => void;
}) {
const home = teamById(teams, match.homeTeamId);
const away = teamById(teams, match.awayTeamId);
const def = defaultScore(match);
const ov = overrides[match.id];
const homeVal = ov?.homeScore ?? def?.homeScore;
const awayVal = ov?.awayScore ?? def?.awayScore;
const homeChanged = ov != null;
const isKO = match.stage !== "GROUP";
const inputStyle = (changed: boolean): React.CSSProperties => ({
width: 36,
height: 28,
textAlign: "center",
fontFamily: "var(--font-mono)",
fontSize: 14,
fontWeight: 700,
color: changed ? "var(--turf)" : "var(--ink)",
background: "var(--bg-raised)",
border: `1px solid ${changed ? "var(--turf)" : "var(--line)"}`,
borderRadius: "var(--radius-sm)",
outline: "none",
MozAppearance: "textfield",
});
return (
<div style={{
display: "flex", alignItems: "center", gap: 10,
padding: "6px 12px",
background: "var(--bg-card)", border: "1px solid var(--line-soft)",
borderRadius: "var(--radius-sm)",
fontSize: 13,
}}>
<span style={{ fontFamily: "var(--font-mono)", fontSize: 10, color: "var(--ink-faint)", minWidth: 75 }}>
{phaseLabel(match)}
</span>
<span style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0, flex: 1, justifyContent: "flex-end" }}>
<Flag team={home} size={18} />
<span style={{ fontWeight: 600, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", maxWidth: 140 }}>
{home?.localisedName ?? home?.name ?? "—"}
</span>
</span>
<input
type="number"
min={0}
style={inputStyle(homeChanged)}
value={homeVal ?? ""}
placeholder=""
onChange={(e) => {
const v = parseInt(e.target.value, 10);
if (!isNaN(v) && v >= 0) {
onScore(match.id, v, awayVal ?? (isKO ? 0 : 1));
} else if (e.target.value === "") {
// Bei Leerung: Standard wiederherstellen
const fallback = def;
if (fallback) onScore(match.id, fallback.homeScore, fallback.awayScore);
}
}}
/>
<span style={{ fontFamily: "var(--font-mono)", fontWeight: 700, color: "var(--ink-faint)" }}>:</span>
<input
type="number"
min={0}
style={inputStyle(homeChanged)}
value={awayVal ?? ""}
placeholder=""
onChange={(e) => {
const v = parseInt(e.target.value, 10);
if (!isNaN(v) && v >= 0) {
onScore(match.id, homeVal ?? (isKO ? 0 : 1), v);
} else if (e.target.value === "") {
const fallback = def;
if (fallback) onScore(match.id, fallback.homeScore, fallback.awayScore);
else onClear(match.id);
}
}}
/>
<span style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0, flex: 1 }}>
<span style={{ fontWeight: 600, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", maxWidth: 140 }}>
{away?.localisedName ?? away?.name ?? "—"}
</span>
<Flag team={away} size={18} />
</span>
<span style={{ fontFamily: "var(--font-mono)", fontSize: 10, color: "var(--ink-faint)", minWidth: 100, textAlign: "right" }}>
{fmtDate(match.utcDate)}
</span>
</div>
);
}

View File

@@ -5,7 +5,10 @@ import { Team, ThirdPlaceRow } from "@/lib/types";
export default function ThirdPlace({
rows, teams,
}: { rows: ThirdPlaceRow[]; teams: Team[] }) {
const name = (id: string) => teams.find((t) => t.id === id)?.name ?? id;
const name = (id: string) => {
const t = teams.find((t) => t.id === id);
return t?.localisedName ?? t?.name ?? id;
};
return (
<div className="third-wrap">

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -118,6 +118,7 @@ a { color: inherit; text-decoration: none; }
font-family: var(--font-display); font-weight: 800; font-size: 16px;
text-transform: uppercase; letter-spacing: 0.02em;
}
.group-name.group-complete { color: var(--turf); }
.group-tag { font-family: var(--font-mono); font-size: 10px; color: var(--ink-faint); }
table.standings { width: 100%; border-collapse: collapse; }
@@ -189,10 +190,19 @@ table.standings { width: 100%; border-collapse: collapse; }
font-family: var(--font-mono); font-size: 12px; color: var(--ink-dim);
}
.bracket-banner .v { font-family: var(--font-mono); font-size: 12px; color: var(--turf); }
.bracket-scroll { overflow-x: auto; padding-bottom: 36px; }
.bracket-scroll {
margin-left: -20px;
margin-right: -20px;
padding-left: 20px;
padding-right: 20px;
overflow-x: hidden;
overflow-y: visible;
padding-bottom: 10px;
scrollbar-gutter: stable;
}
.bracket {
display: flex; gap: 26px; min-width: max-content; align-items: stretch;
padding-bottom: 16px;
padding-bottom: 8px;
}
.round { display: flex; flex-direction: column; min-width: 220px; }
.round-label {
@@ -231,6 +241,22 @@ table.standings { width: 100%; border-collapse: collapse; }
.legend span { display: inline-flex; align-items: center; gap: 6px; }
.legend i { width: 12px; height: 12px; border-radius: 3px; display: inline-block; }
/* Tooltip im K.o.-Baum (position: fixed, damit overflow-x den Tooltip nicht abschneidet) */
.kobaum-tip {
position: fixed;
z-index: 200;
pointer-events: none;
background: var(--bg-card);
border: 1px solid var(--line);
color: var(--ink);
font-family: var(--font-mono);
font-size: 12px;
padding: 6px 9px;
border-radius: var(--radius-sm);
white-space: nowrap;
box-shadow: 0 6px 20px rgba(0,0,0,0.4);
}
/* ---------- Zustände ---------- */
.notice {
background: var(--bg-card); border: 1px solid var(--line);

View File

@@ -7,6 +7,7 @@ import Groups from "./components/Groups";
import ThirdPlace from "./components/ThirdPlace";
import Bracket from "./components/Bracket";
import Fixtures from "./components/Fixtures";
import Simulation from "./components/Simulation";
interface ApiData {
updatedAt: string;
@@ -19,7 +20,7 @@ interface ApiData {
annexResolved: boolean;
}
type Tab = "groups" | "fixtures" | "thirds" | "bracket";
type Tab = "groups" | "fixtures" | "thirds" | "bracket" | "sim";
export default function Home() {
const [data, setData] = useState<ApiData | null>(null);
@@ -90,6 +91,9 @@ export default function Home() {
<button className={`tab ${tab === "bracket" ? "active" : ""}`} onClick={() => setTab("bracket")}>
K.o.-Baum
</button>
<button className={`tab ${tab === "sim" ? "active" : ""}`} onClick={() => setTab("sim")}>
Simulation
</button>
</nav>
</div>
</header>
@@ -133,6 +137,9 @@ export default function Home() {
annexResolved={data.annexResolved}
/>
)}
{data && tab === "sim" && (
<Simulation teams={data.teams} matches={data.matches} />
)}
</section>
</main>

View File

@@ -1,5 +1,6 @@
import { GroupId, Match, MatchStatus, Team } from "./types";
import { venueFor } from "./venues";
import { localisedTeamName } from "./team-mappings";
// ----------------------------------------------------------------------------
// Caching: einfacher In-Memory-Cache mit TTL. Verhindert, dass jede Browser-
@@ -129,6 +130,7 @@ export async function fetchMatchesAndTeams(): Promise<{ matches: Match[]; teams:
teamMap.set(id, {
id, name: side.name, code: side.tla ?? "", group,
crest: side.crest ?? null,
localisedName: localisedTeamName(side.tla ?? "", side.name ?? ""),
});
}
}
@@ -159,79 +161,176 @@ export async function fetchMatchesAndTeams(): Promise<{ matches: Match[]; teams:
}
// ----------------------------------------------------------------------------
// Polymarket Gamma API: Wahrscheinlichkeiten pro Spiel
// Polymarket Gamma API: Wahrscheinlichkeiten pro Spiel (Series soccer-fifwc)
// ----------------------------------------------------------------------------
const PM_BASE = "https://gamma-api.polymarket.com";
const PM_SERIES = "11433"; // WM 2026 Serie
interface PmEvent {
id: string;
slug: string;
title: string;
markets?: PmMarket[];
}
interface PmMarket {
question: string;
outcomes: string; // JSON-String, z.B. "[\"Germany\",\"Draw\",\"Mexico\"]"
outcomePrices: string; // JSON-String, z.B. "[\"0.55\",\"0.25\",\"0.20\"]"
slug: string;
groupItemTitle: string;
outcomes: string; // JSON-String ["Yes","No"]
outcomePrices: string; // JSON-String ["0.385","0.615"]
sportsMarketType?: string;
closed: boolean;
}
// Map: normalisierter Teamname-Schlüssel -> Wahrscheinlichkeit.
export interface OddsEntry {
outcomes: string[];
prices: number[];
question: string;
export interface ParsedOdds {
homeCode: string;
awayCode: string;
homeName: string;
awayName: string;
pHome: number;
pDraw: number;
pAway: number;
}
// Normalisiert Teamcodes zwischen Polymarket-Slug und Feed (3-Buchstaben).
// Bekannte Abweichungen: kr/kor (Südkorea), nld/ned (Niederlande), cdr/cod (DR Kongo).
const CODE_NORM: Record<string, string> = {
kor: "kr",
nld: "ned",
cdr: "cod",
cvi: "cpv",
rsa: "rsa", // ok
swe: "swe", // ok
};
function normCode(c: string): string {
return (CODE_NORM[c] ?? c).toLowerCase();
}
function safeParse<T>(s: string, fallback: T): T {
try { return JSON.parse(s) as T; } catch { return fallback; }
}
// Holt WM-bezogene Märkte über das Tag/Slug der Polymarket-WM-Kollektion.
// Hinweis: Der exakte Slug kann sich ändern; per ENV überschreibbar.
export async function fetchOdds(): Promise<OddsEntry[]> {
const slug = process.env.POLYMARKET_WC_SLUG || "world-cup-2026";
// Holt alle WM-Spiele über den Series-Endpoint mit Pagination.
// Parst pro Event die drei Moneyline-Märkte (Heim/Draw/Auswärts).
export async function fetchOdds(): Promise<ParsedOdds[]> {
return cached("pm:odds", 120_000, async () => {
const res = await fetch(
`${PM_BASE}/events?slug=${encodeURIComponent(slug)}`,
{ cache: "no-store", headers: { "User-Agent": "wm2026-board/1.0" } },
);
if (!res.ok) throw new Error(`polymarket ${res.status}`);
const events = (await res.json()) as Array<{ markets?: PmMarket[] }>;
const entries: OddsEntry[] = [];
for (const ev of events) {
const allEvents: PmEvent[] = [];
for (let offset = 0; ; offset += 100) {
const url = `${PM_BASE}/events?series_id=${PM_SERIES}&active=true&closed=false&limit=100&offset=${offset}`;
const res = await fetch(url, {
cache: "no-store",
headers: { "User-Agent": "wm2026-board/1.0" },
});
if (!res.ok) throw new Error(`polymarket ${res.status}`);
const page = (await res.json()) as PmEvent[];
allEvents.push(...page);
if (page.length < 100) break;
}
//console.log("[polymarket] events fetched:", allEvents.length);
const parsed: ParsedOdds[] = [];
for (const ev of allEvents) {
// Slug: fifwc-{home}-{away}-{yyyy}-{mm}-{dd}
const slugParts = ev.slug.replace("fifwc-", "").split("-");
if (slugParts.length < 5) continue;
const homeCode = normCode(slugParts[0]);
const awayCode = normCode(slugParts[1]);
let pHome = 0, pDraw = 0, pAway = 0;
let homeName = "", awayName = "";
for (const mk of ev.markets ?? []) {
if (mk.sportsMarketType && mk.sportsMarketType !== "moneyline") continue;
if (mk.closed) continue;
const outcomes = safeParse<string[]>(mk.outcomes, []);
const prices = safeParse<string[]>(mk.outcomePrices, []).map(Number);
if (outcomes.length && outcomes.length === prices.length) {
entries.push({ outcomes, prices, question: mk.question });
if (prices.length < 2) continue;
const yes = prices[0]; // erster Preis = "Yes"-Wahrscheinlichkeit
const gti = mk.groupItemTitle.toLowerCase();
if (mk.slug.endsWith("-draw") || gti.startsWith("draw")) {
pDraw = yes;
} else {
// Heuristik: match gegen groupItemTitle (Teamname)
// Prüfe ob der Titel den Home-Code oder Away-Code enthält
// Besser: erster Markt = Home, dritter = Away; Draw in der Mitte.
// Da die Reihenfolge nicht garantiert ist: über slug-Suffix matchen.
if (mk.slug.endsWith("-" + slugParts[0].toLowerCase())) {
pHome = yes;
homeName = mk.groupItemTitle;
} else if (mk.slug.endsWith("-" + slugParts[1].toLowerCase())) {
pAway = yes;
awayName = mk.groupItemTitle;
}
}
}
if (pHome > 0 || pDraw > 0 || pAway > 0) {
parsed.push({ homeCode, awayCode, homeName, awayName, pHome, pDraw, pAway });
}
}
return entries;
console.log("[polymarket] spiele:", parsed.length);
return parsed;
});
}
// Verknüpft Polymarket-Märkte mit Spielen anhand der Teamnamen im Markttitel.
export function attachOdds(matches: Match[], teams: Team[], odds: OddsEntry[]): Match[] {
const nameById = new Map(teams.map((t) => [t.id, t.name.toLowerCase()]));
// Normalisiert Teamnamen für Matching (lowercase, diakritische Zeichen, aliase).
function normName(n: string): string {
return n
.toLowerCase()
.normalize("NFD").replace(/[\u0300-\u036f]/g, "") // diakritische Zeichen entfernen
.replace(/[^a-z0-9 ]/g, "")
.replace(/\s+/g, " ")
.trim();
}
// Verknüpft Polymarket-Odds mit Feed-Matches über Teamnamen (primär) oder Codes.
export function attachOdds(matches: Match[], teams: Team[], odds: ParsedOdds[]): Match[] {
// Map: Team-ID → normierter Name
const nameById = new Map(teams.map((t) => [t.id, normName(t.name)]));
// Map: normierter Name → Team-ID (für Lookup)
const idByName = new Map<string, string>();
for (const t of teams) {
const nn = normName(t.name);
if (!idByName.has(nn)) idByName.set(nn, t.id);
}
// Map: normierter Code → Team-ID
const idByCode = new Map(teams.map((t) => [t.code.toLowerCase(), t.id]));
// Map: Polymarket ParsedOdds → { homeTeamId, awayTeamId }
const oddsMatch = new Map<ParsedOdds, { homeId: string; awayId: string } | null>();
for (const o of odds) {
const hn = normName(o.homeName);
const an = normName(o.awayName);
const homeId = idByName.get(hn) ?? idByCode.get(o.homeCode);
const awayId = idByName.get(an) ?? idByCode.get(o.awayCode);
if (homeId && awayId) {
oddsMatch.set(o, { homeId, awayId });
} else {
oddsMatch.set(o, null);
}
}
return matches.map((m) => {
if (!m.homeTeamId || !m.awayTeamId) return m;
const hn = nameById.get(m.homeTeamId);
const an = nameById.get(m.awayTeamId);
if (!hn || !an) return m;
const hit = odds.find(
(o) => o.question.toLowerCase().includes(hn) && o.question.toLowerCase().includes(an),
);
if (!hit) return m;
// Heuristik: 3 Outcomes = Heim/Unentschieden/Auswärts; 2 = Heim/Auswärts.
const idxHome = hit.outcomes.findIndex((o) => o.toLowerCase().includes(hn));
const idxAway = hit.outcomes.findIndex((o) => o.toLowerCase().includes(an));
const idxDraw = hit.outcomes.findIndex((o) => /draw|unentschieden|tie/i.test(o));
if (idxHome < 0 || idxAway < 0) return m;
return {
...m,
prob: {
home: hit.prices[idxHome] ?? 0,
draw: idxDraw >= 0 ? (hit.prices[idxDraw] ?? null) : null,
away: hit.prices[idxAway] ?? 0,
},
};
// Suche in oddsMatch nach einer Kombination die beide Team-IDs matcht
for (const [o, ids] of oddsMatch) {
if (!ids) continue;
// Beide Richtungen prüfen
if ((ids.homeId === m.homeTeamId && ids.awayId === m.awayTeamId) ||
(ids.homeId === m.awayTeamId && ids.awayId === m.homeTeamId)) {
const swapped = ids.homeId === m.awayTeamId;
return {
...m,
prob: {
home: swapped ? o.pAway : o.pHome,
draw: o.pDraw,
away: swapped ? o.pHome : o.pAway,
},
};
}
}
return m;
});
}

View File

@@ -5,14 +5,6 @@ import {
} from "@/lib/bracket";
import { placeIsSecure } from "@/lib/secure-places";
// Eine Gruppe gilt als abgeschlossen, wenn alle ihre Gruppenspiele beendet sind.
// Wird für die Drittplatzierten-Sicherheit genutzt (gruppenübergreifend).
function groupFinished(group: GroupId, matches: Match[]): boolean {
const groupMatches = matches.filter((m) => m.group === group);
if (groupMatches.length === 0) return false;
return groupMatches.every((m) => m.status === "FINISHED");
}
// Ein aufgelöster Teilnehmer einer K.o.-Begegnung.
export interface ResolvedSide {
teamId: string | null; // null = noch Platzhalter
@@ -21,6 +13,7 @@ export interface ResolvedSide {
score: number | null;
isWinner: boolean;
provisional: boolean; // true = nur auf Basis aktueller Tabelle, noch nicht fix
tooltip: string | null; // Hover-Erklärung (Herkunft + ob fix)
}
export interface ResolvedTie {
@@ -53,6 +46,15 @@ function loserOf(m: Match | undefined): string | null {
return null;
}
// Prüft, ob die gesamte Gruppenphase abgeschlossen ist.
// Erst dann stehen die Drittplatzierten-Rangliste und die
// Annex-C-Zuordnung final fest.
function groupStageComplete(matches: Match[]): boolean {
const groupMatches = matches.filter((m) => m.group != null);
if (groupMatches.length === 0) return false;
return groupMatches.every((m) => m.status === "FINISHED");
}
// Löst einen R32-Slot (W/R/3) zu einer Team-ID auf, sofern bereits bekannt.
// provisional = true, solange die zugrunde liegende Gruppe/Zuordnung nicht fix ist.
function resolveR32Slot(
@@ -64,38 +66,55 @@ function resolveR32Slot(
matches: Match[],
teams: Team[],
annexResolved: boolean,
): { teamId: string | null; provisional: boolean } {
): { teamId: string | null; provisional: boolean; tooltip: string | null } {
const table = (g: GroupId) => tables.find((t) => t.group === g);
if (slot.type === "W") {
const t = table(slot.group!);
const teamId = t?.rows.find((r) => r.rank === 1)?.teamId ?? null;
// Sieger fix, sobald Platz 1 rechnerisch gesichert ist (auch vor Gruppenende).
return { teamId, provisional: !placeIsSecure(slot.group!, 1, teams, matches) };
const provisional = !placeIsSecure(slot.group!, 1, teams, matches);
const prefix = provisional ? "aktuell " : "";
return { teamId, provisional, tooltip: `${prefix}1. Gruppe ${slot.group}` };
}
if (slot.type === "R") {
const t = table(slot.group!);
const teamId = t?.rows.find((r) => r.rank === 2)?.teamId ?? null;
// Zweiter fix, sobald Platz 2 rechnerisch gesichert ist.
return { teamId, provisional: !placeIsSecure(slot.group!, 2, teams, matches) };
const provisional = !placeIsSecure(slot.group!, 2, teams, matches);
const prefix = provisional ? "aktuell " : "";
return { teamId, provisional, tooltip: `${prefix}2. Gruppe ${slot.group}` };
}
// 3. Platz: braucht Annex-C-Zuordnung + den Gruppensieger dieses Spiels
if (slot.type === "3" && assignment && winnerGroup) {
const thirdGroup = assignment[winnerGroup];
if (thirdGroup) {
const row = thirds.find((r) => r.group === thirdGroup && r.qualifies);
// Fix nur, wenn Annex C aufgelöst UND die Quellgruppe abgeschlossen ist
// (Drittplatzierten-Qualifikation ist gruppenübergreifend, bis zuletzt offen).
const fix = annexResolved && groupFinished(thirdGroup, matches);
return { teamId: row?.teamId ?? null, provisional: !fix };
// Fix nur, wenn ALLE drei Bedingungen erfüllt sind:
// 1. Annex C aufgelöst (8 Dritte zuweisbar)
// 2. Komplette Gruppenphase beendet (Dritten-Rangliste final)
// 3. Das Team gehört gesichert zu den besten 8
const fix = annexResolved && groupStageComplete(matches) && row?.qualifies === true;
const provisional = !fix;
const prefix = provisional ? "aktuell " : "";
return {
teamId: row?.teamId ?? null,
provisional,
tooltip: `${prefix}3. der Gruppe ${thirdGroup}`,
};
}
}
return { teamId: null, provisional: true };
if (slot.type === "3") {
const pool = slot.thirdPool?.join("") ?? "?";
return { teamId: null, provisional: true, tooltip: `aktuell 3. Gruppe ${pool}` };
}
return { teamId: null, provisional: true, tooltip: null };
}
function sideFrom(
teamId: string | null, fallbackLabel: string,
teams: Team[], feed: Match | undefined, which: "home" | "away",
provisional: boolean,
tooltip: string | null,
): ResolvedSide {
const team = teamId ? teams.find((t) => t.id === teamId) : undefined;
const score = feed
@@ -104,19 +123,25 @@ function sideFrom(
const w = winnerOf(feed);
return {
teamId,
label: team?.name ?? fallbackLabel,
label: team?.localisedName ?? team?.name ?? fallbackLabel,
code: team?.code ?? "",
score,
isWinner: teamId != null && w === teamId,
provisional: teamId != null && provisional,
tooltip,
};
}
// Baut die komplette aufgelöste Bracket-Struktur.
// resolveWinners: wenn true, werden die Gewinner-TeamIDs aus den aufgelösten
// R32-Slots abgeleitet (basierend auf den Spiel-Scores), statt aus den Team-IDs
// des Feeds. Nötig für die Simulation, wo KO-Matches Scores aber keine Team-IDs
// im Feed haben.
export function resolveBracket(
matches: Match[], teams: Team[], tables: GroupTable[],
thirds: ThirdPlaceRow[], assignment: ThirdAssignment | null,
annexResolved: boolean,
resolveWinners = false,
): { r32: ResolvedTie[]; later: Record<number, ResolvedTie> } {
// Map: Match-Nummer -> Sieger-Team-ID (für Propagation in Folgerunden)
const winners = new Map<number, string | null>();
@@ -133,11 +158,28 @@ export function resolveBracket(
const h = resolveR32Slot(rm.home, winnerGroup, tables, assignment, thirds, matches, teams, annexResolved);
const a = resolveR32Slot(rm.away, winnerGroup, tables, assignment, thirds, matches, teams, annexResolved);
const home = sideFrom(h.teamId, slotLabel(rm.home, assignment, winnerGroup), teams, feed, "home", h.provisional);
const away = sideFrom(a.teamId, slotLabel(rm.away, assignment, winnerGroup), teams, feed, "away", a.provisional);
const home = sideFrom(h.teamId, slotLabel(rm.home, assignment, winnerGroup), teams, feed, "home", h.provisional, h.tooltip);
const away = sideFrom(a.teamId, slotLabel(rm.away, assignment, winnerGroup), teams, feed, "away", a.provisional, a.tooltip);
winners.set(rm.matchNumber, winnerOf(feed));
losers.set(rm.matchNumber, loserOf(feed));
const feedWinner = winnerOf(feed);
if (resolveWinners && !feedWinner && feed && h.teamId && a.teamId
&& feed.homeScore != null && feed.awayScore != null) {
// Simulation: Gewinner aus Scores und aufgelösten Team-IDs ableiten
if (feed.homeScore > feed.awayScore) {
winners.set(rm.matchNumber, h.teamId);
losers.set(rm.matchNumber, loserOf(feed) ?? a.teamId);
} else if (feed.awayScore > feed.homeScore) {
winners.set(rm.matchNumber, a.teamId);
losers.set(rm.matchNumber, loserOf(feed) ?? h.teamId);
} else {
// Unentschieden → Heim gewinnt
winners.set(rm.matchNumber, h.teamId);
losers.set(rm.matchNumber, loserOf(feed) ?? a.teamId);
}
} else {
winners.set(rm.matchNumber, feedWinner);
losers.set(rm.matchNumber, loserOf(feed));
}
decided.set(rm.matchNumber, feed?.status === "FINISHED");
return {
matchNumber: rm.matchNumber, stage: "R32",
@@ -156,10 +198,28 @@ export function resolveBracket(
const awayProv = !(decided.get(km.fromAway) ?? false);
const homeLabel = `${km.losers ? "Verlierer" : "Sieger"} ${km.fromHome}`;
const awayLabel = `${km.losers ? "Verlierer" : "Sieger"} ${km.fromAway}`;
const home = sideFrom(homeId, homeLabel, teams, feed, "home", homeProv);
const away = sideFrom(awayId, awayLabel, teams, feed, "away", awayProv);
winners.set(km.matchNumber, winnerOf(feed));
losers.set(km.matchNumber, loserOf(feed));
const homeTtip = `${km.losers ? "Verlierer" : "Sieger"} aus Spiel ${km.fromHome}`;
const awayTtip = `${km.losers ? "Verlierer" : "Sieger"} aus Spiel ${km.fromAway}`;
const home = sideFrom(homeId, homeLabel, teams, feed, "home", homeProv, homeTtip);
const away = sideFrom(awayId, awayLabel, teams, feed, "away", awayProv, awayTtip);
const feedWinner = winnerOf(feed);
if (resolveWinners && !feedWinner && feed && homeId && awayId
&& feed.homeScore != null && feed.awayScore != null) {
if (feed.homeScore > feed.awayScore) {
winners.set(km.matchNumber, homeId);
losers.set(km.matchNumber, loserOf(feed) ?? awayId);
} else if (feed.awayScore > feed.homeScore) {
winners.set(km.matchNumber, awayId);
losers.set(km.matchNumber, loserOf(feed) ?? homeId);
} else {
winners.set(km.matchNumber, homeId);
losers.set(km.matchNumber, loserOf(feed) ?? awayId);
}
} else {
winners.set(km.matchNumber, feedWinner);
losers.set(km.matchNumber, loserOf(feed));
}
decided.set(km.matchNumber, feed?.status === "FINISHED");
later[km.matchNumber] = {
matchNumber: km.matchNumber, stage: km.stage,

137
lib/simulation.ts Normal file
View File

@@ -0,0 +1,137 @@
import { Match, Team, GroupTable, ThirdPlaceRow } from "./types";
import { ThirdAssignment, LATER_ROUNDS } from "./bracket";
import { ResolvedSide, ResolvedTie } from "./resolve-bracket";
export type SimOverrides = Record<string, { homeScore: number; awayScore: number }>;
const STORAGE_KEY = "wm2026-sim-overrides";
export function loadOverrides(): SimOverrides {
if (typeof window === "undefined") return {};
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : {};
} catch {
return {};
}
}
export function saveOverrides(overrides: SimOverrides): void {
if (typeof window === "undefined") return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(overrides));
} catch { /* quota exceeded */ }
}
// 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.)
// Favorit 0.400.60 → 1:0
// Favorit 0.600.78 → 2:0
// Favorit > 0.78 → 3:0
//
// Beispiele:
// SchweizKanada (0.385/0.315/0.295) → 0:0
// SchottlandBrasilien (0.10/0.17/0.75) → 0:2
// MarokkoHaiti (0.83/0.13/0.05) → 3:0
export function defaultScore(match: Match): { homeScore: number; awayScore: number } | null {
const prob = match.prob;
if (!prob) {
if (match.stage === "GROUP") return { homeScore: 1, awayScore: 1 };
return null;
}
const maxFA = Math.max(prob.home, prob.away);
const isDraw = prob.draw > prob.home && prob.draw > prob.away;
const isGroup = match.stage === "GROUP";
if (isDraw || maxFA < 0.40) {
if (isGroup) return { homeScore: 0, awayScore: 0 };
return { homeScore: 1, awayScore: 0 }; // K.o.: knapp Heim (kein Remis)
}
// Favorit bestimmen
const homeFav = prob.home >= prob.away;
let goals: number;
if (maxFA > 0.78) {
goals = 3;
} else if (maxFA >= 0.60) {
goals = 2;
} else {
goals = 1; // 0.400.60
}
if (homeFav) return { homeScore: goals, awayScore: 0 };
return { homeScore: 0, awayScore: goals };
}
// Baut die simulierte Match-Liste: gespielte Spiele unverändert,
// offene Spiele mit Override oder Polymarket-Default, status = "FINISHED".
export function buildSimMatches(realMatches: Match[], overrides: SimOverrides): Match[] {
return realMatches.map((m) => {
if (m.status === "FINISHED") return m;
const override = overrides[m.id];
if (override) {
return { ...m, homeScore: override.homeScore, awayScore: override.awayScore, status: "FINISHED" as const };
}
const def = defaultScore(m);
if (def) {
return { ...m, homeScore: def.homeScore, awayScore: def.awayScore, status: "FINISHED" as const };
}
return m;
});
}
// Ermittelt die Gewinner-Seite eines aufgelösten Ties anhand der Scores.
function winningSide(tie: ResolvedTie): ResolvedSide | null {
if (tie.home.score == null || tie.away.score == null) return null;
if (tie.home.score > tie.away.score) return tie.home;
if (tie.away.score > tie.home.score) return tie.away;
return tie.home; // unentschieden → Heimseite gewinnt
}
function losingSide(tie: ResolvedTie): ResolvedSide | null {
if (tie.home.score == null || tie.away.score == null) return null;
if (tie.home.score > tie.away.score) return tie.away;
if (tie.away.score > tie.home.score) return tie.home;
return tie.away;
}
// Propagiert Gewinner aus R32 durch alle Folgerunden (R16→QF→SF→Finale).
// Füllt teamId/label/code in den späteren Runden mit den Daten des Gewinners.
export function propagateSimWinners(
r32: ResolvedTie[],
later: Record<number, ResolvedTie>,
): Record<number, ResolvedTie> {
const winners = new Map<number, { teamId: string; label: string; code: string }>();
const losers = new Map<number, { teamId: string; label: string; code: string }>();
function update(tie: ResolvedTie) {
const w = winningSide(tie);
const l = losingSide(tie);
if (w?.teamId) winners.set(tie.matchNumber, { teamId: w.teamId, label: w.label, code: w.code });
if (l?.teamId) losers.set(tie.matchNumber, { teamId: l.teamId, label: l.label, code: l.code });
}
for (const tie of r32) update(tie);
const result: Record<number, ResolvedTie> = {};
for (const km of LATER_ROUNDS) {
const tie = later[km.matchNumber];
if (!tie) continue;
const src = km.losers ? losers : winners;
const hw = src.get(km.fromHome);
const aw = src.get(km.fromAway);
const home = hw
? { ...tie.home, teamId: hw.teamId, label: hw.label, code: hw.code, isWinner: false, provisional: false }
: tie.home;
const away = aw
? { ...tie.away, teamId: aw.teamId, label: aw.label, code: aw.code, isWinner: false, provisional: false }
: tie.away;
const newTie: ResolvedTie = { ...tie, home, away };
update(newTie);
result[km.matchNumber] = newTie;
}
return result;
}

63
lib/team-mappings.ts Normal file
View File

@@ -0,0 +1,63 @@
// Zentrale Lokalisierungstabelle für Teamnamen (TLA → Deutsch / Englisch).
// Schlüssel = FIFA-3-Buchstaben-Code (uppercase), wie er von football-data.org
// im Feld `tla` geliefert wird.
//
// Erweiterung auf weitere Sprachen: einfach pro Sprache ein Feld ergänzen.
export const TEAM_LOCALIZATION: Record<string, { de: string; en: string }> = {
ALG: { de: "Algerien", en: "Algeria" },
ARG: { de: "Argentinien", en: "Argentina" },
AUS: { de: "Australien", en: "Australia" },
AUT: { de: "Österreich", en: "Austria" },
BEL: { de: "Belgien", en: "Belgium" },
BIH: { de: "Bosnien-Herzegowina", en: "Bosnia-Herzegovina" },
BRA: { de: "Brasilien", en: "Brazil" },
CAN: { de: "Kanada", en: "Canada" },
CHE: { de: "Schweiz", en: "Switzerland" },
CIV: { de: "Elfenbeinküste", en: "Ivory Coast" },
COD: { de: "DR Kongo", en: "Congo DR" },
COL: { de: "Kolumbien", en: "Colombia" },
CPV: { de: "Kap Verde", en: "Cape Verde" },
CZE: { de: "Tschechien", en: "Czech Republic" },
ECU: { de: "Ecuador", en: "Ecuador" },
EGY: { de: "Ägypten", en: "Egypt" },
ENG: { de: "England", en: "England" },
ESP: { de: "Spanien", en: "Spain" },
FRA: { de: "Frankreich", en: "France" },
GER: { de: "Deutschland", en: "Germany" },
GHA: { de: "Ghana", en: "Ghana" },
HAI: { de: "Haiti", en: "Haiti" },
HRV: { de: "Kroatien", en: "Croatia" },
IRN: { de: "Iran", en: "Iran" },
IRQ: { de: "Irak", en: "Iraq" },
JOR: { de: "Jordanien", en: "Jordan" },
JPN: { de: "Japan", en: "Japan" },
KOR: { de: "Südkorea", en: "South Korea" },
KSA: { de: "Saudi-Arabien", en: "Saudi Arabia" },
MAR: { de: "Marokko", en: "Morocco" },
MEX: { de: "Mexiko", en: "Mexico" },
NED: { de: "Niederlande", en: "Netherlands" },
NOR: { de: "Norwegen", en: "Norway" },
NZL: { de: "Neuseeland", en: "New Zealand" },
PAN: { de: "Panama", en: "Panama" },
PAR: { de: "Paraguay", en: "Paraguay" },
PRT: { de: "Portugal", en: "Portugal" },
QAT: { de: "Katar", en: "Qatar" },
RSA: { de: "Südafrika", en: "South Africa" },
SCO: { de: "Schottland", en: "Scotland" },
SEN: { de: "Senegal", en: "Senegal" },
SWE: { de: "Schweden", en: "Sweden" },
TUN: { de: "Tunesien", en: "Tunisia" },
TUR: { de: "Türkei", en: "Turkey" },
URU: { de: "Uruguay", en: "Uruguay" },
USA: { de: "USA", en: "USA" },
UZB: { de: "Usbekistan", en: "Uzbekistan" },
};
// Liefert den lokalisierten Namen für ein Team in der gewünschten Sprache.
// Fallback: originalName (englischer Feed-Name), wenn kein Eintrag existiert.
export function localisedTeamName(tla: string, originalName: string, lang: "de" | "en" = "de"): string {
const entry = TEAM_LOCALIZATION[tla.toUpperCase()];
if (entry) return entry[lang];
return originalName;
}

View File

@@ -11,10 +11,11 @@ export const GROUP_IDS: GroupId[] = [
export interface Team {
id: string; // stabile ID aus dem Feed
name: string; // Anzeigename
name: string; // Originalname aus dem Feed (englisch)
code: string; // 3-Buchstaben-Code, z.B. GER
group: GroupId;
crest?: string | null; // URL zur Flagge / zum Wappen (football-data: crest)
localisedName: string; // Lokalisierter Anzeigename (z.B. "Deutschland")
}
export type MatchStatus =
@@ -33,7 +34,7 @@ export interface Match {
homeScore: number | null;
awayScore: number | null;
// Wahrscheinlichkeiten 0..1 (Polymarket), falls vorhanden
prob?: { home: number; draw: number | null; away: number } | null;
prob?: { home: number; draw: number; away: number } | null;
venue?: string | null; // Austragungsort
attendance?: number | null; // Zuschauerzahl, falls verfügbar
}