KO round
This commit is contained in:
201
app/components/KoFixtures.tsx
Normal file
201
app/components/KoFixtures.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useEffect } from "react";
|
||||
import { Match, Team } from "@/lib/types";
|
||||
import Flag from "./Flag";
|
||||
|
||||
function fmtShortDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString("de-DE", { weekday: "short", day: "2-digit", month: "2-digit" });
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
function scoreDisplay(m: Match): string {
|
||||
if (m.status === "SCHEDULED" || (m.homeScore == null && m.awayScore == null)) return "– : –";
|
||||
return `${m.homeScore ?? 0} : ${m.awayScore ?? 0}`;
|
||||
}
|
||||
|
||||
function statusLabel(m: Match): string {
|
||||
switch (m.status) {
|
||||
case "LIVE": case "IN_PLAY": return m.minute != null ? `${m.minute}'` : "Live";
|
||||
case "PAUSED": return "Halbzeit";
|
||||
case "FINISHED": return "Beendet";
|
||||
default: return fmtTime(m.utcDate);
|
||||
}
|
||||
}
|
||||
|
||||
function isLive(m: Match): boolean {
|
||||
return m.status === "LIVE" || m.status === "IN_PLAY" || m.status === "PAUSED";
|
||||
}
|
||||
|
||||
export default function KoFixtures({ teams, matches }: { teams: Team[]; matches: Match[] }) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => { setMounted(true); }, []);
|
||||
|
||||
const koMatches = useMemo(() => {
|
||||
return matches
|
||||
.filter((m) =>
|
||||
m.group == null &&
|
||||
m.homeTeamId != null && m.awayTeamId != null &&
|
||||
// Nur Spiele mit bekannten Teams (keine TBD)
|
||||
teams.some(t => t.id === m.homeTeamId) &&
|
||||
teams.some(t => t.id === m.awayTeamId),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
// Live zuerst
|
||||
const aLive = isLive(a) ? 1 : 0;
|
||||
const bLive = isLive(b) ? 1 : 0;
|
||||
if (aLive !== bLive) return bLive - aLive;
|
||||
// Innerhalb Live/Upcoming/Finished: Datum aufsteigend
|
||||
return +new Date(a.utcDate) - +new Date(b.utcDate);
|
||||
});
|
||||
}, [matches, teams]);
|
||||
|
||||
// Lokale Datums-Extraktion (vermeidet UTC-Tagesverschiebung bei US-Spielen)
|
||||
function localDateKey(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
// Nach Datum gruppieren (lokale Zeitzone nach Hydration, sonst UTC)
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, Match[]>();
|
||||
for (const m of koMatches) {
|
||||
const key = mounted ? localDateKey(m.utcDate) : m.utcDate.slice(0, 10);
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(m);
|
||||
}
|
||||
return [...map.entries()];
|
||||
}, [koMatches, mounted]);
|
||||
|
||||
if (koMatches.length === 0) {
|
||||
return <div className="notice">Keine K.o.-Spiele mit bekannten Teams verfügbar.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{groups.map(([date, groupMatches]) => (
|
||||
<div key={date} style={{ marginBottom: 24 }}>
|
||||
<h3 style={{
|
||||
fontFamily: "var(--font-display)", fontSize: 13,
|
||||
textTransform: "uppercase", letterSpacing: "0.06em",
|
||||
color: "var(--ink-dim)", margin: "0 0 10px",
|
||||
}}>
|
||||
{fmtShortDate(groupMatches[0].utcDate)}
|
||||
</h3>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{groupMatches.map((m) => {
|
||||
const home = m.homeTeamId ? teams.find(t => t.id === m.homeTeamId) : undefined;
|
||||
const away = m.awayTeamId ? teams.find(t => t.id === m.awayTeamId) : undefined;
|
||||
const live = isLive(m);
|
||||
const stageMap: Record<string, string> = {
|
||||
R32: "R32", R16: "Achtelfinale", QF: "Viertelfinale",
|
||||
SF: "Halbfinale", "3RD": "Platz 3", FINAL: "Finale",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
style={{
|
||||
background: "var(--bg-card)", border: `1px solid ${live ? "var(--turf)" : "var(--line-soft)"}`,
|
||||
borderRadius: "var(--radius-sm)", padding: "12px 14px",
|
||||
}}
|
||||
>
|
||||
{/* Kopfzeile */}
|
||||
<div style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||
marginBottom: 8, fontSize: 11,
|
||||
fontFamily: "var(--font-mono)", color: "var(--ink-faint)",
|
||||
}}>
|
||||
<span>{stageMap[m.stage] ?? m.stage} · Spiel {m.matchNumber || "—"}</span>
|
||||
<span style={{ color: live ? "var(--turf)" : undefined, fontWeight: live ? 700 : undefined }}>
|
||||
{statusLabel(m)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Teams + Score */}
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<TeamLabel team={home} />
|
||||
<span style={{
|
||||
fontFamily: "var(--font-mono)", fontSize: 22, fontWeight: 700,
|
||||
color: live ? "var(--turf)" : "var(--ink)",
|
||||
padding: "0 16px",
|
||||
}}>
|
||||
{scoreDisplay(m)}
|
||||
</span>
|
||||
<TeamLabel team={away} reverse />
|
||||
</div>
|
||||
|
||||
{/* Torabfolge */}
|
||||
{m.goals && m.goals.length > 0 && (
|
||||
<div style={{
|
||||
marginTop: 8, padding: "8px 10px",
|
||||
background: "var(--bg-raised)", borderRadius: "var(--radius-sm)",
|
||||
fontSize: 12, fontFamily: "var(--font-mono)",
|
||||
}}>
|
||||
<div style={{ color: "var(--ink-faint)", fontSize: 10, marginBottom: 4 }}>
|
||||
Tore
|
||||
</div>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
|
||||
{m.goals.map((g, i) => (
|
||||
<span key={i} style={{
|
||||
display: "inline-flex", alignItems: "center", gap: 3,
|
||||
color: g.team === "home" ? "var(--ink)" : "var(--ink)",
|
||||
}}>
|
||||
{g.team === "home" ? "⬆" : "⬇"}
|
||||
<span style={{ color: "var(--turf)", fontWeight: 700 }}>
|
||||
{g.scorer}
|
||||
</span>
|
||||
<span style={{ color: "var(--ink-faint)", fontSize: 10 }}>
|
||||
{g.minute}'
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prob (falls vorhanden) */}
|
||||
{m.prob && m.prob.home > 0 && (
|
||||
<div style={{
|
||||
marginTop: 6, fontSize: 10, color: "var(--ink-faint)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
}}>
|
||||
Polymarket: {Math.round(m.prob.home * 100)}% / {Math.round(m.prob.draw * 100)}% / {Math.round(m.prob.away * 100)}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamLabel({ team, reverse }: { team?: Team; reverse?: boolean }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 8,
|
||||
flexDirection: reverse ? "row-reverse" : "row",
|
||||
flex: 1, minWidth: 0,
|
||||
}}>
|
||||
<Flag team={team} size={20} />
|
||||
<span style={{
|
||||
fontWeight: 600, fontSize: 14,
|
||||
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
|
||||
maxWidth: 120,
|
||||
}}>
|
||||
{team?.localisedName ?? team?.name ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user