27 Commits

Author SHA1 Message Date
63d4ed9470 fix locale cache 2026-07-08 22:49:24 -05:00
5138e61854 PSO Score 2026-07-04 10:32:31 -05:00
867d3c96b1 goals 2026-07-04 10:17:41 -05:00
b727c110a2 Cache-Busting 2026-07-03 17:04:43 -05:00
a10a876e0c reduce traffic 2026-07-03 15:55:10 -05:00
8c639a83a9 scrolling fixes 2026-07-02 17:51:59 -05:00
d6a026287a SEO 2026-07-02 10:55:19 -05:00
81d54e3e57 sitemap 2026-07-02 10:24:12 -05:00
7f49ca1626 cache 2026-07-02 10:21:27 -05:00
2c435472f5 fix simulation 2026-07-01 20:04:55 -05:00
202d4460e9 remove footbal-data 2026-07-01 16:40:51 -05:00
b2e4b63729 checks 2026-07-01 16:36:11 -05:00
4e4c72da7f migration to new feed source 2026-07-01 16:34:01 -05:00
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
86 changed files with 2723 additions and 837 deletions

View File

@@ -1,6 +1,4 @@
# football-data.org API-Token (kostenlos: https://www.football-data.org/client/register)
# Ohne Token liefert die API nur eingeschränkte Daten.
FOOTBALL_DATA_TOKEN=dein_token_hier
# FIFA-API als Primärquelle (kein Token nötig)
# Polymarket-Slug des WM-Events (Standard: world-cup-2026).
# Den genauen Slug findest du in der Polymarket-URL nach /event/.

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,37 +148,41 @@ 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>
)}
{(() => {
const stadium = [m.stadiumName, m.stadiumCity].filter(Boolean).join(", ");
const crowd = m.attendance != null ? m.attendance.toLocaleString(intlLocale) : "";
const sep = stadium && crowd ? " · " : "";
const text = stadium + sep + crowd;
return text ? <span className="fx-venue">{text}</span> : null;
})()}
</div>
</div>
);
@@ -184,7 +191,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

@@ -0,0 +1,27 @@
"use client";
import { Team } from "@/lib/types";
import { TLA_TO_ISO2 } from "@/lib/flags";
// Zeigt die Flagge eines Teams aus lokalem circle-flags-Bestand.
// Fallback: kein Rendering bei fehlendem Team/Code (kein Broken-Image).
export default function Flag({
team, size = 22,
}: { team?: Team; size?: number }) {
const style = { width: size, height: size } as const;
const iso2 = team?.code ? TLA_TO_ISO2[team.code.toUpperCase()] : undefined;
if (iso2) {
return (
<img
src={`/flags/${iso2}.svg`}
alt={team!.code || team!.localisedName || team!.name}
className="flag"
style={style}
loading="lazy"
/>
);
}
// Kein Team / kein Code: nichts rendern (kein Broken-Image)
return null;
}

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,301 @@
"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 scoreMain(m: Match): string {
if (m.status === "SCHEDULED" || m.status === "POSTPONED" || (m.homeScore == null && m.awayScore == null)) return " : ";
return `${m.homeScore ?? 0} : ${m.awayScore ?? 0}`;
}
function scorePenalty(m: Match, dict: Dictionary): string | null {
if (m.homePenalty != null && m.awayPenalty != null) {
return `${m.homePenalty}:${m.awayPenalty} ${dict.bracket.penaltyShootout}`;
}
return null;
}
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]);
// Scroll-Ziel: aktueller Tag (heute, oder nächster Spieltag bei Ruhetag)
const targetDateKey = useMemo(() => {
const todayKey = localDateKey(new Date().toISOString());
const dayKeys = groups.map(([key]) => key);
if (dayKeys.includes(todayKey)) return todayKey;
const future = dayKeys.find(k => k >= todayKey);
return future ?? dayKeys[dayKeys.length - 1] ?? null;
}, [groups]);
const targetDateRef = useRef<HTMLHeadingElement>(null);
const hasScrolledRef = useRef<string | null>(null);
useEffect(() => {
if (!mounted || !targetDateRef.current) return;
if (targetDateKey === hasScrolledRef.current) return;
const el = targetDateRef.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 = targetDateKey;
return () => cancelAnimationFrame(raf);
}, [mounted, targetDateKey, 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 ref={date === targetDateKey ? targetDateRef : undefined} 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";
const penalty = scorePenalty(m, dict);
return (
<div
key={m.id}
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} />
<div style={{
position: "relative",
display: "flex", flexDirection: "column", alignItems: "center",
padding: "0 16px",
}}>
<span style={{
fontFamily: "var(--font-mono)", fontSize: 22, fontWeight: 700,
color: live ? "var(--turf)" : "var(--ink)",
lineHeight: 1,
}}>
{scoreMain(m)}
{penalty && <span className="pso-inline"> ({penalty})</span>}
</span>
{penalty && <span className="pso-badge">{penalty}</span>}
</div>
<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;
}

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

@@ -0,0 +1,67 @@
import type { Metadata } from "next";
import Script from "next/script";
import { Locale, getDictionary } from "@/lib/i18n";
const BASE_URL = "https://soccer-2026.info";
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
const dict = getDictionary(locale as Locale);
return {
metadataBase: new URL(BASE_URL),
title: dict.meta.title,
description: dict.meta.description,
alternates: {
canonical: `${BASE_URL}/${locale}`,
languages: {
en: `${BASE_URL}/en`,
de: `${BASE_URL}/de`,
},
},
openGraph: {
title: dict.meta.title,
description: dict.meta.description,
url: `${BASE_URL}/${locale}`,
locale: locale === "en" ? "en_US" : "de_DE",
siteName: "WM 2026 Dashboard",
type: "website",
},
};
}
export async function generateStaticParams() {
return [{ locale: "en" }, { locale: "de" }];
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return (
<html lang={locale}>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
<link
href="https://fonts.googleapis.com/css2?family=Archivo:wght@500;700;800&family=Archivo+Expanded:wght@700;800&family=Inter:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600&display=swap"
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>
</html>
);
}

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}&t=${Date.now()}`, { cache: "no-store", headers: { "Pragma": "no-cache", "Cache-Control": "no-cache" } });
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();
@@ -57,6 +60,11 @@ export default function Home() {
setTab("groupfixtures");
}, []);
// Tab-Wechsel: nach ganz oben scrollen
useEffect(() => {
window.scrollTo({ top: 0, behavior: "auto" });
}, [tab]);
const anyLive = data?.matches.some(
(m) => m.status === "LIVE" || m.status === "IN_PLAY" || m.status === "PAUSED",
);
@@ -67,37 +75,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 +116,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 +127,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>
</>

78
app/api/live/route.ts Normal file
View File

@@ -0,0 +1,78 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { fetchMatchesAndTeamsFifa, fetchFifaScores, applyFifaScores, attachFifaGoals } from "@/lib/feeds";
import type { Match, Team } from "@/lib/types";
export const dynamic = "force-dynamic";
const RECENT_MS = 60 * 60 * 1000;
const MATCH_DURATION = 2.5 * 60 * 60 * 1000;
function isRecentlyFinished(m: Match): boolean {
if (m.status !== "FINISHED") return false;
const kickoff = new Date(m.utcDate).getTime();
if (isNaN(kickoff)) return false;
return kickoff + MATCH_DURATION > Date.now() - RECENT_MS;
}
export async function GET(request: NextRequest) {
const locale = request.nextUrl.searchParams.get("locale") || "de";
try {
const { matches: rawMatches, teams } = await fetchMatchesAndTeamsFifa(locale);
const relevant = rawMatches.filter(m =>
m.status === "LIVE" || m.status === "IN_PLAY" || m.status === "PAUSED" || isRecentlyFinished(m),
);
if (relevant.length === 0) {
return NextResponse.json({
updatedAt: new Date().toISOString(),
liveCount: 0,
recentCount: 0,
teams: [] as Team[],
matches: [] as Match[],
});
}
let fifaData: Awaited<ReturnType<typeof fetchFifaScores>>;
try {
fifaData = await fetchFifaScores();
} catch {
return NextResponse.json({
updatedAt: new Date().toISOString(),
liveCount: relevant.filter(m => m.status !== "FINISHED").length,
recentCount: relevant.filter(m => m.status === "FINISHED").length,
teams,
matches: relevant,
goalsFailed: true,
});
}
let matches = applyFifaScores(relevant, teams, fifaData);
let goalsFailed = false;
try {
matches = await attachFifaGoals(matches, fifaData, locale);
} catch {
goalsFailed = true;
}
const liveCount = matches.filter(m => m.status === "LIVE" || m.status === "IN_PLAY" || m.status === "PAUSED").length;
const recentCount = matches.filter(m => m.status === "FINISHED" && isRecentlyFinished(m)).length;
return NextResponse.json({
updatedAt: new Date().toISOString(),
liveCount,
recentCount,
teams,
matches,
goalsFailed,
});
} catch (err) {
const message = err instanceof Error ? err.message : "unbekannter Fehler";
return NextResponse.json(
{ error: "Live-Feed nicht erreichbar", detail: message },
{ status: 502 },
);
}
}

View File

@@ -1,16 +1,17 @@
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 { fetchMatchesAndTeamsFifa, fetchOdds, attachOdds, attachKOOdds, attachFifaLiveData } from "@/lib/feeds";
import { computeGroupTables, computeThirdPlaceTable } from "@/lib/standings";
import { qualifiedThirdGroups, resolveAnnexC } from "@/lib/bracket";
import { securelyQualifiedThirdTeams } from "@/lib/third-place-security";
// Diese Route wird vom Frontend gepollt. Sie ist der einzige Ort, der die
// Upstream-Feeds berührt — gecacht, damit Rate Limits eingehalten werden.
// Diese Route wird vom Frontend gepollt. FIFA-API als Primärquelle.
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();
const { matches: rawMatches, teams } = await fetchMatchesAndTeamsFifa(locale);
// Odds sind optional: fällt der Polymarket-Call aus, liefern wir trotzdem.
let matches = rawMatches;
@@ -22,31 +23,20 @@ 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);
assignKONumbersBySlots(matches, teams);
if (odds) {
matches = attachKOOdds(matches, teams, odds);
}
// KO-Live-Daten (Tore, Minute) von worldcup26
// FIFA-Live-Scores als häufiger gecachtes Overlay (Pipeline: Scores → Goals)
try {
const koGames = await fetchKOLiveData();
matches = attachKOLiveData(matches, teams, koGames);
matches = await attachFifaLiveData(matches, teams, locale);
} catch (err) {
console.warn("[worldcup26] KO-Live-Daten fehlgeschlagen:", err instanceof Error ? err.message : err);
console.warn("[fifa] Live-Overlay 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 }));
const thirdTable = computeThirdPlaceTable(groupTablesLive);

View File

@@ -1,32 +0,0 @@
"use client";
import { useState } from "react";
import { Team } from "@/lib/types";
// Zeigt die Flagge/das Wappen eines Teams. Fällt auf ein neutrales Rund
// zurück, wenn keine crest-URL vorliegt oder das Bild nicht geladen werden kann.
export default function Flag({
team, size = 22,
}: { team?: Team; size?: number }) {
const [imgFailed, setImgFailed] = useState(false);
const style = { width: size, height: size } as const;
if (team?.crest && !imgFailed) {
return (
<img
src={team.crest}
alt={team.code || team.localisedName || team.name}
className="flag"
style={style}
loading="lazy"
onError={() => setImgFailed(true)}
/>
);
}
// Fallback: Kreis mit Ländercode
return (
<span className="flag flag-fallback" style={style} aria-hidden>
{team?.code?.slice(0, 2) || "··"}
</span>
);
}

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

@@ -285,6 +285,9 @@ table.standings { width: 100%; border-collapse: collapse; }
}
.foot a { color: var(--ink-dim); text-decoration: underline; text-underline-offset: 2px; }
/* PSO-Badge: auf Desktop inline im Score, auf Mobile absolut darunter schwebend */
.pso-badge { display: none; }
/* =====================================================================
MOBIL — Breakpoint 640px (iPhone ~375, Galaxy ~412, alle ≤640)
===================================================================== */
@@ -357,6 +360,22 @@ table.standings { width: 100%; border-collapse: collapse; }
.bracket-banner { padding: 10px 12px; font-size: 13px; }
.legend { font-size: 10px; gap: 12px; }
/* ---------- mobil: PSO-Badge (Elfmeterschießen unter dem Score) ---------- */
.pso-inline { display: none; }
.pso-badge {
display: block;
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
margin-top: 2px;
font-size: 10px;
font-family: var(--font-mono);
color: var(--ink);
white-space: nowrap;
pointer-events: none;
}
/* ---------- mobil: Simulation ---------- */
.sim-row { flex-wrap: wrap; gap: 6px; padding: 8px 10px; }
.sim-row-phase { min-width: 100%; font-size: 9px; }

View File

@@ -1,40 +1,5 @@
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">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
<link
href="https://fonts.googleapis.com/css2?family=Archivo:wght@500;700;800&family=Archivo+Expanded:wght@700;800&family=Inter:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600&display=swap"
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>
</html>
);
return children;
}

12
app/robots.ts Normal file
View File

@@ -0,0 +1,12 @@
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/api/"],
},
sitemap: "https://soccer-2026.info/sitemap.xml",
};
}

33
app/sitemap.ts Normal file
View File

@@ -0,0 +1,33 @@
import type { MetadataRoute } from "next";
const BASE = "https://soccer-2026.info";
export default function sitemap(): MetadataRoute.Sitemap {
const now = new Date();
return [
{
url: `${BASE}/en`,
lastModified: now,
changeFrequency: "hourly",
priority: 1.0,
alternates: {
languages: {
en: `${BASE}/en`,
de: `${BASE}/de`,
},
},
},
{
url: `${BASE}/de`,
lastModified: now,
changeFrequency: "hourly",
priority: 1.0,
alternates: {
languages: {
en: `${BASE}/en`,
de: `${BASE}/de`,
},
},
},
];
}

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,214 +1,65 @@
import { GroupId, Match, MatchStatus, Team } from "./types";
import { venueFor } from "./venues";
import { localisedTeamName } from "./team-mappings";
import { GroupId, GoalEvent, Match, MatchStatus, Team } from "./types";
import { TEAM_LOCALIZATION } from "./team-mappings";
import { R32, LATER_ROUNDS, resolveAnnexC, qualifiedThirdGroups } from "./bracket";
import { computeGroupTables, computeThirdPlaceTable } from "./standings";
import { FIFA_GROUP_MAP, FIFA_STAGE_MAP } from "./fifa-constants";
// ----------------------------------------------------------------------------
// Caching: einfacher In-Memory-Cache mit TTL. Verhindert, dass jede Browser-
// Anfrage einen Upstream-Call auslöst (football-data: 10 req/min Limit).
// Caching: In-Memory-Cache mit TTL + stale-while-revalidate.
// Bei abgelaufenem Cache wird der alte Wert sofort zurückgegeben und die
// Erneuerung im Hintergrund angestoßen (keine Wartezeit für den Nutzer).
// ----------------------------------------------------------------------------
interface CacheEntry<T> { value: T; expires: number; }
interface CacheEntry<T> { value: T; expires: number; refreshing?: boolean; }
const cache = new Map<string, CacheEntry<unknown>>();
// Subscription-System: Benachrichtigt Listener (z.B. API-Routen) bei
// erfolgreichem Hintergrund-Refresh, sodass das Frontend nach dem
// nächsten Poll die aktuellen Daten erhält.
type CacheListener = (value: unknown) => void;
const cacheSubscriptions = new Map<string, Set<CacheListener>>();
function notifyCacheListeners(key: string, value: unknown): void {
const subs = cacheSubscriptions.get(key);
if (!subs) return;
for (const cb of subs) {
try { cb(value); } catch { /* silent */ }
}
}
export function onCacheRefresh<T>(key: string, cb: (value: T) => void): () => void {
if (!cacheSubscriptions.has(key)) cacheSubscriptions.set(key, new Set());
const set = cacheSubscriptions.get(key)!;
const wrapped = cb as CacheListener;
set.add(wrapped);
return () => { set.delete(wrapped); if (set.size === 0) cacheSubscriptions.delete(key); };
}
export async function cached<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T> {
const hit = cache.get(key) as CacheEntry<T> | undefined;
const now = Date.now();
// Frischer Cache → direkt zurück
if (hit && hit.expires > now) return hit.value;
try {
const value = await fn();
cache.set(key, { value, expires: now + ttlMs });
return value;
} catch (err) {
// Bei Upstream-Fehler abgelaufenen Cache weiterverwenden, statt hart zu failen.
if (hit) return hit.value;
throw err;
}
}
// ----------------------------------------------------------------------------
// football-data.org: Teams, Spiele, Status
// ----------------------------------------------------------------------------
const FD_BASE = "https://api.football-data.org/v4";
const FD_COMP = "WC"; // FIFA World Cup
function fdHeaders(): Record<string, string> {
const token = process.env.FOOTBALL_DATA_TOKEN;
return token ? { "X-Auth-Token": token } : {};
}
function mapStatus(s: string): MatchStatus {
switch (s) {
case "LIVE": return "LIVE";
case "IN_PLAY": return "IN_PLAY";
case "PAUSED": return "PAUSED";
case "FINISHED": return "FINISHED";
default: return "SCHEDULED";
}
}
// Wandelt einen football-data-Gruppennamen ("GROUP_A") in unsere GroupId.
function parseGroup(g: string | null | undefined): GroupId | null {
if (!g) return null;
const m = /GROUP_([A-L])/.exec(g);
return m ? (m[1] as GroupId) : null;
}
interface FdMatchesResponse {
matches: Array<{
id: number;
utcDate: string;
status: string;
minute?: number | null;
matchday?: number | null;
stage: string;
group?: string | null;
venue?: string | null;
attendance?: number | null;
homeTeam: { id: number | null; name: string | null; tla?: string | null; crest?: string | null };
awayTeam: { id: number | null; name: string | null; tla?: string | null; crest?: string | null };
score: { fullTime: { home: number | null; away: number | null } };
}>;
}
function stageFor(stage: string, group: GroupId | null): Match["stage"] {
if (group) return "GROUP";
switch (stage) {
case "LAST_32": return "R32";
case "LAST_16": return "R16";
case "QUARTER_FINALS": return "QF";
case "SEMI_FINALS": return "SF";
case "THIRD_PLACE": return "3RD";
case "FINAL": return "FINAL";
default: return "GROUP";
}
}
// Phasen-Reihenfolge für die K.o.-Nummerierung.
const STAGE_ORDER: Record<Match["stage"], number> = {
GROUP: 0, R32: 1, R16: 2, QF: 3, SF: 4, "3RD": 5, FINAL: 6,
};
// Setzt Spielnummern und Stadien.
// K.o.-Spiele: Nummerierung über Slot-Auflösung (assignKONumbersBySlots),
// NICHT mehr chronologisch — die FIFA-Nummern folgen der Bracket-Topologie.
// Venue: Gruppenspiele über die Teampaarung, K.o.-Spiele über die Spielnummer.
function assignNumbersAndVenues(matches: Match[], teams: Team[]): void {
// Stadien setzen (Gruppenphase per Paarung, K.o. per Nummer).
for (const m of matches) {
m.venue = venueFor(m, teams);
}
}
// Vergibt K.o.-Match-Nummern über Slot-Auflösung (FIFA-Topologie)
// statt chronologisch. Für R32-Matches mit Team-IDs wird der Slot gesucht,
// dessen aufgelöste Teams dem Feed-Paar entsprechen.
export function assignKONumbersBySlots(matches: Match[], teams: Team[]): void {
const tables = computeGroupTables(teams, matches);
const thirds = computeThirdPlaceTable(tables);
const qGroups = qualifiedThirdGroups(thirds);
const annex = qGroups.length === 8 ? resolveAnnexC(qGroups) : null;
const byPair = new Map<string, Match>();
for (const m of matches) {
if (m.group != null || !m.homeTeamId || !m.awayTeamId) continue;
byPair.set(`${m.homeTeamId}::${m.awayTeamId}`, m);
byPair.set(`${m.awayTeamId}::${m.homeTeamId}`, m);
}
for (const slot of R32) {
const homeGroup = slot.home.type === "W" ? slot.home.group : undefined;
const awayGroup = slot.away.type === "W" ? slot.away.group : undefined;
const wg = (homeGroup ?? awayGroup) as GroupId | undefined;
let hid: string | null = null;
if (slot.home.type === "W" && slot.home.group) {
hid = tables.find(t => t.group === slot.home.group)?.rows.find(r => r.rank === 1)?.teamId ?? null;
} else if (slot.home.type === "R" && slot.home.group) {
hid = tables.find(t => t.group === slot.home.group)?.rows.find(r => r.rank === 2)?.teamId ?? null;
} else if (slot.home.type === "3" && annex && wg) {
const tg = annex[wg];
if (tg) hid = thirds.find(r => r.group === tg && r.qualifies)?.teamId ?? null;
}
let aid: string | null = null;
if (slot.away.type === "W" && slot.away.group) {
aid = tables.find(t => t.group === slot.away.group)?.rows.find(r => r.rank === 1)?.teamId ?? null;
} else if (slot.away.type === "R" && slot.away.group) {
aid = tables.find(t => t.group === slot.away.group)?.rows.find(r => r.rank === 2)?.teamId ?? null;
} else if (slot.away.type === "3" && annex && wg) {
const tg = annex[wg];
if (tg) aid = thirds.find(r => r.group === tg && r.qualifies)?.teamId ?? null;
}
if (hid && aid) {
const fm = byPair.get(`${hid}::${aid}`);
if (fm) fm.matchNumber = slot.matchNumber;
// Abgelaufen, aber vorhanden → sofort alten Wert liefern, im Hintergrund erneuern
if (hit) {
if (!hit.refreshing) {
hit.refreshing = true;
fn()
.then((value) => {
cache.set(key, { value, expires: Date.now() + ttlMs });
notifyCacheListeners(key, value);
})
.catch((err) => { console.error(`[cache] Hintergrund-Refresh fehlgeschlagen (${key}):`, err); })
.finally(() => { const e = cache.get(key) as CacheEntry<T> | undefined; if (e) e.refreshing = false; });
}
return hit.value;
}
// R16Finale: per Stage + Datum den LATER_ROUNDS-Slots zuordnen
for (const stage of ["R16", "QF", "SF", "3RD", "FINAL"] as const) {
const sm = matches.filter(m => m.stage === stage && m.group == null && m.matchNumber === 0)
.sort((a, b) => +new Date(a.utcDate) - +new Date(b.utcDate));
const ls = LATER_ROUNDS.filter(k => k.stage === (stage === "3RD" ? "3RD" : stage));
for (let i = 0; i < sm.length && i < ls.length; i++) {
sm[i].matchNumber = ls[i].matchNumber;
}
}
}
// Holt alle Spiele + leitet die Teamliste daraus ab (spart einen Extra-Call).
export async function fetchMatchesAndTeams(): Promise<{ matches: Match[]; teams: Team[] }> {
return cached("fd:matches", 60_000, async () => {
const res = await fetch(`${FD_BASE}/competitions/${FD_COMP}/matches`, {
headers: fdHeaders(),
// Next.js: kein eigenes Caching, wir cachen selbst
cache: "no-store",
});
if (!res.ok) throw new Error(`football-data ${res.status}`);
const data = (await res.json()) as FdMatchesResponse;
const teamMap = new Map<string, Team>();
const matches: Match[] = data.matches.map((m) => {
const group = parseGroup(m.group);
// Teams registrieren (nur wenn Gruppenspiel und ID vorhanden)
for (const side of [m.homeTeam, m.awayTeam]) {
if (side.id != null && side.name && group) {
const id = String(side.id);
if (!teamMap.has(id)) {
teamMap.set(id, {
id, name: side.name, code: side.tla ?? "", group,
crest: `/crests/${id}.svg`,
localisedName: localisedTeamName(side.tla ?? "", side.name ?? ""),
});
}
}
}
return {
id: String(m.id),
group,
stage: stageFor(m.stage, group),
// Vorläufig 0 — die echte FIFA-Spielnummer wird unten gesetzt.
matchNumber: 0,
utcDate: m.utcDate,
status: mapStatus(m.status),
minute: m.minute ?? null,
homeTeamId: m.homeTeam.id != null ? String(m.homeTeam.id) : null,
awayTeamId: m.awayTeam.id != null ? String(m.awayTeam.id) : null,
homeScore: m.score.fullTime.home,
awayScore: m.score.fullTime.away,
venue: null, // wird unten aus der Map gesetzt
attendance: m.attendance ?? null,
};
});
const teams = [...teamMap.values()];
assignNumbersAndVenues(matches, teams);
return { matches, teams };
});
// Kaltstart: kein Cache → synchron laden
const value = await fn();
cache.set(key, { value, expires: now + ttlMs });
return value;
}
// ----------------------------------------------------------------------------
@@ -556,195 +407,424 @@ 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`, {
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 ?? [];
// ----------------------------------------------------------------------------
// FIFA-API Calendar-Endpoint: Primärquelle für Spiele + Teams
// ----------------------------------------------------------------------------
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
}
// 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,
status,
interface FifaCalendarTeamBlock {
IdTeam: string;
Abbreviation: string;
TeamName: Array<{ Locale: string; Description: string }>;
}
interface FifaCalendarMatch {
IdMatch: string;
IdStage: string;
IdGroup: string | null;
MatchNumber: number;
Date: string;
Home: FifaCalendarTeamBlock | null;
Away: FifaCalendarTeamBlock | null;
HomeTeamScore: number | null;
AwayTeamScore: number | null;
HomeTeamPenaltyScore: number | null;
AwayTeamPenaltyScore: number | null;
MatchStatus: number;
MatchTime: string | null;
ResultType: number | null;
Winner?: string | null;
Attendance: string | null;
Stadium?: {
Name: Array<{ Locale: string; Description: string }>;
CityName: Array<{ Locale: string; Description: string }>;
} | null;
}
// Holt alle Spiele + Teams von der FIFA-API (parallele Quelle, noch nicht aktiv).
export async function fetchMatchesAndTeamsFifa(locale: string = "de"): Promise<{ matches: Match[]; teams: Team[] }> {
return cached(`fifa:matches:${locale}`, 60_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" },
});
}
console.log("[worldcup26] spiele:", scores.length);
return scores;
if (!res.ok) throw new Error(`fifa-calendar ${res.status}`);
const data = (await res.json()) as { Results: FifaCalendarMatch[] };
console.log("[fifa-fetch] Kalender geladen, Result-Array Länge:", (data.Results ?? []).length);
const teamMap = new Map<string, Team>();
function teamFromBlock(tb: FifaCalendarTeamBlock, idGroup: string | null): Team | null {
const id = tb.IdTeam;
if (teamMap.has(id)) return teamMap.get(id)!;
const code = fifaCodeToAppCode(tb.Abbreviation);
const group = idGroup ? FIFA_GROUP_MAP[idGroup] : null;
if (!group) return null;
const loc = TEAM_LOCALIZATION[code.toUpperCase()];
const name = tb.TeamName?.[0]?.Description ?? "";
const t: Team = {
id,
name,
code,
group,
localisedName: loc ? loc[lang === "en" ? "en" : "de"] : name,
localisedNames: loc ? { de: loc.de, en: loc.en } : { de: name, en: name },
};
teamMap.set(id, t);
return t;
}
function mapFifaStatus(ms: number): MatchStatus {
switch (ms) {
case 0: return "FINISHED";
case 1: return "SCHEDULED";
case 10: return "POSTPONED";
default: return "LIVE";
}
}
const matches: Match[] = (data.Results ?? []).map((fm) => {
const group = fm.IdGroup ? FIFA_GROUP_MAP[fm.IdGroup] ?? null : null;
let homeTeam: Team | null = null;
let awayTeam: Team | null = null;
if (fm.Home) homeTeam = teamFromBlock(fm.Home, fm.IdGroup);
if (fm.Away) awayTeam = teamFromBlock(fm.Away, fm.IdGroup);
const attendance = fm.Attendance ? parseInt(fm.Attendance, 10) || null : null;
const minute = fm.MatchTime ? parseInt(fm.MatchTime, 10) || null : null;
const pref = lang === "en" ? "en-GB" : "de-DE";
const stadiumName = fm.Stadium?.Name?.find(n => n.Locale === pref)?.Description
?? fm.Stadium?.Name?.[0]?.Description ?? null;
const stadiumCity = fm.Stadium?.CityName?.find(n => n.Locale === pref)?.Description
?? fm.Stadium?.CityName?.[0]?.Description ?? null;
return {
id: fm.IdMatch,
group,
stage: FIFA_STAGE_MAP[fm.IdStage] ?? "GROUP",
matchNumber: fm.MatchNumber,
utcDate: fm.Date,
status: mapFifaStatus(fm.MatchStatus),
minute,
homeTeamId: fm.Home?.IdTeam ?? null,
awayTeamId: fm.Away?.IdTeam ?? null,
homeScore: fm.HomeTeamScore,
awayScore: fm.AwayTeamScore,
homePenalty: fm.HomeTeamPenaltyScore,
awayPenalty: fm.AwayTeamPenaltyScore,
winnerTeamId: fm.Winner ?? null,
venue: null,
stadiumName,
stadiumCity,
attendance,
};
});
const teams = [...teamMap.values()];
console.log("[fifa-fetch] Teams:", teams.length, "Matches:", matches.length);
return { matches, teams };
});
}
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[],
): 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 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++;
}
if (applied > 0) console.log("[worldcup26] auf matches 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 Live-Scores von der FIFA-API. Gibt Map MatchNumber → Scores + FIFA-ID→App-Code-Map zurück.
// Scores/Status/Penalties/Winner sind sprachunabhängig → locale-freier Cache-Key.
export async function fetchFifaScores(): Promise<{
scores: Map<number, FifaScores>;
fifaIdToAppCode: Map<string, string>;
}> {
return cached("fifa:scores", 45_000, async () => {
const url = `${FIFA_BASE}/calendar/matches?language=en&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[] };
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";
});
} catch {
return [];
}
if (!res.ok) throw new Error(`fifa ${res.status}`);
const data = (await res.json()) as { Results: FifaMatch[] };
console.log("[fifa-fetch] Scores geladen, Result-Array Länge:", (data.Results ?? []).length);
const scores = new Map<number, FifaScores>();
const fifaIdToAppCode = new Map<string, string>();
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));
}
}
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("[fifa-fetch] Score-Einträge:", scores.size, "Team-Mappings:", fifaIdToAppCode.size);
return { scores, fifaIdToAppCode };
});
}
// 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;
// Wendet FIFA-Live-Scores auf Matches an (MatchNumber als exakter Schlüssel).
// Team-IDs sind jetzt FIFA-IDs → winnerTeamId kann direkt gesetzt werden.
export function applyFifaScores(
matches: Match[], teams: Team[],
fifaData: { scores: Map<number, FifaScores>; fifaIdToAppCode: Map<string, string> },
): Match[] {
const { scores: fifaMap } = fifaData;
if (fifaMap.size === 0) return matches;
let applied = 0;
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;
if (fs.winnerTeamId) r.winnerTeamId = fs.winnerTeamId;
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;
}
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);
// 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",
ttlMs: number = 60_000,
): Promise<FifaGoalRaw[]> {
return cached(`fifa:detail:${locale}:${idMatch}`, ttlMs, 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) { 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,
});
}
}
goals.sort((a, b) => parseInt(a.minute, 10) - parseInt(b.minute, 10));
return goals;
});
}
// Lädt Tor-Details für laufende und alle beendeten Spiele mit Toren.
// Beendete Spiele werden über langlebiges Caching (24h) gespart, nicht über Ausschluss.
// Tore eines beendeten Spiels ändern sich nie mehr → einmal fetchen genügt.
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 === "LIVE" || m.status === "IN_PLAY" || m.status === "PAUSED" || m.status === "FINISHED") &&
((m.homeScore ?? 0) + (m.awayScore ?? 0)) > 0,
);
if (targets.length === 0) return matches;
const goalsByMatchId = new Map<string, GoalEvent[]>();
const results = await Promise.allSettled(targets.map(async (m) => {
const fs = fifaData.scores.get(m.matchNumber);
if (!fs?.idMatch || !fs?.idStage) return;
const isFinished = m.status === "FINISHED";
const ttl = isFinished ? 24 * 60 * 60 * 1000 : 60_000;
const goals = await fetchFifaGoals(fs.idStage, fs.idMatch, locale, ttl);
if (goals.length) goalsByMatchId.set(m.id, goals);
}));
const fetched = results.filter(r => r.status === "fulfilled").length;
const failed = results.filter(r => r.status === "rejected").length;
if (goalsByMatchId.size > 0 || failed > 0) {
console.log("[fifa-fetch] Goals: geladen für", goalsByMatchId.size, "Matches,", fetched, "OK,", failed, "Fehler");
}
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;
});
}
// ----------------------------------------------------------------------------
// FIFA Live-Daten Pipeline: kombiniert Scores + Goals in gestufter Abfolge.
// Step 1: Scores von der FIFA-API holen (enthält idMatch/idStage-Mappings).
// Step 2: Scores auf die Matches anwenden (MatchNumber als Schlüssel).
// Step 3: Goal-Details NUR für live/beendete Matches mit Toren nachladen.
//
// Die Schritte werden sequentiell ausgeführt (kein Promise.all wie zuvor),
// damit fehlende ID-Mappings nicht zu sinnlosen Requests führen.
// Jeder Schritt loggt die Array-/Map-Länge, sodass auf der Vercel-Konsole
// sofort ersichtlich ist, ob der Upstream leer ist oder der Fehler im Mapping liegt.
// ----------------------------------------------------------------------------
export async function attachFifaLiveData(
matches: Match[],
teams: Team[],
locale: string = "de",
): Promise<Match[]> {
// --- Step 1: Scores von FIFA holen ---
let fifaData: Awaited<ReturnType<typeof fetchFifaScores>>;
try {
fifaData = await fetchFifaScores();
} catch (err) {
console.warn("[fifa-fetch] Scores fehlgeschlagen:", err instanceof Error ? err.message : err);
return matches;
}
return matches.map((m) => {
if (m.group != null) return m; // nur K.o.-Spiele
// --- Step 2: Scores auf Matches anwenden ---
const updated = applyFifaScores(matches, teams, fifaData);
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;
// --- Step 3: Goal-Details für alle Spiele mit Toren (live + beendet) ---
// Requests werden durch differenzierte Cache-TTL gespart:
// beendete Spiele → 24h (Tore ändern sich nie)
// laufende Spiele → 60s (neue Tore erscheinen)
const targets = updated.filter(m => {
const fs = fifaData.scores.get(m.matchNumber);
return (
(m.status === "LIVE" || m.status === "IN_PLAY" || m.status === "PAUSED" || m.status === "FINISHED") &&
((m.homeScore ?? 0) + (m.awayScore ?? 0)) > 0 &&
fs?.idMatch != null &&
fs?.idStage != 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));
});
if (targets.length === 0) {
console.log("[fifa-fetch] Pipeline: Scores aktualisiert, keine Goal-Requests nötig");
return updated;
}
if (!wm) return m;
const goalsByMatchId = new Map<string, GoalEvent[]>();
let fetched = 0;
let failed = 0;
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;
for (const m of targets) {
const fs = fifaData.scores.get(m.matchNumber)!;
if (!fs.idMatch || !fs.idStage) continue;
const isFinished = m.status === "FINISHED";
const ttl = isFinished ? 24 * 60 * 60 * 1000 : 60_000; // beendet: 24h, live: 60s
try {
const goals = await fetchFifaGoals(fs.idStage, fs.idMatch, locale, ttl);
fetched++;
if (goals.length) goalsByMatchId.set(m.id, goals);
} catch {
failed++;
}
}
// Spielminute
if (wm.current_minute) {
const min = parseInt(wm.current_minute, 10);
if (!isNaN(min)) result.minute = min;
}
console.log("[fifa-fetch] Pipeline: Goals für", goalsByMatchId.size, "Matches geladen (", fetched, "OK,", failed, "Fehler )");
// 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,
}));
}
if (goalsByMatchId.size === 0) return updated;
return result;
return updated.map(m => {
const g = goalsByMatchId.get(m.id);
return g ? { ...m, goals: g.map(({ scorer, minute, team }) => ({ scorer, minute, team })) } : m;
});
}

14
lib/fifa-constants.ts Normal file
View File

@@ -0,0 +1,14 @@
import { GroupId, Match } from "./types";
// FIFA IdGroup → App-GroupId (verifiziert, fortlaufend 289275-289286)
export const FIFA_GROUP_MAP: Record<string, GroupId> = {
"289275": "A", "289276": "B", "289277": "C", "289278": "D",
"289279": "E", "289280": "F", "289281": "G", "289282": "H",
"289283": "I", "289284": "J", "289285": "K", "289286": "L",
};
// FIFA IdStage → App-Stage (verifiziert)
export const FIFA_STAGE_MAP: Record<string, Match["stage"]> = {
"289273": "GROUP", "289287": "R32", "289288": "R16",
"289289": "QF", "289290": "SF", "289291": "3RD", "289292": "FINAL",
};

52
lib/flags.ts Normal file
View File

@@ -0,0 +1,52 @@
// TLA (3-Buchstaben, wie in TEAM_LOCALIZATION) → circle-flags ISO-2-Dateiname.
// Vollständig für alle Teams aus TEAM_LOCALIZATION.
export const TLA_TO_ISO2: Record<string, string> = {
ALG: "dz",
ARG: "ar",
AUS: "au",
AUT: "at",
BEL: "be",
BIH: "ba",
BRA: "br",
CAN: "ca",
CHE: "ch",
CIV: "ci",
COD: "cd",
COL: "co",
CPV: "cv",
CUW: "cw",
CZE: "cz",
ECU: "ec",
EGY: "eg",
ENG: "gb-eng",
ESP: "es",
FRA: "fr",
GER: "de",
GHA: "gh",
HAI: "ht",
HRV: "hr",
IRN: "ir",
IRQ: "iq",
JOR: "jo",
JPN: "jp",
KOR: "kr",
KSA: "sa",
MAR: "ma",
MEX: "mx",
NED: "nl",
NOR: "no",
NZL: "nz",
PAN: "pa",
PAR: "py",
PRT: "pt",
QAT: "qa",
RSA: "za",
SCO: "gb-sct",
SEN: "sn",
SWE: "se",
TUN: "tn",
TUR: "tr",
URU: "uy",
USA: "us",
UZB: "uz",
};

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

@@ -0,0 +1,140 @@
import { Dictionary } from "./types";
const de: Dictionary = {
meta: {
title: "WM 2026 Live-Ticker, Ergebnisse & K.o.-Baum",
description:
"Live-Ergebnisse, Gruppentabellen, K.o.-Baum und Prognosen zur Fußball-WM 2026 in den USA, Kanada & Mexiko. Alle Spiele in Echtzeit.",
},
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: FIFA · Polymarket · 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: "World Cup 2026 Live Scores, Bracket & Standings",
description:
"Live scores, group standings, knockout bracket and match predictions for the 2026 FIFA World Cup in the USA, Canada & Mexico. Real-time results.",
},
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: FIFA · Polymarket · 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

@@ -26,7 +26,7 @@ export function saveOverrides(overrides: SimOverrides): void {
// Plausibles Standardergebnis aus Polymarket-3-Wege-Wahrscheinlichkeiten.
//
// Schwellen (zentral):
// Draw am höchsten ODER max(home,away) < 0.40 → 0:0 (Gruppe) / 1:0 Heim (K.o.)
// Draw am höchsten ODER max(home,away) < 0.40 → 0:0 (Gruppe) / Favorit 1:0 (K.o.)
// Favorit 0.400.60 → 1:0
// Favorit 0.600.78 → 2:0
// Favorit > 0.78 → 3:0
@@ -48,7 +48,11 @@ export function defaultScore(match: Match): { homeScore: number; awayScore: numb
if (isDraw || maxFA < 0.40) {
if (isGroup) return { homeScore: 0, awayScore: 0 };
return { homeScore: 1, awayScore: 0 }; // K.o.: knapp Heim (kein Remis)
// K.o.: kein Remis möglich → der wahrscheinlichere von Heim/Auswärts gewinnt knapp,
// Draw wird ignoriert (kein gültiges K.o.-Ergebnis).
return prob.home >= prob.away
? { homeScore: 1, awayScore: 0 }
: { homeScore: 0, awayScore: 1 };
}
// Favorit bestimmen

View File

@@ -1,6 +1,5 @@
// Zentrale Lokalisierungstabelle für Teamnamen (TLA → Deutsch / Englisch).
// Schlüssel = FIFA-3-Buchstaben-Code (uppercase), wie er von football-data.org
// im Feld `tla` geliefert wird.
// Schlüssel = App-3-Buchstaben-Code (uppercase), gemappt von FIFA-Abbreviation.
//
// Erweiterung auf weitere Sprachen: einfach pro Sprache ein Feld ergänzen.
@@ -18,6 +17,7 @@ export const TEAM_LOCALIZATION: Record<string, { de: string; en: string }> = {
COD: { de: "DR Kongo", en: "Congo DR" },
COL: { de: "Kolumbien", en: "Colombia" },
CPV: { de: "Kap Verde", en: "Cape Verde" },
CUW: { de: "Curaçao", en: "Curaçao" },
CZE: { de: "Tschechien", en: "Czech Republic" },
ECU: { de: "Ecuador", en: "Ecuador" },
EGY: { de: "Ägypten", en: "Egypt" },

View File

@@ -1,5 +1,5 @@
// Datenmodell für die WM-Seite. Bewusst entkoppelt von den Feed-Formaten:
// Adapter (siehe lib/feeds.ts) übersetzen football-data.org & Polymarket in diese Typen.
// Adapter (siehe lib/feeds.ts) übersetzen FIFA & Polymarket in diese Typen.
export type GroupId =
| "A" | "B" | "C" | "D" | "E" | "F"
@@ -14,16 +14,23 @@ export interface Team {
name: string; // Originalname aus dem Feed (englisch)
code: string; // 3-Buchstaben-Code, z.B. GER
group: GroupId;
crest?: string | null; // URL zur Flagge / zum Wappen (football-data: crest)
crest?: string | null; // URL zur Flagge / zum Wappen (veraltet)
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,16 @@ 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
venue?: string | null; // Austragungsort (veraltet)
stadiumName?: string | null; // FIFA-Stadionname
stadiumCity?: string | null; // FIFA-Stadt
attendance?: number | null; // Zuschauerzahl
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).*)"],
};

16
public/flags/9460.svg Normal file
View File

@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 54 36">
<rect width="54" height="36" fill="#002b7f"/>
<path d="M0,22.5H54V27H0z" fill="#f9e814"/>
<g fill="#fff" id="s">
<g id="f">
<g id="t">
<path d="m12,8v4h2z" transform="rotate(18,12,8)" id="o"/>
<use xlink:href="#o" x="-24" transform="scale(-1,1)"/>
</g>
<use xlink:href="#t" transform="rotate(72,12,12)"/>
</g>
<use xlink:href="#t" transform="rotate(-72,12,12)"/>
<use xlink:href="#f" transform="rotate(144,12,12)"/>
</g>
<use xlink:href="#s" x="-4" y="-4" transform="scale(0.75)"/>
</svg>

After

Width:  |  Height:  |  Size: 593 B

1
public/flags/ar.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#338af3" d="M0 0h512v144.7L488 256l24 111.3V512H0V367.3L26 256 0 144.7z"/><path fill="#eee" d="M0 144.7h512v222.6H0z"/><path fill="#ffda44" d="m332.4 256-31.2 14.7 16.7 30.3-34-6.5-4.2 34.3-23.7-25.2-23.6 25.2-4.3-34.3-34 6.5 16.6-30.3-31.2-14.7 31.3-14.7L194 211l34 6.5 4.3-34.3 23.6 25.2 23.6-25.2 4.4 34.3 34-6.5-16.7 30.3z"/></g></svg>

After

Width:  |  Height:  |  Size: 523 B

1
public/flags/at.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#d80027" d="M0 0h512v167l-23.2 89.7L512 345v167H0V345l29.4-89L0 167z"/><path fill="#eee" d="M0 167h512v178H0z"/></g></svg>

After

Width:  |  Height:  |  Size: 306 B

1
public/flags/au.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#0052b4" d="M0 0h512v512H0z"/><path fill="#eee" d="m154 300 14 30 32-8-14 30 25 20-32 7 1 33-26-21-26 21 1-33-33-7 26-20-14-30 32 8zm222-27h47l-38 27 15-44 14 44zm7-162 7 15 16-4-7 15 12 10-15 3v17l-13-11-13 11v-17l-15-3 12-10-7-15 16 4zm57 67 7 15 16-4-7 15 12 10-15 3v16l-13-10-13 11v-17l-15-3 12-10-7-15 16 4zm-122 22 7 15 16-4-7 15 12 10-15 3v16l-13-10-13 11v-17l-15-3 12-10-7-15 16 4zm65 156 7 15 16-4-7 15 12 10-15 3v17l-13-11-13 11v-17l-15-3 12-10-7-15 16 4zM0 0v32l32 32L0 96v160h32l32-32 32 32h32v-83l83 83h45l-8-16 8-15v-14l-83-83h83V96l-32-32 32-32V0H96L64 32 32 0Z"/><path fill="#d80027" d="M32 0v32H0v64h32v160h64V96h160V32H96V0Zm96 128 128 128v-31l-97-97z"/></g></svg>

After

Width:  |  Height:  |  Size: 866 B

1
public/flags/ba.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#ffda44" d="M0 0h445.3l33.9 255-33.9 257-323.7-134.3L0 66.8z"/><path fill="#0052b4" d="M0 66.8V512h445.4z"/><path fill="#0052b4" d="M445.3 0H512v512h-66.7z"/><path fill="#eee" d="m354.6 456-8.3 25.6h-26.8l21.7 15.8-8.3 25.5 21.7-15.8 21.7 15.8-8.3-25.5 21.7-15.8h-26.8zm-55-55.4-8.3 25.5h-26.8l21.7 15.8-8.3 25.5 21.7-15.8 21.7 15.8-8.3-25.5 21.7-15.8h-26.8zM244.4 345l-8.3 25.5h-26.8l21.7 15.8-8.3 25.5 21.7-15.8 21.7 15.8-8.3-25.5 21.7-15.8h-26.8zm-55.1-55.7-8.3 25.5h-26.8l21.7 15.8-8.3 25.5 21.7-15.8L211 356l-8.3-25.5 21.7-15.8h-26.8zm-55.4-55.7-8.3 25.5H98.8l21.7 15.8-8.3 25.5 21.7-15.8 21.7 15.8-8.3-25.5L169 259h-26.8zM78.7 178l-8.3 25.5H43.6l21.7 15.8-8.3 25.5L78.7 229l21.7 15.8-8.3-25.5 21.7-15.8H87zm-55.2-55.7-8.3 25.5h-26.8l21.7 15.8L1.8 189l21.7-15.8L45.2 189l-8.3-25.5 21.7-15.8H31.8z"/></g></svg>

After

Width:  |  Height:  |  Size: 998 B

1
public/flags/be.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#333" d="M0 0h167l38.2 252.6L167 512H0z"/><path fill="#d80027" d="M345 0h167v512H345l-36.7-256z"/><path fill="#ffda44" d="M167 0h178v512H167z"/></g></svg>

After

Width:  |  Height:  |  Size: 338 B

1
public/flags/br.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#6da544" d="M0 0h512v512H0z"/><path fill="#ffda44" d="M256 100.2 467.5 256 256 411.8 44.5 256z"/><path fill="#eee" d="M174.2 221a87 87 0 0 0-7.2 36.3l162 49.8a88.5 88.5 0 0 0 14.4-34c-40.6-65.3-119.7-80.3-169.1-52z"/><path fill="#0052b4" d="M255.7 167a89 89 0 0 0-41.9 10.6 89 89 0 0 0-39.6 43.4 181.7 181.7 0 0 1 169.1 52.2 89 89 0 0 0-9-59.4 89 89 0 0 0-78.6-46.8zM212 250.5a149 149 0 0 0-45 6.8 89 89 0 0 0 10.5 40.9 89 89 0 0 0 120.6 36.2 89 89 0 0 0 30.7-27.3A151 151 0 0 0 212 250.5z"/></g></svg>

After

Width:  |  Height:  |  Size: 686 B

1
public/flags/ca.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#d80027" d="M0 0v512h144l112-64 112 64h144V0H368L256 64 144 0Z"/><path fill="#eee" d="M144 0h224v512H144Z"/><path fill="#d80027" d="m301 289 44-22-22-11v-22l-45 22 23-44h-23l-22-34-22 33h-23l23 45-45-22v22l-22 11 45 22-12 23h45v33h22v-33h45z"/></g></svg>

After

Width:  |  Height:  |  Size: 438 B

1
public/flags/cd.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#338af3" d="M0 0h401.9L512 110.3V512H110.3L0 401.9z"/><path fill="#ffda44" d="M401.9 0 0 401.9V449l63 63h47.3L512 110.3V63L449 0z"/><path fill="#d80027" d="M449 0 0 449v63h63L512 63V0h-63z"/><path fill="#ffda44" d="m136.4 78 13.8 42.4H195l-36 26.3 13.7 42.5-36.2-26.3-36 26.3 13.7-42.5L78 120.4h44.7z"/></g></svg>

After

Width:  |  Height:  |  Size: 497 B

1
public/flags/ch.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#d80027" d="M0 0h512v512H0z"/><path fill="#eee" d="M389.6 211.5h-89v-89h-89.1v89h-89v89h89v89h89v-89h89z"/></g></svg>

After

Width:  |  Height:  |  Size: 301 B

1
public/flags/ci.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#eee" d="M167 0h178l31 253.2L345 512H167l-33.4-257.4z"/><path fill="#ff9811" d="M0 0h167v512H0z"/><path fill="#6da544" d="M345 0h167v512H345z"/></g></svg>

After

Width:  |  Height:  |  Size: 338 B

1
public/flags/co.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#d80027" d="m0 384 255.8-29.7L512 384v128H0z"/><path fill="#0052b4" d="m0 256 259.5-31L512 256v128H0z"/><path fill="#ffda44" d="M0 0h512v256H0z"/></g></svg>

After

Width:  |  Height:  |  Size: 340 B

1
public/flags/cv.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#0052b4" d="M0 0h512v256.2l-41.9 64.3 41.9 63.7V512H0V384.2L41.3 320 0 256.2z"/><path fill="#eee" d="M0 256.2h512v42.9l-15.7 21.6 15.7 21v42.5H0v-42.5l15.1-21.5L0 299z"/><path fill="#d80027" d="M0 299.1h512v42.6H0z"/><path fill="#ffda44" d="m182.8 190.4 5.2 16.4h17.1l-13.8 10 5.3 16.3-13.8-10-14 10 5.4-16.3-13.9-10h17.1zm0 213.3L188 420h17.1l-13.8 10 5.3 16.2-13.8-10-14 10L174 430l-14-10h17.2zm-99.2-72.1 5.2 16.2h17.1L92.1 358l5.2 16.2-13.7-10-14 10L75 358l-14-10.1h17.2zm37.9-119.8 5 16h17.2l-13.8 10.3 5.2 16.2-13.7-10-14 10 5.4-16.3-14-10.1H116zm-60.4 67h17l5.5-16.2 5.2 16.2h17.1L92.1 289l5.2 16.4L83.6 295l-14 10.3 5.4-16.4zm46.5 143 5.3-16.2L99 395.4h17.1l5.4-16.2 5.2 16.3h17.1L130 405.6l5.3 16.2-13.8-10zM282 331.6l-5.4 16.2h-17l13.8 10.2-5.3 16.2 13.9-10 13.8 10-5.2-16.3 13.7-10.1h-17zm-38-119.8-5.3 16.2h-17.1l14 10.2-5.4 16.2 13.9-10 13.8 10-5.3-16.3 13.8-10.1h-17zm60.3 67h-17l-5.3-16.2-5.4 16.2h-17l13.8 10.1-5.3 16.4L282 295l13.8 10.3-5.2-16.4zm-46.4 143-5.3-16.2 13.8-10.2h-17l-5.3-16.2-5.4 16.3h-17.1l14 10.1-5.4 16.2 13.9-10z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

1
public/flags/cw.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#0052b4" d="M0 0h512v342.3l-22 34.2 22 32.5v103H0V409l25.4-31L0 342.2z"/><path fill="#eee" d="m175.2 164.2 13.8 42.5h44.7L197.6 233l13.8 42.5-36.2-26.3-36.1 26.3 13.8-42.5-36.2-26.3h44.7zm-76.7-44.5 8.2 25.5h26.9L111.9 161l8.3 25.5-21.7-15.7-21.7 15.7L85 161l-21.7-15.7h26.9z"/><path fill="#ffda44" d="M0 342.3h512V409H0z"/></g></svg>

After

Width:  |  Height:  |  Size: 518 B

1
public/flags/cz.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#eee" d="M0 0h512v256l-265 45.2z"/><path fill="#d80027" d="M210 256h302v256H0z"/><path fill="#0052b4" d="M0 0v512l256-256L0 0z"/></g></svg>

After

Width:  |  Height:  |  Size: 323 B

1
public/flags/de.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#ffda44" d="m0 345 256.7-25.5L512 345v167H0z"/><path fill="#d80027" d="m0 167 255-23 257 23v178H0z"/><path fill="#333" d="M0 0h512v167H0z"/></g></svg>

After

Width:  |  Height:  |  Size: 334 B

1
public/flags/dz.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#496e2d" d="M0 0h256l32 256-32 256H0Z"/><path fill="#eee" d="M256 0h256v512H256z"/><path fill="#d80027" d="M245 167a89 89 0 1 0 67 153 72 72 0 0 1-35 8 72 72 0 1 1 35-136 89 89 0 0 0-67-25m66 40-21 29-34-11 21 29-21 29 34-11 21 29v-36l34-11-34-11z"/></g></svg>

After

Width:  |  Height:  |  Size: 444 B

1
public/flags/ec.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#d80027" d="m0 384 254.7-32.7L512 383.9V512H0z"/><path fill="#0052b4" d="m0 256 255-27 257 27v128H0z"/><path fill="#ffda44" d="M0 0h512v256H0z"/><circle cx="256" cy="256" r="89" fill="#ffda44"/><path fill="#338af3" d="M256 311.6c-30.7 0-55.7-25-55.7-55.6v-33.4a55.7 55.7 0 0 1 111.4 0V256c0 30.6-25 55.6-55.7 55.6z"/><path fill="#333" d="M345 122.4h-66.7a22.3 22.3 0 0 0-44.6 0H167a23 23 0 0 0 23 22.3h-.8c0 12.3 10 22.3 22.3 22.3 0 12.3 10 22.2 22.2 22.2h44.6c12.3 0 22.2-10 22.2-22.2 12.3 0 22.3-10 22.3-22.3h-.8a23 23 0 0 0 23-22.3z"/></g></svg>

After

Width:  |  Height:  |  Size: 732 B

1
public/flags/eg.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#eee" d="m0 144 256-32 256 32v224l-256 32L0 368Z"/><path fill="#d80027" d="M0 0h512v144H0Z"/><path fill="#333" d="M0 368h512v144H0Z"/><path fill="#ff9811" d="M250 191c-8 0-17 4-22 14 5-3 16-1 16 13 0 4-2 8-5 10-8 0-14-14-29-14-10 0-19 7-19 17v69l46-7-14 27h66l-14-27 46 7v-69c0-10-9-17-19-17-15 0-21 14-29 14 8-23-7-37-23-37z"/></g></svg>

After

Width:  |  Height:  |  Size: 522 B

1
public/flags/es.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#ffda44" d="m0 128 256-32 256 32v256l-256 32L0 384Z"/><path fill="#eee" d="M196 168q-11 1-15 11l-5-1q-15 1-16 16c-1 15 7 16 16 16q11 0 15-11a16 16 0 0 0 17-4 16 16 0 0 0 17 4 16 16 0 1 0 10-20 16 16 0 0 0-27-5q-4-6-12-6m0 8q8 1 8 8 0 8-8 8-7 0-8-8 1-7 8-8m24 0q8 1 8 8 0 8-8 8-7 0-8-8 1-7 8-8m-44 10 4 1 4 8q-1 7-8 7-9 0-8-8 1-7 8-8m64 0q8 1 8 8 0 8-8 8-7 0-8-7l4-8zm-112 38v80h16v-80zm80 0v40c-26 0-48 14-48 32s22 32 48 32 48-14 48-32v-72zm64 0v80h16v-80z"/><path fill="#ff9811" d="M200 160h16v32h-16z"/><path fill="#d80027" d="M0 0v128h512V0zm208 184c-22 0-40 11-40 24l8 8h64l8-8c0-13-18-24-40-24m-72 8a8 8 0 0 0-8 8v8a8 8 0 1 0 16 0v-8a8 8 0 0 0-8-8m144 0a8 8 0 0 0-8 8v8a8 8 0 1 0 16 0v-8a8 8 0 0 0-8-8m-120 32v24h-38a4 4 0 0 0-4 4 4 4 0 0 0 4 4h38v40a24 24 0 0 0 24 24 24 24 0 0 0 24-24 24 24 0 0 0 24 24 24 24 0 0 0 24-24v-24h-48v-48zm72 8a10 10 0 0 0-10 10v12a10 10 0 1 0 20 0v-12a10 10 0 0 0-10-10m24 16v8h38a4 4 0 0 0 4-4 4 4 0 0 0-4-4zm-134 24a4 4 0 0 0-4 4 4 4 0 0 0 4 4h28a4 4 0 0 0 4-4 4 4 0 0 0-4-4zm144 0a4 4 0 0 0-4 4 4 4 0 0 0 4 4h28a4 4 0 0 0 4-4 4 4 0 0 0-4-4zM0 384v128h512V384z"/><path fill="#ffda44" d="M186 196a6 6 0 0 0-6 6 6 6 0 0 0 6 6 6 6 0 0 0 6-6 6 6 0 0 0-6-6m22 0a6 6 0 0 0-6 6 6 6 0 0 0 6 6 6 6 0 0 0 6-6 6 6 0 0 0-6-6m22 0a6 6 0 0 0-6 6 6 6 0 0 0 6 6 6 6 0 0 0 6-6 6 6 0 0 0-6-6"/><path fill="#ff9811" d="M128 208a8 8 0 1 0 0 16h16a8 8 0 1 0 0-16zm144 0a8 8 0 1 0 0 16h16a8 8 0 1 0 0-16zm-96 8v8h64v-8zm-8 16v8h8v16h-8v8h32v-8h-8v-16h8v-8zm-8 40v24q1 12 9 19v-43zm19 0v47h10v-47zm20 0v43q9-7 9-19v-24zm-71 32a8 8 0 1 0 0 16h16a8 8 0 1 0 0-16zm144 0a8 8 0 1 0 0 16h16a8 8 0 1 0 0-16z"/><path fill="#338af3" d="M208 256a16 16 0 0 0-16 16 16 16 0 0 0 16 16 16 16 0 0 0 16-16 16 16 0 0 0-16-16m-80 64a8 8 0 1 0 0 16h16a8 8 0 1 0 0-16zm144 0a8 8 0 1 0 0 16h16a8 8 0 1 0 0-16z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

1
public/flags/fr.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#eee" d="M167 0h178l25.9 252.3L345 512H167l-29.8-253.4z"/><path fill="#0052b4" d="M0 0h167v512H0z"/><path fill="#d80027" d="M345 0h167v512H345z"/></g></svg>

After

Width:  |  Height:  |  Size: 340 B

1
public/flags/gb-eng.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#eee" d="M0 0h208l48 32 48-32h208v208l-32 48 32 48v208H304l-48-32-48 32H0V304l32-48-32-48Z"/><path fill="#d80027" d="M208 0v208H0v96h208v208h96V304h208v-96H304V0h-96z"/></g></svg>

After

Width:  |  Height:  |  Size: 363 B

1
public/flags/gb-sct.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#0052b4" d="M0 68 68 0h376l68 68v376l-68 68H68L0 444Z"/><path fill="#eee" d="M0 0v68l188 188L0 444v68h68l188-188 188 188h68v-68L324 256 512 68V0h-68L256 188 68 0H0z"/></g></svg>

After

Width:  |  Height:  |  Size: 361 B

1
public/flags/gh.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#ffda44" d="m0 167 256-32 256 32v178l-256 32L0 345Z"/><path fill="#d80027" d="M0 0h512v167H0Z"/><path fill="#496e2d" d="M0 345h512v167H0Z"/><path fill="#333" d="m198 345 151-109H163l151 109-58-178Z"/></g></svg>

After

Width:  |  Height:  |  Size: 394 B

1
public/flags/hr.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#eee" d="m0 167 253.8-19.3L512 167v178l-254.9 32.3L0 345z"/><path fill="#d80027" d="M0 0h512v167H0z"/><path fill="#0052b4" d="M0 345h512v167H0z"/><path fill="#338af3" d="M322.8 178h-44.5l7.4-55.7 29.7-22.2 29.6 22.2V167zm-133.6 0h44.5l-7.4-55.7-29.7-22.2-29.6 22.2V167z"/><path fill="#0052b4" d="M285.7 178h-59.4v-55.7l29.7-22.2 29.7 22.2z"/><path fill="#eee" d="M167 167v122.3a89 89 0 0 0 35.8 71.3l15.5-3.9 19.7 19.8a89.1 89.1 0 0 0 18 1.8 89 89 0 0 0 17.9-1.8l22.4-18.7 13 2.8a89 89 0 0 0 35.7-71.3V167z"/><path fill="#d80027" d="M167 167h35.6v35.5H167zm71.2 0h35.6v35.5h-35.6zm71.2 0H345v35.5h-35.6zm-106.8 35.5h35.6v35.6h-35.6zm71.2 0h35.6v35.6h-35.6zM167 238.1h35.6v35.6H167zm35.6 35.6h35.6v35.6h-35.6zm35.6-35.6h35.6v35.6h-35.6zm71.2 0H345v35.6h-35.6zm-35.6 35.6h35.6v35.6h-35.6zm-35.6 35.6h35.6V345h-35.6zm-35.6 0h-33.3c3 13.3 9 25.4 17.3 35.6h16zM309.4 345h16a88.8 88.8 0 0 0 17.3-35.6h-33.3zm-106.8 0v15.6a88.7 88.7 0 0 0 35.6 16V345zm71.2 0v31.6a88.7 88.7 0 0 0 35.6-16V345z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

1
public/flags/ht.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#a2001d" d="m0 256 254.8-41.8L512 256v256H0z"/><path fill="#0052b4" d="M0 0h512v256H0z"/><path fill="#eee" d="m345 322.8-89-11.1-89 11V189.3h178z"/><circle cx="256" cy="267.1" r="44.5" fill="#0052b4"/><circle cx="256" cy="267.1" r="22.3" fill="#a2001d"/><path fill="#6da544" d="M222.6 211.5h66.8L256 244.9z"/><path fill="#ffda44" d="M244.9 233.7H267v66.8h-22z"/><path fill="#6da544" d="M291.6 293.8h-71.2l-53.4 29h178z"/></g></svg>

After

Width:  |  Height:  |  Size: 615 B

1
public/flags/iq.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#eee" d="m0 167 256-32 256 32v178l-256 32L0 345Z"/><path fill="#a2001d" d="M0 0h512v167H0Z"/><path fill="#333" d="M0 345h512v167H0Z"/><path fill="#496e2d" d="m186.4 223.4-7.5 12.2-4.8 9 8.5 12.1h18.9q2.3 0 3.8 1a6 6 0 0 1 2.4 3.5q.8 2.4.8 6.7v5.5h-47.1v-30.5h-14.7v8.6H129v-8.6h-14.7V287q0 4.4-1.7 6.8a5 5 0 0 1-4.5 2.4l-2-.1h-2.8l-.2 12.2 5.2.3q6.2 0 10.8-2.8 4.8-2.7 7.3-7.7 2.6-5 2.6-11V264h17.7v21.8h76.5V268q0-5-1.4-9.3-1.5-4.3-4-7.6a17 17 0 0 0-6.8-5 23 23 0 0 0-9.5-1.7h-11.1l1.4-2.7 1.6-3a104 104 0 0 1 5.3-8.5zM236 226v59.7h14.6V226zm132 0v47.3h-15.2v-38.6h-14.6v38.6h-15.3v-30.5h-20.4q-7.2 0-12.3 2.6a17 17 0 0 0-7.7 7.3 25 25 0 0 0-2.6 12q0 7 2.6 11.7a16 16 0 0 0 7.7 7q5.1 2.4 12.3 2.3h80.2V226zm26.3 0v59.7H409V226zm-91.8 29.3h5.7v18h-5.7q-2.7 0-4.5-.5a4 4 0 0 1-2.6-2.3q-.8-2-.8-5.8 0-4.2 1-6.2a5 5 0 0 1 2.7-2.6q1.8-.6 4.2-.6m-155.8 39.4v11.2h14.7v-11.2z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/flags/ir.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#eee" d="M0 144.7 258.8 39.6 512 144.7v222.6L257 493 0 367.3z"/><path fill="#6da544" d="M0 0v144.7h105.6v-22.2h33.6v22.2h33.3v-22.2h33.6v22.2h33.3v-22.2H273v22.2h33v-22.2h33.6v22.2h33.2v-22.2h33.6v22.2H512V0z"/><path fill="#d80027" d="M0 367.3V512h512V367.3H406.4v22.4h-33.6v-22.4h-33.2v22.4H306v-22.4h-33v22.4h-33.6v-22.4h-33.3v22.4h-33.6v-22.4h-33.3v22.4h-33.6v-22.4zm339.1-178h-33.4c.2 3.7.4 7.4.4 11.1 0 24.8-6.2 48.8-17 66-3.3 5.2-9 12.6-16.4 17.6v-94.7h-33.4v94.8c-7.5-5-13-12.4-16.4-17.7-10.8-17-17-41-17-65.9 0-3.7.2-7.4.4-11H173a190 190 0 0 0-.4 11c0 68.7 36.7 122.5 83.5 122.5s83.5-53.8 83.5-122.5c0-3.7-.1-7.4-.4-11z"/></g></svg>

After

Width:  |  Height:  |  Size: 824 B

1
public/flags/jo.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#eee" d="m126 158 127.8-10.3L512 167v178l-254.9 32.3L126 335.9z"/><path fill="#333" d="M0 0h512v167H107z"/><path fill="#6da544" d="M107 345h405v167H0z"/><path fill="#d80027" d="M0 0v512l256-256z"/><path fill="#eee" d="m101.6 200.3 14 29.4 31.8-7.3-14.2 29.3 25.5 20.2-31.8 7.2.1 32.6-25.4-20.4-25.4 20.4V279l-31.7-7.2 25.5-20-14.2-29.4 31.8 7.3z"/></g></svg>

After

Width:  |  Height:  |  Size: 542 B

1
public/flags/jp.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#eee" d="M0 0h512v512H0z"/><circle cx="256" cy="256" r="111.3" fill="#d80027"/></g></svg>

After

Width:  |  Height:  |  Size: 273 B

1
public/flags/kr.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#eee" d="M0 0h512v512H0Z"/><path fill="#333" d="m350 335 24-24 16 16-24 23zm-39 39 24-24 15 16-23 24zm87 8 23-24 16 16-24 24zm-40 39 24-23 16 15-24 24Zm16-63 24-23 15 15-23 24zm-39 40 23-24 16 16-24 23zm63-221-63-63 15-15 64 63zm-63-15-24-24 16-16 23 24zm39 39-24-24 16-15 24 23zm8-87-24-23 16-16 24 24Zm39 40-23-24 15-16 24 24ZM91 358l63 63-16 16-63-63zm63 16 23 24-15 15-24-23zm-40-39 24 23-16 16-23-24zm24-24 63 63-16 16-63-63zm16-220-63 63-16-16 63-63zm23 23-63 63-15-16 63-63zm24 24-63 63-16-16 63-63z"/><path fill="#d80027" d="M319 319 193 193a89 89 0 1 1 126 126z"/><path fill="#0052b4" d="M319 319a89 89 0 1 1-126-126z"/><circle cx="224.5" cy="224.5" r="44.5" fill="#d80027"/><circle cx="287.5" cy="287.5" r="44.5" fill="#0052b4"/></g></svg>

After

Width:  |  Height:  |  Size: 933 B

1
public/flags/ma.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#d80027" d="M0 0h512v512H0z"/><path fill="#496e2d" d="M407.3 210H291.7L256 100.3 220.3 210H104.7l93.5 68-35.7 109.8L256 320l93.5 68-35.7-110zm-183 59.5 12.2-37.1h39l12.1 37.1-31.6 23-31.6-23zm44-59.4h-24.6l12.3-37.9zm38.3 45.7-7.7-23.4h39.9zM213 232.4l-7.7 23.4-32.2-23.4zm-8.3 97.3 12.3-38 20 14.5zm70.1-23.4 20-14.5 12.3 37.9z"/></g></svg>

After

Width:  |  Height:  |  Size: 525 B

1
public/flags/mx.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#eee" d="M144 0h223l33 256-33 256H144l-32-256z"/><path fill="#d80027" d="M368 0h144v512H368z"/><path fill="#751a46" d="M256 174c22 11 12 33 11 34l-2-4c-4-15-13-33-31-18v11q10 1 11 11-11 12-4 26l4 8-13 23 29-7 18 18v-11l11 11 23-11-35-21-2-13c22-2 34 4 51 29 9-83-45-86-64-86Z"/><path fill="#6da544" d="M209 183q-5 4-4 12 1 11 10 15c3 2 8 0 10 3 3 3-2 5-4 6q-8 5-9 14 3 10 12 15c2 2 7 4 5 7q-4 2-9-2-12-6-19-19c-2-3-1-10-7-10-7 2-4 10-2 14q8 14 21 23 9 8 20 3 10-6 4-17c-3-6-11-8-14-14-2-3 2-4 4-6q8-4 9-11-2-13-14-15-6 1-7-6c-1-3 3-7 0-11q-2-3-6-1"/><path fill="#496e2d" d="M0 0v512h144V0zm164 235a5 5 0 0 0-5 5 97 97 0 0 0 194 0 5 5 0 0 0-5-5 5 5 0 0 0-5 5 87 87 0 1 1-174 0 5 5 0 0 0-5-5m35 25-4 1q-3 4 1 8 23 21 54 24v17h12v-17q31-3 54-24 4-4 1-8-4-3-8 0a78 78 0 0 1-106 0z"/><path fill="#338af3" d="M256 316q-21 0-40-13l6-9c20 13 48 13 68 0l7 9q-18 13-41 13"/><rect width="34" height="22" x="239" y="299" fill="#ff9811" rx="11" ry="11"/><path fill="#ffda44" d="m234 186-12 11v11l18-9q4-3 1-7zm-62 79a10 10 0 0 0-10 10 10 10 0 0 0 10 10 10 10 0 0 0 10-10 10 10 0 0 0-10-10m169 0a10 10 0 0 0-10 10 10 10 0 0 0 10 10 10 10 0 0 0 10-10 10 10 0 0 0-10-10m-69 4-16 8v5l15-1 4-9zm-83 23a10 10 0 0 0-10 10 10 10 0 0 0 10 10 10 10 0 0 0 10-10 10 10 0 0 0-10-10m135 0a10 10 0 0 0-10 10 10 10 0 0 0 10 10 10 10 0 0 0 10-10 10 10 0 0 0-10-10m-108 21a10 10 0 0 0-10 10 10 10 0 0 0 10 10 10 10 0 0 0 10-10 10 10 0 0 0-10-10m81 0a10 10 0 0 0-10 10 10 10 0 0 0 10 10 10 10 0 0 0 10-10 10 10 0 0 0-10-10"/></g></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

1
public/flags/nl.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#eee" d="m0 167 253.8-19.3L512 167v178l-254.9 32.3L0 345z"/><path fill="#a2001d" d="M0 0h512v167H0z"/><path fill="#0052b4" d="M0 345h512v167H0z"/></g></svg>

After

Width:  |  Height:  |  Size: 340 B

1
public/flags/no.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#d80027" d="M0 0h100.2l66.1 53.5L233.7 0H512v189.3L466.3 257l45.7 65.8V512H233.7l-68-50.7-65.5 50.7H0V322.8l51.4-68.5-51.4-65z"/><path fill="#eee" d="M100.2 0v189.3H0v33.4l24.6 33L0 289.5v33.4h100.2V512h33.4l30.6-26.3 36.1 26.3h33.4V322.8H512v-33.4l-24.6-33.7 24.6-33v-33.4H233.7V0h-33.4l-33.8 25.3L133.6 0z"/><path fill="#0052b4" d="M133.6 0v222.7H0v66.7h133.6V512h66.7V289.4H512v-66.7H200.3V0z"/></g></svg>

After

Width:  |  Height:  |  Size: 592 B

1
public/flags/nz.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#0052b4" d="M256 0h256v512H0V256Z"/><path fill="#eee" d="M0 0v32l32 32L0 96v160h32l32-32 32 32h32v-83l83 83h45l-8-16 8-15v-14l-83-83h83V96l-32-32 32-32V0H96L64 32 32 0Zm382 92-11 35h-37l30 21-12 35 30-22 30 22-12-35 30-21h-37l-11-35Zm61 72-11 35h-37l30 21-11 35 29-21 30 21-12-35 30-21h-37Zm-123 10-11 35h-37l30 22-11 35 29-22 30 22-11-35 29-22h-36zm59 130-11 35h-37l30 21-11 35 29-21 30 21-11-35 29-21h-36z"/><path fill="#d80027" d="M32 0v32H0v64h32v160h64V96h160V32H96V0Zm96 128 128 128v-31l-97-97zm251 201-5 18h-19l15 10-6 18 15-11 15 11-5-18 14-10h-18Zm-59-129-5 17h-19l15 11-6 17 15-11 15 11-6-17 15-11h-18l-6-17zm123-11-6 18h-18l15 11-6 17 15-11 15 11-6-17 15-11h-18l-6-18zm-61-72-6 17h-18l15 11-6 17 15-10 15 10-6-17 15-11h-18z"/></g></svg>

After

Width:  |  Height:  |  Size: 931 B

1
public/flags/pa.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#eee" d="M0 0h256l256 256v256H256L0 256z"/><path fill="#0052b4" d="M0 256v256h256V256z"/><path fill="#d80027" d="M256 0h256v256H256z"/><path fill="#0052b4" d="m152.4 89 16.6 51h53.6l-43.4 31.6 16.6 51-43.4-31.5-43.4 31.5 16.6-51L82.2 140h53.6z"/><path fill="#d80027" d="m359.6 289.4 16.6 51h53.6L386.4 372l16.6 51-43.4-31.5-43.4 31.6 16.6-51-43.4-31.6H343z"/></g></svg>

After

Width:  |  Height:  |  Size: 553 B

1
public/flags/pt.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#6da544" d="M0 512h167l37.9-260.3L167 0H0z"/><path fill="#d80027" d="M512 0H167v512h345z"/><circle cx="167" cy="256" r="89" fill="#ffda44"/><path fill="#d80027" d="M116.9 211.5V267a50 50 0 1 0 100.1 0v-55.6H117z"/><path fill="#eee" d="M167 283.8c-9.2 0-16.7-7.5-16.7-16.7V245h33.4v22c0 9.2-7.5 16.7-16.7 16.7z"/></g></svg>

After

Width:  |  Height:  |  Size: 506 B

1
public/flags/py.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#eee" d="m0 144.7 255.3-36.5L512 144.7v222.6L250.5 407 0 367.3z"/><path fill="#d80027" d="M0 0h512v144.7H0z"/><path fill="#0052b4" d="M0 367.3h512V512H0z"/><path fill="#6da544" d="m319 182-23.6 23.5a55.5 55.5 0 0 1-39.4 95 55.7 55.7 0 0 1-39.3-95L193 182a89 89 0 1 0 126 0z"/><path fill="#ffda44" d="m256 211.5 8.3 25.5H291l-21.7 15.8 8.3 25.5-21.7-15.8-21.7 15.8 8.3-25.5-21.7-15.8h26.8z"/></g></svg>

After

Width:  |  Height:  |  Size: 585 B

1
public/flags/qa.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#eee" d="M0 0h173l61 255.8L173.4 512H0z"/><path fill="#751a46" d="m173 0-72.7 30.8L176 63l-75.7 32.2 75.7 32.1-75.7 32.2 75.7 32.1-75.7 32.1 75.7 32.2-75.7 32.2 75.7 32.1-75.7 32.2 75.7 32.1-75.7 32.2 75.7 32.1-75.7 32.2 73.1 31H512V0z"/></g></svg>

After

Width:  |  Height:  |  Size: 432 B

1
public/flags/sa.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#496e2d" d="M0 0h512v512H0Z"/><path fill="#eee" d="M336 356v16H128l24 24h184v16h16v-16h32v-24h-32v-16zM131.4 174v41.4h-15.8v-26.7H97.8q-6.3 0-10.8 2.3a15 15 0 0 0-6.7 6.4 22 22 0 0 0-2.3 10.5q0 6 2.3 10.3 2.3 4 6.7 6 4.5 2.1 10.8 2.1H173V174h-13v41.4h-15.8V174zm52.9 0v52.3h12.8V174zm55.3 0v41.4h-11v-31h-12.8v31h-9.3v10.9h45.9V174zm24.3 0v52.3h12.8V174zm77.8 0v41.4H326v-26.7h-17.8q-6.3 0-10.8 2.3a15 15 0 0 0-6.7 6.4 22 22 0 0 0-2.3 10.5q0 6 2.3 10.3 2.3 4 6.7 6 4.5 2.1 10.8 2.1h46.5V174zm24.2 0v52.3h12.8V174zm55.3 0v41.4h-11v-31h-12.8v31h-9.3v10.9h46V174ZM97.8 199.6h5v15.8h-5q-2.4 0-4-.4-1.5-.5-2.2-2a13 13 0 0 1-.8-5.1q0-3.7.8-5.4 1-1.8 2.5-2.3 1.5-.6 3.7-.6m210.3 0h5v15.8h-5q-2.4 0-4-.4-1.5-.5-2.2-2a13 13 0 0 1-.8-5.1q0-3.7.8-5.4 1-1.8 2.5-2.3 1.6-.6 3.7-.6M114.8 247v28.5h-10.9V257H91.6q-4.4 0-7.4 1.6-3 1.4-4.6 4.4t-1.6 7.2q0 4.3 1.6 7 1.5 2.9 4.6 4.3t7.4 1.4h51.7v-36h-8.8v28.5h-10.9V247Zm36.3 0v36h8.8v-36Zm39.7 0v35.8q0 1.5-.6 2.7t-2 2q-1.5.6-4 .7t-4.2-.7-2.4-2q-.9-1.3-.9-3.1l.2-2.8 1.5-10.8-8.7-1.1-1.2 8.4-.6 6.4q0 3.7 2 6.7a14 14 0 0 0 5.9 4.8q3.6 1.7 8.3 1.7 4.5 0 8-1.6 3.6-1.6 5.5-4.6 2-2.8 2-6.7V247Zm159.5 10a36 36 0 0 0-10 1.4 40 40 0 0 0-1.3 7.4 57 57 0 0 0 0 9.6h-11v-2a20 20 0 0 0-1.9-9.2q-1.8-3.6-5.4-5.3a20 20 0 0 0-8.7-1.8h-4.2v7.5h4.2q2.7 0 4.3.8 1.5.7 2.2 2.6.6 1.8.7 5.4v2h-12.7v7.6H434v-12.8q0-5-1.6-7.8-1.5-3-4.7-4.1-3.2-1.4-8-1.3a36 36 0 0 0-10 1.4 40 40 0 0 0-1.4 7.4 57 57 0 0 0 0 9.6h-10.9v-4.8q0-4.2-2-7.2t-5.5-4.6a18 18 0 0 0-7.9-1.7q-2.1 0-4.2.4l-4.3.8.7 7a48 48 0 0 1 6.7-.6q4 0 6 1.5 1.7 1.5 1.7 4.4v4.9h-23.9v-5.3q0-5-1.6-7.8-1.6-3-4.8-4.1-3-1.4-8-1.3m-131.7.1q-4.3 0-7.4 1.6-3 1.4-4.6 4.4t-1.6 7.2q0 4.3 1.6 7 1.6 2.9 4.6 4.3t7.4 1.4h3.5v1.6q0 2.3-1.5 3.4-1.4 1.2-4.7 1.2l-3-.2q-1.8 0-4.3-.4l-1.2 7a59 59 0 0 0 8.5 1q4.5 0 7.8-1.4 3.4-1.5 5.3-4.2a11 11 0 0 0 1.9-6.4V283h22.9a15 15 0 0 0 7.9-2q1 .6 2.2 1 2.3 1 4.7 1h13.9v-14l-.3-3.3-1.3-8.6-8.7 1.3a118 118 0 0 1 1.4 10.5v6.6h-5l-1.9-.4-.9-.5q.7-2.7.7-6.2v-8.6h-8.8v8.6q0 3-.4 4.6-.3 1.5-1 2a5 5 0 0 1-2.5.5h-3v-13h-9v13H231V257zm73.8 0v26.6q0 2.7-1 4-1 1.5-2.8 1.5h-2.8l-.2 7.3 3.1.2q3.8 0 6.6-1.7t4.3-4.6 1.6-6.7V257zm58 7.4q2.1 0 3.3.5t1.7 1.7q.4 1.3.4 3.4v5.4h-8a71 71 0 0 1 0-8.6l.2-2.3zm69.3 0q2.2 0 3.4.5t1.6 1.7q.5 1.3.5 3.4v5.4h-8a71 71 0 0 1-.1-8.6l.2-2.3zm-328.1.1H95v10.9h-3.4q-1.7 0-2.7-.3-1-.4-1.6-1.4a9 9 0 0 1-.5-3.5q0-2.6.6-3.7A3 3 0 0 1 89 265a8 8 0 0 1 2.5-.4m127 0h3.5v10.9h-3.5q-1.6 0-2.7-.3-1-.4-1.6-1.4a9 9 0 0 1-.5-3.5q0-2.6.6-3.7a3 3 0 0 1 1.7-1.6 8 8 0 0 1 2.5-.4"/></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

1
public/flags/se.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#0052b4" d="M0 0h133.6l35.3 16.7L200.3 0H512v222.6l-22.6 31.7 22.6 35.1V512H200.3l-32-19.8-34.7 19.8H0V289.4l22.1-33.3L0 222.6z"/><path fill="#ffda44" d="M133.6 0v222.6H0v66.8h133.6V512h66.7V289.4H512v-66.8H200.3V0z"/></g></svg>

After

Width:  |  Height:  |  Size: 412 B

1
public/flags/sn.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#ffda44" d="M144.8 0h222.4l32 260-32 252H144.8l-32.1-256z"/><path fill="#496e2d" d="M0 0h144.8v512H0z"/><path fill="#d80027" d="M367.2 0H512v512H367.2z"/><path fill="#496e2d" d="m256.1 167 22.1 68h71.5L292 277l22 68-57.8-42-57.9 42 22.1-68-57.8-42H234z"/></g></svg>

After

Width:  |  Height:  |  Size: 449 B

1
public/flags/tn.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#d80027" d="M0 0h512v512H0z"/><circle cx="256" cy="256" r="123" fill="#eee"/><path fill="#d80027" d="M251 167a89 89 0 1 0 67 153 72 72 0 0 1-34 8 72 72 0 1 1 34-136 89 89 0 0 0-67-25m20 42v36l-34 11 34 11v36l21-29 34 11-21-29 21-29-34 11z"/></g></svg>

After

Width:  |  Height:  |  Size: 435 B

1
public/flags/tr.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#d80027" d="M0 0h512v512H0z"/><path fill="#eee" d="M208 115a141 141 0 1 0 106 242q-25 13-54 13a114 114 0 1 1 54-215 141 141 0 0 0-106-40m142 67v56l-54 18 54 17v57l33-46 54 18-33-46 33-46-54 18z"/></g></svg>

After

Width:  |  Height:  |  Size: 390 B

1
public/flags/us.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#eee" d="M256 0h256v64l-32 32 32 32v64l-32 32 32 32v64l-32 32 32 32v64l-256 32L0 448v-64l32-32-32-32v-64z"/><path fill="#d80027" d="M224 64h288v64H224Zm0 128h288v64H256ZM0 320h512v64H0Zm0 128h512v64H0Z"/><path fill="#0052b4" d="M0 0h256v256H0Z"/><path fill="#eee" d="m187 243 57-41h-70l57 41-22-67zm-81 0 57-41H93l57 41-22-67zm-81 0 57-41H12l57 41-22-67zm162-81 57-41h-70l57 41-22-67zm-81 0 57-41H93l57 41-22-67zm-81 0 57-41H12l57 41-22-67Zm162-82 57-41h-70l57 41-22-67Zm-81 0 57-41H93l57 41-22-67zm-81 0 57-41H12l57 41-22-67Z"/></g></svg>

After

Width:  |  Height:  |  Size: 723 B

1
public/flags/uy.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#338af3" d="M0 256 256 0h256v55.7l-20.7 34.5 20.7 32.2v66.8l-21.2 32.7L512 256v66.8l-24 31.7 24 35.1v66.7l-259.1 28.3L0 456.3v-66.7l27.1-33.3L0 322.8z"/><path fill="#eee" d="M256 256h256v-66.8H236.9zm-19.1-133.6H512V55.7H236.9zM512 512v-55.7H0V512zM0 389.6h512v-66.8H0z"/><path fill="#eee" d="M0 0h256v256H0z"/><path fill="#ffda44" d="m222.6 149.8-31.3 14.7 16.7 30.3-34-6.5-4.3 34.3-23.6-25.2-23.7 25.2-4.3-34.3-33.9 6.5 16.6-30.3-31.2-14.7 31.2-14.7-16.6-30.3 34 6.5 4.2-34.3 23.7 25.3L169.7 77l4.3 34.3 34-6.5-16.7 30.3z"/></g></svg>

After

Width:  |  Height:  |  Size: 720 B

1
public/flags/uz.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#d80027" d="m0 178 254.2-22L512 178v22.3l-40.2 54.1 40.2 57.3V334l-254 23.4L0 334v-22.3l36.7-59.4-36.7-52z"/><path fill="#338af3" d="M0 0h512v178H0z"/><path fill="#eee" d="M0 200.3h512v111.4H0z"/><path fill="#6da544" d="M0 334h512v178H0z"/><path fill="#eee" d="M117.2 105.7a50 50 0 0 1 39.3-48.9 50.2 50.2 0 0 0-10.7-1.1 50 50 0 1 0 10.7 99c-22.5-5-39.3-25-39.3-49zm69 22.8 3.3 10.4h11l-9 6.5 3.5 10.4-9-6.4-8.7 6.4 3.4-10.4-9-6.5h11zm35 0 3.4 10.4h11l-9 6.5 3.4 10.4-8.8-6.4-9 6.4 3.5-10.4-9-6.5h11zm35 0 3.4 10.4h11l-9 6.5 3.5 10.4-9-6.4-8.8 6.4 3.4-10.4-9-6.5h11zm35 0 3.4 10.4h11l-9 6.5 3.5 10.4-9-6.4-8.8 6.4 3.4-10.4-9-6.5h11zm35 0 3.4 10.4h11l-9 6.5 3.5 10.4-9-6.4-8.8 6.4 3.4-10.4-8.8-6.5h11zm-105-36.4 3.4 10.4h11l-9 6.5 3.4 10.4-8.8-6.5-9 6.5 3.5-10.4-9-6.5h11zm35 0 3.4 10.4h11l-9 6.5 3.5 10.4-9-6.5-8.8 6.5 3.4-10.4-9-6.5h11zm35 0 3.4 10.4h11l-9 6.5 3.5 10.4-9-6.5-8.8 6.5 3.4-10.4-9-6.5h11zm35 0 3.4 10.4h11l-9 6.5 3.5 10.4-9-6.5-8.8 6.5 3.4-10.4-8.8-6.5h11zm-70-36.4 3.4 10.4h11l-9 6.4 3.6 10.5-9-6.5-8.8 6.5 3.4-10.5-9-6.4h11zm35 0 3.4 10.4h11l-9 6.4 3.6 10.5-9-6.5-8.8 6.5 3.4-10.5-9-6.4h11zm35 0 3.4 10.4h11l-9 6.4 3.6 10.5-9-6.5-8.8 6.5 3.4-10.5-8.8-6.4h11z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/flags/za.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><mask id="a"><circle cx="256" cy="256" r="256" fill="#fff"/></mask><g mask="url(#a)"><path fill="#eee" d="m0 0 192 256L0 512h47l465-189v-34l-32-33 32-33v-34L47 0Z"/><path fill="#333" d="M0 142v228l140-114z"/><path fill="#ffda44" d="M192 256 0 95v47l114 114L0 370v47z"/><path fill="#6da544" d="M512 223H223L0 0v94l161 162L0 418v94l223-223h289z"/><path fill="#d80027" d="M512 0H47l189 189h276z"/><path fill="#0052b4" d="M512 512H47l189-189h276z"/></g></svg>

After

Width:  |  Height:  |  Size: 542 B

50
scripts/download-flags.ts Normal file
View File

@@ -0,0 +1,50 @@
// download-flags.ts — Lädt circle-flags SVGs für alle Teams aus TLA_TO_ISO2.
// Ausführung: npx tsx scripts/download-flags.ts
import { TLA_TO_ISO2 } from "../lib/flags";
import * as fs from "fs";
import * as path from "path";
const BASE = "https://hatscripts.github.io/circle-flags/flags";
const OUT = path.join(__dirname, "..", "public", "flags");
async function main() {
const codes = Object.values(TLA_TO_ISO2);
const unique = [...new Set(codes)];
console.log(`Lade ${unique.length} Flaggen (${codes.length} Team-Codes)...`);
if (!fs.existsSync(OUT)) fs.mkdirSync(OUT, { recursive: true });
let ok = 0;
let failed = 0;
for (const iso2 of unique) {
const url = `${BASE}/${iso2}.svg`;
try {
const res = await fetch(url, {
headers: { "User-Agent": "circle-flags-download/1.0" },
});
if (!res.ok) {
console.warn(`${iso2}: HTTP ${res.status}`);
failed++;
continue;
}
const svg = await res.text();
// Prüfen: keine leere/Fehler-SVG (<10 Bytes = kaputt)
if (svg.trim().length < 10) {
console.warn(`${iso2}: leere SVG (${svg.length} Bytes)`);
failed++;
continue;
}
fs.writeFileSync(path.join(OUT, `${iso2}.svg`), svg);
ok++;
} catch (err) {
console.warn(`${iso2}: ${err instanceof Error ? err.message : err}`);
failed++;
}
}
console.log(`\n✅ ${ok} OK, ❌ ${failed} fehlgeschlagen`);
if (failed > 0) process.exit(1);
}
main();

119
scripts/fifa-eval.ts Normal file
View File

@@ -0,0 +1,119 @@
// fifa-eval.ts — Analyse-Skript: IdGroup, MatchNumber, Attendance, MatchStatus
// Ausführung: npx ts-node --compiler-options '{"module":"commonjs","target":"es2020"}' scripts/fifa-eval.ts
// Oder mit Deno, Bun: bun run scripts/fifa-eval.ts
const FIFA_BASE = "https://api.fifa.com/api/v3";
const FIFA_SEASON = "285023";
interface RawMatch {
MatchNumber: number;
MatchStatus: number;
IdGroup: string | null;
IdStage: string;
StageName: Array<{ Locale: string; Description: string }>;
GroupName?: Array<{ Locale: string; Description: string }>;
Home: { IdTeam: string; Abbreviation: string; TeamName?: Array<{ Locale: string; Description: string }> } | null;
Away: { IdTeam: string; Abbreviation: string; TeamName?: Array<{ Locale: string; Description: string }> } | null;
HomeTeamScore: number | null;
AwayTeamScore: number | null;
Attendance: string | null;
Date: string;
LocalDate: string;
}
async function main() {
console.log("📡 Rufe FIFA Calendar-Endpoint ab...");
const res = await fetch(
`${FIFA_BASE}/calendar/matches?language=de&count=500&idSeason=${FIFA_SEASON}`,
{ headers: { "User-Agent": "fifa-eval/1.0" } },
);
if (!res.ok) { console.error("Fehler:", res.status, res.statusText); process.exit(1); }
const data = (await res.json()) as { Results: RawMatch[] };
const matches = data.Results ?? [];
console.log(`${matches.length} Matches geladen\n`);
// ── A) IdGroup → GroupName Mapping ──
console.log("═══ A) IdGroup → Gruppe (A-L) ═══");
const groupMap = new Map<string, string>();
for (const m of matches) {
if (!m.IdGroup || !m.GroupName) continue;
const desc = m.GroupName.find(g => g.Locale === "de-DE")?.Description
?? m.GroupName[0]?.Description
?? `ID:${m.IdGroup}`;
if (!groupMap.has(m.IdGroup)) groupMap.set(m.IdGroup, desc);
}
console.log(`Anzahl distinct IdGroup-Werte: ${groupMap.size}`);
const sorted = [...groupMap.entries()].sort((a, b) => parseInt(a[0]) - parseInt(b[0]));
for (const [id, name] of sorted) {
console.log(` ${id} → "${name}"`);
}
// Als kopierbare Konstante
console.log("\n// Kopierbare Konstante:");
console.log("const FIFA_IDGROUP_TO_GROUP: Record<string, string> = {");
for (const [id, name] of sorted) {
const letter = name.replace(/^Gruppe\s+/, "");
console.log(` "${id}": "${letter}", // ${name}`);
}
console.log("};");
// ── B) MatchNumber-Konsistenz ──
console.log("\n═══ B) MatchNumber-Konsistenz ═══");
const groupMatches = matches.filter(m => m.IdGroup != null).sort((a, b) => a.MatchNumber - b.MatchNumber);
const koMatches = matches.filter(m => m.IdGroup == null).sort((a, b) => a.MatchNumber - b.MatchNumber);
console.log(`Gruppenspiele: ${groupMatches.length} (MatchNumber ${groupMatches[0]?.MatchNumber}${groupMatches[groupMatches.length-1]?.MatchNumber})`);
console.log(`K.o.-Spiele: ${koMatches.length} (MatchNumber ${koMatches[0]?.MatchNumber}${koMatches[koMatches.length-1]?.MatchNumber})`);
// Stichproben für K.o.-Slots
const checkNums = [73, 74, 76, 89, 104];
for (const n of checkNums) {
const m = matches.find(x => x.MatchNumber === n);
if (m) {
const home = m.Home?.Abbreviation ?? "?";
const away = m.Away?.Abbreviation ?? "?";
console.log(` #${n}: ${home} vs ${away} | Stage=${m.StageName?.[0]?.Description ?? "?"} | Status=${m.MatchStatus}`);
} else {
console.log(` #${n}: NICHT GEFUNDEN`);
}
}
// ── C) Attendance + Scheduled ──
console.log("\n═══ C) Attendance + Scheduled-Matches ═══");
const withAttendance = matches.filter(m => m.Attendance != null);
console.log(`Matches mit Attendance: ${withAttendance.length}`);
if (withAttendance.length > 0) {
const ex = withAttendance[0];
console.log(` Beispiel: #${ex.MatchNumber}${ex.Attendance}`);
}
const scheduled = matches.filter(m => m.MatchStatus === 1);
console.log(`Scheduled-Matches (Status=1): ${scheduled.length}`);
if (scheduled.length > 0) {
const ex = scheduled[0];
console.log(` Beispiel: #${ex.MatchNumber} ${ex.Home?.Abbreviation ?? "?"} vs ${ex.Away?.Abbreviation ?? "?"} | Date=${ex.Date}`);
}
// ── D) MatchStatus-Werte ──
console.log("\n═══ D) Distinct MatchStatus-Werte ═══");
const statuses = new Map<number, number>();
for (const m of matches) {
statuses.set(m.MatchStatus, (statuses.get(m.MatchStatus) ?? 0) + 1);
}
const statusLabels: Record<number, string> = { 0: "finished", 1: "scheduled", 10: "postponed", 3: "live?" };
for (const [s, c] of [...statuses.entries()].sort((a, b) => a[0] - b[0])) {
console.log(` MatchStatus ${s}: ${c} Matches ${statusLabels[s] ? `(${statusLabels[s]})` : ""}`);
}
// ── E) Stage-Werte ──
console.log("\n═══ E) Distinct Stages ═══");
const stages = new Map<string, number>();
for (const m of matches) {
const sn = m.StageName?.[0]?.Description ?? String(m.IdStage);
stages.set(sn, (stages.get(sn) ?? 0) + 1);
}
for (const [s, c] of stages.entries()) {
console.log(` "${s}" (IdStage=${matches.find(m => (m.StageName?.[0]?.Description ?? "") === s)?.IdStage}): ${c}`);
}
console.log("\n✅ Analyse abgeschlossen.");
}
main().catch(console.error);

164
scripts/verify-migration.ts Normal file
View File

@@ -0,0 +1,164 @@
// verify-migration.ts — Automatisierte FIFA-Migrations-Checks
// Ausführung: npx tsx scripts/verify-migration.ts
import { TEAM_LOCALIZATION } from "../lib/team-mappings";
import { TLA_TO_ISO2 } from "../lib/flags";
import { FIFA_GROUP_MAP } from "../lib/fifa-constants";
import * as fs from "fs";
import * as path from "path";
const FIFA_BASE = "https://api.fifa.com/api/v3";
const FIFA_SEASON = "285023";
const FLAGS_DIR = path.join(__dirname, "..", "public", "flags");
interface RawMatch {
MatchNumber: number;
IdGroup: string | null;
IdStage: string;
Home: { IdTeam: string; Abbreviation: string; TeamName?: Array<{ Locale: string; Description: string }> } | null;
Away: { IdTeam: string; Abbreviation: string; TeamName?: Array<{ Locale: string; Description: string }> } | null;
HomeTeamScore: number | null;
AwayTeamScore: number | null;
}
let failures = 0;
function pass(msg: string) { console.log(" \x1b[32m✓\x1b[0m " + msg); }
function fail(msg: string) { console.log(" \x1b[31m✗\x1b[0m " + msg); failures++; }
function warn(msg: string) { console.log(" \x1b[33m!\x1b[0m " + msg); }
async function main() {
console.log("\n═══ FIFA-Migration Verifikation ═══\n");
// ── 1. Flaggen-Vollständigkeit ──
console.log("1. Flaggen-Vollständigkeit");
const missingFlags: string[] = [];
const emptyFlags: string[] = [];
for (const code of Object.keys(TEAM_LOCALIZATION)) {
const iso2 = TLA_TO_ISO2[code];
if (!iso2) { missingFlags.push(`${code} (kein ISO-2)`); continue; }
const filePath = path.join(FLAGS_DIR, `${iso2}.svg`);
if (!fs.existsSync(filePath)) { missingFlags.push(`${code}${iso2}.svg`); continue; }
const content = fs.readFileSync(filePath, "utf8");
if (content.trim().length === 0) { emptyFlags.push(`${code}${iso2}.svg (leer)`); continue; }
if (!content.includes("<svg")) { emptyFlags.push(`${code}${iso2}.svg (kein <svg>, ${content.length} bytes)`); continue; }
}
if (missingFlags.length === 0 && emptyFlags.length === 0) {
pass(`${Object.keys(TEAM_LOCALIZATION).length} Teams, alle Flaggen ok`);
} else {
for (const f of missingFlags) fail(`Fehlende Flagge: ${f}`);
for (const f of emptyFlags) fail(`Leere/defekte Flagge: ${f}`);
}
// ── 2. FIFA-Feed abrufen ──
console.log("\n2. FIFA-Feed-Daten abrufen");
let rawMatches: RawMatch[] = [];
try {
const url = `${FIFA_BASE}/calendar/matches?language=de&count=500&idSeason=${FIFA_SEASON}`;
const res = await fetch(url, { headers: { "User-Agent": "wm2026-board/1.0" }, signal: AbortSignal.timeout(10000) });
if (!res.ok) { fail(`HTTP ${res.status}`); } else {
const data = (await res.json()) as { Results: RawMatch[] };
rawMatches = data.Results ?? [];
pass(`${rawMatches.length} Matches geladen`);
}
} catch (err) {
fail(`Feed nicht erreichbar: ${err instanceof Error ? err.message : err}`);
}
if (rawMatches.length === 0) {
console.log(`\n\x1b[31m${failures} Fehler\x1b[0m (Feed-Fehler, Rest übersprungen)`);
process.exit(failures > 0 ? 1 : 0);
}
// ── 3. Gruppenzuordnung ──
console.log("\n3. Gruppenzuordnung");
const perGroup = new Map<string, RawMatch[]>();
const teamsPerGroup = new Map<string, Set<string>>();
for (const m of rawMatches) {
const gid = m.IdGroup ? FIFA_GROUP_MAP[m.IdGroup] : null;
if (gid) {
if (!perGroup.has(gid)) perGroup.set(gid, []);
perGroup.get(gid)!.push(m);
if (!teamsPerGroup.has(gid)) teamsPerGroup.set(gid, new Set());
if (m.Home?.IdTeam) teamsPerGroup.get(gid)!.add(m.Home.IdTeam);
if (m.Away?.IdTeam) teamsPerGroup.get(gid)!.add(m.Away.IdTeam);
}
}
const groupIds = ["A","B","C","D","E","F","G","H","I","J","K","L"];
let groupOk = true;
for (const g of groupIds) {
const matches = perGroup.get(g)?.length ?? 0;
const teams = teamsPerGroup.get(g)?.size ?? 0;
if (matches !== 6) { warn(`Gruppe ${g}: ${matches} Spiele (erwartet 6)`); groupOk = false; }
if (teams !== 4) { warn(`Gruppe ${g}: ${teams} Teams (erwartet 4)`); groupOk = false; }
}
if (groupOk) {
const total = [...perGroup.values()].reduce((s, a) => s + a.length, 0);
pass(`${total} Gruppenspiele, 12 Gruppen mit je 6 Spielen + 4 Teams`);
}
// ── 4. MatchNumber-Vollständigkeit ──
console.log("\n4. MatchNumber-Vollständigkeit (1104)");
const byNum = new Map<number, RawMatch[]>();
for (const m of rawMatches) {
if (!byNum.has(m.MatchNumber)) byNum.set(m.MatchNumber, []);
byNum.get(m.MatchNumber)!.push(m);
}
const missing: number[] = [];
const dupes: number[] = [];
for (let n = 1; n <= 104; n++) {
const entries = byNum.get(n);
if (!entries || entries.length === 0) missing.push(n);
else if (entries.length > 1) dupes.push(n);
}
if (missing.length === 0 && dupes.length === 0) {
pass("Alle 104 MatchNumbers genau einmal vorhanden");
} else {
if (missing.length > 0) fail(`Fehlend: ${missing.join(", ")}`);
if (dupes.length > 0) fail(`Duplikate: ${dupes.join(", ")}`);
}
// ── 5. Team-Code-Auflösung ──
console.log("\n5. Team-Code-Auflösung");
const teamCodes = new Set<string>();
for (const m of rawMatches) {
for (const tb of [m.Home, m.Away]) {
if (tb?.Abbreviation) teamCodes.add(tb.Abbreviation);
}
}
const unresolved: string[] = [];
for (const code of teamCodes) {
const appCode = code; // Rohwert aus FIFA
// FIFA_CODE_OVERRIDE: CRO→HRV, POR→PRT, SUI→CHE
const overrides: Record<string, string> = { CRO: "HRV", POR: "PRT", SUI: "CHE" };
const resolved = overrides[code] ?? code;
if (!TEAM_LOCALIZATION[resolved]) unresolved.push(`${code}${resolved}`);
if (!TLA_TO_ISO2[resolved]) unresolved.push(`${code}${resolved} (kein ISO-2)`);
}
if (unresolved.length === 0) {
pass(`${teamCodes.size} eindeutige Team-Codes, alle in TEAM_LOCALIZATION + TLA_TO_ISO2`);
} else {
for (const u of unresolved) fail(`Nicht auflösbar: ${u}`);
}
// ── 6. K.o.-Paarung 89/90 ──
console.log("\n6. K.o.-Paarung 89/90");
const m89 = rawMatches.find(m => m.MatchNumber === 89);
const m90 = rawMatches.find(m => m.MatchNumber === 90);
if (m89) {
const h = m89.Home?.Abbreviation ?? "?";
const a = m89.Away?.Abbreviation ?? "?";
const ok = h === "PAR" && a === "FRA";
(ok ? pass : fail)(`Spiel 89: ${h}/${a} ${ok ? "(korrekt PAR/FRA)" : "(erwartet PAR/FRA)"}`);
} else fail("Spiel 89 nicht gefunden");
if (m90) {
const h = m90.Home?.Abbreviation ?? "?";
const a = m90.Away?.Abbreviation ?? "?";
const ok = h === "CAN" && a === "MAR";
(ok ? pass : fail)(`Spiel 90: ${h}/${a} ${ok ? "(korrekt CAN/MAR)" : "(erwartet CAN/MAR)"}`);
} else fail("Spiel 90 nicht gefunden");
console.log(`\n═══ Ergebnis: ${failures} Fehler ═══\n`);
process.exit(failures > 0 ? 1 : 0);
}
main().catch(err => { console.error(err); process.exit(1); });