Compare commits
10 Commits
e4d9f901cb
...
573bc74cfa
| Author | SHA1 | Date | |
|---|---|---|---|
| 573bc74cfa | |||
| 2f8e8b4a12 | |||
| 7996ebc504 | |||
| aa19c16f93 | |||
| b25bcb85bd | |||
| ddaa1db876 | |||
| 253b8cfbcd | |||
| 4dc88f8e0f | |||
| 8dcf9d5e08 | |||
| e2bfb0a17d |
@@ -16,12 +16,16 @@ export async function GET() {
|
|||||||
try {
|
try {
|
||||||
const odds = await fetchOdds();
|
const odds = await fetchOdds();
|
||||||
matches = attachOdds(rawMatches, teams, odds);
|
matches = attachOdds(rawMatches, teams, odds);
|
||||||
} catch {
|
} catch (err) {
|
||||||
// still ohne Wahrscheinlichkeiten
|
console.error("[polymarket] fetchOdds fehlgeschlagen:", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
const groupTables = computeGroupTables(teams, matches);
|
const groupTables = computeGroupTables(teams, matches);
|
||||||
const groupTablesLive = computeGroupTables(teams, matches, true);
|
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 thirdTable = computeThirdPlaceTable(groupTablesLive);
|
||||||
const qGroups = qualifiedThirdGroups(thirdTable);
|
const qGroups = qualifiedThirdGroups(thirdTable);
|
||||||
const annex = qGroups.length === 8 ? resolveAnnexC(qGroups) : null;
|
const annex = qGroups.length === 8 ? resolveAnnexC(qGroups) : null;
|
||||||
@@ -29,7 +33,7 @@ export async function GET() {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
teams,
|
teams,
|
||||||
matches,
|
matches: normalizedMatches,
|
||||||
groupTables,
|
groupTables,
|
||||||
groupTablesLive,
|
groupTablesLive,
|
||||||
thirdTable,
|
thirdTable,
|
||||||
|
|||||||
@@ -1,18 +1,33 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
import { GroupTable, Match, Team, ThirdPlaceRow } from "@/lib/types";
|
import { GroupTable, Match, Team, ThirdPlaceRow } from "@/lib/types";
|
||||||
import { ThirdAssignment, orderedRound } from "@/lib/bracket";
|
import { ThirdAssignment, orderedRound } from "@/lib/bracket";
|
||||||
import { resolveBracket, ResolvedSide, ResolvedTie } from "@/lib/resolve-bracket";
|
import { resolveBracket, ResolvedSide, ResolvedTie } from "@/lib/resolve-bracket";
|
||||||
import Flag from "./Flag";
|
import Flag from "./Flag";
|
||||||
|
|
||||||
|
interface TipState { x: number; y: number; text: string }
|
||||||
|
|
||||||
function Side({
|
function Side({
|
||||||
s, prob, teams,
|
s, prob, teams, onShowTip, onHideTip,
|
||||||
}: { s: ResolvedSide; prob?: number | null; teams: Team[] }) {
|
}: {
|
||||||
|
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;
|
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 fix = s.teamId != null && !s.provisional;
|
||||||
|
const hasTip = !!s.tooltip;
|
||||||
|
|
||||||
return (
|
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">
|
<span className="nm">
|
||||||
{s.teamId ? (
|
{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 (
|
return (
|
||||||
<div className={`tie ${isFinal ? "final-tie" : ""}`}>
|
<div className={`tie ${isFinal ? "final-tie" : ""}`}>
|
||||||
<div className="tie-head">Spiel {tie.matchNumber}</div>
|
<div className="tie-head">Spiel {tie.matchNumber}</div>
|
||||||
<Side s={tie.home} prob={tie.prob?.home} 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} />
|
<Side s={tie.away} prob={tie.prob?.away} teams={teams} onShowTip={onShowTip} onHideTip={onHideTip} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Bracket({
|
export default function Bracket({
|
||||||
matches, teams, tables, thirds, assignment, annexResolved,
|
matches, teams, tables, thirds, assignment, annexResolved,
|
||||||
|
preResolved,
|
||||||
}: {
|
}: {
|
||||||
matches: Match[]; teams: Team[]; tables: GroupTable[];
|
matches: Match[]; teams: Team[]; tables: GroupTable[];
|
||||||
thirds: ThirdPlaceRow[]; assignment: ThirdAssignment | null; annexResolved: boolean;
|
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,
|
matches, teams, tables, thirds, assignment, annexResolved,
|
||||||
);
|
);
|
||||||
|
|
||||||
const r32ByNum = new Map(r32Array.map((t) => [t.matchNumber, t]));
|
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 sfOrder = orderedRound([104]);
|
||||||
const qfOrder = orderedRound(sfOrder);
|
const qfOrder = orderedRound(sfOrder);
|
||||||
const r16Order = orderedRound(qfOrder);
|
const r16Order = orderedRound(qfOrder);
|
||||||
@@ -68,6 +89,10 @@ export default function Bracket({
|
|||||||
const fin = later[104];
|
const fin = later[104];
|
||||||
const third = later[103];
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="bracket-banner">
|
<div className="bracket-banner">
|
||||||
@@ -84,35 +109,35 @@ export default function Bracket({
|
|||||||
<div className="round">
|
<div className="round">
|
||||||
<div className="round-label">Letzte 32</div>
|
<div className="round-label">Letzte 32</div>
|
||||||
<div className="round-matches">
|
<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>
|
</div>
|
||||||
<div className="round">
|
<div className="round">
|
||||||
<div className="round-label">Achtelfinale</div>
|
<div className="round-label">Achtelfinale</div>
|
||||||
<div className="round-matches">
|
<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>
|
</div>
|
||||||
<div className="round">
|
<div className="round">
|
||||||
<div className="round-label">Viertelfinale</div>
|
<div className="round-label">Viertelfinale</div>
|
||||||
<div className="round-matches">
|
<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>
|
</div>
|
||||||
<div className="round">
|
<div className="round">
|
||||||
<div className="round-label">Halbfinale</div>
|
<div className="round-label">Halbfinale</div>
|
||||||
<div className="round-matches">
|
<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>
|
</div>
|
||||||
<div className="round">
|
<div className="round">
|
||||||
<div className="round-label">Finale</div>
|
<div className="round-label">Finale</div>
|
||||||
<div className="round-matches">
|
<div className="round-matches">
|
||||||
{fin && <Tie tie={fin} teams={teams} isFinal />}
|
{fin && <Tie tie={fin} teams={teams} isFinal onShowTip={showTip} onHideTip={hideTip} />}
|
||||||
{third && (
|
{third && (
|
||||||
<div style={{ marginTop: 20 }}>
|
<div style={{ marginTop: 20 }}>
|
||||||
<div className="round-label" style={{ marginBottom: 8 }}>Spiel um Platz 3</div>
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -128,6 +153,12 @@ export default function Bracket({
|
|||||||
<span><i style={{ background: "var(--ink-faint)" }} />Platzhalter offen</span>
|
<span><i style={{ background: "var(--ink-faint)" }} />Platzhalter offen</span>
|
||||||
<span>%-Werte: Polymarket-Wahrscheinlichkeit (falls verfügbar)</span>
|
<span>%-Werte: Polymarket-Wahrscheinlichkeit (falls verfügbar)</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{tip && (
|
||||||
|
<div className="kobaum-tip" style={{ left: tip.x + 12, top: tip.y + 12 }}>
|
||||||
|
{tip.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ function GroupStandings({
|
|||||||
<td className="team">
|
<td className="team">
|
||||||
<span className={`rankdot ${cls}`}>{r.rank}</span>
|
<span className={`rankdot ${cls}`}>{r.rank}</span>
|
||||||
<Flag team={team} size={20} />
|
<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" />}
|
{isLive && <span className="row-live-dot" title="läuft gerade" />}
|
||||||
</td>
|
</td>
|
||||||
<td>{r.played}</td>
|
<td>{r.played}</td>
|
||||||
@@ -163,12 +163,12 @@ export default function Fixtures({
|
|||||||
<div className="fx-teams">
|
<div className="fx-teams">
|
||||||
<div className={`fx-team home ${homeWin ? "win" : ""}`}>
|
<div className={`fx-team home ${homeWin ? "win" : ""}`}>
|
||||||
<Flag team={home} size={22} />
|
<Flag team={home} size={22} />
|
||||||
<span className="fx-name">{home?.name ?? "—"}</span>
|
<span className="fx-name">{home?.localisedName ?? home?.name ?? "—"}</span>
|
||||||
</div>
|
</div>
|
||||||
<ScoreCell m={m} />
|
<ScoreCell m={m} />
|
||||||
<div className={`fx-team away ${awayWin ? "win" : ""}`}>
|
<div className={`fx-team away ${awayWin ? "win" : ""}`}>
|
||||||
<Flag team={away} size={22} />
|
<Flag team={away} size={22} />
|
||||||
<span className="fx-name">{away?.name ?? "—"}</span>
|
<span className="fx-name">{away?.localisedName ?? away?.name ?? "—"}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="fx-meta">
|
<div className="fx-meta">
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export default function Flag({
|
|||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={team.crest}
|
src={team.crest}
|
||||||
alt={team.code || team.name}
|
alt={team.code || team.localisedName || team.name}
|
||||||
className="flag"
|
className="flag"
|
||||||
style={style}
|
style={style}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export default function Groups({
|
|||||||
{tables.map((t) => {
|
{tables.map((t) => {
|
||||||
const live = liveMatchFor(t.group, matches);
|
const live = liveMatchFor(t.group, matches);
|
||||||
const liveIds = liveTeamIdsFor(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 (
|
return (
|
||||||
<div className="group-card" key={t.group}>
|
<div className="group-card" key={t.group}>
|
||||||
<button
|
<button
|
||||||
@@ -43,7 +45,7 @@ export default function Groups({
|
|||||||
onClick={() => onOpenGroup(t.group)}
|
onClick={() => onOpenGroup(t.group)}
|
||||||
title={`Spiele der Gruppe ${t.group} ansehen`}
|
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">
|
<span className="group-tag">
|
||||||
{live ? "● LIVE" : "Spiele ansehen →"}
|
{live ? "● LIVE" : "Spiele ansehen →"}
|
||||||
</span>
|
</span>
|
||||||
@@ -66,7 +68,7 @@ export default function Groups({
|
|||||||
<td className="team">
|
<td className="team">
|
||||||
<span className={`rankdot ${cls}`}>{r.rank}</span>
|
<span className={`rankdot ${cls}`}>{r.rank}</span>
|
||||||
<Flag team={team} size={20} />
|
<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" />}
|
{isLive && <span className="row-live-dot" title="läuft gerade" />}
|
||||||
</td>
|
</td>
|
||||||
<td>{r.played}</td>
|
<td>{r.played}</td>
|
||||||
|
|||||||
350
app/components/Simulation.tsx
Normal file
350
app/components/Simulation.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,10 @@ import { Team, ThirdPlaceRow } from "@/lib/types";
|
|||||||
export default function ThirdPlace({
|
export default function ThirdPlace({
|
||||||
rows, teams,
|
rows, teams,
|
||||||
}: { rows: ThirdPlaceRow[]; teams: Team[] }) {
|
}: { 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 (
|
return (
|
||||||
<div className="third-wrap">
|
<div className="third-wrap">
|
||||||
|
|||||||
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -118,6 +118,7 @@ a { color: inherit; text-decoration: none; }
|
|||||||
font-family: var(--font-display); font-weight: 800; font-size: 16px;
|
font-family: var(--font-display); font-weight: 800; font-size: 16px;
|
||||||
text-transform: uppercase; letter-spacing: 0.02em;
|
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); }
|
.group-tag { font-family: var(--font-mono); font-size: 10px; color: var(--ink-faint); }
|
||||||
|
|
||||||
table.standings { width: 100%; border-collapse: collapse; }
|
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);
|
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-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 {
|
.bracket {
|
||||||
display: flex; gap: 26px; min-width: max-content; align-items: stretch;
|
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 { display: flex; flex-direction: column; min-width: 220px; }
|
||||||
.round-label {
|
.round-label {
|
||||||
@@ -231,6 +241,22 @@ table.standings { width: 100%; border-collapse: collapse; }
|
|||||||
.legend span { display: inline-flex; align-items: center; gap: 6px; }
|
.legend span { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
.legend i { width: 12px; height: 12px; border-radius: 3px; display: inline-block; }
|
.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 ---------- */
|
/* ---------- Zustände ---------- */
|
||||||
.notice {
|
.notice {
|
||||||
background: var(--bg-card); border: 1px solid var(--line);
|
background: var(--bg-card); border: 1px solid var(--line);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import Groups from "./components/Groups";
|
|||||||
import ThirdPlace from "./components/ThirdPlace";
|
import ThirdPlace from "./components/ThirdPlace";
|
||||||
import Bracket from "./components/Bracket";
|
import Bracket from "./components/Bracket";
|
||||||
import Fixtures from "./components/Fixtures";
|
import Fixtures from "./components/Fixtures";
|
||||||
|
import Simulation from "./components/Simulation";
|
||||||
|
|
||||||
interface ApiData {
|
interface ApiData {
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -19,7 +20,7 @@ interface ApiData {
|
|||||||
annexResolved: boolean;
|
annexResolved: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tab = "groups" | "fixtures" | "thirds" | "bracket";
|
type Tab = "groups" | "fixtures" | "thirds" | "bracket" | "sim";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [data, setData] = useState<ApiData | null>(null);
|
const [data, setData] = useState<ApiData | null>(null);
|
||||||
@@ -90,6 +91,9 @@ export default function Home() {
|
|||||||
<button className={`tab ${tab === "bracket" ? "active" : ""}`} onClick={() => setTab("bracket")}>
|
<button className={`tab ${tab === "bracket" ? "active" : ""}`} onClick={() => setTab("bracket")}>
|
||||||
K.o.-Baum
|
K.o.-Baum
|
||||||
</button>
|
</button>
|
||||||
|
<button className={`tab ${tab === "sim" ? "active" : ""}`} onClick={() => setTab("sim")}>
|
||||||
|
Simulation
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -133,6 +137,9 @@ export default function Home() {
|
|||||||
annexResolved={data.annexResolved}
|
annexResolved={data.annexResolved}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{data && tab === "sim" && (
|
||||||
|
<Simulation teams={data.teams} matches={data.matches} />
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
195
lib/feeds.ts
195
lib/feeds.ts
@@ -1,5 +1,6 @@
|
|||||||
import { GroupId, Match, MatchStatus, Team } from "./types";
|
import { GroupId, Match, MatchStatus, Team } from "./types";
|
||||||
import { venueFor } from "./venues";
|
import { venueFor } from "./venues";
|
||||||
|
import { localisedTeamName } from "./team-mappings";
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// Caching: einfacher In-Memory-Cache mit TTL. Verhindert, dass jede Browser-
|
// 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, {
|
teamMap.set(id, {
|
||||||
id, name: side.name, code: side.tla ?? "", group,
|
id, name: side.name, code: side.tla ?? "", group,
|
||||||
crest: side.crest ?? null,
|
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_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 {
|
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;
|
slug: string;
|
||||||
|
groupItemTitle: string;
|
||||||
|
outcomes: string; // JSON-String ["Yes","No"]
|
||||||
|
outcomePrices: string; // JSON-String ["0.385","0.615"]
|
||||||
|
sportsMarketType?: string;
|
||||||
closed: boolean;
|
closed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map: normalisierter Teamname-Schlüssel -> Wahrscheinlichkeit.
|
export interface ParsedOdds {
|
||||||
export interface OddsEntry {
|
homeCode: string;
|
||||||
outcomes: string[];
|
awayCode: string;
|
||||||
prices: number[];
|
homeName: string;
|
||||||
question: 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 {
|
function safeParse<T>(s: string, fallback: T): T {
|
||||||
try { return JSON.parse(s) as T; } catch { return fallback; }
|
try { return JSON.parse(s) as T; } catch { return fallback; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Holt WM-bezogene Märkte über das Tag/Slug der Polymarket-WM-Kollektion.
|
// Holt alle WM-Spiele über den Series-Endpoint mit Pagination.
|
||||||
// Hinweis: Der exakte Slug kann sich ändern; per ENV überschreibbar.
|
// Parst pro Event die drei Moneyline-Märkte (Heim/Draw/Auswärts).
|
||||||
export async function fetchOdds(): Promise<OddsEntry[]> {
|
export async function fetchOdds(): Promise<ParsedOdds[]> {
|
||||||
const slug = process.env.POLYMARKET_WC_SLUG || "world-cup-2026";
|
|
||||||
return cached("pm:odds", 120_000, async () => {
|
return cached("pm:odds", 120_000, async () => {
|
||||||
const res = await fetch(
|
const allEvents: PmEvent[] = [];
|
||||||
`${PM_BASE}/events?slug=${encodeURIComponent(slug)}`,
|
for (let offset = 0; ; offset += 100) {
|
||||||
{ cache: "no-store", headers: { "User-Agent": "wm2026-board/1.0" } },
|
const url = `${PM_BASE}/events?series_id=${PM_SERIES}&active=true&closed=false&limit=100&offset=${offset}`;
|
||||||
);
|
const res = await fetch(url, {
|
||||||
if (!res.ok) throw new Error(`polymarket ${res.status}`);
|
cache: "no-store",
|
||||||
const events = (await res.json()) as Array<{ markets?: PmMarket[] }>;
|
headers: { "User-Agent": "wm2026-board/1.0" },
|
||||||
const entries: OddsEntry[] = [];
|
});
|
||||||
for (const ev of events) {
|
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 ?? []) {
|
for (const mk of ev.markets ?? []) {
|
||||||
|
if (mk.sportsMarketType && mk.sportsMarketType !== "moneyline") continue;
|
||||||
if (mk.closed) continue;
|
if (mk.closed) continue;
|
||||||
const outcomes = safeParse<string[]>(mk.outcomes, []);
|
|
||||||
const prices = safeParse<string[]>(mk.outcomePrices, []).map(Number);
|
const prices = safeParse<string[]>(mk.outcomePrices, []).map(Number);
|
||||||
if (outcomes.length && outcomes.length === prices.length) {
|
if (prices.length < 2) continue;
|
||||||
entries.push({ outcomes, prices, question: mk.question });
|
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.
|
// Normalisiert Teamnamen für Matching (lowercase, diakritische Zeichen, aliase).
|
||||||
export function attachOdds(matches: Match[], teams: Team[], odds: OddsEntry[]): Match[] {
|
function normName(n: string): string {
|
||||||
const nameById = new Map(teams.map((t) => [t.id, t.name.toLowerCase()]));
|
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) => {
|
return matches.map((m) => {
|
||||||
if (!m.homeTeamId || !m.awayTeamId) return m;
|
if (!m.homeTeamId || !m.awayTeamId) return m;
|
||||||
const hn = nameById.get(m.homeTeamId);
|
// Suche in oddsMatch nach einer Kombination die beide Team-IDs matcht
|
||||||
const an = nameById.get(m.awayTeamId);
|
for (const [o, ids] of oddsMatch) {
|
||||||
if (!hn || !an) return m;
|
if (!ids) continue;
|
||||||
const hit = odds.find(
|
// Beide Richtungen prüfen
|
||||||
(o) => o.question.toLowerCase().includes(hn) && o.question.toLowerCase().includes(an),
|
if ((ids.homeId === m.homeTeamId && ids.awayId === m.awayTeamId) ||
|
||||||
);
|
(ids.homeId === m.awayTeamId && ids.awayId === m.homeTeamId)) {
|
||||||
if (!hit) return m;
|
const swapped = ids.homeId === m.awayTeamId;
|
||||||
// Heuristik: 3 Outcomes = Heim/Unentschieden/Auswärts; 2 = Heim/Auswärts.
|
return {
|
||||||
const idxHome = hit.outcomes.findIndex((o) => o.toLowerCase().includes(hn));
|
...m,
|
||||||
const idxAway = hit.outcomes.findIndex((o) => o.toLowerCase().includes(an));
|
prob: {
|
||||||
const idxDraw = hit.outcomes.findIndex((o) => /draw|unentschieden|tie/i.test(o));
|
home: swapped ? o.pAway : o.pHome,
|
||||||
if (idxHome < 0 || idxAway < 0) return m;
|
draw: o.pDraw,
|
||||||
return {
|
away: swapped ? o.pHome : o.pAway,
|
||||||
...m,
|
},
|
||||||
prob: {
|
};
|
||||||
home: hit.prices[idxHome] ?? 0,
|
}
|
||||||
draw: idxDraw >= 0 ? (hit.prices[idxDraw] ?? null) : null,
|
}
|
||||||
away: hit.prices[idxAway] ?? 0,
|
return m;
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -5,14 +5,6 @@ import {
|
|||||||
} from "@/lib/bracket";
|
} from "@/lib/bracket";
|
||||||
import { placeIsSecure } from "@/lib/secure-places";
|
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.
|
// Ein aufgelöster Teilnehmer einer K.o.-Begegnung.
|
||||||
export interface ResolvedSide {
|
export interface ResolvedSide {
|
||||||
teamId: string | null; // null = noch Platzhalter
|
teamId: string | null; // null = noch Platzhalter
|
||||||
@@ -21,6 +13,7 @@ export interface ResolvedSide {
|
|||||||
score: number | null;
|
score: number | null;
|
||||||
isWinner: boolean;
|
isWinner: boolean;
|
||||||
provisional: boolean; // true = nur auf Basis aktueller Tabelle, noch nicht fix
|
provisional: boolean; // true = nur auf Basis aktueller Tabelle, noch nicht fix
|
||||||
|
tooltip: string | null; // Hover-Erklärung (Herkunft + ob fix)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResolvedTie {
|
export interface ResolvedTie {
|
||||||
@@ -53,6 +46,15 @@ function loserOf(m: Match | undefined): string | null {
|
|||||||
return 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.
|
// 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.
|
// provisional = true, solange die zugrunde liegende Gruppe/Zuordnung nicht fix ist.
|
||||||
function resolveR32Slot(
|
function resolveR32Slot(
|
||||||
@@ -64,38 +66,55 @@ function resolveR32Slot(
|
|||||||
matches: Match[],
|
matches: Match[],
|
||||||
teams: Team[],
|
teams: Team[],
|
||||||
annexResolved: boolean,
|
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);
|
const table = (g: GroupId) => tables.find((t) => t.group === g);
|
||||||
if (slot.type === "W") {
|
if (slot.type === "W") {
|
||||||
const t = table(slot.group!);
|
const t = table(slot.group!);
|
||||||
const teamId = t?.rows.find((r) => r.rank === 1)?.teamId ?? null;
|
const teamId = t?.rows.find((r) => r.rank === 1)?.teamId ?? null;
|
||||||
// Sieger fix, sobald Platz 1 rechnerisch gesichert ist (auch vor Gruppenende).
|
// 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") {
|
if (slot.type === "R") {
|
||||||
const t = table(slot.group!);
|
const t = table(slot.group!);
|
||||||
const teamId = t?.rows.find((r) => r.rank === 2)?.teamId ?? null;
|
const teamId = t?.rows.find((r) => r.rank === 2)?.teamId ?? null;
|
||||||
// Zweiter fix, sobald Platz 2 rechnerisch gesichert ist.
|
// 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
|
// 3. Platz: braucht Annex-C-Zuordnung + den Gruppensieger dieses Spiels
|
||||||
if (slot.type === "3" && assignment && winnerGroup) {
|
if (slot.type === "3" && assignment && winnerGroup) {
|
||||||
const thirdGroup = assignment[winnerGroup];
|
const thirdGroup = assignment[winnerGroup];
|
||||||
if (thirdGroup) {
|
if (thirdGroup) {
|
||||||
const row = thirds.find((r) => r.group === thirdGroup && r.qualifies);
|
const row = thirds.find((r) => r.group === thirdGroup && r.qualifies);
|
||||||
// Fix nur, wenn Annex C aufgelöst UND die Quellgruppe abgeschlossen ist
|
// Fix nur, wenn ALLE drei Bedingungen erfüllt sind:
|
||||||
// (Drittplatzierten-Qualifikation ist gruppenübergreifend, bis zuletzt offen).
|
// 1. Annex C aufgelöst (8 Dritte zuweisbar)
|
||||||
const fix = annexResolved && groupFinished(thirdGroup, matches);
|
// 2. Komplette Gruppenphase beendet (Dritten-Rangliste final)
|
||||||
return { teamId: row?.teamId ?? null, provisional: !fix };
|
// 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(
|
function sideFrom(
|
||||||
teamId: string | null, fallbackLabel: string,
|
teamId: string | null, fallbackLabel: string,
|
||||||
teams: Team[], feed: Match | undefined, which: "home" | "away",
|
teams: Team[], feed: Match | undefined, which: "home" | "away",
|
||||||
provisional: boolean,
|
provisional: boolean,
|
||||||
|
tooltip: string | null,
|
||||||
): ResolvedSide {
|
): ResolvedSide {
|
||||||
const team = teamId ? teams.find((t) => t.id === teamId) : undefined;
|
const team = teamId ? teams.find((t) => t.id === teamId) : undefined;
|
||||||
const score = feed
|
const score = feed
|
||||||
@@ -104,19 +123,25 @@ function sideFrom(
|
|||||||
const w = winnerOf(feed);
|
const w = winnerOf(feed);
|
||||||
return {
|
return {
|
||||||
teamId,
|
teamId,
|
||||||
label: team?.name ?? fallbackLabel,
|
label: team?.localisedName ?? team?.name ?? fallbackLabel,
|
||||||
code: team?.code ?? "",
|
code: team?.code ?? "",
|
||||||
score,
|
score,
|
||||||
isWinner: teamId != null && w === teamId,
|
isWinner: teamId != null && w === teamId,
|
||||||
provisional: teamId != null && provisional,
|
provisional: teamId != null && provisional,
|
||||||
|
tooltip,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Baut die komplette aufgelöste Bracket-Struktur.
|
// 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(
|
export function resolveBracket(
|
||||||
matches: Match[], teams: Team[], tables: GroupTable[],
|
matches: Match[], teams: Team[], tables: GroupTable[],
|
||||||
thirds: ThirdPlaceRow[], assignment: ThirdAssignment | null,
|
thirds: ThirdPlaceRow[], assignment: ThirdAssignment | null,
|
||||||
annexResolved: boolean,
|
annexResolved: boolean,
|
||||||
|
resolveWinners = false,
|
||||||
): { r32: ResolvedTie[]; later: Record<number, ResolvedTie> } {
|
): { r32: ResolvedTie[]; later: Record<number, ResolvedTie> } {
|
||||||
// Map: Match-Nummer -> Sieger-Team-ID (für Propagation in Folgerunden)
|
// Map: Match-Nummer -> Sieger-Team-ID (für Propagation in Folgerunden)
|
||||||
const winners = new Map<number, string | null>();
|
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 h = resolveR32Slot(rm.home, winnerGroup, tables, assignment, thirds, matches, teams, annexResolved);
|
||||||
const a = resolveR32Slot(rm.away, 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 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);
|
const away = sideFrom(a.teamId, slotLabel(rm.away, assignment, winnerGroup), teams, feed, "away", a.provisional, a.tooltip);
|
||||||
|
|
||||||
winners.set(rm.matchNumber, winnerOf(feed));
|
const feedWinner = winnerOf(feed);
|
||||||
losers.set(rm.matchNumber, loserOf(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");
|
decided.set(rm.matchNumber, feed?.status === "FINISHED");
|
||||||
return {
|
return {
|
||||||
matchNumber: rm.matchNumber, stage: "R32",
|
matchNumber: rm.matchNumber, stage: "R32",
|
||||||
@@ -156,10 +198,28 @@ export function resolveBracket(
|
|||||||
const awayProv = !(decided.get(km.fromAway) ?? false);
|
const awayProv = !(decided.get(km.fromAway) ?? false);
|
||||||
const homeLabel = `${km.losers ? "Verlierer" : "Sieger"} ${km.fromHome}`;
|
const homeLabel = `${km.losers ? "Verlierer" : "Sieger"} ${km.fromHome}`;
|
||||||
const awayLabel = `${km.losers ? "Verlierer" : "Sieger"} ${km.fromAway}`;
|
const awayLabel = `${km.losers ? "Verlierer" : "Sieger"} ${km.fromAway}`;
|
||||||
const home = sideFrom(homeId, homeLabel, teams, feed, "home", homeProv);
|
const homeTtip = `${km.losers ? "Verlierer" : "Sieger"} aus Spiel ${km.fromHome}`;
|
||||||
const away = sideFrom(awayId, awayLabel, teams, feed, "away", awayProv);
|
const awayTtip = `${km.losers ? "Verlierer" : "Sieger"} aus Spiel ${km.fromAway}`;
|
||||||
winners.set(km.matchNumber, winnerOf(feed));
|
const home = sideFrom(homeId, homeLabel, teams, feed, "home", homeProv, homeTtip);
|
||||||
losers.set(km.matchNumber, loserOf(feed));
|
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");
|
decided.set(km.matchNumber, feed?.status === "FINISHED");
|
||||||
later[km.matchNumber] = {
|
later[km.matchNumber] = {
|
||||||
matchNumber: km.matchNumber, stage: km.stage,
|
matchNumber: km.matchNumber, stage: km.stage,
|
||||||
|
|||||||
137
lib/simulation.ts
Normal file
137
lib/simulation.ts
Normal 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.40–0.60 → 1:0
|
||||||
|
// Favorit 0.60–0.78 → 2:0
|
||||||
|
// Favorit > 0.78 → 3:0
|
||||||
|
//
|
||||||
|
// Beispiele:
|
||||||
|
// Schweiz–Kanada (0.385/0.315/0.295) → 0:0
|
||||||
|
// Schottland–Brasilien (0.10/0.17/0.75) → 0:2
|
||||||
|
// Marokko–Haiti (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.40–0.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
63
lib/team-mappings.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -11,10 +11,11 @@ export const GROUP_IDS: GroupId[] = [
|
|||||||
|
|
||||||
export interface Team {
|
export interface Team {
|
||||||
id: string; // stabile ID aus dem Feed
|
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
|
code: string; // 3-Buchstaben-Code, z.B. GER
|
||||||
group: GroupId;
|
group: GroupId;
|
||||||
crest?: string | null; // URL zur Flagge / zum Wappen (football-data: crest)
|
crest?: string | null; // URL zur Flagge / zum Wappen (football-data: crest)
|
||||||
|
localisedName: string; // Lokalisierter Anzeigename (z.B. "Deutschland")
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MatchStatus =
|
export type MatchStatus =
|
||||||
@@ -33,7 +34,7 @@ export interface Match {
|
|||||||
homeScore: number | null;
|
homeScore: number | null;
|
||||||
awayScore: number | null;
|
awayScore: number | null;
|
||||||
// Wahrscheinlichkeiten 0..1 (Polymarket), falls vorhanden
|
// 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
|
venue?: string | null; // Austragungsort
|
||||||
attendance?: number | null; // Zuschauerzahl, falls verfügbar
|
attendance?: number | null; // Zuschauerzahl, falls verfügbar
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user