Finale
- {fin &&
}
+ {fin &&
}
{third && (
)}
@@ -105,6 +112,8 @@ export default function Bracket({
Sieger / weiter
Finale
+ fix qualifiziert
+ vorläufig (≈, nach aktueller Tabelle)
Platzhalter offen
%-Werte: Polymarket-Wahrscheinlichkeit (falls verfügbar)
diff --git a/app/components/Fixtures.tsx b/app/components/Fixtures.tsx
new file mode 100644
index 0000000..f482aff
--- /dev/null
+++ b/app/components/Fixtures.tsx
@@ -0,0 +1,109 @@
+"use client";
+
+import { GROUP_IDS, GroupId, Match, Team } from "@/lib/types";
+import Flag from "./Flag";
+
+function teamById(teams: Team[], id: string | null) {
+ return id ? teams.find((t) => t.id === id) : undefined;
+}
+
+function fmtDate(iso: string): string {
+ const d = new Date(iso);
+ return d.toLocaleString("de-DE", {
+ weekday: "short", day: "2-digit", month: "2-digit",
+ hour: "2-digit", minute: "2-digit",
+ });
+}
+
+function statusText(m: Match): { text: string; live: boolean } {
+ switch (m.status) {
+ case "LIVE":
+ case "IN_PLAY":
+ return { text: m.minute != null ? `${m.minute}'` : "läuft", live: true };
+ case "PAUSED":
+ return { text: "Halbzeit", live: true };
+ case "FINISHED":
+ return { text: "Beendet", live: false };
+ default:
+ return { text: fmtDate(m.utcDate), live: false };
+ }
+}
+
+function ScoreOrTime({ m }: { m: Match }) {
+ const hasScore = m.homeScore != null && m.awayScore != null;
+ const st = statusText(m);
+ if (hasScore) {
+ return (
+
+ {m.homeScore} : {m.awayScore}
+
+ );
+ }
+ return
{fmtDate(m.utcDate)} ;
+}
+
+export default function Fixtures({
+ group, teams, matches, onSelectGroup,
+}: {
+ group: GroupId; teams: Team[]; matches: Match[];
+ onSelectGroup: (g: GroupId) => void;
+}) {
+ const list = matches
+ .filter((m) => m.group === group)
+ .sort((a, b) => +new Date(a.utcDate) - +new Date(b.utcDate));
+
+ return (
+
+
+ {GROUP_IDS.map((g) => (
+ onSelectGroup(g)}
+ >
+ {g}
+
+ ))}
+
+
+ {list.length === 0 ? (
+
Für Gruppe {group} liegen noch keine Spiele im Feed vor.
+ ) : (
+
+ {list.map((m) => {
+ const home = teamById(teams, m.homeTeamId);
+ const away = teamById(teams, m.awayTeamId);
+ const st = statusText(m);
+ 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 (
+
+
+ {st.live && }
+ {st.text}
+
+
+
+
+ {home?.name ?? "—"}
+
+
+
+ {away?.name ?? "—"}
+
+
+
+
+ {m.venue && 📍 {m.venue} }
+ {m.attendance != null && (
+ 👥 {m.attendance.toLocaleString("de-DE")}
+ )}
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/app/components/Flag.tsx b/app/components/Flag.tsx
new file mode 100644
index 0000000..b1832e2
--- /dev/null
+++ b/app/components/Flag.tsx
@@ -0,0 +1,29 @@
+"use client";
+
+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. SVG-Crests (football-data) skalieren
+// sauber; das Rendern als rundes Bild gibt den Flaggen-Look aus dem Screenshot.
+export default function Flag({
+ team, size = 22,
+}: { team?: Team; size?: number }) {
+ const style = { width: size, height: size } as const;
+ if (team?.crest) {
+ return (
+
+ );
+ }
+ // Fallback: Kreis mit Ländercode
+ return (
+
+ {team?.code?.slice(0, 2) || "··"}
+
+ );
+}
diff --git a/app/components/Groups.tsx b/app/components/Groups.tsx
index 52d9b8f..1a52d36 100644
--- a/app/components/Groups.tsx
+++ b/app/components/Groups.tsx
@@ -1,6 +1,7 @@
"use client";
-import { GroupTable, Match, Team } from "@/lib/types";
+import { GroupId, GroupTable, Match, Team } from "@/lib/types";
+import Flag from "./Flag";
function teamById(teams: Team[], id: string) {
return teams.find((t) => t.id === id);
@@ -13,24 +14,31 @@ function liveMatchFor(group: string, matches: Match[]) {
}
export default function Groups({
- tables, teams, matches,
-}: { tables: GroupTable[]; teams: Team[]; matches: Match[] }) {
+ tables, teams, matches, onOpenGroup,
+}: {
+ tables: GroupTable[]; teams: Team[]; matches: Match[];
+ onOpenGroup: (g: GroupId) => void;
+}) {
return (
{tables.map((t) => {
const live = liveMatchFor(t.group, matches);
return (
-
+ onOpenGroup(t.group)}
+ title={`Spiele der Gruppe ${t.group} ansehen`}
+ >
Gruppe {t.group}
- {live ? "● LIVE" : `${t.rows.reduce((a, r) => a + r.played, 0)} Spiele`}
+ {live ? "● LIVE" : "Spiele ansehen →"}
-
+
- Team
+ Mannschaft
Sp S U N
Tore ± Pkt
@@ -43,8 +51,8 @@ export default function Groups({
{r.rank}
+
{team?.name ?? r.teamId}
- {team?.code && {team.code} }
{r.played}
{r.won}
diff --git a/app/globals.css b/app/globals.css
index b824f9c..c958d6f 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -245,3 +245,94 @@ table.standings { width: 100%; border-collapse: collapse; }
.tab { padding: 14px 12px; font-size: 12px; }
.group-grid { grid-template-columns: 1fr; }
}
+
+/* ============ 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: 120px 1fr auto; 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: 1fr auto 1fr; align-items: center; gap: 14px;
+}
+.fx-team { display: flex; align-items: center; gap: 10px; min-width: 0; }
+.fx-team.away { justify-content: flex-end; }
+.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; }
+.fx-score.score-live { color: var(--live); }
+.fx-time { font-family: var(--font-mono); font-size: 13px; color: var(--ink-faint); white-space: nowrap; }
+.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;
+}
+
+@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-Fix oben (Match-Nummer ragt über den Rand) */
+.round-matches { padding-top: 8px; }
+.tie-num {
+ top: -8px; left: 10px; white-space: nowrap;
+}
+
+/* Bracket: fix vs. vorläufig */
+.side.prov .nm { color: var(--ink-dim); }
+.side.prov .nm span:not(.prov-mark) { font-style: italic; }
+.prov-mark {
+ color: #f0a23a; font-family: var(--font-mono); font-weight: 700;
+ font-size: 12px; margin-left: 2px;
+}
+/* fix qualifizierte (nicht vorläufige) Teams: voller Kontrast + linker Marker */
+.side:not(.prov) .nm > span:not(.c):not(.prob):not(.prov-mark) { color: var(--ink); font-weight: 600; }
+.side:not(.prov):not(.win) { box-shadow: inset 2px 0 0 var(--turf-deep); }
+
+/* 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;
+}
diff --git a/app/page.tsx b/app/page.tsx
index 4ff34ba..461f8c6 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,11 +1,12 @@
"use client";
import { useEffect, useState, useCallback } from "react";
-import { GroupTable, Match, Team, ThirdPlaceRow } from "@/lib/types";
+import { GroupId, GroupTable, Match, Team, ThirdPlaceRow } from "@/lib/types";
import { ThirdAssignment } from "@/lib/bracket";
import Groups from "./components/Groups";
import ThirdPlace from "./components/ThirdPlace";
import Bracket from "./components/Bracket";
+import Fixtures from "./components/Fixtures";
interface ApiData {
updatedAt: string;
@@ -17,12 +18,14 @@ interface ApiData {
annexResolved: boolean;
}
-type Tab = "groups" | "thirds" | "bracket";
+type Tab = "groups" | "fixtures" | "thirds" | "bracket";
export default function Home() {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [tab, setTab] = useState("groups");
+ // Zuletzt gewählte Gruppe für den Spiele-Tab. null = noch keine gewählt.
+ const [fixturesGroup, setFixturesGroup] = useState(null);
const load = useCallback(async () => {
try {
@@ -44,6 +47,12 @@ export default function Home() {
return () => clearInterval(id);
}, [load]);
+ // Klick auf einen Gruppen-Header: Gruppe merken und zum Spiele-Tab wechseln.
+ const openGroupFixtures = useCallback((g: GroupId) => {
+ setFixturesGroup(g);
+ setTab("fixtures");
+ }, []);
+
const anyLive = data?.matches.some(
(m) => m.status === "LIVE" || m.status === "IN_PLAY" || m.status === "PAUSED",
);
@@ -72,6 +81,12 @@ export default function Home() {
setTab("groups")}>
Gruppen
+ { if (!fixturesGroup) setFixturesGroup("A"); setTab("fixtures"); }}
+ >
+ {fixturesGroup ? `Spiele – Gruppe ${fixturesGroup}` : "Spiele"}
+
setTab("thirds")}>
Drittplatzierte
@@ -96,7 +111,16 @@ export default function Home() {
)}
{data && tab === "groups" && (
-
+
+ )}
+ {data && tab === "fixtures" && fixturesGroup && (
+
)}
{data && tab === "thirds" && (
diff --git a/docker-compose.local.yml b/docker-compose.local.yml
index 46ad7fd..43888a0 100644
--- a/docker-compose.local.yml
+++ b/docker-compose.local.yml
@@ -1,3 +1,8 @@
+# Lokaler Test — ohne externes Caddy-Netz, Port direkt gemappt.
+# Aufruf: docker compose -f docker-compose.local.yml up --build
+# Seite: http://localhost:3000
+# Stoppen: Strg+C, danach docker compose -f docker-compose.local.yml down
+
services:
wm2026:
build: .
@@ -12,4 +17,4 @@ services:
interval: 60s
timeout: 10s
retries: 3
- start_period: 20s
\ No newline at end of file
+ start_period: 20s
diff --git a/lib/feeds.ts b/lib/feeds.ts
index 987e1cd..f9a4b8e 100644
--- a/lib/feeds.ts
+++ b/lib/feeds.ts
@@ -59,8 +59,10 @@ interface FdMatchesResponse {
matchday?: number | null;
stage: string;
group?: string | null;
- homeTeam: { id: number | null; name: string | null; tla?: string | null };
- awayTeam: { id: number | null; name: string | null; tla?: 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 } };
}>;
}
@@ -97,7 +99,10 @@ export async function fetchMatchesAndTeams(): Promise<{ matches: Match[]; teams:
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 });
+ teamMap.set(id, {
+ id, name: side.name, code: side.tla ?? "", group,
+ crest: side.crest ?? null,
+ });
}
}
}
@@ -113,6 +118,8 @@ export async function fetchMatchesAndTeams(): Promise<{ matches: Match[]; teams:
awayTeamId: m.awayTeam.id != null ? String(m.awayTeam.id) : null,
homeScore: m.score.fullTime.home,
awayScore: m.score.fullTime.away,
+ venue: m.venue ?? null,
+ attendance: m.attendance ?? null,
};
});
diff --git a/lib/resolve-bracket.ts b/lib/resolve-bracket.ts
index c1d81f0..8a91f34 100644
--- a/lib/resolve-bracket.ts
+++ b/lib/resolve-bracket.ts
@@ -4,6 +4,14 @@ import {
ThirdAssignment, slotLabel,
} from "@/lib/bracket";
+// Eine Gruppe gilt als abgeschlossen, wenn alle ihre Gruppenspiele beendet sind
+// (regulär 6 Spiele pro Vierergruppe). Erst dann sind Platzierungen fix.
+function groupFinished(group: GroupId, matches: Match[]): boolean {
+ const groupMatches = matches.filter((m) => m.group === group);
+ if (groupMatches.length === 0) return false;
+ return groupMatches.every((m) => m.status === "FINISHED");
+}
+
// Ein aufgelöster Teilnehmer einer K.o.-Begegnung.
export interface ResolvedSide {
teamId: string | null; // null = noch Platzhalter
@@ -11,6 +19,7 @@ export interface ResolvedSide {
code: string;
score: number | null;
isWinner: boolean;
+ provisional: boolean; // true = nur auf Basis aktueller Tabelle, noch nicht fix
}
export interface ResolvedTie {
@@ -44,36 +53,44 @@ function loserOf(m: Match | undefined): string | null {
}
// Löst einen R32-Slot (W/R/3) zu einer Team-ID auf, sofern bereits bekannt.
+// provisional = true, solange die zugrunde liegende Gruppe/Zuordnung nicht fix ist.
function resolveR32Slot(
slot: BracketSlot,
winnerGroup: GroupId | undefined,
tables: GroupTable[],
assignment: ThirdAssignment | null,
thirds: ThirdPlaceRow[],
-): string | null {
+ matches: Match[],
+ annexResolved: boolean,
+): { teamId: string | null; provisional: boolean } {
const table = (g: GroupId) => tables.find((t) => t.group === g);
if (slot.type === "W") {
const t = table(slot.group!);
- return t?.rows.find((r) => r.rank === 1)?.teamId ?? null;
+ const teamId = t?.rows.find((r) => r.rank === 1)?.teamId ?? null;
+ return { teamId, provisional: !groupFinished(slot.group!, matches) };
}
if (slot.type === "R") {
const t = table(slot.group!);
- return t?.rows.find((r) => r.rank === 2)?.teamId ?? null;
+ const teamId = t?.rows.find((r) => r.rank === 2)?.teamId ?? null;
+ return { teamId, provisional: !groupFinished(slot.group!, matches) };
}
// 3. Platz: braucht Annex-C-Zuordnung + den Gruppensieger dieses Spiels
if (slot.type === "3" && assignment && winnerGroup) {
const thirdGroup = assignment[winnerGroup];
if (thirdGroup) {
const row = thirds.find((r) => r.group === thirdGroup && r.qualifies);
- return row?.teamId ?? null;
+ // Fix nur, wenn Annex C aufgelöst UND die Quellgruppe abgeschlossen ist.
+ const fix = annexResolved && groupFinished(thirdGroup, matches);
+ return { teamId: row?.teamId ?? null, provisional: !fix };
}
}
- return null;
+ return { teamId: null, provisional: true };
}
function sideFrom(
teamId: string | null, fallbackLabel: string,
teams: Team[], feed: Match | undefined, which: "home" | "away",
+ provisional: boolean,
): ResolvedSide {
const team = teamId ? teams.find((t) => t.id === teamId) : undefined;
const score = feed
@@ -86,6 +103,7 @@ function sideFrom(
code: team?.code ?? "",
score,
isWinner: teamId != null && w === teamId,
+ provisional: teamId != null && provisional,
};
}
@@ -93,10 +111,13 @@ function sideFrom(
export function resolveBracket(
matches: Match[], teams: Team[], tables: GroupTable[],
thirds: ThirdPlaceRow[], assignment: ThirdAssignment | null,
+ annexResolved: boolean,
): { r32: ResolvedTie[]; later: Record } {
// Map: Match-Nummer -> Sieger-Team-ID (für Propagation in Folgerunden)
const winners = new Map();
const losers = new Map();
+ // Map: Match-Nummer -> ist das Spiel beendet (Sieger fix)?
+ const decided = new Map();
const r32: ResolvedTie[] = R32.map((rm: R32Match) => {
const feed = feedMatch(matches, rm.matchNumber);
@@ -105,13 +126,14 @@ export function resolveBracket(
// Für 3.-Platz-Slots: Gruppensieger ist die andere Seite (immer W)
const winnerGroup = (homeGroup ?? awayGroup) as GroupId | undefined;
- const homeId = resolveR32Slot(rm.home, winnerGroup, tables, assignment, thirds);
- const awayId = resolveR32Slot(rm.away, winnerGroup, tables, assignment, thirds);
- const home = sideFrom(homeId, slotLabel(rm.home, assignment, winnerGroup), teams, feed, "home");
- const away = sideFrom(awayId, slotLabel(rm.away, assignment, winnerGroup), teams, feed, "away");
+ const h = resolveR32Slot(rm.home, winnerGroup, tables, assignment, thirds, matches, annexResolved);
+ const a = resolveR32Slot(rm.away, winnerGroup, tables, assignment, thirds, matches, annexResolved);
+ const home = sideFrom(h.teamId, slotLabel(rm.home, assignment, winnerGroup), teams, feed, "home", h.provisional);
+ const away = sideFrom(a.teamId, slotLabel(rm.away, assignment, winnerGroup), teams, feed, "away", a.provisional);
winners.set(rm.matchNumber, winnerOf(feed));
losers.set(rm.matchNumber, loserOf(feed));
+ decided.set(rm.matchNumber, feed?.status === "FINISHED");
return {
matchNumber: rm.matchNumber, stage: "R32",
home, away, status: feed?.status ?? "SCHEDULED", prob: feed?.prob,
@@ -124,12 +146,16 @@ export function resolveBracket(
const src = km.losers ? losers : winners;
const homeId = src.get(km.fromHome) ?? null;
const awayId = src.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 home = sideFrom(homeId, homeLabel, teams, feed, "home");
- const away = sideFrom(awayId, awayLabel, teams, feed, "away");
+ const home = sideFrom(homeId, homeLabel, teams, feed, "home", homeProv);
+ const away = sideFrom(awayId, awayLabel, teams, feed, "away", awayProv);
winners.set(km.matchNumber, winnerOf(feed));
losers.set(km.matchNumber, loserOf(feed));
+ decided.set(km.matchNumber, feed?.status === "FINISHED");
later[km.matchNumber] = {
matchNumber: km.matchNumber, stage: km.stage,
home, away, status: feed?.status ?? "SCHEDULED", prob: feed?.prob,
diff --git a/lib/types.ts b/lib/types.ts
index 455fec7..0fee3a6 100644
--- a/lib/types.ts
+++ b/lib/types.ts
@@ -14,6 +14,7 @@ export interface Team {
name: string; // Anzeigename
code: string; // 3-Buchstaben-Code, z.B. GER
group: GroupId;
+ crest?: string | null; // URL zur Flagge / zum Wappen (football-data: crest)
}
export type MatchStatus =
@@ -33,6 +34,8 @@ export interface Match {
awayScore: number | null;
// Wahrscheinlichkeiten 0..1 (Polymarket), falls vorhanden
prob?: { home: number; draw: number | null; away: number } | null;
+ venue?: string | null; // Austragungsort
+ attendance?: number | null; // Zuschauerzahl, falls verfügbar
}
// Eine berechnete Tabellenzeile innerhalb einer Gruppe.