14 Commits

Author SHA1 Message Date
cfb778776e worldcup26.ir removed 2026-07-01 12:07:17 -05:00
09020b7aef locale second stage 2026-07-01 11:21:29 -05:00
dca3a66206 init localize 2026-07-01 10:56:47 -05:00
275dda65e0 delay status 2026-06-30 20:30:41 -05:00
2a57be6e2f goals 2026-06-30 11:13:19 -05:00
63108cb5a1 sim fix 2026-06-30 00:18:14 -05:00
d982ff1068 KO Bracket fixed 2026-06-29 23:30:26 -05:00
6811fc8161 Korrekturen bei KO Spiele 2026-06-29 22:52:42 -05:00
edaca815a7 sortierung nach DAtum 2026-06-29 15:51:36 -05:00
40618d2953 parser corrections 2026-06-29 14:26:57 -05:00
7c910e9199 goals fix 2026-06-29 13:39:17 -05:00
1d94f805fd new footer 2026-06-28 22:31:36 -05:00
b3eb21e59b scolling 2026-06-28 18:16:42 -05:00
87624ae484 scrolling 2026-06-28 17:03:21 -05:00
24 changed files with 1788 additions and 585 deletions

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@ node_modules
.env
.env.local
*.log
testdata

View File

@@ -1,18 +1,19 @@
"use client";
import { useMemo, useState, useCallback } from "react";
import { GroupTable, Match, Team, ThirdPlaceRow } from "@/lib/types";
import { GroupTable, Match, Team, ThirdPlaceRow, teamName } from "@/lib/types";
import { ThirdAssignment, orderedRound } from "@/lib/bracket";
import { resolveBracket, ResolvedSide, ResolvedTie } from "@/lib/resolve-bracket";
import { Dictionary } from "@/lib/i18n";
import { STADIUMS, MATCH_STADIUMS, MATCH_DATES } from "@/lib/stadiums";
import Flag from "./Flag";
interface TipState { x: number; y: number; text: string }
function Side({
s, prob, teams, onShowTip, onHideTip,
s, prob, teams, locale, dict, onShowTip, onHideTip,
}: {
s: ResolvedSide; prob?: number | null; teams: Team[];
s: ResolvedSide; prob?: number | null; teams: Team[]; locale: string; dict: Dictionary;
onShowTip: (text: string, x: number, y: number) => void;
onHideTip: () => void;
}) {
@@ -33,8 +34,8 @@ function Side({
{s.teamId ? (
<>
<Flag team={team} size={18} />
<span className="tn">{s.label}</span>
{s.provisional && <span className="prov-mark" title="vorläufig Gruppe/Zuordnung noch nicht fix"></span>}
<span className="tn">{teamName(team, locale)}</span>
{s.provisional && <span className="prov-mark" title={dict.bracket.provisionalTooltip}></span>}
</>
) : (
<span className="lbl">{s.label}</span>
@@ -48,11 +49,12 @@ function Side({
);
}
function fmtMatchInfo(utcDate?: string, stadiumId?: string): string {
function fmtMatchInfo(utcDate?: string, stadiumId?: string, locale: string = "de"): string {
if (!utcDate) return "";
const intlLocale = locale === "en" ? "en-US" : "de-DE";
const d = new Date(utcDate);
const dateStr = d.toLocaleDateString("de-DE", { day: "numeric", month: "numeric" });
const timeStr = d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
const dateStr = d.toLocaleDateString(intlLocale, { day: "numeric", month: "numeric" });
const timeStr = d.toLocaleTimeString(intlLocale, { hour: "2-digit", minute: "2-digit" });
const city = stadiumId ? STADIUMS[stadiumId]?.city : "";
const parts = [`${dateStr}`, `${timeStr}`];
if (city) parts.push(city);
@@ -60,10 +62,11 @@ function fmtMatchInfo(utcDate?: string, stadiumId?: string): string {
}
function Tie({
tie, teams, isFinal, matchInfo, onShowTip, onHideTip,
tie, teams, isFinal, matchInfo, locale, dict, onShowTip, onHideTip,
}: {
tie: ResolvedTie; teams: Team[]; isFinal?: boolean;
matchInfo?: string;
locale: string; dict: Dictionary;
onShowTip: (text: string, x: number, y: number) => void;
onHideTip: () => void;
}) {
@@ -71,24 +74,30 @@ function Tie({
<div className={`tie ${isFinal ? "final-tie" : ""}`}>
<div className="match-badge">{tie.matchNumber}</div>
<div className="tie-meta">
{matchInfo || `Spiel ${tie.matchNumber}`}
{matchInfo || dict.bracket.match(tie.matchNumber)}
</div>
<Side s={tie.home} prob={tie.prob?.home} teams={teams} onShowTip={onShowTip} onHideTip={onHideTip} />
<Side s={tie.away} prob={tie.prob?.away} teams={teams} onShowTip={onShowTip} onHideTip={onHideTip} />
<Side s={tie.home} prob={tie.prob?.home} teams={teams} locale={locale} dict={dict} onShowTip={onShowTip} onHideTip={onHideTip} />
<Side s={tie.away} prob={tie.prob?.away} teams={teams} locale={locale} dict={dict} onShowTip={onShowTip} onHideTip={onHideTip} />
{tie.homePenalty != null && tie.awayPenalty != null && (
<div className="tie-meta" style={{ padding: "2px 10px 4px", textAlign: "center" }}>
({tie.homePenalty}:{tie.awayPenalty} {dict.bracket.penaltyShootout})
</div>
)}
</div>
);
}
export default function Bracket({
matches, teams, tables, thirds, assignment, annexResolved,
preResolved,
preResolved, dict, locale,
}: {
matches: Match[]; teams: Team[]; tables: GroupTable[];
thirds: ThirdPlaceRow[]; assignment: ThirdAssignment | null; annexResolved: boolean;
preResolved?: { r32: ResolvedTie[]; later: Record<number, ResolvedTie> };
dict: Dictionary; locale: string;
}) {
const { r32: r32Array, later } = preResolved ?? resolveBracket(
matches, teams, tables, thirds, assignment, annexResolved,
matches, teams, tables, thirds, assignment, annexResolved, undefined, dict,
);
const r32ByNum = new Map(r32Array.map((t) => [t.matchNumber, t]));
@@ -99,11 +108,11 @@ export default function Bracket({
for (let n = 73; n <= 104; n++) {
const utcDate = MATCH_DATES[n];
const stadiumId = MATCH_STADIUMS[n];
const info = fmtMatchInfo(utcDate, stadiumId);
const info = fmtMatchInfo(utcDate, stadiumId, locale);
if (info) meta.set(n, info);
}
return meta;
}, []);
}, [locale]);
const sfOrder = orderedRound([104]);
const qfOrder = orderedRound(sfOrder);
@@ -124,48 +133,48 @@ export default function Bracket({
return (
<div>
<div className="bracket-banner">
<span className="k">Annex-C-Zuordnung der Drittplatzierten:</span>
<span className="k">{dict.bracket.annexHeader}</span>
<span className="v">
{annexResolved
? "aufgelöst — die acht Dritten sind den Gruppensiegern fest zugeteilt"
: "noch offen — sobald die 8 besten Dritten feststehen, verbindet sich der Baum automatisch"}
? dict.bracket.annexResolved
: dict.bracket.annexPending}
</span>
</div>
<div className="bracket-scroll">
<div className="bracket">
<div className="round">
<div className="round-label">Letzte 32</div>
<div className="round-label">{dict.round.r32}</div>
<div className="round-matches">
{r32.map((t) => <Tie key={t.matchNumber} tie={t} teams={teams} matchInfo={matchMeta.get(t.matchNumber)} onShowTip={showTip} onHideTip={hideTip} />)}
{r32.map((t) => <Tie key={t.matchNumber} tie={t} teams={teams} locale={locale} dict={dict} matchInfo={matchMeta.get(t.matchNumber)} onShowTip={showTip} onHideTip={hideTip} />)}
</div>
</div>
<div className="round">
<div className="round-label">Achtelfinale</div>
<div className="round-label">{dict.round.r16}</div>
<div className="round-matches">
{r16.map((t) => <Tie key={t.matchNumber} tie={t} teams={teams} matchInfo={matchMeta.get(t.matchNumber)} onShowTip={showTip} onHideTip={hideTip} />)}
{r16.map((t) => <Tie key={t.matchNumber} tie={t} teams={teams} locale={locale} dict={dict} matchInfo={matchMeta.get(t.matchNumber)} onShowTip={showTip} onHideTip={hideTip} />)}
</div>
</div>
<div className="round">
<div className="round-label">Viertelfinale</div>
<div className="round-label">{dict.round.qf}</div>
<div className="round-matches">
{qf.map((t) => <Tie key={t.matchNumber} tie={t} teams={teams} matchInfo={matchMeta.get(t.matchNumber)} onShowTip={showTip} onHideTip={hideTip} />)}
{qf.map((t) => <Tie key={t.matchNumber} tie={t} teams={teams} locale={locale} dict={dict} matchInfo={matchMeta.get(t.matchNumber)} onShowTip={showTip} onHideTip={hideTip} />)}
</div>
</div>
<div className="round">
<div className="round-label">Halbfinale</div>
<div className="round-label">{dict.round.sf}</div>
<div className="round-matches">
{sf.map((t) => <Tie key={t.matchNumber} tie={t} teams={teams} matchInfo={matchMeta.get(t.matchNumber)} onShowTip={showTip} onHideTip={hideTip} />)}
{sf.map((t) => <Tie key={t.matchNumber} tie={t} teams={teams} locale={locale} dict={dict} matchInfo={matchMeta.get(t.matchNumber)} onShowTip={showTip} onHideTip={hideTip} />)}
</div>
</div>
<div className="round">
<div className="round-label">Finale</div>
<div className="round-label">{dict.round.final}</div>
<div className="round-matches">
{fin && <Tie tie={fin} teams={teams} isFinal matchInfo={matchMeta.get(fin.matchNumber)} onShowTip={showTip} onHideTip={hideTip} />}
{fin && <Tie tie={fin} teams={teams} isFinal locale={locale} dict={dict} matchInfo={matchMeta.get(fin.matchNumber)} onShowTip={showTip} onHideTip={hideTip} />}
{third && (
<div style={{ marginTop: 20 }}>
<div className="round-label" style={{ marginBottom: 8 }}>Spiel um Platz 3</div>
<Tie tie={third} teams={teams} matchInfo={matchMeta.get(third.matchNumber)} onShowTip={showTip} onHideTip={hideTip} />
<div className="round-label" style={{ marginBottom: 8 }}>{dict.round.thirdPlace}</div>
<Tie tie={third} teams={teams} locale={locale} dict={dict} matchInfo={matchMeta.get(third.matchNumber)} onShowTip={showTip} onHideTip={hideTip} />
</div>
)}
</div>
@@ -174,12 +183,12 @@ export default function Bracket({
</div>
<div className="legend">
<span><i style={{ background: "var(--turf)" }} />Sieger / weiter</span>
<span><i style={{ background: "var(--gold)" }} />Finale</span>
<span><i className="leg-fix" />fix qualifiziert</span>
<span><i className="leg-prov" />vorläufig (, nach aktueller Tabelle)</span>
<span><i style={{ background: "var(--ink-faint)" }} />Platzhalter offen</span>
<span>%-Werte: Polymarket-Wahrscheinlichkeit (falls verfügbar)</span>
<span><i style={{ background: "var(--turf)" }} />{dict.legend.winner}</span>
<span><i style={{ background: "var(--gold)" }} />{dict.legend.final}</span>
<span><i className="leg-fix" />{dict.legend.fixed}</span>
<span><i className="leg-prov" />{dict.legend.provisional}</span>
<span><i style={{ background: "var(--ink-faint)" }} />{dict.legend.placeholder}</span>
<span>{dict.legend.probExplanation}</span>
</div>
{tip && (

View File

@@ -1,53 +1,54 @@
"use client";
import { GROUP_IDS, GroupId, Match, Team } from "@/lib/types";
import { GROUP_IDS, GroupId, Match, Team, teamName } from "@/lib/types";
import { computeGroupTables } from "@/lib/standings";
import { Dictionary } from "@/lib/i18n";
import Flag from "./Flag";
function teamById(teams: Team[], id: string | null) {
return id ? teams.find((t) => t.id === id) : undefined;
}
// Kürzel der lokalen Browser-Zeitzone, z.B. "MEZ"/"GMT+1" einmal ermittelt.
const TZ_LABEL = (() => {
function tzLabel(locale: string): string {
try {
const parts = new Intl.DateTimeFormat("de-DE", { timeZoneName: "short" })
const parts = new Intl.DateTimeFormat(locale === "en" ? "en-US" : "de-DE", { timeZoneName: "short" })
.formatToParts(new Date());
return parts.find((p) => p.type === "timeZoneName")?.value ?? "";
} catch {
return "";
}
})();
}
function fmtDate(iso: string): string {
function fmtDate(iso: string, locale: string): string {
const d = new Date(iso);
const s = d.toLocaleString("de-DE", {
const s = d.toLocaleString(locale === "en" ? "en-US" : "de-DE", {
weekday: "short", day: "2-digit", month: "2-digit",
hour: "2-digit", minute: "2-digit",
});
return TZ_LABEL ? `${s} ${TZ_LABEL}` : s;
const tz = tzLabel(locale);
return tz ? `${s} ${tz}` : s;
}
function statusText(m: Match): { text: string; live: boolean } {
function statusText(m: Match, dict: Dictionary): { text: string; live: boolean } {
switch (m.status) {
case "LIVE":
case "IN_PLAY":
return { text: m.minute != null ? `${m.minute}'` : "läuft", live: true };
return { text: m.minute != null ? `${m.minute}'` : dict.status.running, live: true };
case "PAUSED":
return { text: "Halbzeit", live: true };
return { text: dict.status.halftime, live: true };
case "FINISHED":
return { text: "Beendet", live: false };
return { text: dict.status.finished, live: false };
default:
return { text: fmtDate(m.utcDate), live: false };
return { text: "", live: false };
}
}
// Mitte: Ergebnis (wenn vorhanden) oder ein schlichtes "" bei ungespielten
// Spielen. Datum/Uhrzeit steht bereits links im Status und wird hier NICHT
// wiederholt.
function ScoreCell({ m }: { m: Match }) {
function ScoreCell({ m, dict }: { m: Match; dict: Dictionary }) {
const hasScore = m.homeScore != null && m.awayScore != null;
const st = statusText(m);
const st = statusText(m, dict);
if (hasScore) {
return (
<span className={`fx-score ${st.live ? "score-live" : ""}`}>
@@ -59,8 +60,8 @@ function ScoreCell({ m }: { m: Match }) {
}
function GroupStandings({
group, teams, matches,
}: { group: GroupId; teams: Team[]; matches: Match[] }) {
group, teams, matches, dict,
}: { group: GroupId; teams: Team[]; matches: Match[]; dict: Dictionary }) {
// Live-Tabelle: laufende Spiele werden mit Zwischenstand eingerechnet.
const table = computeGroupTables(teams, matches, true).find((t) => t.group === group);
if (!table) return null;
@@ -80,15 +81,15 @@ function GroupStandings({
return (
<div className="fx-standings">
<div className="fx-standings-title">
Tabelle Gruppe {group}
{hasLive && <span className="fx-standings-live"> LIVE</span>}
{dict.fixtures.standingsTitle(group)}
{hasLive && <span className="fx-standings-live">{dict.label.liveIndicator}</span>}
</div>
<table className="standings">
<thead>
<tr>
<th className="team">Mannschaft</th>
<th>Sp</th><th>S</th><th>U</th><th>N</th>
<th>Tore</th><th>±</th><th>Pkt</th>
<th className="team">{dict.table.team}</th>
<th>{dict.table.matches}</th><th>{dict.table.won}</th><th>{dict.table.drawn}</th><th>{dict.table.lost}</th>
<th>{dict.table.goals}</th><th>{dict.table.goalDiff}</th><th>{dict.table.points}</th>
</tr>
</thead>
<tbody>
@@ -101,8 +102,8 @@ function GroupStandings({
<td className="team">
<span className={`rankdot ${cls}`}>{r.rank}</span>
<Flag team={team} size={20} />
<span className="team-name">{team?.localisedName ?? team?.name ?? r.teamId}</span>
{isLive && <span className="row-live-dot" title="läuft gerade" />}
<span className="team-name">{teamName(team, "de")}</span>
{isLive && <span className="row-live-dot" title={dict.status.running} />}
</td>
<td>{r.played}</td>
<td>{r.won}</td>
@@ -121,11 +122,13 @@ function GroupStandings({
}
export default function Fixtures({
group, teams, matches, onSelectGroup,
group, teams, matches, onSelectGroup, dict, locale,
}: {
group: GroupId; teams: Team[]; matches: Match[];
onSelectGroup: (g: GroupId) => void;
dict: Dictionary; locale: string;
}) {
const intlLocale = locale === "en" ? "en-US" : "de-DE";
const list = matches
.filter((m) => m.group === group)
.sort((a, b) => +new Date(a.utcDate) - +new Date(b.utcDate));
@@ -145,36 +148,37 @@ export default function Fixtures({
</div>
{list.length === 0 ? (
<div className="notice">Für Gruppe {group} liegen noch keine Spiele im Feed vor.</div>
<div className="notice">{dict.fixtures.noMatches(group)}</div>
) : (
<div className="fixtures">
{list.map((m) => {
const home = teamById(teams, m.homeTeamId);
const away = teamById(teams, m.awayTeamId);
const st = statusText(m);
const st = statusText(m, dict);
const displayText = st.text || fmtDate(m.utcDate, locale);
const homeWin = m.homeScore != null && m.awayScore != null && m.homeScore > m.awayScore;
const awayWin = m.homeScore != null && m.awayScore != null && m.awayScore > m.homeScore;
return (
<div className={`fx ${st.live ? "fx-live" : ""}`} key={m.id}>
<div className="fx-status">
{st.live && <span className="dot live" />}
<span>{st.text}</span>
<span>{displayText}</span>
</div>
<div className="fx-teams">
<div className={`fx-team home ${homeWin ? "win" : ""}`}>
<Flag team={home} size={22} />
<span className="fx-name">{home?.localisedName ?? home?.name ?? "—"}</span>
<span className="fx-name">{teamName(home, locale)}</span>
</div>
<ScoreCell m={m} />
<ScoreCell m={m} dict={dict} />
<div className={`fx-team away ${awayWin ? "win" : ""}`}>
<Flag team={away} size={22} />
<span className="fx-name">{away?.localisedName ?? away?.name ?? "—"}</span>
<span className="fx-name">{teamName(away, locale)}</span>
</div>
</div>
<div className="fx-meta">
{m.venue && <span className="fx-venue">📍 {m.venue}</span>}
{m.attendance != null && (
<span className="fx-att">👥 {m.attendance.toLocaleString("de-DE")}</span>
<span className="fx-att">👥 {m.attendance.toLocaleString(intlLocale)}</span>
)}
</div>
</div>
@@ -184,7 +188,7 @@ export default function Fixtures({
)}
{list.length > 0 && (
<GroupStandings group={group} teams={teams} matches={matches} />
<GroupStandings group={group} teams={teams} matches={matches} dict={dict} />
)}
</div>
);

View File

@@ -1,6 +1,7 @@
"use client";
import { GroupId, GroupTable, Match, Team } from "@/lib/types";
import { GroupId, GroupTable, Match, Team, teamName } from "@/lib/types";
import { Dictionary } from "@/lib/i18n";
import Flag from "./Flag";
function teamById(teams: Team[], id: string) {
@@ -26,10 +27,11 @@ function liveTeamIdsFor(group: GroupId, matches: Match[]): Set<string> {
}
export default function Groups({
tables, teams, matches, onOpenGroup,
tables, teams, matches, onOpenGroup, dict, locale,
}: {
tables: GroupTable[]; teams: Team[]; matches: Match[];
onOpenGroup: (g: GroupId) => void;
dict: Dictionary; locale: string;
}) {
return (
<div className="group-grid">
@@ -43,19 +45,19 @@ export default function Groups({
<button
className="group-head group-head-btn"
onClick={() => onOpenGroup(t.group)}
title={`Spiele der Gruppe ${t.group} ansehen`}
title={dict.groups.viewGroupGames(t.group)}
>
<span className={`group-name${groupComplete ? " group-complete" : ""}`}>Gruppe {t.group}</span>
<span className={`group-name${groupComplete ? " group-complete" : ""}`}>{dict.groups.groupLabel(t.group)}</span>
<span className="group-tag">
{live ? "● LIVE" : "Spiele ansehen →"}
{live ? dict.label.liveIndicator : dict.groups.viewGames}
</span>
</button>
<table className="standings">
<thead>
<tr>
<th className="team">Mannschaft</th>
<th>Sp</th><th>S</th><th>U</th><th>N</th>
<th>Tore</th><th>±</th><th>Pkt</th>
<th className="team">{dict.table.team}</th>
<th>{dict.table.matches}</th><th>{dict.table.won}</th><th>{dict.table.drawn}</th><th>{dict.table.lost}</th>
<th>{dict.table.goals}</th><th>{dict.table.goalDiff}</th><th>{dict.table.points}</th>
</tr>
</thead>
<tbody>
@@ -68,8 +70,8 @@ export default function Groups({
<td className="team">
<span className={`rankdot ${cls}`}>{r.rank}</span>
<Flag team={team} size={20} />
<span className="team-name">{team?.localisedName ?? team?.name ?? r.teamId}</span>
{isLive && <span className="row-live-dot" title="läuft gerade" />}
<span className="team-name">{teamName(team, locale)}</span>
{isLive && <span className="row-live-dot" title={dict.status.running} />}
</td>
<td>{r.played}</td>
<td>{r.won}</td>

View File

@@ -0,0 +1,281 @@
"use client";
import { useMemo, useState, useEffect, useRef } from "react";
import { Match, Team, teamName } from "@/lib/types";
import { STADIUMS, MATCH_STADIUMS } from "@/lib/stadiums";
import { Dictionary } from "@/lib/i18n";
import Flag from "./Flag";
function fmtShortDate(iso: string, locale: string): string {
const d = new Date(iso);
return d.toLocaleDateString(locale === "en" ? "en-US" : "de-DE", { weekday: "short", day: "2-digit", month: "2-digit" });
}
function fmtTime(iso: string, locale: string): string {
const d = new Date(iso);
return d.toLocaleTimeString(locale === "en" ? "en-US" : "de-DE", { hour: "2-digit", minute: "2-digit" });
}
function scoreDisplay(m: Match, dict: Dictionary): 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} ${dict.bracket.penaltyShootout})`;
}
return base;
}
function isLive(m: Match): boolean {
return m.status === "LIVE" || m.status === "IN_PLAY" || m.status === "PAUSED";
}
export default function KoFixtures({ teams, matches, dict, locale }: { teams: Team[]; matches: Match[]; dict: Dictionary; locale: string }) {
const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []);
function statusLabel(m: Match): string {
switch (m.status) {
case "LIVE": case "IN_PLAY": return m.minute != null ? `${m.minute}'` : dict.status.live;
case "PAUSED": return dict.status.halftime;
case "POSTPONED": return dict.status.postponed;
case "FINISHED": return dict.status.finished;
default: return fmtTime(m.utcDate, locale);
}
}
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">{dict.koFixtures.noMatches}</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, locale)}
</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: dict.stage.r16, QF: dict.stage.qf,
SF: dict.stage.sf, "3RD": dict.stage.thirdPlace, FINAL: dict.stage.final,
};
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 ? dict.koFixtures.liveBadge : dict.koFixtures.nextMatch}
</span>
)}
{m.status === "POSTPONED" && (
<span style={{
background: "var(--ink-faint)", color: "#fff",
fontSize: 9, fontWeight: 700,
padding: "1px 6px", borderRadius: 999,
letterSpacing: "0.04em",
}}>
{dict.koFixtures.postponedBadge}
</span>
)}
{stageMap[m.stage] ?? m.stage} · {m.matchNumber ? dict.koFixtures.match(m.matchNumber) : dict.koFixtures.match(0).replace("0", "—")}
{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} locale={locale} />
<span style={{
fontFamily: "var(--font-mono)", fontSize: 22, fontWeight: 700,
color: live ? "var(--turf)" : "var(--ink)",
padding: "0 16px",
}}>
{scoreDisplay(m, dict)}
</span>
<TeamLabel team={away} reverse locale={locale} />
</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 }}>
{dict.label.goals}
</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={dict.label.scrollToTop}
>
</button>
)}
</>
);
}
function TeamLabel({ team, reverse, locale }: { team?: Team; reverse?: boolean; locale: string }) {
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,
}}>
{teamName(team, locale)}
</span>
</div>
);
}

View File

@@ -2,13 +2,14 @@
import { useMemo } from "react";
import { Match, Team } from "@/lib/types";
import { Dictionary } from "@/lib/i18n";
import { computeGroupTables, computeThirdPlaceTable } from "@/lib/standings";
import { qualifiedThirdGroups, resolveAnnexC, LATER_ROUNDS } from "@/lib/bracket";
import { resolveBracket, ResolvedTie } from "@/lib/resolve-bracket";
import { buildSimMatches } from "@/lib/simulation";
import Bracket from "./Bracket";
export default function Simulation({ teams, matches }: { teams: Team[]; matches: Match[] }) {
export default function Simulation({ teams, matches, dict, locale }: { teams: Team[]; matches: Match[]; dict: Dictionary; locale: string }) {
// ---- Simulations-Pipeline (Polymarket-Defaults, keine manuellen Overrides) ----
const simMatches = useMemo(() => buildSimMatches(matches, {}), [matches]);
const simTables = useMemo(() => computeGroupTables(teams, simMatches), [teams, simMatches]);
@@ -21,8 +22,8 @@ export default function Simulation({ teams, matches }: { teams: Team[]; matches:
const simAnnexResolved = simAnnex != null;
const simBracket = useMemo(
() => resolveBracket(simMatches, teams, simTables, simThirds, simAnnex, simAnnexResolved, true),
[simMatches, teams, simTables, simThirds, simAnnex, simAnnexResolved],
() => resolveBracket(simMatches, teams, simTables, simThirds, simAnnex, simAnnexResolved, true, dict),
[simMatches, teams, simTables, simThirds, simAnnex, simAnnexResolved, dict],
);
// Echte Tabellen/Bracket für fix/provisional-Flags (nur tatsächlich FINISHED).
@@ -34,8 +35,8 @@ export default function Simulation({ teams, matches }: { teams: Team[]; matches:
[realQGroups],
);
const realBracket = useMemo(
() => resolveBracket(matches, teams, realTables, realThirds, realAnnex, realAnnex != null),
[matches, teams, realTables, realThirds, realAnnex],
() => resolveBracket(matches, teams, realTables, realThirds, realAnnex, realAnnex != null, undefined, dict),
[matches, teams, realTables, realThirds, realAnnex, dict],
);
// R32: provisional-Flags aus dem echten Bracket übernehmen.
@@ -95,12 +96,11 @@ export default function Simulation({ teams, matches }: { teams: Team[]; matches:
return (
<div>
<div className="notice" style={{ marginBottom: 20 }}>
Simulation alle ungespielten Spiele sind mit dem Polymarket-Favoriten
vorbelegt. Die angezeigten Wahrscheinlichkeiten stammen von Polymarket.
{dict.sim.notice}
</div>
<h3 style={{ fontFamily: "var(--font-display)", fontSize: 15, textTransform: "uppercase", letterSpacing: "0.06em", color: "var(--ink-dim)", margin: "0 0 12px" }}>
Simulierter K.o.-Baum
{dict.sim.heading}
</h3>
<Bracket
matches={simMatches}
@@ -110,6 +110,8 @@ export default function Simulation({ teams, matches }: { teams: Team[]; matches:
assignment={simAnnex}
annexResolved={simAnnexResolved}
preResolved={preResolved}
dict={dict}
locale={locale}
/>
</div>
);

View File

@@ -1,29 +1,27 @@
"use client";
import { Team, ThirdPlaceRow } from "@/lib/types";
import { Team, ThirdPlaceRow, teamName } from "@/lib/types";
import { Dictionary } from "@/lib/i18n";
export default function ThirdPlace({
rows, teams, secureTeams,
}: { rows: ThirdPlaceRow[]; teams: Team[]; secureTeams?: string[] }) {
rows, teams, secureTeams, dict,
}: { rows: ThirdPlaceRow[]; teams: Team[]; secureTeams?: string[]; dict: Dictionary }) {
const name = (id: string) => {
const t = teams.find((t) => t.id === id);
return t?.localisedName ?? t?.name ?? id;
return teamName(t, "de");
};
const secureSet = secureTeams ? new Set(secureTeams) : null;
return (
<div className="third-wrap">
<p className="notice" style={{ marginBottom: 16 }}>
Acht der zwölf Gruppendritten erreichen die Runde der letzten 32. Gewertet wird
gruppenübergreifend nach Punkten, Tordifferenz und Toren der Direktvergleich
entfällt, weil diese Teams nie gegeneinander gespielt haben. Die Trennlinie markiert
den Schnitt zwischen Platz 8 und 9.
{dict.thirds.explanation}
</p>
<table className="third-table">
<thead>
<tr>
<th>#</th><th>Gruppe</th><th>Team</th>
<th>Sp</th><th>Pkt</th><th>±</th><th>Tore</th><th>Status</th>
<th>{dict.table.rank}</th><th>{dict.table.group}</th><th>Team</th>
<th>{dict.table.matches}</th><th>{dict.table.points}</th><th>{dict.table.goalDiff}</th><th>{dict.table.goals}</th><th>{dict.table.status}</th>
</tr>
</thead>
<tbody>
@@ -39,7 +37,7 @@ export default function ThirdPlace({
<td className={r.played === 3 ? "team-complete" : ""} style={{ fontWeight: 600 }}>
{name(r.teamId)}
{secureSet?.has(r.teamId) && (
<span style={{ color: "var(--turf)", marginLeft: 6, fontSize: 13 }} title="sicher qualifiziert"></span>
<span style={{ color: "var(--turf)", marginLeft: 6, fontSize: 13 }} title={dict.label.securelyQualified}></span>
)}
</td>
<td>{r.played}</td>
@@ -48,7 +46,7 @@ export default function ThirdPlace({
<td>{r.goalsFor}:{r.goalsAgainst}</td>
<td>
<span className={`qual-badge ${r.qualifies ? "yes" : "no"}`}>
{r.qualifies ? "weiter" : "raus"}
{r.qualifies ? dict.thirds.advances : dict.thirds.eliminated}
</span>
</td>
</tr>

BIN
app/[locale]/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

501
app/[locale]/globals.css Normal file
View File

@@ -0,0 +1,501 @@
:root {
/* Palette: Stadion bei Nacht über drei Zeitzonen.
Tiefes Mitternachtsblau, kühles Flutlicht-Weiß, warmer Rasen-Akzent,
ein Signal-Magenta für "live". Bewusst nicht die üblichen AI-Defaults. */
--bg: #0b1020;
--bg-raised: #121a32;
--bg-card: #16203c;
--line: #243152;
--line-soft: #1b2540;
--ink: #eef2fb;
--ink-dim: #9aa6c4;
--ink-faint: #5f6d92;
--turf: #4ade80; /* Rasen / qualifiziert */
--turf-deep: #1f7a45;
--floodlight: #cfe0ff;
--live: #ff3d7f; /* Live-Signal */
--gold: #ffd24a; /* Sieger / Finale */
--radius: 10px;
--radius-sm: 6px;
--shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
--font-display: "Archivo Expanded", "Archivo", system-ui, sans-serif;
--font-body: "Inter", system-ui, -apple-system, sans-serif;
--font-mono: "Geist Mono", "SFMono-Regular", ui-monospace, monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
* { animation-duration: 0.001ms !important; transition-duration: 0.001ms !important; }
}
body {
background:
radial-gradient(1200px 600px at 70% -10%, #16224a 0%, transparent 55%),
radial-gradient(900px 500px at 10% 0%, #102046 0%, transparent 50%),
var(--bg);
color: var(--ink);
font-family: var(--font-body);
font-size: 15px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
min-height: 100vh;
}
a { color: inherit; text-decoration: none; }
.wrap { max-width: 1240px; margin: 0 auto; padding: 0 20px 0 0; }
/* ---------- Header / Hero ---------- */
.masthead {
border-bottom: 1px solid var(--line);
background: linear-gradient(180deg, rgba(18,26,50,0.7), transparent);
position: sticky; top: 0; z-index: 50;
backdrop-filter: blur(10px);
}
.masthead-inner {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 0; gap: 16px; flex-wrap: wrap;
}
.brand { display: flex; align-items: baseline; gap: 12px; }
.brand-mark {
font-family: var(--font-display);
font-weight: 800; letter-spacing: -0.02em;
font-size: clamp(20px, 3vw, 28px);
text-transform: uppercase;
}
.brand-mark .accent { color: var(--turf); }
.brand-sub {
font-family: var(--font-mono); font-size: 11px;
color: var(--ink-faint); letter-spacing: 0.08em; text-transform: uppercase;
}
.status-pill {
display: inline-flex; align-items: center; gap: 8px;
font-family: var(--font-mono); font-size: 12px;
color: var(--ink-dim);
border: 1px solid var(--line); border-radius: 999px;
padding: 6px 12px; background: var(--bg-card);
}
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--ink-faint); }
.dot.on { background: var(--turf); box-shadow: 0 0 0 3px rgba(74,222,128,0.18); }
.dot.live { background: var(--live); box-shadow: 0 0 0 3px rgba(255,61,127,0.2); animation: pulse 1.6s infinite; }
@keyframes pulse { 50% { box-shadow: 0 0 0 6px rgba(255,61,127,0); } }
/* ---------- Tabs ---------- */
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin: 0 0 4px; }
.masthead-tabs { width: 100%; border-bottom: none; margin: 0; }
.tab {
font-family: var(--font-display); font-weight: 700;
text-transform: uppercase; letter-spacing: 0.01em;
font-size: 14px; color: var(--ink-faint);
padding: 16px 18px; cursor: pointer; border: none; background: none;
border-bottom: 2px solid transparent; transition: color .15s, border-color .15s;
}
.tab:hover { color: var(--ink-dim); }
.tab.active { color: var(--ink); border-bottom-color: var(--turf); }
.section { padding: 28px 0 64px; }
/* ---------- Gruppen ---------- */
.group-grid {
display: grid; gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(330px, 1fr));
}
.group-card {
background: var(--bg-card); border: 1px solid var(--line);
border-radius: var(--radius); overflow: hidden;
}
.group-head {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 14px; border-bottom: 1px solid var(--line-soft);
background: var(--bg-raised);
}
.group-name {
font-family: var(--font-display); font-weight: 800; font-size: 16px;
text-transform: uppercase; letter-spacing: 0.02em;
}
.group-name.group-complete { color: var(--turf); }
.group-tag { font-family: var(--font-mono); font-size: 10px; color: var(--ink-faint); }
table.standings { width: 100%; border-collapse: collapse; }
.standings th {
font-family: var(--font-mono); font-size: 10px; font-weight: 500;
text-transform: uppercase; letter-spacing: 0.06em;
color: var(--ink-faint); text-align: right; padding: 8px 6px;
}
.standings th.team { text-align: left; padding-left: 14px; }
.standings td {
padding: 9px 6px; text-align: right; font-variant-numeric: tabular-nums;
border-top: 1px solid var(--line-soft); font-size: 13px;
}
.standings td.team {
text-align: left; padding-left: 14px; display: flex; align-items: center; gap: 9px;
}
.rankdot {
width: 18px; height: 18px; border-radius: 5px; flex: none;
display: grid; place-items: center;
font-family: var(--font-mono); font-size: 10px; font-weight: 600;
color: var(--bg); background: var(--ink-faint);
}
.rankdot.q1, .rankdot.q2 { background: var(--turf); }
.rankdot.q3 { background: var(--gold); color: #2a2200; }
.rankdot.q3.out { background: var(--ink-faint); color: var(--bg); }
.team-name { font-weight: 600; }
.team-code { font-family: var(--font-mono); font-size: 11px; color: var(--ink-faint); }
.pts { font-weight: 700; color: var(--floodlight); }
/* Live-Zeile */
.score-live { color: var(--live); font-weight: 700; }
/* Gruppen-Tab: grüne Hervorhebung für Teams mit laufendem Spiel */
.group-grid .row-live { background: rgba(74, 222, 128, 0.08); }
.group-grid .row-live .team-name { color: var(--turf); }
.group-grid .row-live-dot {
background: var(--turf);
}
/* ---------- Drittplatzierte ---------- */
.third-wrap { margin-top: 8px; }
.third-table { width: 100%; border-collapse: collapse; background: var(--bg-card);
border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
.third-table th {
font-family: var(--font-mono); font-size: 10px; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--ink-faint); padding: 11px 12px; text-align: right;
background: var(--bg-raised); border-bottom: 1px solid var(--line);
}
.third-table th:first-child, .third-table td:first-child { text-align: left; }
.third-table td {
padding: 10px 12px; text-align: right; font-variant-numeric: tabular-nums;
border-top: 1px solid var(--line-soft); font-size: 13px;
}
.third-row.qual { background: linear-gradient(90deg, rgba(74,222,128,0.07), transparent); }
.third-row.cut td { border-top: 2px solid var(--turf-deep); }
.qual-badge {
font-family: var(--font-mono); font-size: 10px; padding: 2px 7px; border-radius: 999px;
}
.qual-badge.yes { background: rgba(74,222,128,0.16); color: var(--turf); }
.qual-badge.no { background: rgba(95,109,146,0.16); color: var(--ink-faint); }
/* Drittplatzierte: Teamname grün, wenn alle 3 Gruppenspiele gespielt */
.third-table td.team-complete { color: var(--turf); }
/* ---------- Bracket ---------- */
.bracket-banner {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
background: var(--bg-card); border: 1px solid var(--line);
border-radius: var(--radius); padding: 14px 16px; margin-bottom: 20px;
}
.bracket-banner .k {
font-family: var(--font-mono); font-size: 12px; color: var(--ink-dim);
}
.bracket-banner .v { font-family: var(--font-mono); font-size: 12px; color: var(--turf); }
.bracket-scroll {
margin-left: -20px;
margin-right: -20px;
padding-left: 20px;
padding-right: 20px;
overflow-x: auto;
overflow-y: visible;
padding-bottom: 10px;
scrollbar-gutter: stable;
}
.bracket {
display: flex; gap: 26px; min-width: max-content; align-items: stretch;
padding-bottom: 8px;
}
.round { display: flex; flex-direction: column; min-width: 220px; }
.round-label {
font-family: var(--font-display); font-weight: 800; text-transform: uppercase;
font-size: 12px; letter-spacing: 0.06em; color: var(--ink-faint);
margin-bottom: 10px; padding-left: 2px;
}
.round-matches { display: flex; flex-direction: column; justify-content: space-around; flex: 1; gap: 12px; }
.tie {
background: var(--bg-card); border: 1px solid var(--line);
border-radius: var(--radius-sm); position: relative;
}
.tie.final-tie { border-color: var(--gold); box-shadow: 0 0 0 1px rgba(255,210,74,0.2); }
.tie-meta {
font-family: var(--font-mono); font-size: 9px; color: rgba(255,255,255,0.55);
letter-spacing: 0.04em; padding: 5px 10px 0;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.match-badge {
position: absolute; top: -6px; right: -6px;
font-family: var(--font-mono); font-size: 9px; font-weight: 700;
line-height: 18px; min-width: 18px; text-align: center;
border-radius: 999px; padding: 0 4px;
background: #1a2744; color: #ffffff; border: 1px solid rgba(255,255,255,0.15);
}
.side {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 10px; font-size: 13px;
}
.side + .side { border-top: 1px solid var(--line-soft); }
.side .nm { display: flex; align-items: center; gap: 7px; min-width: 0; }
.side .nm .c { font-family: var(--font-mono); font-size: 10px; color: var(--ink-faint); }
.side .lbl { color: var(--ink-dim); font-style: italic; }
.side .sc { font-family: var(--font-mono); font-weight: 700; color: var(--floodlight); }
.side.win .sc { color: var(--turf); }
.side .prob {
font-family: var(--font-mono); font-size: 10px; color: var(--ink-faint);
margin-left: 6px;
}
.legend { display: flex; gap: 18px; flex-wrap: wrap; margin-top: 16px;
font-family: var(--font-mono); font-size: 11px; color: var(--ink-faint); }
.legend span { display: inline-flex; align-items: center; gap: 6px; }
.legend i { width: 12px; height: 12px; border-radius: 3px; display: inline-block; }
/* Tooltip im K.o.-Baum (position: fixed, damit overflow-x den Tooltip nicht abschneidet) */
.kobaum-tip {
position: fixed;
z-index: 200;
pointer-events: none;
background: var(--bg-card);
border: 1px solid var(--line);
color: var(--ink);
font-family: var(--font-mono);
font-size: 12px;
padding: 6px 9px;
border-radius: var(--radius-sm);
white-space: nowrap;
box-shadow: 0 6px 20px rgba(0,0,0,0.4);
}
/* ---------- Zustände ---------- */
.notice {
background: var(--bg-card); border: 1px solid var(--line);
border-radius: var(--radius); padding: 18px 20px; color: var(--ink-dim);
font-size: 14px;
}
.notice.err { border-color: #5a2230; color: #ffb0c0; }
.skel { background: var(--bg-card); border: 1px solid var(--line);
border-radius: var(--radius); height: 220px; animation: shimmer 1.4s infinite; }
@keyframes shimmer { 50% { opacity: .55; } }
.foot {
border-top: 1px solid var(--line); padding: 24px 0 48px;
color: var(--ink-faint); font-size: 12px; font-family: var(--font-mono);
}
.foot a { color: var(--ink-dim); text-decoration: underline; text-underline-offset: 2px; }
/* =====================================================================
MOBIL — Breakpoint 640px (iPhone ~375, Galaxy ~412, alle ≤640)
===================================================================== */
@media (max-width: 640px) {
body { font-size: 14px; }
.wrap { padding: 0 12px; }
/* ---------- mobil: Header ---------- */
.masthead { padding: 0 12px; }
.masthead-inner { padding: 10px 0; gap: 8px; }
.brand { gap: 6px; }
.brand-mark { font-size: 18px; }
.brand-sub { font-size: 9px; letter-spacing: 0.04em; }
.status-pill { font-size: 10px; padding: 4px 10px; gap: 5px; }
.dot { width: 6px; height: 6px; }
/* ---------- mobil: Tabs (zwei Zeilen, kein horizontaler Scroll) ---------- */
.masthead-tabs {
flex-wrap: wrap;
justify-content: center;
gap: 2px 4px;
}
.tab { padding: 8px 13px; font-size: 12px; }
.section { padding: 18px 0 48px; }
/* ---------- mobil: Gruppen ---------- */
.group-grid { grid-template-columns: 1fr; gap: 12px; }
.group-head { padding: 10px 12px; }
.group-name { font-size: 14px; }
.standings th { font-size: 9px; padding: 6px 3px; }
.standings th.team { padding-left: 10px; }
.standings td { font-size: 11px; padding: 7px 3px; }
.standings td.team { padding-left: 10px; gap: 6px; }
.rankdot { width: 16px; height: 16px; font-size: 8px; }
.team-name { font-size: 12px; }
/* ---------- mobil: Spiele (Fixtures) ---------- */
.fx { grid-template-columns: 1fr; gap: 8px; padding: 10px 12px; }
.fx-meta { flex-direction: row; gap: 14px; align-items: center; }
.fx-status { order: -1; }
.fx-teams { column-gap: 16px; }
.fx-score { font-size: 16px; min-width: 44px; }
.fx-vs { font-size: 14px; min-width: 44px; }
.fx-name { font-size: 13px; max-width: 110px; }
.grp-switch { gap: 8px; }
.grp-chip { width: 40px; height: 40px; font-size: 14px; }
/* ---------- mobil: Drittplatzierte ---------- */
.third-table th { font-size: 9px; padding: 8px 6px; }
.third-table td { font-size: 11px; padding: 7px 6px; }
/* ---------- mobil: K.o.-Baum ---------- */
.bracket-scroll {
margin-left: -12px;
margin-right: -12px;
padding-left: 12px;
padding-right: 12px;
-webkit-overflow-scrolling: touch;
}
.bracket { gap: 18px; }
.round { min-width: 170px; }
.round-label { font-size: 10px; }
.round-matches { gap: 8px; }
.tie-meta { font-size: 8px; padding: 3px 8px 0; }
.side { padding: 6px 8px; font-size: 11px; }
.side .nm { gap: 5px; }
.prov-mark { font-size: 10px; }
.side .prob { font-size: 9px; }
.bracket-banner { padding: 10px 12px; font-size: 13px; }
.legend { font-size: 10px; gap: 12px; }
/* ---------- mobil: Simulation ---------- */
.sim-row { flex-wrap: wrap; gap: 6px; padding: 8px 10px; }
.sim-row-phase { min-width: 100%; font-size: 9px; }
.sim-row-date { min-width: auto; font-size: 9px; }
.sim-row input[type="number"] { width: 32px; height: 32px; font-size: 15px; }
/* ---------- mobil: Footer ---------- */
.foot { font-size: 11px; padding: 16px 0 32px; }
/* Kein horizontaler Überlauf außer im Bracket */
body { overflow-x: hidden; }
.bracket-scroll { overflow-x: auto; }
}
/* ============ Erweiterungen v2 ============ */
/* Flaggen / Wappen */
.flag {
border-radius: 50%; object-fit: cover; flex: none;
background: var(--bg-raised); border: 1px solid var(--line-soft);
}
.flag-fallback {
display: grid; place-items: center;
font-family: var(--font-mono); font-size: 9px; color: var(--ink-faint);
text-transform: uppercase;
}
/* Gruppen-Header als Button */
.group-head-btn {
width: 100%; cursor: pointer; text-align: left;
font: inherit; color: inherit;
transition: background .15s;
}
.group-head-btn:hover { background: var(--bg-card); }
.group-head-btn:hover .group-tag { color: var(--turf); }
.group-head-btn:focus-visible { outline: 2px solid var(--turf); outline-offset: -2px; }
/* Gruppen-Wechsler im Spiele-Tab */
.grp-switch { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 20px; }
.grp-chip {
width: 38px; height: 38px; border-radius: 8px;
border: 1px solid var(--line); background: var(--bg-card);
color: var(--ink-dim); font-family: var(--font-display); font-weight: 700;
font-size: 14px; cursor: pointer; transition: all .15s;
}
.grp-chip:hover { border-color: var(--turf); color: var(--ink); }
.grp-chip.active { background: var(--turf); color: var(--bg); border-color: var(--turf); }
/* Spiele-Karten */
.fixtures { display: flex; flex-direction: column; gap: 10px; }
.fx {
background: var(--bg-card); border: 1px solid var(--line);
border-radius: var(--radius); padding: 12px 16px;
display: grid; grid-template-columns: 110px minmax(0, 560px) 1fr; gap: 14px; align-items: center;
}
.fx-live { border-color: rgba(255,61,127,0.4); }
.fx-status {
display: flex; align-items: center; gap: 7px;
font-family: var(--font-mono); font-size: 12px; color: var(--ink-dim);
}
.fx-teams {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center; column-gap: 34px;
}
/* Beide Teams: Flagge links, Name daneben. Linkes Team rechtsbündig an den Score,
rechtes Team linksbündig so ist der Abstand zum Ergebnis beidseitig gleich. */
.fx-team { display: flex; align-items: center; gap: 8px; min-width: 0; }
.fx-team.home { justify-content: flex-end; }
.fx-team.away { justify-content: flex-start; }
.fx-team .fx-name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.fx-team.win .fx-name { color: var(--turf); }
.fx-score { font-family: var(--font-mono); font-weight: 700; font-size: 18px; color: var(--floodlight); white-space: nowrap; text-align: center; min-width: 54px; }
.fx-score.score-live { color: var(--live); }
.fx-vs { font-family: var(--font-mono); font-size: 15px; color: var(--ink-faint); text-align: center; min-width: 54px; }
.fx-meta {
display: flex; flex-direction: column; gap: 3px; align-items: flex-end;
font-family: var(--font-mono); font-size: 11px; color: var(--ink-faint); white-space: nowrap;
}
/* Austragungsort heller hervorheben. */
.fx-venue { color: var(--ink-dim); }
/* Live-Tabelle der Gruppe unterhalb der Spiele. */
.fx-standings { margin-top: 22px; }
.fx-standings-title {
font-family: var(--font-display, var(--font-mono)); font-size: 13px; letter-spacing: 0.06em;
text-transform: uppercase; color: var(--ink-dim); margin: 0 2px 10px;
display: flex; align-items: center; gap: 10px;
}
.fx-standings-live {
font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.04em;
color: var(--live); font-weight: 700;
animation: livepulse 1.6s ease-in-out infinite;
}
.fx-standings .standings { width: 100%; }
/* Zeile eines Teams mit laufendem Spiel hervorheben. */
.fx-standings .row-live { background: rgba(255, 61, 127, 0.07); }
.fx-standings .row-live .team-name { color: var(--floodlight); }
.row-live-dot {
display: inline-block; width: 7px; height: 7px; border-radius: 50%;
background: var(--live); margin-left: 8px; vertical-align: middle;
animation: livepulse 1.6s ease-in-out infinite;
}
@keyframes livepulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
@media (max-width: 720px) {
.fx { grid-template-columns: 1fr; gap: 8px; }
.fx-meta { flex-direction: row; gap: 14px; align-items: center; }
.fx-status { order: -1; }
}
/* Bracket: Label-Header braucht kein Extra-Padding mehr (sitzt im Kasten) */
.round-matches { padding-top: 0; }
/* Bracket: fix vs. vorläufig.
.nm trägt die Standardfarbe; Team-Name liegt in .tn. */
.side .nm .tn { color: var(--ink); }
/* vorläufig: gedimmt + kursiv + Marker */
.side.prov .nm .tn { color: var(--ink-dim); font-style: italic; }
.prov-mark {
color: #f0a23a; font-family: var(--font-mono); font-weight: 700;
font-size: 12px; margin-left: 2px;
}
/* fix: echtes Team steht fest -> fett weiß + grüner Marker links */
.side.fix .nm .tn { color: #ffffff; font-weight: 700; }
.side.fix { box-shadow: inset 3px 0 0 var(--turf-deep); }
/* Sieger eines bereits gespielten Tie sticht zusätzlich grün hervor */
.side.win .nm .tn { color: var(--turf); font-weight: 700; }
.side.win { box-shadow: inset 3px 0 0 var(--turf); }
/* Legende-Marker */
.legend i.leg-fix { background: var(--turf-deep); }
.legend i.leg-prov {
background: repeating-linear-gradient(45deg, #f0a23a, #f0a23a 3px, transparent 3px, transparent 6px);
border: 1px solid #f0a23a;
}

36
app/[locale]/layout.tsx Normal file
View File

@@ -0,0 +1,36 @@
import type { Metadata } from "next";
import Script from "next/script";
import { Locale, getDictionary } from "@/lib/i18n";
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
const dict = getDictionary(locale as Locale);
return {
title: dict.meta.title,
description: dict.meta.description,
};
}
export async function generateStaticParams() {
return [{ locale: "en" }, { locale: "de" }];
}
export default function LocaleLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
{children}
{process.env.NEXT_PUBLIC_UMAMI_SRC && process.env.NEXT_PUBLIC_UMAMI_ID && (
<Script
defer
src={process.env.NEXT_PUBLIC_UMAMI_SRC}
data-website-id={process.env.NEXT_PUBLIC_UMAMI_ID}
strategy="afterInteractive"
/>
)}
</>
);
}

View File

@@ -1,8 +1,9 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useEffect, useState, useCallback, useMemo, use } from "react";
import { GroupId, GroupTable, Match, Team, ThirdPlaceRow } from "@/lib/types";
import { ThirdAssignment } from "@/lib/bracket";
import { getDictionary, Locale, Dictionary } from "@/lib/i18n";
import Groups from "./components/Groups";
import ThirdPlace from "./components/ThirdPlace";
import Bracket from "./components/Bracket";
@@ -24,7 +25,9 @@ interface ApiData {
type Tab = "groups" | "groupfixtures" | "kofixtures" | "thirds" | "bracket" | "sim";
export default function Home() {
export default function Home({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = use(params) as { locale: Locale };
const dict = useMemo(() => getDictionary(locale), [locale]);
const [data, setData] = useState<ApiData | null>(null);
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<Tab>("kofixtures");
@@ -33,7 +36,7 @@ export default function Home() {
const load = useCallback(async () => {
try {
const res = await fetch("/api/matches", { cache: "no-store" });
const res = await fetch(`/api/matches?locale=${locale}`, { cache: "no-store" });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.detail || `Fehler ${res.status}`);
@@ -43,7 +46,7 @@ export default function Home() {
} catch (e) {
setError(e instanceof Error ? e.message : "Verbindung fehlgeschlagen");
}
}, []);
}, [locale]);
useEffect(() => {
load();
@@ -67,37 +70,37 @@ export default function Home() {
<div className="wrap masthead-inner">
<div className="brand">
<span className="brand-mark">WM <span className="accent">26</span></span>
<span className="brand-sub">USA · Kanada · Mexiko</span>
<span className="brand-sub">{dict.header.hostCountries}</span>
</div>
<span className="status-pill">
<span className={`dot ${anyLive ? "live" : data ? "on" : ""}`} />
{error
? "Feed offline"
? dict.status.feedOffline
: data
? anyLive ? "Live" : `Aktualisiert ${new Date(data.updatedAt).toLocaleTimeString("de-DE")}`
: "Lade Daten…"}
? anyLive ? dict.status.live : dict.status.updated(new Date(data.updatedAt).toLocaleTimeString(locale === "en" ? "en-US" : "de-DE"))
: dict.status.loading}
</span>
<nav className="tabs masthead-tabs">
<button className={`tab ${tab === "kofixtures" ? "active" : ""}`} onClick={() => setTab("kofixtures")}>
K.O.-Spiele
{dict.nav.koFixtures}
</button>
<button className={`tab ${tab === "groups" ? "active" : ""}`} onClick={() => setTab("groups")}>
Gruppen
{dict.nav.groups}
</button>
<button
className={`tab ${tab === "groupfixtures" ? "active" : ""}`}
onClick={() => { if (!fixturesGroup) setFixturesGroup("A"); setTab("groupfixtures"); }}
>
{fixturesGroup ? `Gruppenspiele ${fixturesGroup}` : "Gruppenspiele"}
{fixturesGroup ? dict.nav.groupFixtures(fixturesGroup) : dict.nav.groupFixturesNoGroup}
</button>
<button className={`tab ${tab === "thirds" ? "active" : ""}`} onClick={() => setTab("thirds")}>
Drittplatzierte
{dict.nav.thirdPlace}
</button>
<button className={`tab ${tab === "bracket" ? "active" : ""}`} onClick={() => setTab("bracket")}>
K.o.-Baum
{dict.nav.bracket}
</button>
<button className={`tab ${tab === "sim" ? "active" : ""}`} onClick={() => setTab("sim")}>
Simulation
{dict.nav.simulation}
</button>
</nav>
</div>
@@ -108,9 +111,7 @@ export default function Home() {
<section className="section">
{error && (
<div className="notice err">
Die Live-Feeds sind gerade nicht erreichbar: {error}.
Prüfe den <code>FOOTBALL_DATA_TOKEN</code> und die Netzwerkfreigabe des Servers.
Die Seite versucht es automatisch erneut.
{dict.error.feedUnreachable}
</div>
)}
@@ -121,40 +122,75 @@ export default function Home() {
)}
{data && tab === "kofixtures" && (
<KoFixtures teams={data.teams} matches={data.matches} />
<KoFixtures teams={data.teams} matches={data.matches} dict={dict} locale={locale} />
)}
{data && tab === "groups" && (
<Groups
tables={data.groupTablesLive} teams={data.teams} matches={data.matches}
onOpenGroup={openGroupFixtures}
onOpenGroup={openGroupFixtures} dict={dict} locale={locale}
/>
)}
{data && tab === "groupfixtures" && fixturesGroup && (
<Fixtures
group={fixturesGroup} teams={data.teams} matches={data.matches}
onSelectGroup={setFixturesGroup}
onSelectGroup={setFixturesGroup} dict={dict} locale={locale}
/>
)}
{data && tab === "thirds" && (
<ThirdPlace rows={data.thirdTable} teams={data.teams} secureTeams={data.secureThirdTeams} />
<ThirdPlace rows={data.thirdTable} teams={data.teams} secureTeams={data.secureThirdTeams} dict={dict} />
)}
{data && tab === "bracket" && (
<Bracket
matches={data.matches} teams={data.teams} tables={data.groupTablesLive}
thirds={data.thirdTable} assignment={data.annexAssignment}
annexResolved={data.annexResolved}
annexResolved={data.annexResolved} dict={dict} locale={locale}
/>
)}
{data && tab === "sim" && (
<Simulation teams={data.teams} matches={data.matches} />
<Simulation teams={data.teams} matches={data.matches} dict={dict} locale={locale} />
)}
</section>
</main>
<footer className="foot">
<div className="wrap">
Daten: football-data.org (Spiele &amp; Tabellen) · Polymarket Gamma API (Wahrscheinlichkeiten) ·
Annex-C-Zuordnung nach den FIFA-Wettbewerbsregeln WM 2026. Kein offizielles FIFA-Produkt.
<footer style={{
borderTop: "1px solid var(--line-soft)",
padding: "24px 0",
marginTop: 40,
}}>
<div className="wrap" style={{
display: "flex", flexDirection: "column", alignItems: "center", gap: 8,
}}>
<div style={{
display: "flex", alignItems: "center", gap: 12,
}}>
<span style={{
display: "inline-flex", alignItems: "center", justifyContent: "center",
width: 28, height: 28,
fontFamily: "var(--font-display)", fontSize: 13, fontWeight: 700,
color: "var(--ink-dim)", background: "var(--bg-card)",
border: "1px solid var(--line-soft)", borderRadius: "var(--radius-sm)",
}}>
AK
</span>
<a href="#" style={{
fontFamily: "var(--font-mono)", fontSize: 12,
color: "var(--ink-faint)", textDecoration: "none",
}}>
Andreas Knuth
</a>
</div>
<span style={{
fontFamily: "var(--font-mono)", fontSize: 10,
color: "var(--ink-faint)",
}}>
{dict.footer.dashboard}
</span>
<span style={{
fontFamily: "var(--font-mono)", fontSize: 9,
color: "var(--ink-faint)", opacity: 0.5, marginTop: 8,
}}>
{dict.footer.dataSources}
</span>
</div>
</footer>
</>

View File

@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { fetchMatchesAndTeams, fetchOdds, attachOdds, attachKOOdds, fetchLiveScores, applyLiveScores, assignKONumbersBySlots, fetchKOLiveData, attachKOLiveData } from "@/lib/feeds";
import type { NextRequest } from "next/server";
import { fetchMatchesAndTeams, fetchOdds, attachOdds, attachKOOdds, fetchFifaScores, applyFifaScores, assignKONumbersBySlots, attachFifaGoals } from "@/lib/feeds";
import { computeGroupTables, computeThirdPlaceTable } from "@/lib/standings";
import { qualifiedThirdGroups, resolveAnnexC } from "@/lib/bracket";
import { securelyQualifiedThirdTeams } from "@/lib/third-place-security";
@@ -8,7 +9,8 @@ import { securelyQualifiedThirdTeams } from "@/lib/third-place-security";
// Upstream-Feeds berührt — gecacht, damit Rate Limits eingehalten werden.
export const dynamic = "force-dynamic";
export async function GET() {
export async function GET(request: NextRequest) {
const locale = request.nextUrl.searchParams.get("locale") || "de";
try {
const { matches: rawMatches, teams } = await fetchMatchesAndTeams();
@@ -22,30 +24,27 @@ export async function GET() {
console.error("[polymarket] fetchOdds fehlgeschlagen:", err);
}
// Live-Scores von worldcup26.ir (additiv, Fallback auf football-data)
try {
const liveScores = await fetchLiveScores();
matches = applyLiveScores(matches, teams, liveScores);
} catch (err) {
console.warn("[worldcup26] fetchLiveScores fehlgeschlagen:", err instanceof Error ? err.message : err);
}
const groupTables = computeGroupTables(teams, matches);
const groupTablesLive = computeGroupTables(teams, matches, true);
// FIFA-Live-Scores (additiv, Fallback auf football-data)
assignKONumbersBySlots(matches, teams);
if (odds) {
matches = attachKOOdds(matches, teams, odds);
}
// KO-Live-Daten (Tore, Minute) von worldcup26
try {
const koGames = await fetchKOLiveData();
matches = attachKOLiveData(matches, teams, koGames);
const fifaData = await fetchFifaScores(locale);
matches = applyFifaScores(matches, teams, fifaData);
try {
matches = await attachFifaGoals(matches, fifaData, locale);
} catch (err) {
console.warn("[worldcup26] KO-Live-Daten fehlgeschlagen:", err instanceof Error ? err.message : err);
console.warn("[fifa] Goals fehlgeschlagen:", err instanceof Error ? err.message : err);
}
} catch (err) {
console.warn("[fifa] Scores fehlgeschlagen:", err instanceof Error ? err.message : err);
}
const groupTables = computeGroupTables(teams, matches);
const groupTablesLive = computeGroupTables(teams, matches, true);
const normalizedMatches = matches.map((m) => ({ ...m, prob: m.prob ?? null }));

View File

@@ -1,207 +0,0 @@
"use client";
import { useMemo, useState, useEffect } 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.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",
};
const city = STADIUMS[MATCH_STADIUMS[m.matchNumber]]?.city;
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 || "—"}
{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: 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}&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>
);
}
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>
);
}

View File

@@ -1,21 +1,8 @@
import type { Metadata } from "next";
import Script from "next/script";
import "./globals.css";
export const metadata: Metadata = {
title: "WM 26 — Gruppen & K.o.-Baum",
description:
"Live-Gruppentabellen, Drittplatzierten-Wertung und der vollständige K.o.-Baum der FIFA WM 2026 mit Annex-C-Zuordnung und Polymarket-Wahrscheinlichkeiten.",
};
export const viewport = {
width: "device-width",
initialScale: 1,
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="de">
<html lang="en">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
@@ -24,17 +11,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
rel="stylesheet"
/>
</head>
<body>
{children}
{process.env.NEXT_PUBLIC_UMAMI_SRC && process.env.NEXT_PUBLIC_UMAMI_ID && (
<Script
defer
src={process.env.NEXT_PUBLIC_UMAMI_SRC}
data-website-id={process.env.NEXT_PUBLIC_UMAMI_ID}
strategy="afterInteractive"
/>
)}
</body>
<body>{children}</body>
</html>
);
}

View File

@@ -1,5 +1,6 @@
import { ANNEX_C } from "./annexc-data";
import { GroupId, GroupTable, ThirdPlaceRow } from "./types";
import { Dictionary } from "./i18n";
// Die festen Paarungen der Runde der letzten 32 (FIFA-Spielplan, Annex zur Auslosung).
// Quelle: FIFA WM 2026 Wettbewerbsregeln, Spiele 73-88.
@@ -100,14 +101,16 @@ export function slotLabel(
slot: BracketSlot,
assignment: ThirdAssignment | null,
winnerGroupForThisMatch?: GroupId,
dict?: Dictionary,
): string {
if (slot.type === "W") return `Sieger ${slot.group}`;
if (slot.type === "R") return `Zweiter ${slot.group}`;
const s = dict?.slot;
if (slot.type === "W") return s?.winner(slot.group!) ?? `Sieger ${slot.group}`;
if (slot.type === "R") return s?.runnerUp(slot.group!) ?? `Zweiter ${slot.group}`;
// 3. Platz
if (assignment && winnerGroupForThisMatch && assignment[winnerGroupForThisMatch]) {
return `3. der Gruppe ${assignment[winnerGroupForThisMatch]}`;
return s?.thirdOfGroup(assignment[winnerGroupForThisMatch]!) ?? `3. der Gruppe ${assignment[winnerGroupForThisMatch]}`;
}
return `3. ${slot.thirdPool?.join("/") ?? "?"}`;
return s?.thirdPlacePool(slot.thirdPool?.join("/") ?? "?") ?? `3. ${slot.thirdPool?.join("/") ?? "?"}`;
}
// Lookup: matchNumber → Quellspiele (fromHome, fromAway, losers).

View File

@@ -1,4 +1,4 @@
import { GroupId, Match, MatchStatus, Team } from "./types";
import { GroupId, GoalEvent, Match, MatchStatus, Team } from "./types";
import { venueFor } from "./venues";
import { localisedTeamName } from "./team-mappings";
import { R32, LATER_ROUNDS, resolveAnnexC, qualifiedThirdGroups } from "./bracket";
@@ -181,6 +181,10 @@ export async function fetchMatchesAndTeams(): Promise<{ matches: Match[]; teams:
id, name: side.name, code: side.tla ?? "", group,
crest: `/crests/${id}.svg`,
localisedName: localisedTeamName(side.tla ?? "", side.name ?? ""),
localisedNames: {
de: localisedTeamName(side.tla ?? "", side.name ?? "", "de"),
en: localisedTeamName(side.tla ?? "", side.name ?? "", "en"),
},
});
}
}
@@ -556,195 +560,230 @@ export function attachKOOdds(matches: Match[], teams: Team[], odds: ParsedOdds[]
}
// ----------------------------------------------------------------------------
// worldcup26.ir: schnelle Live-Scores (additiv, Fallback auf football-data)
// FIFA-API: Live-Scores, Elfmeterschießen, Spielminute
// ----------------------------------------------------------------------------
const WC26_BASE = "https://worldcup26.ir";
const FIFA_BASE = "https://api.fifa.com/api/v3";
const FIFA_SEASON = "285023";
interface Wc26Game {
home_team_name_en?: string;
away_team_name_en?: string;
group?: string;
matchday?: string;
home_score?: string;
away_score?: string;
time_elapsed?: string;
goals?: Array<{ name?: string; minute?: string; team?: string }>;
current_minute?: string;
// FIFA-Code → App-Code (nur Abweichungen; sonst identisch)
const FIFA_CODE_OVERRIDE: Record<string, string> = {
CRO: "HRV", // Kroatien
POR: "PRT", // Portugal
SUI: "CHE", // Schweiz
};
function fifaCodeToAppCode(fifaCode: string): string {
return FIFA_CODE_OVERRIDE[fifaCode] ?? fifaCode;
}
interface LiveScore {
homeName: string;
awayName: string;
group: string;
matchday: string;
interface FifaTeamBlock {
IdTeam: string; // FIFA-Team-ID
Abbreviation: string; // FIFA-Code (z.B. PAR, CRO)
}
interface FifaMatch {
MatchNumber: number;
IdMatch?: string | null;
IdStage?: string | null;
Home: FifaTeamBlock | null;
Away: FifaTeamBlock | null;
HomeTeamScore: number | null;
AwayTeamScore: number | null;
HomeTeamPenaltyScore: number | null;
AwayTeamPenaltyScore: number | null;
MatchStatus: number; // 0=finished, 1=scheduled, else=live
MatchTime: string | null; // z.B. "132'"
ResultType: number | null; // 1=regular, 2=penalties
Winner?: string | null; // Team-ID des Siegers
}
interface FifaScores {
homeScore: number | null;
awayScore: number | null;
status: string; // "IN_PLAY" | "FINISHED"
homePenalty: number | null;
awayPenalty: number | null;
resultType: number | null;
status: MatchStatus;
matchTime: string | null;
winnerTeamId: string | null;
idMatch: string | null;
idStage: string | null;
}
// Ruft alle Spiele von worldcup26.ir ab.
export async function fetchLiveScores(): Promise<LiveScore[]> {
const res = await fetch(`${WC26_BASE}/get/games`, {
// Holt Live-Scores von der FIFA-API. Gibt Map MatchNumber → Scores + FIFA-ID→App-Code-Map zurück.
export async function fetchFifaScores(locale: string = "de"): Promise<{
scores: Map<number, FifaScores>;
fifaIdToAppCode: Map<string, string>;
}> {
return cached(`fifa:scores:${locale}`, 45_000, async () => {
const lang = locale === "en" ? "en" : "de";
const url = `${FIFA_BASE}/calendar/matches?language=${lang}&count=500&idSeason=${FIFA_SEASON}`;
const res = await fetch(url, {
cache: "no-store",
headers: { "User-Agent": "wm2026-board/1.0" },
signal: AbortSignal.timeout(5000),
});
if (!res.ok) throw new Error(`worldcup26 ${res.status}`);
const data = (await res.json()) as { games: Wc26Game[] };
const games = data.games ?? [];
if (!res.ok) throw new Error(`fifa ${res.status}`);
const data = (await res.json()) as { Results: FifaMatch[] };
const scores = new Map<number, FifaScores>();
const fifaIdToAppCode = new Map<string, string>();
const scores: LiveScore[] = [];
for (const g of games) {
if (!g.home_team_name_en || !g.away_team_name_en) continue;
const hScore = parseScore(g.home_score);
const aScore = parseScore(g.away_score);
let status = "";
switch (g.time_elapsed) {
case "live": status = "IN_PLAY"; break;
case "finished": status = "FINISHED"; break;
default: continue; // notstarted → überspringen
for (const fm of data.Results ?? []) {
// Team-Mapping sammeln
for (const tb of [fm.Home, fm.Away]) {
if (tb && !fifaIdToAppCode.has(tb.IdTeam)) {
fifaIdToAppCode.set(tb.IdTeam, fifaCodeToAppCode(tb.Abbreviation));
}
// Nur anwenden, wenn mindestens ein Score vorhanden ist
if (hScore == null && aScore == null) continue;
scores.push({
homeName: g.home_team_name_en,
awayName: g.away_team_name_en,
group: g.group ?? "",
matchday: String(g.matchday ?? ""),
homeScore: hScore,
awayScore: aScore,
}
let status: MatchStatus;
switch (fm.MatchStatus) {
case 0: status = "FINISHED"; break;
case 1: status = "SCHEDULED"; break;
case 10: status = "POSTPONED"; break;
default: status = "LIVE"; break;
}
scores.set(fm.MatchNumber, {
homeScore: fm.HomeTeamScore,
awayScore: fm.AwayTeamScore,
homePenalty: fm.HomeTeamPenaltyScore,
awayPenalty: fm.AwayTeamPenaltyScore,
resultType: fm.ResultType,
status,
matchTime: fm.MatchTime,
winnerTeamId: fm.Winner ?? null,
idMatch: fm.IdMatch ?? null,
idStage: fm.IdStage ?? null,
});
}
console.log("[worldcup26] spiele:", scores.length);
return scores;
return { scores, fifaIdToAppCode };
});
}
function parseScore(s: string | undefined): number | null {
if (!s || s === "null") return null;
const n = Number(s);
return Number.isFinite(n) ? n : null;
}
// Wendet worldcup26-Live-Scores auf football-data-Matches an.
// Matching über normalisierte Teamnamen + group + matchday.
export function applyLiveScores(
matches: Match[], teams: Team[], liveScores: LiveScore[],
// Wendet FIFA-Live-Scores auf Matches an (MatchNumber als exakter Schlüssel).
export function applyFifaScores(
matches: Match[], teams: Team[],
fifaData: { scores: Map<number, FifaScores>; fifaIdToAppCode: Map<string, string> },
): Match[] {
// Build lookup: normName(home)::normName(away)::group::matchday → match
// Für Group-Phase ist die Paarung eindeutig (jedes Paar spielt 1x).
const byPair = new Map<string, Match>();
for (const m of matches) {
if (m.group == null) continue; // nur Gruppenphase
if (!m.homeTeamId || !m.awayTeamId) continue;
const ht = teams.find((t) => t.id === m.homeTeamId);
const at = teams.find((t) => t.id === m.awayTeamId);
if (!ht || !at) continue;
const hn = normName(ht.name);
const an = normName(at.name);
// Beide Richtungen
byPair.set(`${hn}::${an}::${m.group}`, m);
byPair.set(`${an}::${hn}::${m.group}`, m);
const { scores: fifaMap, fifaIdToAppCode } = fifaData;
if (fifaMap.size === 0) return matches;
// Baue App-Code → App-Team-ID Map
const appCodeToId = new Map<string, string>();
for (const t of teams) {
if (t.code) appCodeToId.set(t.code.toLowerCase(), t.id);
}
const result = [...matches];
let applied = 0;
for (const ls of liveScores) {
const hn = normName(ls.homeName);
const an = normName(ls.awayName);
const key = `${hn}::${an}::${ls.group}`;
const fdMatch = byPair.get(key);
if (!fdMatch) continue;
const idx = result.findIndex((m) => m.id === fdMatch.id);
if (idx < 0) continue;
result[idx] = {
...result[idx],
homeScore: ls.homeScore ?? result[idx].homeScore,
awayScore: ls.awayScore ?? result[idx].awayScore,
status: ls.status as Match["status"],
};
applied++;
const result = matches.map((m) => {
const fs = fifaMap.get(m.matchNumber);
if (!fs) return m;
const r = { ...m };
if (fs.homeScore != null) r.homeScore = fs.homeScore;
if (fs.awayScore != null) r.awayScore = fs.awayScore;
if (fs.homePenalty != null) r.homePenalty = fs.homePenalty;
if (fs.awayPenalty != null) r.awayPenalty = fs.awayPenalty;
// FIFA-ID → App-Team-ID auflösen
if (fs.winnerTeamId) {
const appCode = fifaIdToAppCode.get(fs.winnerTeamId);
if (appCode) {
const appTeamId = appCodeToId.get(appCode.toLowerCase());
if (appTeamId) {
r.winnerTeamId = appTeamId;
} else {
console.warn("[fifa] Winner-Team-Code nicht in App-Teams:", appCode, "| matchNumber:", m.matchNumber);
}
if (applied > 0) console.log("[worldcup26] auf matches angewandt:", applied);
} else {
console.warn("[fifa] FIFA-Winner-ID nicht in Team-Map:", fs.winnerTeamId, "| matchNumber:", m.matchNumber);
}
}
if (fs.matchTime) {
const min = parseInt(fs.matchTime, 10);
if (!isNaN(min)) r.minute = min;
}
r.status = fs.status;
applied++;
return r;
});
if (applied > 0) console.log("[fifa] scores angewandt:", applied);
return result;
}
// Holt KO-Live-Daten von worldcup26 (Tore, Minute) und hängt sie an die Feed-Matches.
export async function fetchKOLiveData(): Promise<Wc26Game[]> {
try {
const res = await fetch(`${WC26_BASE}/get/games`, {
// Holt Tor-Details pro Spiel vom FIFA-Detail-Endpoint.
interface FifaGoalRaw { scorer: string; minute: string; team: "home" | "away"; type: number | null; }
function locName(arr: Array<{ Locale: string; Description: string }> | undefined): string {
if (!arr || arr.length === 0) return "";
return (arr.find(x => x.Locale === "de-DE")
?? arr.find(x => x.Locale === "en-GB")
?? arr[0])?.Description ?? "";
}
function normMinuteStr(min: string | null | undefined): string {
return (min ?? "").replace(/'/g, "").replace(/\s+/g, "").trim();
}
async function fetchFifaGoals(idStage: string, idMatch: string, locale: string = "de"): Promise<FifaGoalRaw[]> {
return cached(`fifa:detail:${locale}:${idMatch}`, 60_000, async () => {
const lang = locale === "en" ? "en" : "de";
const url = `${FIFA_BASE}/live/football/17/${FIFA_SEASON}/${idStage}/${idMatch}?language=${lang}`;
const res = await fetch(url, {
cache: "no-store",
headers: { "User-Agent": "wm2026-board/1.0" },
signal: AbortSignal.timeout(5000),
});
if (!res.ok) throw new Error(`worldcup26 ${res.status}`);
const data = (await res.json()) as { games: Wc26Game[] };
return (data.games ?? []).filter(g => {
const grp = (g.group ?? "").toUpperCase();
return grp === "" || grp === "R32" || grp === "R16" || grp === "QF" || grp === "SF" || grp === "3RD" || grp === "FINAL" || grp === "FINALIST";
if (!res.ok) { console.warn("[fifa-detail] status", res.status, idMatch); return []; }
const dj: any = await res.json();
const buildPlayerMap = (teamBlock: any): Map<string, string> => {
const map = new Map<string, string>();
for (const p of teamBlock?.Players ?? []) {
map.set(String(p.IdPlayer), locName(p.PlayerName) || locName(p.ShortName));
}
return map;
};
const homeBlock = dj.HomeTeam, awayBlock = dj.AwayTeam;
const homePlayers = buildPlayerMap(homeBlock);
const awayPlayers = buildPlayerMap(awayBlock);
const goals: FifaGoalRaw[] = [];
for (const [block, players, side] of [
[homeBlock, homePlayers, "home"] as const,
[awayBlock, awayPlayers, "away"] as const,
]) {
for (const g of block?.Goals ?? []) {
const min = normMinuteStr(g.Minute);
if (min === "" || isNaN(parseInt(min, 10))) continue;
goals.push({
scorer: players.get(String(g.IdPlayer)) || "?",
minute: normMinuteStr(g.Minute),
team: side,
type: g.Type ?? null,
});
} catch {
return [];
}
}
// Hängt worldcup26-KO-Live-Daten an die Matches an (Tore, Minute, Scores).
export function attachKOLiveData(matches: Match[], teams: Team[], koGames: Wc26Game[]): Match[] {
if (koGames.length === 0) return matches;
const nameById = new Map<string, string>();
for (const t of teams) {
const nn = normName(t.name);
if (!nameById.has(nn)) nameById.set(nn, t.id);
}
return matches.map((m) => {
if (m.group != null) return m; // nur K.o.-Spiele
const hId = m.homeTeamId;
const aId = m.awayTeamId;
const hName = hId ? teams.find(t => t.id === hId)?.name : null;
const aName = aId ? teams.find(t => t.id === aId)?.name : null;
// Finde worldcup26-Spiel über Teamnamen
const wm = koGames.find(g => {
if (!hName || !aName) return false;
const gh = normName(g.home_team_name_en ?? "");
const ga = normName(g.away_team_name_en ?? "");
return (gh === normName(hName) && ga === normName(aName)) ||
(gh === normName(aName) && ga === normName(hName));
goals.sort((a, b) => parseInt(a.minute, 10) - parseInt(b.minute, 10));
return goals;
});
if (!wm) return m;
const result = { ...m };
// Live-Score + Status
if (wm.time_elapsed === "live" || wm.time_elapsed === "finished") {
result.status = wm.time_elapsed === "finished" ? "FINISHED" : "IN_PLAY";
const hs = parseScore(wm.home_score);
const as = parseScore(wm.away_score);
if (hs != null) result.homeScore = hs;
if (as != null) result.awayScore = as;
}
// Spielminute
if (wm.current_minute) {
const min = parseInt(wm.current_minute, 10);
if (!isNaN(min)) result.minute = min;
}
// Torereignisse
if (wm.goals && wm.goals.length > 0) {
result.goals = wm.goals.map(g => ({
scorer: g.name ?? "?",
minute: parseInt(g.minute ?? "0", 10) || 0,
team: g.team?.toLowerCase() === "away" ? "away" as const : "home" as const,
// Lädt Tor-Details für beendete/laufende Spiele mit Toren und hängt sie an die Matches an.
export async function attachFifaGoals(
matches: Match[],
fifaData: { scores: Map<number, FifaScores>; fifaIdToAppCode: Map<string, string> },
locale: string = "de",
): Promise<Match[]> {
const targets = matches.filter(m =>
(m.status === "FINISHED" || m.status === "LIVE" || m.status === "IN_PLAY") &&
((m.homeScore ?? 0) + (m.awayScore ?? 0)) > 0,
);
const goalsByMatchId = new Map<string, GoalEvent[]>();
await Promise.all(targets.map(async (m) => {
const fs = fifaData.scores.get(m.matchNumber);
if (!fs?.idMatch || !fs?.idStage) return;
const goals = await fetchFifaGoals(fs.idStage, fs.idMatch, locale);
if (goals.length) goalsByMatchId.set(m.id, goals);
}));
}
return result;
if (goalsByMatchId.size === 0) return matches;
return matches.map(m => {
const g = goalsByMatchId.get(m.id);
return g ? { ...m, goals: g.map(({ scorer, minute, team }) => ({ scorer, minute, team })) } : m;
});
}

140
lib/i18n/de.ts Normal file
View File

@@ -0,0 +1,140 @@
import { Dictionary } from "./types";
const de: Dictionary = {
meta: {
title: "WM 26 — Gruppen & K.o.-Baum",
description:
"Live-Gruppentabellen, Drittplatzierten-Wertung und der vollständige K.o.-Baum der FIFA WM 2026 mit Annex-C-Zuordnung und Polymarket-Wahrscheinlichkeiten.",
},
header: {
hostCountries: "USA · Kanada · Mexiko",
},
status: {
feedOffline: "Feed offline",
updated: (time: string) => `Aktualisiert ${time}`,
live: "Live",
loading: "Lade Daten…",
running: "läuft",
halftime: "Halbzeit",
finished: "Beendet",
postponed: "Verzögert",
},
nav: {
koFixtures: "K.O.-Spiele",
groups: "Gruppen",
groupFixtures: (group: string) => `Gruppenspiele ${group}`,
groupFixturesNoGroup: "Gruppenspiele",
thirdPlace: "Drittplatzierte",
bracket: "K.o.-Baum",
simulation: "Simulation",
},
error: {
feedUnreachable:
"Die Live-Feeds sind gerade nicht erreichbar. Die Seite versucht es automatisch erneut.",
unknown: "unbekannter Fehler",
},
footer: {
dashboard: "WM 2026 Dashboard",
dataSources: "Daten: football-data.org · Polymarket Gamma API · FIFA Annex C",
},
sim: {
notice:
"Simulation — alle ungespielten Spiele sind mit dem Polymarket-Favoriten vorbelegt. Die angezeigten Wahrscheinlichkeiten stammen von Polymarket.",
heading: "Simulierter K.o.-Baum",
},
koFixtures: {
noMatches: "Keine K.o.-Spiele mit bekannten Teams verfügbar.",
liveBadge: "LIVE",
nextMatch: "NÄCHSTES SPIEL",
postponedBadge: "VERZÖGERT",
match: (n: number) => `Spiel ${n}`,
},
label: {
goals: "Tore",
liveIndicator: "● LIVE",
scrollToTop: "Nach oben scrollen",
securelyQualified: "sicher qualifiziert",
},
stage: {
r16: "Achtelfinale",
qf: "Viertelfinale",
sf: "Halbfinale",
thirdPlace: "Platz 3",
final: "Finale",
},
round: {
r32: "Letzte 32",
r16: "Achtelfinale",
qf: "Viertelfinale",
sf: "Halbfinale",
final: "Finale",
thirdPlace: "Spiel um Platz 3",
},
bracket: {
provisionalTooltip: "vorläufig Gruppe/Zuordnung noch nicht fix",
match: (n: number) => `Spiel ${n}`,
penaltyShootout: "i.E.",
annexHeader: "Annex-C-Zuordnung der Drittplatzierten:",
annexResolved:
"aufgelöst — die acht Dritten sind den Gruppensiegern fest zugeteilt",
annexPending:
"noch offen — sobald die 8 besten Dritten feststehen, verbindet sich der Baum automatisch",
winner: "Sieger",
loser: "Verlierer",
winnerFromMatch: (n: number) => `Sieger aus Spiel ${n}`,
loserFromMatch: (n: number) => `Verlierer aus Spiel ${n}`,
},
legend: {
winner: "Sieger / weiter",
final: "Finale",
fixed: "fix qualifiziert",
provisional: "vorläufig (≈, nach aktueller Tabelle)",
placeholder: "Platzhalter offen",
probExplanation:
"%-Werte: Polymarket-Wahrscheinlichkeit (falls verfügbar)",
},
tooltips: {
provisionalPrefix: "aktuell",
firstOfGroup: (group: string) => `1. Gruppe ${group}`,
secondOfGroup: (group: string) => `2. Gruppe ${group}`,
thirdOfGroup: (group: string) => `3. der Gruppe ${group}`,
provisionalThird: (pool: string) => `aktuell 3. Gruppe ${pool}`,
},
slot: {
winner: (group: string) => `Sieger ${group}`,
runnerUp: (group: string) => `Zweiter ${group}`,
thirdOfGroup: (group: string) => `3. der Gruppe ${group}`,
thirdPlacePool: (pool: string) => `3. ${pool}`,
},
table: {
rank: "#",
team: "Mannschaft",
group: "Gruppe",
matches: "Sp",
won: "S",
drawn: "U",
lost: "N",
goals: "Tore",
goalDiff: "±",
points: "Pkt",
status: "Status",
},
thirds: {
explanation:
"Acht der zwölf Gruppendritten erreichen die Runde der letzten 32. Gewertet wird gruppenübergreifend nach Punkten, Tordifferenz und Toren — der Direktvergleich entfällt, weil diese Teams nie gegeneinander gespielt haben. Die Trennlinie markiert den Schnitt zwischen Platz 8 und 9.",
advances: "weiter",
eliminated: "raus",
},
fixtures: {
standingsTitle: (group: string) => `Tabelle Gruppe ${group}`,
noMatches: (group: string) =>
`Für Gruppe ${group} liegen noch keine Spiele im Feed vor.`,
},
groups: {
viewGroupGames: (group: string) => `Spiele der Gruppe ${group} ansehen`,
groupLabel: (group: string) => `Gruppe ${group}`,
viewGames: "Spiele ansehen →",
},
};
export default de;

145
lib/i18n/en.ts Normal file
View File

@@ -0,0 +1,145 @@
import { Dictionary } from "./types";
function ordinal(n: number): string {
const s = ["th", "st", "nd", "rd"];
const v = n % 100;
return n + (s[(v - 20) % 10] || s[v] || s[0]);
}
const en: Dictionary = {
meta: {
title: "WC 26 — Groups & KO Bracket",
description:
"Live group tables, third-place ranking and the complete KO bracket for the FIFA World Cup 2026 with Annex C assignment and Polymarket probabilities.",
},
header: {
hostCountries: "USA · Canada · Mexico",
},
status: {
feedOffline: "Feed offline",
updated: (time: string) => `Updated ${time}`,
live: "Live",
loading: "Loading…",
running: "running",
halftime: "Half time",
finished: "Finished",
postponed: "Postponed",
},
nav: {
koFixtures: "KO Matches",
groups: "Groups",
groupFixtures: (group: string) => `Group Stage ${group}`,
groupFixturesNoGroup: "Group Stage",
thirdPlace: "Third Place",
bracket: "KO Bracket",
simulation: "Simulation",
},
error: {
feedUnreachable:
"Live feeds are currently unavailable. The page will retry automatically.",
unknown: "unknown error",
},
footer: {
dashboard: "WC 2026 Dashboard",
dataSources: "Data: football-data.org · Polymarket Gamma API · FIFA Annex C",
},
sim: {
notice:
"Simulation — all unplayed matches are pre-filled with the Polymarket favourite. Probabilities shown are from Polymarket.",
heading: "Simulated KO Bracket",
},
koFixtures: {
noMatches: "No KO matches with known teams available.",
liveBadge: "LIVE",
nextMatch: "NEXT MATCH",
postponedBadge: "POSTPONED",
match: (n: number) => `Match ${n}`,
},
label: {
goals: "Goals",
liveIndicator: "● LIVE",
scrollToTop: "Scroll to top",
securelyQualified: "securely qualified",
},
stage: {
r16: "Round of 16",
qf: "Quarter-finals",
sf: "Semi-finals",
thirdPlace: "3rd Place",
final: "Final",
},
round: {
r32: "Round of 32",
r16: "Round of 16",
qf: "Quarter-finals",
sf: "Semi-finals",
final: "Final",
thirdPlace: "Third Place Match",
},
bracket: {
provisionalTooltip: "provisional group/assignment not yet fixed",
match: (n: number) => `Match ${n}`,
penaltyShootout: "PSO",
annexHeader: "Annex C third-place assignment:",
annexResolved:
"resolved — the eight third-placed teams are permanently assigned to group winners",
annexPending:
"pending — once the 8 best third-placed teams are determined, the bracket auto-connects",
winner: "Winner",
loser: "Loser",
winnerFromMatch: (n: number) => `Winner of match ${n}`,
loserFromMatch: (n: number) => `Loser of match ${n}`,
},
legend: {
winner: "Winner / advances",
final: "Final",
fixed: "securely qualified",
provisional: "provisional (≈, current standings)",
placeholder: "placeholder open",
probExplanation: "% values: Polymarket probability (if available)",
},
tooltips: {
provisionalPrefix: "currently",
firstOfGroup: (group: string) => `${ordinal(1)} in Group ${group}`,
secondOfGroup: (group: string) => `${ordinal(2)} in Group ${group}`,
thirdOfGroup: (group: string) => `${ordinal(3)} in Group ${group}`,
provisionalThird: (pool: string) => `currently 3rd of Group ${pool}`,
},
slot: {
winner: (group: string) => `Winner ${group}`,
runnerUp: (group: string) => `Runner-up ${group}`,
thirdOfGroup: (group: string) => `3rd of Group ${group}`,
thirdPlacePool: (pool: string) => `3rd ${pool}`,
},
table: {
rank: "#",
team: "Team",
group: "Group",
matches: "GP",
won: "W",
drawn: "D",
lost: "L",
goals: "Goals",
goalDiff: "±",
points: "Pts",
status: "Status",
},
thirds: {
explanation:
"Eight of the twelve group third-placed teams advance to the Round of 32. Ranking is across all groups by points, goal difference and goals scored — head-to-head does not apply because these teams have never met. The separator marks the cut between 8th and 9th place.",
advances: "advances",
eliminated: "out",
},
fixtures: {
standingsTitle: (group: string) => `Standings Group ${group}`,
noMatches: (group: string) =>
`No matches available yet for Group ${group}.`,
},
groups: {
viewGroupGames: (group: string) => `View Group ${group} matches`,
groupLabel: (group: string) => `Group ${group}`,
viewGames: "View matches →",
},
};
export default en;

11
lib/i18n/index.ts Normal file
View File

@@ -0,0 +1,11 @@
import { Locale, Dictionary } from "./types";
import de from "./de";
import en from "./en";
export const dictionaries: Record<Locale, Dictionary> = { de, en };
export function getDictionary(locale: Locale): Dictionary {
return dictionaries[locale] ?? en;
}
export { type Locale, type Dictionary } from "./types";

130
lib/i18n/types.ts Normal file
View File

@@ -0,0 +1,130 @@
export type Locale = "de" | "en";
export interface Dictionary {
meta: {
title: string;
description: string;
};
header: {
hostCountries: string;
};
status: {
feedOffline: string;
updated: (time: string) => string;
live: string;
loading: string;
running: string;
halftime: string;
finished: string;
postponed: string;
};
nav: {
koFixtures: string;
groups: string;
groupFixtures: (group: string) => string;
groupFixturesNoGroup: string;
thirdPlace: string;
bracket: string;
simulation: string;
};
error: {
feedUnreachable: string;
unknown: string;
};
footer: {
dashboard: string;
dataSources: string;
};
sim: {
notice: string;
heading: string;
};
koFixtures: {
noMatches: string;
liveBadge: string;
nextMatch: string;
postponedBadge: string;
match: (n: number) => string;
};
label: {
goals: string;
liveIndicator: string;
scrollToTop: string;
securelyQualified: string;
};
stage: {
r16: string;
qf: string;
sf: string;
thirdPlace: string;
final: string;
};
round: {
r32: string;
r16: string;
qf: string;
sf: string;
final: string;
thirdPlace: string;
};
bracket: {
provisionalTooltip: string;
match: (n: number) => string;
penaltyShootout: string;
annexHeader: string;
annexResolved: string;
annexPending: string;
winner: string;
loser: string;
winnerFromMatch: (n: number) => string;
loserFromMatch: (n: number) => string;
};
legend: {
winner: string;
final: string;
fixed: string;
provisional: string;
placeholder: string;
probExplanation: string;
};
tooltips: {
provisionalPrefix: string;
firstOfGroup: (group: string) => string;
secondOfGroup: (group: string) => string;
thirdOfGroup: (group: string) => string;
provisionalThird: (pool: string) => string;
};
slot: {
winner: (group: string) => string;
runnerUp: (group: string) => string;
thirdOfGroup: (group: string) => string;
thirdPlacePool: (pool: string) => string;
};
table: {
rank: string;
team: string;
group: string;
matches: string;
won: string;
drawn: string;
lost: string;
goals: string;
goalDiff: string;
points: string;
status: string;
};
thirds: {
explanation: string;
advances: string;
eliminated: string;
};
fixtures: {
standingsTitle: (group: string) => string;
noMatches: (group: string) => string;
};
groups: {
viewGroupGames: (group: string) => string;
groupLabel: (group: string) => string;
viewGames: string;
};
}

View File

@@ -5,6 +5,7 @@ import {
} from "@/lib/bracket";
import { placeIsSecure } from "@/lib/secure-places";
import { thirdSlotIsSecure, securelyQualifiedThirdTeams } from "@/lib/third-place-security";
import { Dictionary } from "@/lib/i18n";
// Ein aufgelöster Teilnehmer einer K.o.-Begegnung.
export interface ResolvedSide {
@@ -24,6 +25,8 @@ export interface ResolvedTie {
away: ResolvedSide;
status: string;
prob?: { home: number; draw: number | null; away: number } | null;
homePenalty?: number | null;
awayPenalty?: number | null;
}
// Findet das tatsächliche Spiel (aus dem Feed) zu einer FIFA-Match-Nummer.
@@ -34,13 +37,36 @@ function feedMatch(matches: Match[], num: number): Match | undefined {
// Bestimmt den Sieger eines abgeschlossenen Spiels (Feed) als Team-ID.
function winnerOf(m: Match | undefined): string | null {
if (!m || m.status !== "FINISHED") return null;
if (m.winnerTeamId) return m.winnerTeamId;
if (m.homeScore == null || m.awayScore == null) return null;
if (m.homeScore > m.awayScore) return m.homeTeamId;
if (m.awayScore > m.homeScore) return m.awayTeamId;
return null; // Unentschieden -> Elfmeter; Feed liefert i.d.R. Sieger separat
return null;
}
// Bestimmt im Sim-Modus die Sieger-Slot-ID.
// Real gespielte Spiele: echtes winnerTeamId (inkl. Elfmeter) auf die
// aufgelöste Slot-Seite mappen. Simulierte Spiele: Score-Vergleich.
function simWinnerSlotId(feed: Match, homeSlotId: string, awaySlotId: string): string {
if (feed.status === "FINISHED" && feed.winnerTeamId) {
if (feed.winnerTeamId === homeSlotId) return homeSlotId;
if (feed.winnerTeamId === awaySlotId) return awaySlotId;
if (feed.homePenalty != null && feed.awayPenalty != null
&& feed.homePenalty !== feed.awayPenalty) {
return feed.homePenalty > feed.awayPenalty ? homeSlotId : awaySlotId;
}
}
if (feed.homeScore != null && feed.awayScore != null) {
if (feed.homeScore > feed.awayScore) return homeSlotId;
if (feed.awayScore > feed.homeScore) return awaySlotId;
}
return homeSlotId;
}
function loserOf(m: Match | undefined): string | null {
if (!m || m.status !== "FINISHED") return null;
if (m.winnerTeamId) {
return m.homeTeamId === m.winnerTeamId ? m.awayTeamId : m.homeTeamId;
}
if (m.homeScore == null || m.awayScore == null) return null;
if (m.homeScore > m.awayScore) return m.awayTeamId;
if (m.awayScore > m.homeScore) return m.homeTeamId;
@@ -59,23 +85,25 @@ function resolveR32Slot(
teams: Team[],
annexResolved: boolean,
secureTeamIds: Set<string>,
dict?: Dictionary,
): { teamId: string | null; provisional: boolean; tooltip: string | null } {
const t = dict?.tooltips;
const table = (g: GroupId) => tables.find((t) => t.group === g);
if (slot.type === "W") {
const t = table(slot.group!);
const teamId = t?.rows.find((r) => r.rank === 1)?.teamId ?? null;
const tab = table(slot.group!);
const teamId = tab?.rows.find((r) => r.rank === 1)?.teamId ?? null;
// Sieger fix, sobald Platz 1 rechnerisch gesichert ist (auch vor Gruppenende).
const provisional = !placeIsSecure(slot.group!, 1, teams, matches);
const prefix = provisional ? "aktuell " : "";
return { teamId, provisional, tooltip: `${prefix}1. Gruppe ${slot.group}` };
const prefix = provisional ? (t?.provisionalPrefix ?? "aktuell") + " " : "";
return { teamId, provisional, tooltip: `${prefix}${t?.firstOfGroup(slot.group!) ?? `1. Gruppe ${slot.group}`}` };
}
if (slot.type === "R") {
const t = table(slot.group!);
const teamId = t?.rows.find((r) => r.rank === 2)?.teamId ?? null;
const tab = table(slot.group!);
const teamId = tab?.rows.find((r) => r.rank === 2)?.teamId ?? null;
// Zweiter fix, sobald Platz 2 rechnerisch gesichert ist.
const provisional = !placeIsSecure(slot.group!, 2, teams, matches);
const prefix = provisional ? "aktuell " : "";
return { teamId, provisional, tooltip: `${prefix}2. Gruppe ${slot.group}` };
const prefix = provisional ? (t?.provisionalPrefix ?? "aktuell") + " " : "";
return { teamId, provisional, tooltip: `${prefix}${t?.secondOfGroup(slot.group!) ?? `2. Gruppe ${slot.group}`}` };
}
// 3. Platz: braucht Annex-C-Zuordnung + den Gruppensieger dieses Spiels
if (slot.type === "3" && assignment && winnerGroup) {
@@ -91,17 +119,17 @@ function resolveR32Slot(
const teamSecure = row?.teamId ? secureTeamIds.has(row.teamId) : false;
const fix = annexResolved && slotStable && teamSecure && row?.qualifies === true;
const provisional = !fix;
const prefix = provisional ? "aktuell " : "";
const prefix = provisional ? (t?.provisionalPrefix ?? "aktuell") + " " : "";
return {
teamId: row?.teamId ?? null,
provisional,
tooltip: `${prefix}3. der Gruppe ${thirdGroup}`,
tooltip: `${prefix}${t?.thirdOfGroup(thirdGroup) ?? `3. der Gruppe ${thirdGroup}`}`,
};
}
}
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: t?.provisionalThird(pool) ?? `aktuell 3. Gruppe ${pool}` };
}
return { teamId: null, provisional: true, tooltip: null };
}
@@ -138,6 +166,7 @@ export function resolveBracket(
thirds: ThirdPlaceRow[], assignment: ThirdAssignment | null,
annexResolved: boolean,
resolveWinners = false,
dict?: Dictionary,
): { r32: ResolvedTie[]; later: Record<number, ResolvedTie> } {
// Map: Match-Nummer -> Sieger-Team-ID (für Propagation in Folgerunden)
const winners = new Map<number, string | null>();
@@ -146,6 +175,8 @@ export function resolveBracket(
const decided = new Map<number, boolean>();
const secureTeamIds = securelyQualifiedThirdTeams(matches, teams);
const b = dict?.bracket;
const r32: ResolvedTie[] = R32.map((rm: R32Match) => {
const feed = feedMatch(matches, rm.matchNumber);
const homeGroup = rm.home.type === "W" ? rm.home.group : undefined;
@@ -153,26 +184,18 @@ export function resolveBracket(
// Für 3.-Platz-Slots: Gruppensieger ist die andere Seite (immer W)
const winnerGroup = (homeGroup ?? awayGroup) as GroupId | undefined;
const h = resolveR32Slot(rm.home, winnerGroup, tables, assignment, thirds, matches, teams, annexResolved, secureTeamIds);
const a = resolveR32Slot(rm.away, winnerGroup, tables, assignment, thirds, matches, teams, annexResolved, secureTeamIds);
const home = sideFrom(h.teamId, slotLabel(rm.home, assignment, winnerGroup), teams, feed, "home", h.provisional, h.tooltip);
const away = sideFrom(a.teamId, slotLabel(rm.away, assignment, winnerGroup), teams, feed, "away", a.provisional, a.tooltip);
const h = resolveR32Slot(rm.home, winnerGroup, tables, assignment, thirds, matches, teams, annexResolved, secureTeamIds, dict);
const a = resolveR32Slot(rm.away, winnerGroup, tables, assignment, thirds, matches, teams, annexResolved, secureTeamIds, dict);
const home = sideFrom(h.teamId, slotLabel(rm.home, assignment, winnerGroup, dict), teams, feed, "home", h.provisional, h.tooltip);
const away = sideFrom(a.teamId, slotLabel(rm.away, assignment, winnerGroup, dict), teams, feed, "away", a.provisional, a.tooltip);
const feedWinner = winnerOf(feed);
// Sim-Modus: Gewinner NUR aus Scores + Slot-Team-IDs ableiten.
// feedWinner (aus feed.homeTeamId/awayTeamId) wird IGNORIERT,
// weil matchNumber-Zuweisung (assignNumbersAndVenues) von der
// FIFA-Nummerierung abweichen kann → feed gehört evtl. zum falschen Spiel.
if (resolveWinners && feed && h.teamId && a.teamId
&& feed.homeScore != null && feed.awayScore != null) {
// Simulation: Gewinner aus Scores und aufgelösten Team-IDs ableiten.
// Nutze NICHT feed.homeTeamId/awayTeamId (sind null für KO-Matches),
// sondern die aufgelösten h.teamId / a.teamId aus resolveR32Slot.
const homeWins = feed.homeScore > feed.awayScore;
const awayWins = feed.awayScore > feed.homeScore;
const winnerId = homeWins ? h.teamId : awayWins ? a.teamId : h.teamId;
const winnerId = simWinnerSlotId(feed, h.teamId, a.teamId);
const loserId = winnerId === h.teamId ? a.teamId : h.teamId;
winners.set(rm.matchNumber, winnerId);
losers.set(rm.matchNumber, loserOf(feed) ?? (homeWins ? a.teamId : awayWins ? h.teamId : a.teamId));
losers.set(rm.matchNumber, loserId);
} else {
winners.set(rm.matchNumber, feedWinner);
losers.set(rm.matchNumber, loserOf(feed));
@@ -181,6 +204,7 @@ export function resolveBracket(
return {
matchNumber: rm.matchNumber, stage: "R32",
home, away, status: feed?.status ?? "SCHEDULED", prob: feed?.prob,
homePenalty: feed?.homePenalty, awayPenalty: feed?.awayPenalty,
};
});
@@ -190,25 +214,44 @@ export function resolveBracket(
const src = km.losers ? losers : winners;
const homeId = src.get(km.fromHome) ?? null;
const awayId = src.get(km.fromAway) ?? null;
if (km.matchNumber === 89 || km.matchNumber === 90) {
console.log("[PROPAGATE-R16]", {
zielSlot: km.matchNumber,
fromHome: km.fromHome,
fromAway: km.fromAway,
homeIdAusWinners: homeId,
awayIdAusWinners: awayId,
homeIdAusLosers: losers.get(km.fromHome) ?? null,
awayIdAusLosers: losers.get(km.fromAway) ?? null,
});
}
// Ein Folgeteam ist fix, wenn das speisende Spiel beendet ist.
const homeProv = !(decided.get(km.fromHome) ?? false);
const awayProv = !(decided.get(km.fromAway) ?? false);
const homeLabel = `${km.losers ? "Verlierer" : "Sieger"} ${km.fromHome}`;
const awayLabel = `${km.losers ? "Verlierer" : "Sieger"} ${km.fromAway}`;
const homeTtip = `${km.losers ? "Verlierer" : "Sieger"} aus Spiel ${km.fromHome}`;
const awayTtip = `${km.losers ? "Verlierer" : "Sieger"} aus Spiel ${km.fromAway}`;
const loserLabel = km.losers ? true : false;
const homeLabel = loserLabel
? (b?.loser ?? "Verlierer") + " " + km.fromHome
: (b?.winner ?? "Sieger") + " " + km.fromHome;
const awayLabel = loserLabel
? (b?.loser ?? "Verlierer") + " " + km.fromAway
: (b?.winner ?? "Sieger") + " " + km.fromAway;
const homeTtip = loserLabel
? (b?.loserFromMatch(km.fromHome) ?? `Verlierer aus Spiel ${km.fromHome}`)
: (b?.winnerFromMatch(km.fromHome) ?? `Sieger aus Spiel ${km.fromHome}`);
const awayTtip = loserLabel
? (b?.loserFromMatch(km.fromAway) ?? `Verlierer aus Spiel ${km.fromAway}`)
: (b?.winnerFromMatch(km.fromAway) ?? `Sieger aus Spiel ${km.fromAway}`);
const home = sideFrom(homeId, homeLabel, teams, feed, "home", homeProv, homeTtip);
const away = sideFrom(awayId, awayLabel, teams, feed, "away", awayProv, awayTtip);
const feedWinner = winnerOf(feed);
if (resolveWinners && feed && homeId && awayId
&& feed.homeScore != null && feed.awayScore != null) {
const homeWins = feed.homeScore > feed.awayScore;
const awayWins = feed.awayScore > feed.homeScore;
const winnerId = homeWins ? homeId : awayWins ? awayId : homeId;
const winnerId = simWinnerSlotId(feed, homeId, awayId);
const loserId = winnerId === homeId ? awayId : homeId;
winners.set(km.matchNumber, winnerId);
losers.set(km.matchNumber, loserOf(feed) ?? (homeWins ? awayId : awayWins ? homeId : awayId));
losers.set(km.matchNumber, loserId);
} else {
winners.set(km.matchNumber, feedWinner);
losers.set(km.matchNumber, loserOf(feed));
@@ -217,6 +260,7 @@ export function resolveBracket(
later[km.matchNumber] = {
matchNumber: km.matchNumber, stage: km.stage,
home, away, status: feed?.status ?? "SCHEDULED", prob: feed?.prob,
homePenalty: feed?.homePenalty, awayPenalty: feed?.awayPenalty,
};
}

View File

@@ -16,14 +16,21 @@ export interface Team {
group: GroupId;
crest?: string | null; // URL zur Flagge / zum Wappen (football-data: crest)
localisedName: string; // Lokalisierter Anzeigename (z.B. "Deutschland")
localisedNames?: { de: string; en: string };
}
export function teamName(team: Team | undefined, locale: string): string {
if (!team) return "—";
if (locale === "en") return team.localisedNames?.en ?? team.name ?? "—";
return team.localisedNames?.de ?? team.name ?? "—";
}
export type MatchStatus =
| "SCHEDULED" | "LIVE" | "IN_PLAY" | "PAUSED" | "FINISHED";
| "SCHEDULED" | "LIVE" | "IN_PLAY" | "PAUSED" | "FINISHED" | "POSTPONED";
export interface GoalEvent {
scorer: string; // Torschützen-Name
minute: number; // Spielminute
minute: string; // Spielminute (z.B. "72", "90+1")
team: "home" | "away";
}
@@ -39,11 +46,14 @@ export interface Match {
awayTeamId: string | null;
homeScore: number | null;
awayScore: number | null;
homePenalty?: number | null; // Elfmeterschießen (FIFA-API)
awayPenalty?: number | null;
winnerTeamId?: string | null; // FIFA-Winner (auch bei Elfmeterschießen)
// Wahrscheinlichkeiten 0..1 (Polymarket), falls vorhanden
prob?: { home: number; draw: number; away: number } | null;
venue?: string | null; // Austragungsort
attendance?: number | null; // Zuschauerzahl, falls verfügbar
goals?: GoalEvent[] | null; // Torereignisse von worldcup26.ir
goals?: GoalEvent[] | null; // Torereignisse
}
// Eine berechnete Tabellenzeile innerhalb einer Gruppe.

42
middleware.ts Normal file
View File

@@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const SUPPORTED_LOCALES = ["en", "de"] as const;
function getLocale(request: NextRequest): string {
const acceptLang = request.headers.get("accept-language") ?? "";
const preferred = acceptLang.split(",")[0]?.trim().slice(0, 2);
if (preferred && SUPPORTED_LOCALES.includes(preferred as any)) return preferred;
return "en";
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// API routes and static files pass through
if (
pathname.startsWith("/api/") ||
pathname.includes(".") ||
pathname.startsWith("/_next")
) {
return NextResponse.next();
}
// Check if pathname already has a supported locale
const hasLocale = SUPPORTED_LOCALES.some(
(l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`),
);
if (!hasLocale) {
const locale = getLocale(request);
const url = request.nextUrl.clone();
url.pathname = `/${locale}${pathname === "/" ? "" : pathname}`;
return NextResponse.redirect(url);
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!_next|api|favicon.ico).*)"],
};