This commit is contained in:
2026-06-22 15:44:54 -05:00
parent f5ab324cca
commit 73d07e7f18
12 changed files with 385 additions and 42 deletions

View File

@@ -20,8 +20,9 @@ ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
# Standalone-Output enthält nur das Nötigste
COPY --from=builder /app/public ./public
# Standalone-Output enthält nur das Nötigste.
# publi[c] mit Glob: bricht nicht ab, falls der Ordner mal fehlt.
COPY --from=builder /app/publi[c] ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

View File

@@ -118,3 +118,34 @@ offiziellen Regeln.
Kein offizielles FIFA-Produkt. football-data.org ist für nicht-kommerzielle
Nutzung kostenlos; bei kommerzieller Nutzung dort anfragen. Polymarket-Zugang
und -Daten unterliegen den jeweiligen lokalen Bestimmungen.
## Lokal testen (ohne VPS/Caddy)
Für den lokalen Docker-Test gibt es eine separate Compose-Datei ohne externes
Caddy-Netz und mit direktem Port-Mapping:
```bash
cp .env.example .env # FOOTBALL_DATA_TOKEN eintragen
docker compose -f docker-compose.local.yml up --build
# -> http://localhost:3000
```
Die VPS-Variante (`docker compose up --build`) bleibt davon unberührt.
## Tabs & Bedienung
- **Gruppen** — alle 12 Tabellen mit Flaggen. Klick auf einen Gruppen-Header
öffnet die **Spiele** dieser Gruppe.
- **Spiele** — Anstoßzeit/Ergebnis/Live-Minute, Flaggen, Austragungsort und
(falls im Feed) Zuschauerzahl. Der Tab merkt sich die zuletzt gewählte Gruppe
und zeigt sie im Label an („Spiele Gruppe H"). Oben ein Chip-Wechsler AL.
- **Drittplatzierte** — gruppenübergreifende Wertung, Schnittlinie bei Platz 8/9.
- **K.o.-Baum** — fixe Teams stehen voll ausgeschrieben mit grünem Marker;
vorläufige (nur nach aktueller Tabelle, Gruppe noch nicht durchgespielt oder
Annex C noch offen) erscheinen kursiv mit „≈"-Zeichen. Flaggen inklusive.
## Flaggen
Die Flaggen kommen aus dem `crest`-Feld von football-data.org (SVG-URLs auf
`crests.football-data.org`). Fehlt eine URL, zeigt die App einen neutralen Kreis
mit Ländercode als Fallback.

View File

@@ -3,15 +3,20 @@
import { GroupTable, Match, Team, ThirdPlaceRow } from "@/lib/types";
import { ThirdAssignment } from "@/lib/bracket";
import { resolveBracket, ResolvedSide, ResolvedTie } from "@/lib/resolve-bracket";
import Flag from "./Flag";
function Side({ s, prob }: { s: ResolvedSide; prob?: number | null }) {
function Side({
s, prob, teams,
}: { s: ResolvedSide; prob?: number | null; teams: Team[] }) {
const team = s.teamId ? teams.find((t) => t.id === s.teamId) : undefined;
return (
<div className={`side ${s.isWinner ? "win" : ""}`}>
<div className={`side ${s.isWinner ? "win" : ""} ${s.provisional ? "prov" : ""}`}>
<span className="nm">
{s.teamId ? (
<>
<Flag team={team} size={18} />
<span>{s.label}</span>
{s.code && <span className="c">{s.code}</span>}
{s.provisional && <span className="prov-mark" title="vorläufig Gruppe/Zuordnung noch nicht fix"></span>}
</>
) : (
<span className="lbl">{s.label}</span>
@@ -25,12 +30,12 @@ function Side({ s, prob }: { s: ResolvedSide; prob?: number | null }) {
);
}
function Tie({ tie, isFinal }: { tie: ResolvedTie; isFinal?: boolean }) {
function Tie({ tie, teams, isFinal }: { tie: ResolvedTie; teams: Team[]; isFinal?: boolean }) {
return (
<div className={`tie ${isFinal ? "final-tie" : ""}`}>
<span className="tie-num">#{tie.matchNumber}</span>
<Side s={tie.home} prob={tie.prob?.home} />
<Side s={tie.away} prob={tie.prob?.away} />
<span className="tie-num">Spiel {tie.matchNumber}</span>
<Side s={tie.home} prob={tie.prob?.home} teams={teams} />
<Side s={tie.away} prob={tie.prob?.away} teams={teams} />
</div>
);
}
@@ -41,7 +46,9 @@ export default function Bracket({
matches: Match[]; teams: Team[]; tables: GroupTable[];
thirds: ThirdPlaceRow[]; assignment: ThirdAssignment | null; annexResolved: boolean;
}) {
const { r32, later } = resolveBracket(matches, teams, tables, thirds, assignment);
const { r32, later } = resolveBracket(
matches, teams, tables, thirds, assignment, annexResolved,
);
const pick = (nums: number[]) => nums.map((n) => later[n]).filter(Boolean);
const r16 = pick([89, 90, 91, 92, 93, 94, 95, 96]);
@@ -66,35 +73,35 @@ export default function Bracket({
<div className="round">
<div className="round-label">Letzte 32</div>
<div className="round-matches">
{r32.map((t) => <Tie key={t.matchNumber} tie={t} />)}
{r32.map((t) => <Tie key={t.matchNumber} tie={t} teams={teams} />)}
</div>
</div>
<div className="round">
<div className="round-label">Achtelfinale</div>
<div className="round-matches">
{r16.map((t) => <Tie key={t.matchNumber} tie={t} />)}
{r16.map((t) => <Tie key={t.matchNumber} tie={t} teams={teams} />)}
</div>
</div>
<div className="round">
<div className="round-label">Viertelfinale</div>
<div className="round-matches">
{qf.map((t) => <Tie key={t.matchNumber} tie={t} />)}
{qf.map((t) => <Tie key={t.matchNumber} tie={t} teams={teams} />)}
</div>
</div>
<div className="round">
<div className="round-label">Halbfinale</div>
<div className="round-matches">
{sf.map((t) => <Tie key={t.matchNumber} tie={t} />)}
{sf.map((t) => <Tie key={t.matchNumber} tie={t} teams={teams} />)}
</div>
</div>
<div className="round">
<div className="round-label">Finale</div>
<div className="round-matches">
{fin && <Tie tie={fin} isFinal />}
{fin && <Tie tie={fin} teams={teams} isFinal />}
{third && (
<div style={{ marginTop: 20 }}>
<div className="round-label" style={{ marginBottom: 8 }}>Spiel um Platz 3</div>
<Tie tie={third} />
<Tie tie={third} teams={teams} />
</div>
)}
</div>
@@ -105,6 +112,8 @@ export default function Bracket({
<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>
</div>

109
app/components/Fixtures.tsx Normal file
View File

@@ -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 (
<span className={`fx-score ${st.live ? "score-live" : ""}`}>
{m.homeScore} : {m.awayScore}
</span>
);
}
return <span className="fx-time">{fmtDate(m.utcDate)}</span>;
}
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 (
<div>
<div className="grp-switch">
{GROUP_IDS.map((g) => (
<button
key={g}
className={`grp-chip ${g === group ? "active" : ""}`}
onClick={() => onSelectGroup(g)}
>
{g}
</button>
))}
</div>
{list.length === 0 ? (
<div className="notice">Für Gruppe {group} liegen noch keine Spiele im Feed vor.</div>
) : (
<div className="fixtures">
{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 (
<div className={`fx ${st.live ? "fx-live" : ""}`} key={m.id}>
<div className="fx-status">
{st.live && <span className="dot live" />}
<span>{st.text}</span>
</div>
<div className="fx-teams">
<div className={`fx-team ${homeWin ? "win" : ""}`}>
<Flag team={home} size={22} />
<span className="fx-name">{home?.name ?? "—"}</span>
</div>
<ScoreOrTime m={m} />
<div className={`fx-team away ${awayWin ? "win" : ""}`}>
<span className="fx-name">{away?.name ?? "—"}</span>
<Flag team={away} size={22} />
</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>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}

29
app/components/Flag.tsx Normal file
View File

@@ -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 (
<img
src={team.crest}
alt={team.code || team.name}
className="flag"
style={style}
loading="lazy"
/>
);
}
// Fallback: Kreis mit Ländercode
return (
<span className="flag flag-fallback" style={style} aria-hidden>
{team?.code?.slice(0, 2) || "··"}
</span>
);
}

View File

@@ -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 (
<div className="group-grid">
{tables.map((t) => {
const live = liveMatchFor(t.group, matches);
return (
<div className="group-card" key={t.group}>
<div className="group-head">
<button
className="group-head group-head-btn"
onClick={() => onOpenGroup(t.group)}
title={`Spiele der Gruppe ${t.group} ansehen`}
>
<span className="group-name">Gruppe {t.group}</span>
<span className="group-tag">
{live ? "● LIVE" : `${t.rows.reduce((a, r) => a + r.played, 0)} Spiele`}
{live ? "● LIVE" : "Spiele ansehen →"}
</span>
</div>
</button>
<table className="standings">
<thead>
<tr>
<th className="team">Team</th>
<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>
</tr>
@@ -43,8 +51,8 @@ export default function Groups({
<tr key={r.teamId}>
<td className="team">
<span className={`rankdot ${cls}`}>{r.rank}</span>
<Flag team={team} size={20} />
<span className="team-name">{team?.name ?? r.teamId}</span>
{team?.code && <span className="team-code">{team.code}</span>}
</td>
<td>{r.played}</td>
<td>{r.won}</td>

View File

@@ -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;
}

View File

@@ -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<ApiData | null>(null);
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<Tab>("groups");
// Zuletzt gewählte Gruppe für den Spiele-Tab. null = noch keine gewählt.
const [fixturesGroup, setFixturesGroup] = useState<GroupId | null>(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() {
<button className={`tab ${tab === "groups" ? "active" : ""}`} onClick={() => setTab("groups")}>
Gruppen
</button>
<button
className={`tab ${tab === "fixtures" ? "active" : ""}`}
onClick={() => { if (!fixturesGroup) setFixturesGroup("A"); setTab("fixtures"); }}
>
{fixturesGroup ? `Spiele Gruppe ${fixturesGroup}` : "Spiele"}
</button>
<button className={`tab ${tab === "thirds" ? "active" : ""}`} onClick={() => setTab("thirds")}>
Drittplatzierte
</button>
@@ -96,7 +111,16 @@ export default function Home() {
)}
{data && tab === "groups" && (
<Groups tables={data.groupTables} teams={data.teams} matches={data.matches} />
<Groups
tables={data.groupTables} teams={data.teams} matches={data.matches}
onOpenGroup={openGroupFixtures}
/>
)}
{data && tab === "fixtures" && fixturesGroup && (
<Fixtures
group={fixturesGroup} teams={data.teams} matches={data.matches}
onSelectGroup={setFixturesGroup}
/>
)}
{data && tab === "thirds" && (
<ThirdPlace rows={data.thirdTable} teams={data.teams} />

View File

@@ -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
start_period: 20s

View File

@@ -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,
};
});

View File

@@ -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<number, ResolvedTie> } {
// Map: Match-Nummer -> Sieger-Team-ID (für Propagation in Folgerunden)
const winners = new Map<number, string | null>();
const losers = new Map<number, string | null>();
// Map: Match-Nummer -> ist das Spiel beendet (Sieger fix)?
const decided = new Map<number, boolean>();
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,

View File

@@ -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.