Files
wm-projekt/app/components/Simulation.tsx
2026-06-24 12:17:46 -05:00

351 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}