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>

View File

@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
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";
@@ -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();
@@ -30,10 +32,10 @@ export async function GET() {
}
try {
const fifaData = await fetchFifaScores();
const fifaData = await fetchFifaScores(locale);
matches = applyFifaScores(matches, teams, fifaData);
try {
matches = await attachFifaGoals(matches, fifaData);
matches = await attachFifaGoals(matches, fifaData, locale);
} catch (err) {
console.warn("[fifa] Goals fehlgeschlagen:", err instanceof Error ? err.message : err);
}

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

@@ -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"),
},
});
}
}
@@ -606,12 +610,13 @@ interface FifaScores {
}
// Holt Live-Scores von der FIFA-API. Gibt Map MatchNumber → Scores + FIFA-ID→App-Code-Map zurück.
export async function fetchFifaScores(): Promise<{
export async function fetchFifaScores(locale: string = "de"): Promise<{
scores: Map<number, FifaScores>;
fifaIdToAppCode: Map<string, string>;
}> {
return cached("fifa:scores", 45_000, async () => {
const url = `${FIFA_BASE}/calendar/matches?language=de&count=500&idSeason=${FIFA_SEASON}`;
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" },
@@ -715,9 +720,10 @@ function normMinuteStr(min: string | null | undefined): string {
return (min ?? "").replace(/'/g, "").replace(/\s+/g, "").trim();
}
async function fetchFifaGoals(idStage: string, idMatch: string): Promise<FifaGoalRaw[]> {
return cached(`fifa:detail:${idMatch}`, 60_000, async () => {
const url = `${FIFA_BASE}/live/football/17/${FIFA_SEASON}/${idStage}/${idMatch}?language=de`;
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" },
@@ -762,6 +768,7 @@ async function fetchFifaGoals(idStage: string, idMatch: string): Promise<FifaGoa
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") &&
@@ -771,7 +778,7 @@ export async function attachFifaGoals(
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);
const goals = await fetchFifaGoals(fs.idStage, fs.idMatch, locale);
if (goals.length) goalsByMatchId.set(m.id, goals);
}));
if (goalsByMatchId.size === 0) return matches;

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 {
@@ -84,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) {
@@ -116,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 };
}
@@ -163,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>();
@@ -171,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;
@@ -178,10 +184,10 @@ 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);
if (resolveWinners && feed && h.teamId && a.teamId
@@ -223,10 +229,19 @@ export function resolveBracket(
// 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);

View File

@@ -16,6 +16,13 @@ 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 =