init localize

This commit is contained in:
2026-07-01 10:56:47 -05:00
parent 275dda65e0
commit dca3a66206
18 changed files with 1008 additions and 25 deletions

View File

@@ -0,0 +1,280 @@
"use client";
import { useMemo, useState, useEffect, useRef } from "react";
import { Match, Team } from "@/lib/types";
import { STADIUMS, MATCH_STADIUMS } from "@/lib/stadiums";
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.status === "POSTPONED" || (m.homeScore == null && m.awayScore == null)) return " : ";
const base = `${m.homeScore ?? 0} : ${m.awayScore ?? 0}`;
if (m.homePenalty != null && m.awayPenalty != null) {
return `${base} (${m.homePenalty}:${m.awayPenalty} i.E.)`;
}
return base;
}
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 "POSTPONED": return "Verzögert";
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) => +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]);
const targetMatchId = useMemo(() => {
const live = koMatches.find(m => isLive(m));
if (live) return live.id;
const upcoming = koMatches.find(m => m.status !== "FINISHED");
return upcoming?.id ?? null;
}, [koMatches]);
const targetRef = useRef<HTMLDivElement>(null);
const hasScrolledRef = useRef<string | null>(null);
useEffect(() => {
if (!mounted || !targetRef.current) return;
if (targetMatchId === hasScrolledRef.current) return;
const el = targetRef.current;
const raf = requestAnimationFrame(() => {
const rect = el.getBoundingClientRect();
const absoluteTop = rect.top + window.scrollY;
const offset = 180;
window.scrollTo({ top: Math.max(0, absoluteTop - offset), behavior: "smooth" });
});
hasScrolledRef.current = targetMatchId;
return () => cancelAnimationFrame(raf);
}, [mounted, targetMatchId, koMatches.length]);
const [showScrollTop, setShowScrollTop] = useState(false);
useEffect(() => {
const onScroll = () => setShowScrollTop(window.scrollY > 100);
window.addEventListener("scroll", onScroll, { passive: true });
onScroll();
return () => window.removeEventListener("scroll", onScroll);
}, []);
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",
};
const city = STADIUMS[MATCH_STADIUMS[m.matchNumber]]?.city;
const isTarget = String(m.id) === String(targetMatchId);
const finished = m.status === "FINISHED";
return (
<div
key={m.id}
ref={isTarget ? targetRef : undefined}
style={{
background: "var(--bg-card)",
border: `${isTarget ? 2 : 1}px solid ${live ? "var(--turf)" : isTarget ? "var(--turf)" : "var(--line-soft)"}`,
borderRadius: "var(--radius-sm)", padding: "12px 14px",
opacity: finished && !isTarget ? 0.65 : 1,
boxShadow: isTarget ? "0 0 0 1px var(--turf)" : undefined,
}}
>
{/* Kopfzeile */}
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
marginBottom: 8, fontSize: 11,
fontFamily: "var(--font-mono)", color: "var(--ink-faint)",
}}>
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
{isTarget && (
<span style={{
background: live ? "var(--turf)" : "var(--turf-deep)",
color: "#fff", fontSize: 9, fontWeight: 700,
padding: "1px 6px", borderRadius: 999,
letterSpacing: "0.04em",
}}>
{live ? "LIVE" : "NÄCHSTES SPIEL"}
</span>
)}
{m.status === "POSTPONED" && (
<span style={{
background: "var(--ink-faint)", color: "#fff",
fontSize: 9, fontWeight: 700,
padding: "1px 6px", borderRadius: 999,
letterSpacing: "0.04em",
}}>
VERZÖGERT
</span>
)}
{stageMap[m.stage] ?? m.stage} · Spiel {m.matchNumber || "—"}
{city ? ` · ${city}` : ""}
</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: 4,
}}>
<Flag team={g.team === "home" ? home : away} size={14} />
<span style={{ color: "var(--turf)", fontWeight: 700 }}>
{g.scorer}
</span>
<span style={{ color: "var(--ink-faint)", fontSize: 10 }}>
{g.minute}&apos;
</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>
{showScrollTop && (
<button
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
style={{
position: "fixed", right: 24, bottom: 24, zIndex: 50,
width: 48, height: 48, borderRadius: "50%",
background: "var(--bg-card)", color: "var(--ink-dim)",
border: "1px solid var(--line)", boxShadow: "0 2px 8px rgba(0,0,0,0.4)",
cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 22, lineHeight: 1,
}}
title="Nach oben scrollen"
>
</button>
)}
</>
);
}
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>
);
}