From 73d07e7f18f9dea6fc31a5010bcd9d878be1b97b Mon Sep 17 00:00:00 2001 From: Andreas Knuth Date: Mon, 22 Jun 2026 15:44:54 -0500 Subject: [PATCH] flags --- Dockerfile | 5 +- README.md | 31 ++++++++++ app/components/Bracket.tsx | 37 +++++++----- app/components/Fixtures.tsx | 109 ++++++++++++++++++++++++++++++++++++ app/components/Flag.tsx | 29 ++++++++++ app/components/Groups.tsx | 24 +++++--- app/globals.css | 91 ++++++++++++++++++++++++++++++ app/page.tsx | 30 +++++++++- docker-compose.local.yml | 7 ++- lib/feeds.ts | 13 ++++- lib/resolve-bracket.ts | 48 ++++++++++++---- lib/types.ts | 3 + 12 files changed, 385 insertions(+), 42 deletions(-) create mode 100644 app/components/Fixtures.tsx create mode 100644 app/components/Flag.tsx diff --git a/Dockerfile b/Dockerfile index fd9e170..f8f0672 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 234cacd..a3bcf99 100644 --- a/README.md +++ b/README.md @@ -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 A–L. +- **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. diff --git a/app/components/Bracket.tsx b/app/components/Bracket.tsx index 28c1483..3bec4dd 100644 --- a/app/components/Bracket.tsx +++ b/app/components/Bracket.tsx @@ -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 ( -
+
{s.teamId ? ( <> + {s.label} - {s.code && {s.code}} + {s.provisional && } ) : ( {s.label} @@ -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 (
- #{tie.matchNumber} - - + Spiel {tie.matchNumber} + +
); } @@ -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({
Letzte 32
- {r32.map((t) => )} + {r32.map((t) => )}
Achtelfinale
- {r16.map((t) => )} + {r16.map((t) => )}
Viertelfinale
- {qf.map((t) => )} + {qf.map((t) => )}
Halbfinale
- {sf.map((t) => )} + {sf.map((t) => )}
Finale
- {fin && } + {fin && } {third && (
Spiel um Platz 3
- +
)}
@@ -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) => ( + + ))} +
+ + {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 ( + {team.code + ); + } + // 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 (
-
+
+ - + @@ -43,8 +51,8 @@ export default function Groups({ 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() { + @@ -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.
TeamMannschaft SpSUN Tore±Pkt
{r.rank} + {team?.name ?? r.teamId} - {team?.code && {team.code}} {r.played} {r.won}