locale second stage

This commit is contained in:
2026-07-01 11:21:29 -05:00
parent dca3a66206
commit 09020b7aef
12 changed files with 237 additions and 191 deletions

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,13 +74,13 @@ 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} i.E.)
({tie.homePenalty}:{tie.awayPenalty} {dict.bracket.penaltyShootout})
</div>
)}
</div>
@@ -86,14 +89,15 @@ function Tie({
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]));
@@ -104,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);
@@ -129,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>
@@ -179,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

@@ -1,47 +1,48 @@
"use client";
import { useMemo, useState, useEffect, useRef } from "react";
import { Match, Team } from "@/lib/types";
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): string {
function fmtShortDate(iso: string, locale: string): string {
const d = new Date(iso);
return d.toLocaleDateString("de-DE", { weekday: "short", day: "2-digit", month: "2-digit" });
return d.toLocaleDateString(locale === "en" ? "en-US" : "de-DE", { weekday: "short", day: "2-digit", month: "2-digit" });
}
function fmtTime(iso: string): string {
function fmtTime(iso: string, locale: string): string {
const d = new Date(iso);
return d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
return d.toLocaleTimeString(locale === "en" ? "en-US" : "de-DE", { hour: "2-digit", minute: "2-digit" });
}
function scoreDisplay(m: Match): string {
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} i.E.)`;
return `${base} (${m.homePenalty}:${m.awayPenalty} ${dict.bracket.penaltyShootout})`;
}
return base;
}
function statusLabel(m: Match): string {
switch (m.status) {
case "LIVE": case "IN_PLAY": return m.minute != null ? `${m.minute}'` : "Live";
case "PAUSED": return "Halbzeit";
case "POSTPONED": return "Verzögert";
case "FINISHED": return "Beendet";
default: return fmtTime(m.utcDate);
}
}
function isLive(m: Match): boolean {
return m.status === "LIVE" || m.status === "IN_PLAY" || m.status === "PAUSED";
}
export default function KoFixtures({ teams, matches }: { teams: Team[]; matches: Match[] }) {
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) =>
@@ -107,7 +108,7 @@ export default function KoFixtures({ teams, matches }: { teams: Team[]; matches:
}, []);
if (koMatches.length === 0) {
return <div className="notice">Keine K.o.-Spiele mit bekannten Teams verfügbar.</div>;
return <div className="notice">{dict.koFixtures.noMatches}</div>;
}
return (
@@ -120,7 +121,7 @@ export default function KoFixtures({ teams, matches }: { teams: Team[]; matches:
textTransform: "uppercase", letterSpacing: "0.06em",
color: "var(--ink-dim)", margin: "0 0 10px",
}}>
{fmtShortDate(groupMatches[0].utcDate)}
{fmtShortDate(groupMatches[0].utcDate, locale)}
</h3>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{groupMatches.map((m) => {
@@ -128,8 +129,8 @@ export default function KoFixtures({ teams, matches }: { teams: Team[]; matches:
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",
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;
@@ -162,7 +163,7 @@ export default function KoFixtures({ teams, matches }: { teams: Team[]; matches:
padding: "1px 6px", borderRadius: 999,
letterSpacing: "0.04em",
}}>
{live ? "LIVE" : "NÄCHSTES SPIEL"}
{live ? dict.koFixtures.liveBadge : dict.koFixtures.nextMatch}
</span>
)}
{m.status === "POSTPONED" && (
@@ -172,10 +173,10 @@ export default function KoFixtures({ teams, matches }: { teams: Team[]; matches:
padding: "1px 6px", borderRadius: 999,
letterSpacing: "0.04em",
}}>
VERZÖGERT
{dict.koFixtures.postponedBadge}
</span>
)}
{stageMap[m.stage] ?? m.stage} · Spiel {m.matchNumber || "—"}
{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 }}>
@@ -185,15 +186,15 @@ export default function KoFixtures({ teams, matches }: { teams: Team[]; matches:
{/* Teams + Score */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<TeamLabel team={home} />
<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)}
{scoreDisplay(m, dict)}
</span>
<TeamLabel team={away} reverse />
<TeamLabel team={away} reverse locale={locale} />
</div>
{/* Torabfolge */}
@@ -204,7 +205,7 @@ export default function KoFixtures({ teams, matches }: { teams: Team[]; matches:
fontSize: 12, fontFamily: "var(--font-mono)",
}}>
<div style={{ color: "var(--ink-faint)", fontSize: 10, marginBottom: 4 }}>
Tore
{dict.label.goals}
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{m.goals.map((g, i) => (
@@ -251,7 +252,7 @@ export default function KoFixtures({ teams, matches }: { teams: Team[]; matches:
cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 22, lineHeight: 1,
}}
title="Nach oben scrollen"
title={dict.label.scrollToTop}
>
</button>
@@ -260,7 +261,7 @@ export default function KoFixtures({ teams, matches }: { teams: Team[]; matches:
);
}
function TeamLabel({ team, reverse }: { team?: Team; reverse?: boolean }) {
function TeamLabel({ team, reverse, locale }: { team?: Team; reverse?: boolean; locale: string }) {
return (
<div style={{
display: "flex", alignItems: "center", gap: 8,
@@ -273,7 +274,7 @@ function TeamLabel({ team, reverse }: { team?: Team; reverse?: boolean }) {
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
maxWidth: 120,
}}>
{team?.localisedName ?? team?.name ?? "—"}
{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>

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,32 +122,32 @@ 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>
@@ -182,13 +183,13 @@ export default function Home() {
fontFamily: "var(--font-mono)", fontSize: 10,
color: "var(--ink-faint)",
}}>
WM 2026 Dashboard
{dict.footer.dashboard}
</span>
<span style={{
fontFamily: "var(--font-mono)", fontSize: 9,
color: "var(--ink-faint)", opacity: 0.5, marginTop: 8,
}}>
Daten: football-data.org · Polymarket Gamma API · FIFA Annex C
{dict.footer.dataSources}
</span>
</div>
</footer>