flags
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
31
README.md
31
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.
|
||||
|
||||
@@ -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
109
app/components/Fixtures.tsx
Normal 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
29
app/components/Flag.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
30
app/page.tsx
30
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<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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
13
lib/feeds.ts
13
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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user