This commit is contained in:
2026-06-22 15:32:32 -05:00
commit f5ab324cca
27 changed files with 3024 additions and 0 deletions

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
.next
.git
npm-debug.log
.env
.env.local

7
.env.example Normal file
View File

@@ -0,0 +1,7 @@
# football-data.org API-Token (kostenlos: https://www.football-data.org/client/register)
# Ohne Token liefert die API nur eingeschränkte Daten.
FOOTBALL_DATA_TOKEN=dein_token_hier
# Polymarket-Slug des WM-Events (Standard: world-cup-2026).
# Den genauen Slug findest du in der Polymarket-URL nach /event/.
POLYMARKET_WC_SLUG=world-cup-2026

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules
.next
.env
.env.local
*.log

7
Caddyfile.snippet Normal file
View File

@@ -0,0 +1,7 @@
# In deinen bestehenden Caddyfile einfügen (eigene Domain/Subdomain wählen).
# Caddy und der wm2026-Container müssen im selben Docker-Netz "caddy_net" sein.
wm.deine-domain.de {
encode zstd gzip
reverse_proxy wm2026:3000
}

31
Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
# --- Stufe 1: Dependencies ---
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
# --- Stufe 2: Build ---
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# --- Stufe 3: Runtime (standalone) ---
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
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
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000 HOSTNAME=0.0.0.0
CMD ["node", "server.js"]

120
README.md Normal file
View File

@@ -0,0 +1,120 @@
# WM 2026 Board
Live-Gruppentabellen, Drittplatzierten-Wertung und vollständiger K.o.-Baum der
FIFA WM 2026 — mit korrekter **Annex-C-Zuordnung** der acht besten Gruppendritten
und optionalen **Polymarket-Wahrscheinlichkeiten** pro Spiel.
Gebaut als Next.js 15 (App Router) mit serverseitigem Feed-Proxy und Caching,
Docker-Standalone-Image, ausgelegt für Caddy als Reverse Proxy.
## Warum ein Backend-Proxy?
Das Frontend kann die Feeds **nicht direkt** aufrufen:
- **football-data.org** erlaubt im Free-Tier nur 10 Anfragen/Minute und verlangt
einen `X-Auth-Token` (im Browser nicht sicher unterzubringen).
- **Polymarket Gamma** liefert `outcomePrices` als JSON-String und hat Rate Limits.
- Beide setzen kein CORS für beliebige Browser-Origins.
Deshalb holt die Route `app/api/matches` beide Feeds **serverseitig**, cached sie
in-memory (Spiele 60 s, Odds 120 s) und liefert ein fertiges JSON. Das Frontend
pollt nur diese eine eigene Route (alle 30 s).
## Architektur
```
Browser ──poll 30s──> /api/matches (Next.js, gecacht)
├── football-data.org/v4/competitions/WC/matches
└── gamma-api.polymarket.com/events?slug=...
├── computeGroupTables() (FIFA-Tiebreaker)
├── computeThirdPlaceTable() (beste 8 Dritte)
└── resolveAnnexC() (495 Szenarien, lib/annexc-data.ts)
```
Die Annex-C-Tabelle (`lib/annexc-data.ts`) enthält alle 495 möglichen
Kombinationen aus den FIFA-Wettbewerbsregeln. Sobald genau 8 Gruppendritte
feststehen, ordnet `resolveAnnexC()` jedem der acht betroffenen Gruppensieger
(A, B, D, E, G, I, K, L) seinen Drittplatzierten zu, und der Bracket „verbindet"
sich automatisch.
## Schnellstart (lokal)
```bash
cp .env.example .env # FOOTBALL_DATA_TOKEN eintragen
npm install
npm run dev # http://localhost:3000
```
Token kostenlos registrieren: https://www.football-data.org/client/register
## Deployment auf dem VPS (Docker + Caddy)
Das Projekt geht davon aus, dass Caddy bereits als Reverse Proxy in einem
externen Docker-Netz läuft. Heißt dein Netz anders, in `docker-compose.yml`
und im Caddy-Snippet anpassen.
```bash
# 1. Netz prüfen/anlegen (falls noch nicht vorhanden)
docker network ls | grep caddy_net || docker network create caddy_net
# 2. .env anlegen
cp .env.example .env && nano .env
# 3. Bauen und starten
docker compose up -d --build
# 4. Caddy-Route einbinden
# Inhalt von Caddyfile.snippet in deinen Caddyfile übernehmen,
# Domain anpassen, dann Caddy neu laden:
docker exec -w /etc/caddy caddy caddy reload
```
Danach ist die Seite unter `https://wm.deine-domain.de` erreichbar. Caddy
besorgt TLS automatisch.
## Konfiguration (.env)
| Variable | Pflicht | Beschreibung |
|-----------------------|---------|--------------|
| `FOOTBALL_DATA_TOKEN` | ja | Token von football-data.org. Ohne ihn liefert die API nur die Wettbewerbsliste. |
| `POLYMARKET_WC_SLUG` | nein | Slug des WM-Events auf Polymarket (Standard `world-cup-2026`). Den exakten Slug aus der URL nach `/event/` ablesen. |
## Feeds verifizieren
Beide Upstream-Strukturen vor dem Turnierstart kurz gegenchecken (Felder können
sich ändern):
```bash
# football-data: Gruppenfeld "GROUP_X", score.fullTime, stage
curl -H "X-Auth-Token: $FOOTBALL_DATA_TOKEN" \
"https://api.football-data.org/v4/competitions/WC/matches" | jq '.matches[0]'
# Polymarket: markets[].outcomes / outcomePrices (JSON-Strings!)
curl "https://gamma-api.polymarket.com/events?slug=world-cup-2026" | jq '.[0].markets[0]'
```
Stimmen Feldnamen nicht, nur die Adapter in `lib/feeds.ts` anpassen — der Rest
(Tabellen, Annex C, Bracket) ist davon entkoppelt.
## Was anpassbar ist
- **Anderer Ergebnis-Feed** (API-Football, TheStatsAPI): nur `fetchMatchesAndTeams`
in `lib/feeds.ts` neu schreiben, sodass es `Match[]` + `Team[]` liefert.
- **Odds-Matching**: `attachOdds` verknüpft per Teamname im Markttitel. Bei
abweichenden Namen (z. B. „USA" vs. „United States") dort eine Alias-Tabelle ergänzen.
- **Poll-Intervall**: in `app/page.tsx` (`setInterval`, Standard 30 s) und den
Cache-TTLs in `lib/feeds.ts`.
## Korrektheit der Annex-C-Logik
`lib/annexc-data.ts` wurde gegen die veröffentlichte FIFA-/Wikipedia-Tabelle
geprüft. Beispiel Szenario 415 (Dritte aus A,B,C,E,F,H,I,J) ergibt die Zuordnung
`H J B C A F E I` für die Gruppensieger A,B,D,E,G,I,K,L — exakt wie in den
offiziellen Regeln.
## Lizenz / Hinweise
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.

44
app/api/matches/route.ts Normal file
View File

@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { fetchMatchesAndTeams, fetchOdds, attachOdds } from "@/lib/feeds";
import { computeGroupTables, computeThirdPlaceTable } from "@/lib/standings";
import { qualifiedThirdGroups, resolveAnnexC } from "@/lib/bracket";
// Diese Route wird vom Frontend gepollt. Sie ist der einzige Ort, der die
// Upstream-Feeds berührt — gecacht, damit Rate Limits eingehalten werden.
export const dynamic = "force-dynamic";
export async function GET() {
try {
const { matches: rawMatches, teams } = await fetchMatchesAndTeams();
// Odds sind optional: fällt der Polymarket-Call aus, liefern wir trotzdem.
let matches = rawMatches;
try {
const odds = await fetchOdds();
matches = attachOdds(rawMatches, teams, odds);
} catch {
// still ohne Wahrscheinlichkeiten
}
const groupTables = computeGroupTables(teams, matches);
const thirdTable = computeThirdPlaceTable(groupTables);
const qGroups = qualifiedThirdGroups(thirdTable);
const annex = qGroups.length === 8 ? resolveAnnexC(qGroups) : null;
return NextResponse.json({
updatedAt: new Date().toISOString(),
teams,
matches,
groupTables,
thirdTable,
annexAssignment: annex,
annexResolved: annex != null,
});
} catch (err) {
const message = err instanceof Error ? err.message : "unbekannter Fehler";
return NextResponse.json(
{ error: "Feed nicht erreichbar", detail: message },
{ status: 502 },
);
}
}

113
app/components/Bracket.tsx Normal file
View File

@@ -0,0 +1,113 @@
"use client";
import { GroupTable, Match, Team, ThirdPlaceRow } from "@/lib/types";
import { ThirdAssignment } from "@/lib/bracket";
import { resolveBracket, ResolvedSide, ResolvedTie } from "@/lib/resolve-bracket";
function Side({ s, prob }: { s: ResolvedSide; prob?: number | null }) {
return (
<div className={`side ${s.isWinner ? "win" : ""}`}>
<span className="nm">
{s.teamId ? (
<>
<span>{s.label}</span>
{s.code && <span className="c">{s.code}</span>}
</>
) : (
<span className="lbl">{s.label}</span>
)}
{prob != null && prob > 0 && (
<span className="prob">{Math.round(prob * 100)}%</span>
)}
</span>
<span className="sc">{s.score != null ? s.score : ""}</span>
</div>
);
}
function Tie({ tie, isFinal }: { tie: ResolvedTie; 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} />
</div>
);
}
export default function Bracket({
matches, teams, tables, thirds, assignment, annexResolved,
}: {
matches: Match[]; teams: Team[]; tables: GroupTable[];
thirds: ThirdPlaceRow[]; assignment: ThirdAssignment | null; annexResolved: boolean;
}) {
const { r32, later } = resolveBracket(matches, teams, tables, thirds, assignment);
const pick = (nums: number[]) => nums.map((n) => later[n]).filter(Boolean);
const r16 = pick([89, 90, 91, 92, 93, 94, 95, 96]);
const qf = pick([97, 98, 99, 100]);
const sf = pick([101, 102]);
const fin = later[104];
const third = later[103];
return (
<div>
<div className="bracket-banner">
<span className="k">Annex-C-Zuordnung der Drittplatzierten:</span>
<span className="v">
{annexResolved
? "aufgelöst — die acht Dritten sind den Gruppensiegern fest zugeteilt"
: "noch offen — sobald die 8 besten Dritten feststehen, verbindet sich der Baum automatisch"}
</span>
</div>
<div className="bracket-scroll">
<div className="bracket">
<div className="round">
<div className="round-label">Letzte 32</div>
<div className="round-matches">
{r32.map((t) => <Tie key={t.matchNumber} tie={t} />)}
</div>
</div>
<div className="round">
<div className="round-label">Achtelfinale</div>
<div className="round-matches">
{r16.map((t) => <Tie key={t.matchNumber} tie={t} />)}
</div>
</div>
<div className="round">
<div className="round-label">Viertelfinale</div>
<div className="round-matches">
{qf.map((t) => <Tie key={t.matchNumber} tie={t} />)}
</div>
</div>
<div className="round">
<div className="round-label">Halbfinale</div>
<div className="round-matches">
{sf.map((t) => <Tie key={t.matchNumber} tie={t} />)}
</div>
</div>
<div className="round">
<div className="round-label">Finale</div>
<div className="round-matches">
{fin && <Tie tie={fin} isFinal />}
{third && (
<div style={{ marginTop: 20 }}>
<div className="round-label" style={{ marginBottom: 8 }}>Spiel um Platz 3</div>
<Tie tie={third} />
</div>
)}
</div>
</div>
</div>
</div>
<div className="legend">
<span><i style={{ background: "var(--turf)" }} />Sieger / weiter</span>
<span><i style={{ background: "var(--gold)" }} />Finale</span>
<span><i style={{ background: "var(--ink-faint)" }} />Platzhalter offen</span>
<span>%-Werte: Polymarket-Wahrscheinlichkeit (falls verfügbar)</span>
</div>
</div>
);
}

66
app/components/Groups.tsx Normal file
View File

@@ -0,0 +1,66 @@
"use client";
import { GroupTable, Match, Team } from "@/lib/types";
function teamById(teams: Team[], id: string) {
return teams.find((t) => t.id === id);
}
function liveMatchFor(group: string, matches: Match[]) {
return matches.find(
(m) => m.group === group && (m.status === "LIVE" || m.status === "IN_PLAY" || m.status === "PAUSED"),
);
}
export default function Groups({
tables, teams, matches,
}: { tables: GroupTable[]; teams: Team[]; matches: Match[] }) {
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">
<span className="group-name">Gruppe {t.group}</span>
<span className="group-tag">
{live ? "● LIVE" : `${t.rows.reduce((a, r) => a + r.played, 0)} Spiele`}
</span>
</div>
<table className="standings">
<thead>
<tr>
<th className="team">Team</th>
<th>Sp</th><th>S</th><th>U</th><th>N</th>
<th>Tore</th><th>±</th><th>Pkt</th>
</tr>
</thead>
<tbody>
{t.rows.map((r) => {
const team = teamById(teams, r.teamId);
const cls = r.rank <= 2 ? `q${r.rank}` : r.rank === 3 ? "q3" : "";
return (
<tr key={r.teamId}>
<td className="team">
<span className={`rankdot ${cls}`}>{r.rank}</span>
<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>
<td>{r.drawn}</td>
<td>{r.lost}</td>
<td>{r.goalsFor}:{r.goalsAgainst}</td>
<td>{r.goalDiff > 0 ? `+${r.goalDiff}` : r.goalDiff}</td>
<td className="pts">{r.points}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,52 @@
"use client";
import { Team, ThirdPlaceRow } from "@/lib/types";
export default function ThirdPlace({
rows, teams,
}: { rows: ThirdPlaceRow[]; teams: Team[] }) {
const name = (id: string) => teams.find((t) => t.id === id)?.name ?? id;
return (
<div className="third-wrap">
<p className="notice" style={{ marginBottom: 16 }}>
Acht der zwölf Gruppendritten erreichen die Runde der letzten 32. Gewertet wird
gruppenübergreifend nach Punkten, Tordifferenz und Toren der Direktvergleich
entfällt, weil diese Teams nie gegeneinander gespielt haben. Die Trennlinie markiert
den Schnitt zwischen Platz 8 und 9.
</p>
<table className="third-table">
<thead>
<tr>
<th>#</th><th>Gruppe</th><th>Team</th>
<th>Sp</th><th>Pkt</th><th>±</th><th>Tore</th><th>Status</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => {
const isCut = i === 8; // erste nicht-qualifizierte Zeile
return (
<tr
key={r.teamId}
className={`third-row ${r.qualifies ? "qual" : ""} ${isCut ? "cut" : ""}`}
>
<td>{r.overallRank}</td>
<td>{r.group}</td>
<td style={{ fontWeight: 600 }}>{name(r.teamId)}</td>
<td>{r.played}</td>
<td className="pts">{r.points}</td>
<td>{r.goalDiff > 0 ? `+${r.goalDiff}` : r.goalDiff}</td>
<td>{r.goalsFor}:{r.goalsAgainst}</td>
<td>
<span className={`qual-badge ${r.qualifies ? "yes" : "no"}`}>
{r.qualifies ? "weiter" : "raus"}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

247
app/globals.css Normal file
View File

@@ -0,0 +1,247 @@
:root {
/* Palette: Stadion bei Nacht über drei Zeitzonen.
Tiefes Mitternachtsblau, kühles Flutlicht-Weiß, warmer Rasen-Akzent,
ein Signal-Magenta für "live". Bewusst nicht die üblichen AI-Defaults. */
--bg: #0b1020;
--bg-raised: #121a32;
--bg-card: #16203c;
--line: #243152;
--line-soft: #1b2540;
--ink: #eef2fb;
--ink-dim: #9aa6c4;
--ink-faint: #5f6d92;
--turf: #4ade80; /* Rasen / qualifiziert */
--turf-deep: #1f7a45;
--floodlight: #cfe0ff;
--live: #ff3d7f; /* Live-Signal */
--gold: #ffd24a; /* Sieger / Finale */
--radius: 10px;
--radius-sm: 6px;
--shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
--font-display: "Archivo Expanded", "Archivo", system-ui, sans-serif;
--font-body: "Inter", system-ui, -apple-system, sans-serif;
--font-mono: "Geist Mono", "SFMono-Regular", ui-monospace, monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
* { animation-duration: 0.001ms !important; transition-duration: 0.001ms !important; }
}
body {
background:
radial-gradient(1200px 600px at 70% -10%, #16224a 0%, transparent 55%),
radial-gradient(900px 500px at 10% 0%, #102046 0%, transparent 50%),
var(--bg);
color: var(--ink);
font-family: var(--font-body);
font-size: 15px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
min-height: 100vh;
}
a { color: inherit; text-decoration: none; }
.wrap { max-width: 1240px; margin: 0 auto; padding: 0 20px; }
/* ---------- Header / Hero ---------- */
.masthead {
border-bottom: 1px solid var(--line);
background: linear-gradient(180deg, rgba(18,26,50,0.7), transparent);
position: sticky; top: 0; z-index: 50;
backdrop-filter: blur(10px);
}
.masthead-inner {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 0; gap: 16px; flex-wrap: wrap;
}
.brand { display: flex; align-items: baseline; gap: 12px; }
.brand-mark {
font-family: var(--font-display);
font-weight: 800; letter-spacing: -0.02em;
font-size: clamp(20px, 3vw, 28px);
text-transform: uppercase;
}
.brand-mark .accent { color: var(--turf); }
.brand-sub {
font-family: var(--font-mono); font-size: 11px;
color: var(--ink-faint); letter-spacing: 0.08em; text-transform: uppercase;
}
.status-pill {
display: inline-flex; align-items: center; gap: 8px;
font-family: var(--font-mono); font-size: 12px;
color: var(--ink-dim);
border: 1px solid var(--line); border-radius: 999px;
padding: 6px 12px; background: var(--bg-card);
}
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--ink-faint); }
.dot.on { background: var(--turf); box-shadow: 0 0 0 3px rgba(74,222,128,0.18); }
.dot.live { background: var(--live); box-shadow: 0 0 0 3px rgba(255,61,127,0.2); animation: pulse 1.6s infinite; }
@keyframes pulse { 50% { box-shadow: 0 0 0 6px rgba(255,61,127,0); } }
/* ---------- Tabs ---------- */
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin: 0 0 4px; }
.tab {
font-family: var(--font-display); font-weight: 700;
text-transform: uppercase; letter-spacing: 0.01em;
font-size: 14px; color: var(--ink-faint);
padding: 16px 18px; cursor: pointer; border: none; background: none;
border-bottom: 2px solid transparent; transition: color .15s, border-color .15s;
}
.tab:hover { color: var(--ink-dim); }
.tab.active { color: var(--ink); border-bottom-color: var(--turf); }
.section { padding: 28px 0 64px; }
/* ---------- Gruppen ---------- */
.group-grid {
display: grid; gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(330px, 1fr));
}
.group-card {
background: var(--bg-card); border: 1px solid var(--line);
border-radius: var(--radius); overflow: hidden;
}
.group-head {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 14px; border-bottom: 1px solid var(--line-soft);
background: var(--bg-raised);
}
.group-name {
font-family: var(--font-display); font-weight: 800; font-size: 16px;
text-transform: uppercase; letter-spacing: 0.02em;
}
.group-tag { font-family: var(--font-mono); font-size: 10px; color: var(--ink-faint); }
table.standings { width: 100%; border-collapse: collapse; }
.standings th {
font-family: var(--font-mono); font-size: 10px; font-weight: 500;
text-transform: uppercase; letter-spacing: 0.06em;
color: var(--ink-faint); text-align: right; padding: 8px 6px;
}
.standings th.team { text-align: left; padding-left: 14px; }
.standings td {
padding: 9px 6px; text-align: right; font-variant-numeric: tabular-nums;
border-top: 1px solid var(--line-soft); font-size: 13px;
}
.standings td.team {
text-align: left; padding-left: 14px; display: flex; align-items: center; gap: 9px;
}
.rankdot {
width: 18px; height: 18px; border-radius: 5px; flex: none;
display: grid; place-items: center;
font-family: var(--font-mono); font-size: 10px; font-weight: 600;
color: var(--bg); background: var(--ink-faint);
}
.rankdot.q1, .rankdot.q2 { background: var(--turf); }
.rankdot.q3 { background: var(--gold); color: #2a2200; }
.rankdot.q3.out { background: var(--ink-faint); color: var(--bg); }
.team-name { font-weight: 600; }
.team-code { font-family: var(--font-mono); font-size: 11px; color: var(--ink-faint); }
.pts { font-weight: 700; color: var(--floodlight); }
/* Live-Zeile */
.score-live { color: var(--live); font-weight: 700; }
/* ---------- Drittplatzierte ---------- */
.third-wrap { margin-top: 8px; }
.third-table { width: 100%; border-collapse: collapse; background: var(--bg-card);
border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
.third-table th {
font-family: var(--font-mono); font-size: 10px; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--ink-faint); padding: 11px 12px; text-align: right;
background: var(--bg-raised); border-bottom: 1px solid var(--line);
}
.third-table th:first-child, .third-table td:first-child { text-align: left; }
.third-table td {
padding: 10px 12px; text-align: right; font-variant-numeric: tabular-nums;
border-top: 1px solid var(--line-soft); font-size: 13px;
}
.third-row.qual { background: linear-gradient(90deg, rgba(74,222,128,0.07), transparent); }
.third-row.cut td { border-top: 2px solid var(--turf-deep); }
.qual-badge {
font-family: var(--font-mono); font-size: 10px; padding: 2px 7px; border-radius: 999px;
}
.qual-badge.yes { background: rgba(74,222,128,0.16); color: var(--turf); }
.qual-badge.no { background: rgba(95,109,146,0.16); color: var(--ink-faint); }
/* ---------- Bracket ---------- */
.bracket-banner {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
background: var(--bg-card); border: 1px solid var(--line);
border-radius: var(--radius); padding: 14px 16px; margin-bottom: 20px;
}
.bracket-banner .k {
font-family: var(--font-mono); font-size: 12px; color: var(--ink-dim);
}
.bracket-banner .v { font-family: var(--font-mono); font-size: 12px; color: var(--turf); }
.bracket-scroll { overflow-x: auto; padding-bottom: 16px; }
.bracket {
display: flex; gap: 26px; min-width: max-content; align-items: stretch;
}
.round { display: flex; flex-direction: column; min-width: 220px; }
.round-label {
font-family: var(--font-display); font-weight: 800; text-transform: uppercase;
font-size: 12px; letter-spacing: 0.06em; color: var(--ink-faint);
margin-bottom: 10px; padding-left: 2px;
}
.round-matches { display: flex; flex-direction: column; justify-content: space-around; flex: 1; gap: 12px; }
.tie {
background: var(--bg-card); border: 1px solid var(--line);
border-radius: var(--radius-sm); overflow: hidden; position: relative;
}
.tie.final-tie { border-color: var(--gold); box-shadow: 0 0 0 1px rgba(255,210,74,0.2); }
.tie-num {
position: absolute; top: -7px; left: 8px;
font-family: var(--font-mono); font-size: 9px; color: var(--ink-faint);
background: var(--bg); padding: 0 5px;
}
.side {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 10px; font-size: 13px;
}
.side + .side { border-top: 1px solid var(--line-soft); }
.side .nm { display: flex; align-items: center; gap: 7px; min-width: 0; }
.side .nm .c { font-family: var(--font-mono); font-size: 10px; color: var(--ink-faint); }
.side .lbl { color: var(--ink-dim); font-style: italic; }
.side .sc { font-family: var(--font-mono); font-weight: 700; color: var(--floodlight); }
.side.win .nm { color: var(--turf); font-weight: 700; }
.side.win .sc { color: var(--turf); }
.side .prob {
font-family: var(--font-mono); font-size: 10px; color: var(--ink-faint);
margin-left: 6px;
}
.legend { display: flex; gap: 18px; flex-wrap: wrap; margin-top: 16px;
font-family: var(--font-mono); font-size: 11px; color: var(--ink-faint); }
.legend span { display: inline-flex; align-items: center; gap: 6px; }
.legend i { width: 12px; height: 12px; border-radius: 3px; display: inline-block; }
/* ---------- Zustände ---------- */
.notice {
background: var(--bg-card); border: 1px solid var(--line);
border-radius: var(--radius); padding: 18px 20px; color: var(--ink-dim);
font-size: 14px;
}
.notice.err { border-color: #5a2230; color: #ffb0c0; }
.skel { background: var(--bg-card); border: 1px solid var(--line);
border-radius: var(--radius); height: 220px; animation: shimmer 1.4s infinite; }
@keyframes shimmer { 50% { opacity: .55; } }
.foot {
border-top: 1px solid var(--line); padding: 24px 0 48px;
color: var(--ink-faint); font-size: 12px; font-family: var(--font-mono);
}
.foot a { color: var(--ink-dim); text-decoration: underline; text-underline-offset: 2px; }
@media (max-width: 640px) {
.tab { padding: 14px 12px; font-size: 12px; }
.group-grid { grid-template-columns: 1fr; }
}

24
app/layout.tsx Normal file
View File

@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "WM 26 — Gruppen & K.o.-Baum",
description:
"Live-Gruppentabellen, Drittplatzierten-Wertung und der vollständige K.o.-Baum der FIFA WM 2026 mit Annex-C-Zuordnung und Polymarket-Wahrscheinlichkeiten.",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="de">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
<link
href="https://fonts.googleapis.com/css2?family=Archivo:wght@500;700;800&family=Archivo+Expanded:wght@700;800&family=Inter:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600&display=swap"
rel="stylesheet"
/>
</head>
<body>{children}</body>
</html>
);
}

122
app/page.tsx Normal file
View File

@@ -0,0 +1,122 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { 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";
interface ApiData {
updatedAt: string;
teams: Team[];
matches: Match[];
groupTables: GroupTable[];
thirdTable: ThirdPlaceRow[];
annexAssignment: ThirdAssignment | null;
annexResolved: boolean;
}
type Tab = "groups" | "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");
const load = useCallback(async () => {
try {
const res = await fetch("/api/matches", { cache: "no-store" });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.detail || `Fehler ${res.status}`);
}
setData(await res.json());
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : "Verbindung fehlgeschlagen");
}
}, []);
useEffect(() => {
load();
const id = setInterval(load, 30_000); // alle 30 s aktualisieren
return () => clearInterval(id);
}, [load]);
const anyLive = data?.matches.some(
(m) => m.status === "LIVE" || m.status === "IN_PLAY" || m.status === "PAUSED",
);
return (
<>
<header className="masthead">
<div className="wrap masthead-inner">
<div className="brand">
<span className="brand-mark">WM <span className="accent">26</span></span>
<span className="brand-sub">USA · Kanada · Mexiko</span>
</div>
<span className="status-pill">
<span className={`dot ${anyLive ? "live" : data ? "on" : ""}`} />
{error
? "Feed offline"
: data
? anyLive ? "Live" : `Aktualisiert ${new Date(data.updatedAt).toLocaleTimeString("de-DE")}`
: "Lade Daten…"}
</span>
</div>
</header>
<main className="wrap">
<nav className="tabs">
<button className={`tab ${tab === "groups" ? "active" : ""}`} onClick={() => setTab("groups")}>
Gruppen
</button>
<button className={`tab ${tab === "thirds" ? "active" : ""}`} onClick={() => setTab("thirds")}>
Drittplatzierte
</button>
<button className={`tab ${tab === "bracket" ? "active" : ""}`} onClick={() => setTab("bracket")}>
K.o.-Baum
</button>
</nav>
<section className="section">
{error && (
<div className="notice err">
Die Live-Feeds sind gerade nicht erreichbar: {error}.
Prüfe den <code>FOOTBALL_DATA_TOKEN</code> und die Netzwerkfreigabe des Servers.
Die Seite versucht es automatisch erneut.
</div>
)}
{!data && !error && (
<div className="group-grid">
{Array.from({ length: 6 }).map((_, i) => <div className="skel" key={i} />)}
</div>
)}
{data && tab === "groups" && (
<Groups tables={data.groupTables} teams={data.teams} matches={data.matches} />
)}
{data && tab === "thirds" && (
<ThirdPlace rows={data.thirdTable} teams={data.teams} />
)}
{data && tab === "bracket" && (
<Bracket
matches={data.matches} teams={data.teams} tables={data.groupTables}
thirds={data.thirdTable} assignment={data.annexAssignment}
annexResolved={data.annexResolved}
/>
)}
</section>
</main>
<footer className="foot">
<div className="wrap">
Daten: football-data.org (Spiele &amp; Tabellen) · Polymarket Gamma API (Wahrscheinlichkeiten) ·
Annex-C-Zuordnung nach den FIFA-Wettbewerbsregeln WM 2026. Kein offizielles FIFA-Produkt.
</div>
</footer>
</>
);
}

15
docker-compose.local.yml Normal file
View File

@@ -0,0 +1,15 @@
services:
wm2026:
build: .
image: wm2026-board:latest
container_name: wm2026-local
restart: unless-stopped
env_file: .env
ports:
- "3001:3000"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/matches"]
interval: 60s
timeout: 10s
retries: 3
start_period: 20s

25
docker-compose.yml Normal file
View File

@@ -0,0 +1,25 @@
# WM 2026 Board — passt in dein bestehendes Caddy-Setup auf dem Contabo-VPS.
# Annahme: Caddy läuft als Reverse-Proxy in einem gemeinsamen externen Netz "caddy_net".
# Falls dein Netz anders heißt, unten anpassen.
services:
wm2026:
build: .
image: wm2026-board:latest
container_name: wm2026
restart: unless-stopped
env_file: .env
expose:
- "3000"
networks:
- caddy_net
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/matches"]
interval: 60s
timeout: 10s
retries: 3
start_period: 20s
networks:
caddy_net:
external: true

500
lib/annexc-data.ts Normal file
View File

@@ -0,0 +1,500 @@
// Annex C der FIFA WM 2026 Wettbewerbsregeln: 495 Szenarien.
// Schluessel = 8 Gruppen (alphabetisch) der besten Drittplatzierten.
// Wert = Zuordnung in Spaltenreihenfolge der Gruppensieger [1A,1B,1D,1E,1G,1I,1K,1L].
export const ANNEX_C: Record<string,string> = {
"EFGHIJKL": "EJIFHGLK",
"DFGHIJKL": "HGIDJFLK",
"DEGHIJKL": "EJIDHGLK",
"DEFHIJKL": "EJIDHFLK",
"DEFGIJKL": "EGIDJFLK",
"DEFGHJKL": "EGJDHFLK",
"DEFGHIKL": "EGIDHFLK",
"DEFGHIJL": "EGJDHFLI",
"DEFGHIJK": "EGJDHFIK",
"CFGHIJKL": "HGICJFLK",
"CEGHIJKL": "EJICHGLK",
"CEFHIJKL": "EJICHFLK",
"CEFGIJKL": "EGICJFLK",
"CEFGHJKL": "EGJCHFLK",
"CEFGHIKL": "EGICHFLK",
"CEFGHIJL": "EGJCHFLI",
"CEFGHIJK": "EGJCHFIK",
"CDGHIJKL": "HGICJDLK",
"CDFHIJKL": "CJIDHFLK",
"CDFGIJKL": "CGIDJFLK",
"CDFGHJKL": "CGJDHFLK",
"CDFGHIKL": "CGIDHFLK",
"CDFGHIJL": "CGJDHFLI",
"CDFGHIJK": "CGJDHFIK",
"CDEHIJKL": "EJICHDLK",
"CDEGIJKL": "EGICJDLK",
"CDEGHJKL": "EGJCHDLK",
"CDEGHIKL": "EGICHDLK",
"CDEGHIJL": "EGJCHDLI",
"CDEGHIJK": "EGJCHDIK",
"CDEFIJKL": "CJEDIFLK",
"CDEFHJKL": "CJEDHFLK",
"CDEFHIKL": "CEIDHFLK",
"CDEFHIJL": "CJEDHFLI",
"CDEFHIJK": "CJEDHFIK",
"CDEFGJKL": "CGEDJFLK",
"CDEFGIKL": "CGEDIFLK",
"CDEFGIJL": "CGEDJFLI",
"CDEFGIJK": "CGEDJFIK",
"CDEFGHKL": "CGEDHFLK",
"CDEFGHJL": "CGJDHFLE",
"CDEFGHJK": "CGJDHFEK",
"CDEFGHIL": "CGEDHFLI",
"CDEFGHIK": "CGEDHFIK",
"CDEFGHIJ": "CGJDHFEI",
"BFGHIJKL": "HJBFIGLK",
"BEGHIJKL": "EJIBHGLK",
"BEFHIJKL": "EJBFIHLK",
"BEFGIJKL": "EJBFIGLK",
"BEFGHJKL": "EJBFHGLK",
"BEFGHIKL": "EGBFIHLK",
"BEFGHIJL": "EJBFHGLI",
"BEFGHIJK": "EJBFHGIK",
"BDGHIJKL": "HJBDIGLK",
"BDFHIJKL": "HJBDIFLK",
"BDFGIJKL": "IGBDJFLK",
"BDFGHJKL": "HGBDJFLK",
"BDFGHIKL": "HGBDIFLK",
"BDFGHIJL": "HGBDJFLI",
"BDFGHIJK": "HGBDJFIK",
"BDEHIJKL": "EJBDIHLK",
"BDEGIJKL": "EJBDIGLK",
"BDEGHJKL": "EJBDHGLK",
"BDEGHIKL": "EGBDIHLK",
"BDEGHIJL": "EJBDHGLI",
"BDEGHIJK": "EJBDHGIK",
"BDEFIJKL": "EJBDIFLK",
"BDEFHJKL": "EJBDHFLK",
"BDEFHIKL": "EIBDHFLK",
"BDEFHIJL": "EJBDHFLI",
"BDEFHIJK": "EJBDHFIK",
"BDEFGJKL": "EGBDJFLK",
"BDEFGIKL": "EGBDIFLK",
"BDEFGIJL": "EGBDJFLI",
"BDEFGIJK": "EGBDJFIK",
"BDEFGHKL": "EGBDHFLK",
"BDEFGHJL": "HGBDJFLE",
"BDEFGHJK": "HGBDJFEK",
"BDEFGHIL": "EGBDHFLI",
"BDEFGHIK": "EGBDHFIK",
"BDEFGHIJ": "HGBDJFEI",
"BCGHIJKL": "HJBCIGLK",
"BCFHIJKL": "HJBCIFLK",
"BCFGIJKL": "IGBCJFLK",
"BCFGHJKL": "HGBCJFLK",
"BCFGHIKL": "HGBCIFLK",
"BCFGHIJL": "HGBCJFLI",
"BCFGHIJK": "HGBCJFIK",
"BCEHIJKL": "EJBCIHLK",
"BCEGIJKL": "EJBCIGLK",
"BCEGHJKL": "EJBCHGLK",
"BCEGHIKL": "EGBCIHLK",
"BCEGHIJL": "EJBCHGLI",
"BCEGHIJK": "EJBCHGIK",
"BCEFIJKL": "EJBCIFLK",
"BCEFHJKL": "EJBCHFLK",
"BCEFHIKL": "EIBCHFLK",
"BCEFHIJL": "EJBCHFLI",
"BCEFHIJK": "EJBCHFIK",
"BCEFGJKL": "EGBCJFLK",
"BCEFGIKL": "EGBCIFLK",
"BCEFGIJL": "EGBCJFLI",
"BCEFGIJK": "EGBCJFIK",
"BCEFGHKL": "EGBCHFLK",
"BCEFGHJL": "HGBCJFLE",
"BCEFGHJK": "HGBCJFEK",
"BCEFGHIL": "EGBCHFLI",
"BCEFGHIK": "EGBCHFIK",
"BCEFGHIJ": "HGBCJFEI",
"BCDHIJKL": "HJBCIDLK",
"BCDGIJKL": "IGBCJDLK",
"BCDGHJKL": "HGBCJDLK",
"BCDGHIKL": "HGBCIDLK",
"BCDGHIJL": "HGBCJDLI",
"BCDGHIJK": "HGBCJDIK",
"BCDFIJKL": "CJBDIFLK",
"BCDFHJKL": "CJBDHFLK",
"BCDFHIKL": "CIBDHFLK",
"BCDFHIJL": "CJBDHFLI",
"BCDFHIJK": "CJBDHFIK",
"BCDFGJKL": "CGBDJFLK",
"BCDFGIKL": "CGBDIFLK",
"BCDFGIJL": "CGBDJFLI",
"BCDFGIJK": "CGBDJFIK",
"BCDFGHKL": "CGBDHFLK",
"BCDFGHJL": "CGBDHFLJ",
"BCDFGHJK": "HGBCJFDK",
"BCDFGHIL": "CGBDHFLI",
"BCDFGHIK": "CGBDHFIK",
"BCDFGHIJ": "HGBCJFDI",
"BCDEIJKL": "EJBCIDLK",
"BCDEHJKL": "EJBCHDLK",
"BCDEHIKL": "EIBCHDLK",
"BCDEHIJL": "EJBCHDLI",
"BCDEHIJK": "EJBCHDIK",
"BCDEGJKL": "EGBCJDLK",
"BCDEGIKL": "EGBCIDLK",
"BCDEGIJL": "EGBCJDLI",
"BCDEGIJK": "EGBCJDIK",
"BCDEGHKL": "EGBCHDLK",
"BCDEGHJL": "HGBCJDLE",
"BCDEGHJK": "HGBCJDEK",
"BCDEGHIL": "EGBCHDLI",
"BCDEGHIK": "EGBCHDIK",
"BCDEGHIJ": "HGBCJDEI",
"BCDEFJKL": "CJBDEFLK",
"BCDEFIKL": "CEBDIFLK",
"BCDEFIJL": "CJBDEFLI",
"BCDEFIJK": "CJBDEFIK",
"BCDEFHKL": "CEBDHFLK",
"BCDEFHJL": "CJBDHFLE",
"BCDEFHJK": "CJBDHFEK",
"BCDEFHIL": "CEBDHFLI",
"BCDEFHIK": "CEBDHFIK",
"BCDEFHIJ": "CJBDHFEI",
"BCDEFGKL": "CGBDEFLK",
"BCDEFGJL": "CGBDJFLE",
"BCDEFGJK": "CGBDJFEK",
"BCDEFGIL": "CGBDEFLI",
"BCDEFGIK": "CGBDEFIK",
"BCDEFGIJ": "CGBDJFEI",
"BCDEFGHL": "CGBDHFLE",
"BCDEFGHK": "CGBDHFEK",
"BCDEFGHJ": "HGBCJFDE",
"BCDEFGHI": "CGBDHFEI",
"AFGHIJKL": "HJIFAGLK",
"AEGHIJKL": "EJIAHGLK",
"AEFHIJKL": "EJIFAHLK",
"AEFGIJKL": "EJIFAGLK",
"AEFGHJKL": "EGJFAHLK",
"AEFGHIKL": "EGIFAHLK",
"AEFGHIJL": "EGJFAHLI",
"AEFGHIJK": "EGJFAHIK",
"ADGHIJKL": "HJIDAGLK",
"ADFHIJKL": "HJIDAFLK",
"ADFGIJKL": "IGJDAFLK",
"ADFGHJKL": "HGJDAFLK",
"ADFGHIKL": "HGIDAFLK",
"ADFGHIJL": "HGJDAFLI",
"ADFGHIJK": "HGJDAFIK",
"ADEHIJKL": "EJIDAHLK",
"ADEGIJKL": "EJIDAGLK",
"ADEGHJKL": "EGJDAHLK",
"ADEGHIKL": "EGIDAHLK",
"ADEGHIJL": "EGJDAHLI",
"ADEGHIJK": "EGJDAHIK",
"ADEFIJKL": "EJIDAFLK",
"ADEFHJKL": "HJEDAFLK",
"ADEFHIKL": "HEIDAFLK",
"ADEFHIJL": "HJEDAFLI",
"ADEFHIJK": "HJEDAFIK",
"ADEFGJKL": "EGJDAFLK",
"ADEFGIKL": "EGIDAFLK",
"ADEFGIJL": "EGJDAFLI",
"ADEFGIJK": "EGJDAFIK",
"ADEFGHKL": "HGEDAFLK",
"ADEFGHJL": "HGJDAFLE",
"ADEFGHJK": "HGJDAFEK",
"ADEFGHIL": "HGEDAFLI",
"ADEFGHIK": "HGEDAFIK",
"ADEFGHIJ": "HGJDAFEI",
"ACGHIJKL": "HJICAGLK",
"ACFHIJKL": "HJICAFLK",
"ACFGIJKL": "IGJCAFLK",
"ACFGHJKL": "HGJCAFLK",
"ACFGHIKL": "HGICAFLK",
"ACFGHIJL": "HGJCAFLI",
"ACFGHIJK": "HGJCAFIK",
"ACEHIJKL": "EJICAHLK",
"ACEGIJKL": "EJICAGLK",
"ACEGHJKL": "EGJCAHLK",
"ACEGHIKL": "EGICAHLK",
"ACEGHIJL": "EGJCAHLI",
"ACEGHIJK": "EGJCAHIK",
"ACEFIJKL": "EJICAFLK",
"ACEFHJKL": "HJECAFLK",
"ACEFHIKL": "HEICAFLK",
"ACEFHIJL": "HJECAFLI",
"ACEFHIJK": "HJECAFIK",
"ACEFGJKL": "EGJCAFLK",
"ACEFGIKL": "EGICAFLK",
"ACEFGIJL": "EGJCAFLI",
"ACEFGIJK": "EGJCAFIK",
"ACEFGHKL": "HGECAFLK",
"ACEFGHJL": "HGJCAFLE",
"ACEFGHJK": "HGJCAFEK",
"ACEFGHIL": "HGECAFLI",
"ACEFGHIK": "HGECAFIK",
"ACEFGHIJ": "HGJCAFEI",
"ACDHIJKL": "HJICADLK",
"ACDGIJKL": "IGJCADLK",
"ACDGHJKL": "HGJCADLK",
"ACDGHIKL": "HGICADLK",
"ACDGHIJL": "HGJCADLI",
"ACDGHIJK": "HGJCADIK",
"ACDFIJKL": "CJIDAFLK",
"ACDFHJKL": "HJFCADLK",
"ACDFHIKL": "HFICADLK",
"ACDFHIJL": "HJFCADLI",
"ACDFHIJK": "HJFCADIK",
"ACDFGJKL": "CGJDAFLK",
"ACDFGIKL": "CGIDAFLK",
"ACDFGIJL": "CGJDAFLI",
"ACDFGIJK": "CGJDAFIK",
"ACDFGHKL": "HGFCADLK",
"ACDFGHJL": "CGJDAFLH",
"ACDFGHJK": "HGJCAFDK",
"ACDFGHIL": "HGFCADLI",
"ACDFGHIK": "HGFCADIK",
"ACDFGHIJ": "HGJCAFDI",
"ACDEIJKL": "EJICADLK",
"ACDEHJKL": "HJECADLK",
"ACDEHIKL": "HEICADLK",
"ACDEHIJL": "HJECADLI",
"ACDEHIJK": "HJECADIK",
"ACDEGJKL": "EGJCADLK",
"ACDEGIKL": "EGICADLK",
"ACDEGIJL": "EGJCADLI",
"ACDEGIJK": "EGJCADIK",
"ACDEGHKL": "HGECADLK",
"ACDEGHJL": "HGJCADLE",
"ACDEGHJK": "HGJCADEK",
"ACDEGHIL": "HGECADLI",
"ACDEGHIK": "HGECADIK",
"ACDEGHIJ": "HGJCADEI",
"ACDEFJKL": "CJEDAFLK",
"ACDEFIKL": "CEIDAFLK",
"ACDEFIJL": "CJEDAFLI",
"ACDEFIJK": "CJEDAFIK",
"ACDEFHKL": "HEFCADLK",
"ACDEFHJL": "HJFCADLE",
"ACDEFHJK": "HJECAFDK",
"ACDEFHIL": "HEFCADLI",
"ACDEFHIK": "HEFCADIK",
"ACDEFHIJ": "HJECAFDI",
"ACDEFGKL": "CGEDAFLK",
"ACDEFGJL": "CGJDAFLE",
"ACDEFGJK": "CGJDAFEK",
"ACDEFGIL": "CGEDAFLI",
"ACDEFGIK": "CGEDAFIK",
"ACDEFGIJ": "CGJDAFEI",
"ACDEFGHL": "HGFCADLE",
"ACDEFGHK": "HGECAFDK",
"ACDEFGHJ": "HGJCAFDE",
"ACDEFGHI": "HGECAFDI",
"ABGHIJKL": "HJBAIGLK",
"ABFHIJKL": "HJBAIFLK",
"ABFGIJKL": "IJBFAGLK",
"ABFGHJKL": "HJBFAGLK",
"ABFGHIKL": "HGBAIFLK",
"ABFGHIJL": "HJBFAGLI",
"ABFGHIJK": "HJBFAGIK",
"ABEHIJKL": "EJBAIHLK",
"ABEGIJKL": "EJBAIGLK",
"ABEGHJKL": "EJBAHGLK",
"ABEGHIKL": "EGBAIHLK",
"ABEGHIJL": "EJBAHGLI",
"ABEGHIJK": "EJBAHGIK",
"ABEFIJKL": "EJBAIFLK",
"ABEFHJKL": "EJBFAHLK",
"ABEFHIKL": "EIBFAHLK",
"ABEFHIJL": "EJBFAHLI",
"ABEFHIJK": "EJBFAHIK",
"ABEFGJKL": "EJBFAGLK",
"ABEFGIKL": "EGBAIFLK",
"ABEFGIJL": "EJBFAGLI",
"ABEFGIJK": "EJBFAGIK",
"ABEFGHKL": "EGBFAHLK",
"ABEFGHJL": "HJBFAGLE",
"ABEFGHJK": "HJBFAGEK",
"ABEFGHIL": "EGBFAHLI",
"ABEFGHIK": "EGBFAHIK",
"ABEFGHIJ": "HJBFAGEI",
"ABDHIJKL": "IJBDAHLK",
"ABDGIJKL": "IJBDAGLK",
"ABDGHJKL": "HJBDAGLK",
"ABDGHIKL": "IGBDAHLK",
"ABDGHIJL": "HJBDAGLI",
"ABDGHIJK": "HJBDAGIK",
"ABDFIJKL": "IJBDAFLK",
"ABDFHJKL": "HJBDAFLK",
"ABDFHIKL": "HIBDAFLK",
"ABDFHIJL": "HJBDAFLI",
"ABDFHIJK": "HJBDAFIK",
"ABDFGJKL": "FJBDAGLK",
"ABDFGIKL": "IGBDAFLK",
"ABDFGIJL": "FJBDAGLI",
"ABDFGIJK": "FJBDAGIK",
"ABDFGHKL": "HGBDAFLK",
"ABDFGHJL": "HGBDAFLJ",
"ABDFGHJK": "HGBDAFJK",
"ABDFGHIL": "HGBDAFLI",
"ABDFGHIK": "HGBDAFIK",
"ABDFGHIJ": "HGBDAFIJ",
"ABDEIJKL": "EJBAIDLK",
"ABDEHJKL": "EJBDAHLK",
"ABDEHIKL": "EIBDAHLK",
"ABDEHIJL": "EJBDAHLI",
"ABDEHIJK": "EJBDAHIK",
"ABDEGJKL": "EJBDAGLK",
"ABDEGIKL": "EGBAIDLK",
"ABDEGIJL": "EJBDAGLI",
"ABDEGIJK": "EJBDAGIK",
"ABDEGHKL": "EGBDAHLK",
"ABDEGHJL": "HJBDAGLE",
"ABDEGHJK": "HJBDAGEK",
"ABDEGHIL": "EGBDAHLI",
"ABDEGHIK": "EGBDAHIK",
"ABDEGHIJ": "HJBDAGEI",
"ABDEFJKL": "EJBDAFLK",
"ABDEFIKL": "EIBDAFLK",
"ABDEFIJL": "EJBDAFLI",
"ABDEFIJK": "EJBDAFIK",
"ABDEFHKL": "HEBDAFLK",
"ABDEFHJL": "HJBDAFLE",
"ABDEFHJK": "HJBDAFEK",
"ABDEFHIL": "HEBDAFLI",
"ABDEFHIK": "HEBDAFIK",
"ABDEFHIJ": "HJBDAFEI",
"ABDEFGKL": "EGBDAFLK",
"ABDEFGJL": "EGBDAFLJ",
"ABDEFGJK": "EGBDAFJK",
"ABDEFGIL": "EGBDAFLI",
"ABDEFGIK": "EGBDAFIK",
"ABDEFGIJ": "EGBDAFIJ",
"ABDEFGHL": "HGBDAFLE",
"ABDEFGHK": "HGBDAFEK",
"ABDEFGHJ": "HGBDAFEJ",
"ABDEFGHI": "HGBDAFEI",
"ABCHIJKL": "IJBCAHLK",
"ABCGIJKL": "IJBCAGLK",
"ABCGHJKL": "HJBCAGLK",
"ABCGHIKL": "IGBCAHLK",
"ABCGHIJL": "HJBCAGLI",
"ABCGHIJK": "HJBCAGIK",
"ABCFIJKL": "IJBCAFLK",
"ABCFHJKL": "HJBCAFLK",
"ABCFHIKL": "HIBCAFLK",
"ABCFHIJL": "HJBCAFLI",
"ABCFHIJK": "HJBCAFIK",
"ABCFGJKL": "CJBFAGLK",
"ABCFGIKL": "IGBCAFLK",
"ABCFGIJL": "CJBFAGLI",
"ABCFGIJK": "CJBFAGIK",
"ABCFGHKL": "HGBCAFLK",
"ABCFGHJL": "HGBCAFLJ",
"ABCFGHJK": "HGBCAFJK",
"ABCFGHIL": "HGBCAFLI",
"ABCFGHIK": "HGBCAFIK",
"ABCFGHIJ": "HGBCAFIJ",
"ABCEIJKL": "EJBAICLK",
"ABCEHJKL": "EJBCAHLK",
"ABCEHIKL": "EIBCAHLK",
"ABCEHIJL": "EJBCAHLI",
"ABCEHIJK": "EJBCAHIK",
"ABCEGJKL": "EJBCAGLK",
"ABCEGIKL": "EGBAICLK",
"ABCEGIJL": "EJBCAGLI",
"ABCEGIJK": "EJBCAGIK",
"ABCEGHKL": "EGBCAHLK",
"ABCEGHJL": "HJBCAGLE",
"ABCEGHJK": "HJBCAGEK",
"ABCEGHIL": "EGBCAHLI",
"ABCEGHIK": "EGBCAHIK",
"ABCEGHIJ": "HJBCAGEI",
"ABCEFJKL": "EJBCAFLK",
"ABCEFIKL": "EIBCAFLK",
"ABCEFIJL": "EJBCAFLI",
"ABCEFIJK": "EJBCAFIK",
"ABCEFHKL": "HEBCAFLK",
"ABCEFHJL": "HJBCAFLE",
"ABCEFHJK": "HJBCAFEK",
"ABCEFHIL": "HEBCAFLI",
"ABCEFHIK": "HEBCAFIK",
"ABCEFHIJ": "HJBCAFEI",
"ABCEFGKL": "EGBCAFLK",
"ABCEFGJL": "EGBCAFLJ",
"ABCEFGJK": "EGBCAFJK",
"ABCEFGIL": "EGBCAFLI",
"ABCEFGIK": "EGBCAFIK",
"ABCEFGIJ": "EGBCAFIJ",
"ABCEFGHL": "HGBCAFLE",
"ABCEFGHK": "HGBCAFEK",
"ABCEFGHJ": "HGBCAFEJ",
"ABCEFGHI": "HGBCAFEI",
"ABCDIJKL": "IJBCADLK",
"ABCDHJKL": "HJBCADLK",
"ABCDHIKL": "HIBCADLK",
"ABCDHIJL": "HJBCADLI",
"ABCDHIJK": "HJBCADIK",
"ABCDGJKL": "CJBDAGLK",
"ABCDGIKL": "IGBCADLK",
"ABCDGIJL": "CJBDAGLI",
"ABCDGIJK": "CJBDAGIK",
"ABCDGHKL": "HGBCADLK",
"ABCDGHJL": "HGBCADLJ",
"ABCDGHJK": "HGBCADJK",
"ABCDGHIL": "HGBCADLI",
"ABCDGHIK": "HGBCADIK",
"ABCDGHIJ": "HGBCADIJ",
"ABCDFJKL": "CJBDAFLK",
"ABCDFIKL": "CIBDAFLK",
"ABCDFIJL": "CJBDAFLI",
"ABCDFIJK": "CJBDAFIK",
"ABCDFHKL": "HFBCADLK",
"ABCDFHJL": "CJBDAFLH",
"ABCDFHJK": "HJBCAFDK",
"ABCDFHIL": "HFBCADLI",
"ABCDFHIK": "HFBCADIK",
"ABCDFHIJ": "HJBCAFDI",
"ABCDFGKL": "CGBDAFLK",
"ABCDFGJL": "CGBDAFLJ",
"ABCDFGJK": "CGBDAFJK",
"ABCDFGIL": "CGBDAFLI",
"ABCDFGIK": "CGBDAFIK",
"ABCDFGIJ": "CGBDAFIJ",
"ABCDFGHL": "CGBDAFLH",
"ABCDFGHK": "HGBCAFDK",
"ABCDFGHJ": "HGBCAFDJ",
"ABCDFGHI": "HGBCAFDI",
"ABCDEJKL": "EJBCADLK",
"ABCDEIKL": "EIBCADLK",
"ABCDEIJL": "EJBCADLI",
"ABCDEIJK": "EJBCADIK",
"ABCDEHKL": "HEBCADLK",
"ABCDEHJL": "HJBCADLE",
"ABCDEHJK": "HJBCADEK",
"ABCDEHIL": "HEBCADLI",
"ABCDEHIK": "HEBCADIK",
"ABCDEHIJ": "HJBCADEI",
"ABCDEGKL": "EGBCADLK",
"ABCDEGJL": "EGBCADLJ",
"ABCDEGJK": "EGBCADJK",
"ABCDEGIL": "EGBCADLI",
"ABCDEGIK": "EGBCADIK",
"ABCDEGIJ": "EGBCADIJ",
"ABCDEGHL": "HGBCADLE",
"ABCDEGHK": "HGBCADEK",
"ABCDEGHJ": "HGBCADEJ",
"ABCDEGHI": "HGBCADEI",
"ABCDEFKL": "CEBDAFLK",
"ABCDEFJL": "CJBDAFLE",
"ABCDEFJK": "CJBDAFEK",
"ABCDEFIL": "CEBDAFLI",
"ABCDEFIK": "CEBDAFIK",
"ABCDEFIJ": "CJBDAFEI",
"ABCDEFHL": "HFBCADLE",
"ABCDEFHK": "HEBCAFDK",
"ABCDEFHJ": "HJBCAFDE",
"ABCDEFHI": "HEBCAFDI",
"ABCDEFGL": "CGBDAFLE",
"ABCDEFGK": "CGBDAFEK",
"ABCDEFGJ": "CGBDAFEJ",
"ABCDEFGI": "CGBDAFEI",
"ABCDEFGH": "HGBCAFDE",
};

111
lib/bracket.ts Normal file
View File

@@ -0,0 +1,111 @@
import { ANNEX_C } from "./annexc-data";
import { GroupId, GroupTable, ThirdPlaceRow } from "./types";
// Die festen Paarungen der Runde der letzten 32 (FIFA-Spielplan, Annex zur Auslosung).
// Quelle: FIFA WM 2026 Wettbewerbsregeln, Spiele 73-88.
// "W"=Sieger, "R"=Zweiter, "3"=bester Drittplatzierter (Gruppe per Annex C bestimmt).
export interface BracketSlot {
type: "W" | "R" | "3";
group?: GroupId; // bei W/R: feste Gruppe
thirdPool?: GroupId[]; // bei 3: mögliche Quellgruppen (Info / UI)
}
export interface R32Match {
matchNumber: number;
home: BracketSlot;
away: BracketSlot;
}
// Die acht Gruppensieger, die einen Drittplatzierten bekommen — in der
// Spaltenreihenfolge von Annex C: 1A, 1B, 1D, 1E, 1G, 1I, 1K, 1L.
export const ANNEX_WINNER_ORDER: GroupId[] = ["A", "B", "D", "E", "G", "I", "K", "L"];
// Statische R32-Struktur. thirdPool dient nur der Anzeige vor Turnierende.
export const R32: R32Match[] = [
{ matchNumber: 73, home: { type: "R", group: "A" }, away: { type: "R", group: "B" } },
{ matchNumber: 74, home: { type: "W", group: "E" }, away: { type: "3", thirdPool: ["A","B","C","D","F"] } },
{ matchNumber: 75, home: { type: "W", group: "F" }, away: { type: "R", group: "C" } },
{ matchNumber: 76, home: { type: "W", group: "C" }, away: { type: "R", group: "F" } },
{ matchNumber: 77, home: { type: "W", group: "I" }, away: { type: "3", thirdPool: ["C","D","F","G","H"] } },
{ matchNumber: 78, home: { type: "R", group: "E" }, away: { type: "R", group: "I" } },
{ matchNumber: 79, home: { type: "W", group: "A" }, away: { type: "3", thirdPool: ["C","E","F","H","I"] } },
{ matchNumber: 80, home: { type: "W", group: "L" }, away: { type: "3", thirdPool: ["E","H","I","J","K"] } },
{ matchNumber: 81, home: { type: "W", group: "D" }, away: { type: "3", thirdPool: ["B","E","F","I","J"] } },
{ matchNumber: 82, home: { type: "W", group: "G" }, away: { type: "3", thirdPool: ["A","E","H","I","J"] } },
{ matchNumber: 83, home: { type: "R", group: "K" }, away: { type: "R", group: "L" } },
{ matchNumber: 84, home: { type: "W", group: "H" }, away: { type: "R", group: "J" } },
{ matchNumber: 85, home: { type: "W", group: "B" }, away: { type: "3", thirdPool: ["E","F","G","I","J"] } },
{ matchNumber: 86, home: { type: "W", group: "J" }, away: { type: "R", group: "H" } },
{ matchNumber: 87, home: { type: "W", group: "K" }, away: { type: "3", thirdPool: ["D","E","I","J","L"] } },
{ matchNumber: 88, home: { type: "R", group: "D" }, away: { type: "R", group: "G" } },
];
// Folgerunden: jedes Spiel speist sich aus zwei Vorspielen (FIFA-Spielplan).
export interface KnockoutMatch {
matchNumber: number;
stage: "R16" | "QF" | "SF" | "3RD" | "FINAL";
fromHome: number; // Match-Nr des Vorspiels (Sieger), bei 3RD: Verlierer
fromAway: number;
losers?: boolean; // true beim Spiel um Platz 3
}
export const LATER_ROUNDS: KnockoutMatch[] = [
{ matchNumber: 89, stage: "R16", fromHome: 74, fromAway: 77 },
{ matchNumber: 90, stage: "R16", fromHome: 73, fromAway: 75 },
{ matchNumber: 91, stage: "R16", fromHome: 76, fromAway: 78 },
{ matchNumber: 92, stage: "R16", fromHome: 79, fromAway: 80 },
{ matchNumber: 93, stage: "R16", fromHome: 83, fromAway: 84 },
{ matchNumber: 94, stage: "R16", fromHome: 81, fromAway: 82 },
{ matchNumber: 95, stage: "R16", fromHome: 86, fromAway: 88 },
{ matchNumber: 96, stage: "R16", fromHome: 85, fromAway: 87 },
{ matchNumber: 97, stage: "QF", fromHome: 89, fromAway: 90 },
{ matchNumber: 98, stage: "QF", fromHome: 93, fromAway: 94 },
{ matchNumber: 99, stage: "QF", fromHome: 91, fromAway: 92 },
{ matchNumber: 100, stage: "QF", fromHome: 95, fromAway: 96 },
{ matchNumber: 101, stage: "SF", fromHome: 97, fromAway: 98 },
{ matchNumber: 102, stage: "SF", fromHome: 99, fromAway: 100 },
{ matchNumber: 103, stage: "3RD", fromHome: 101, fromAway: 102, losers: true },
{ matchNumber: 104, stage: "FINAL", fromHome: 101, fromAway: 102 },
];
// Ergebnis der Annex-C-Auflösung: pro Gruppensieger der zugeordnete Dritte.
export type ThirdAssignment = Partial<Record<GroupId, GroupId>>;
// Normalisiert die qualifizierten Gruppen zum Annex-C-Schlüssel (alphabetisch).
export function annexKey(qualifiedGroups: GroupId[]): string {
return [...qualifiedGroups].sort().join("");
}
// Schlägt die Zuordnung in Annex C nach. Gibt {Gruppensieger -> 3.Gruppe} zurück.
// Liefert null, wenn nicht genau 8 Gruppen oder kein Treffer.
export function resolveAnnexC(qualifiedGroups: GroupId[]): ThirdAssignment | null {
if (qualifiedGroups.length !== 8) return null;
const key = annexKey(qualifiedGroups);
const value = ANNEX_C[key];
if (!value) return null;
const assignment: ThirdAssignment = {};
for (let i = 0; i < ANNEX_WINNER_ORDER.length; i++) {
assignment[ANNEX_WINNER_ORDER[i]] = value[i] as GroupId;
}
return assignment;
}
// Bestimmt die qualifizierten Drittplatzierten-Gruppen aus der Tabelle.
export function qualifiedThirdGroups(thirds: ThirdPlaceRow[]): GroupId[] {
return thirds.filter((t) => t.qualifies).map((t) => t.group);
}
// Hilfslabel für einen Slot, abhängig davon ob die Annex-C-Zuordnung schon feststeht.
export function slotLabel(
slot: BracketSlot,
assignment: ThirdAssignment | null,
winnerGroupForThisMatch?: GroupId,
): string {
if (slot.type === "W") return `Sieger ${slot.group}`;
if (slot.type === "R") return `Zweiter ${slot.group}`;
// 3. Platz
if (assignment && winnerGroupForThisMatch && assignment[winnerGroupForThisMatch]) {
return `3. der Gruppe ${assignment[winnerGroupForThisMatch]}`;
}
return `3. ${slot.thirdPool?.join("/") ?? "?"}`;
}

199
lib/feeds.ts Normal file
View File

@@ -0,0 +1,199 @@
import { GroupId, Match, MatchStatus, Team } from "./types";
// ----------------------------------------------------------------------------
// Caching: einfacher In-Memory-Cache mit TTL. Verhindert, dass jede Browser-
// Anfrage einen Upstream-Call auslöst (football-data: 10 req/min Limit).
// ----------------------------------------------------------------------------
interface CacheEntry<T> { value: T; expires: number; }
const cache = new Map<string, CacheEntry<unknown>>();
export async function cached<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T> {
const hit = cache.get(key) as CacheEntry<T> | undefined;
const now = Date.now();
if (hit && hit.expires > now) return hit.value;
try {
const value = await fn();
cache.set(key, { value, expires: now + ttlMs });
return value;
} catch (err) {
// Bei Upstream-Fehler abgelaufenen Cache weiterverwenden, statt hart zu failen.
if (hit) return hit.value;
throw err;
}
}
// ----------------------------------------------------------------------------
// football-data.org: Teams, Spiele, Status
// ----------------------------------------------------------------------------
const FD_BASE = "https://api.football-data.org/v4";
const FD_COMP = "WC"; // FIFA World Cup
function fdHeaders(): Record<string, string> {
const token = process.env.FOOTBALL_DATA_TOKEN;
return token ? { "X-Auth-Token": token } : {};
}
function mapStatus(s: string): MatchStatus {
switch (s) {
case "LIVE": return "LIVE";
case "IN_PLAY": return "IN_PLAY";
case "PAUSED": return "PAUSED";
case "FINISHED": return "FINISHED";
default: return "SCHEDULED";
}
}
// Wandelt einen football-data-Gruppennamen ("GROUP_A") in unsere GroupId.
function parseGroup(g: string | null | undefined): GroupId | null {
if (!g) return null;
const m = /GROUP_([A-L])/.exec(g);
return m ? (m[1] as GroupId) : null;
}
interface FdMatchesResponse {
matches: Array<{
id: number;
utcDate: string;
status: string;
minute?: number | null;
matchday?: number | null;
stage: string;
group?: string | null;
homeTeam: { id: number | null; name: string | null; tla?: string | null };
awayTeam: { id: number | null; name: string | null; tla?: string | null };
score: { fullTime: { home: number | null; away: number | null } };
}>;
}
function stageFor(stage: string, group: GroupId | null): Match["stage"] {
if (group) return "GROUP";
switch (stage) {
case "LAST_32": return "R32";
case "LAST_16": return "R16";
case "QUARTER_FINALS": return "QF";
case "SEMI_FINALS": return "SF";
case "THIRD_PLACE": return "3RD";
case "FINAL": return "FINAL";
default: return "GROUP";
}
}
// Holt alle Spiele + leitet die Teamliste daraus ab (spart einen Extra-Call).
export async function fetchMatchesAndTeams(): Promise<{ matches: Match[]; teams: Team[] }> {
return cached("fd:matches", 60_000, async () => {
const res = await fetch(`${FD_BASE}/competitions/${FD_COMP}/matches`, {
headers: fdHeaders(),
// Next.js: kein eigenes Caching, wir cachen selbst
cache: "no-store",
});
if (!res.ok) throw new Error(`football-data ${res.status}`);
const data = (await res.json()) as FdMatchesResponse;
const teamMap = new Map<string, Team>();
const matches: Match[] = data.matches.map((m) => {
const group = parseGroup(m.group);
// Teams registrieren (nur wenn Gruppenspiel und ID vorhanden)
for (const side of [m.homeTeam, m.awayTeam]) {
if (side.id != null && side.name && group) {
const id = String(side.id);
if (!teamMap.has(id)) {
teamMap.set(id, { id, name: side.name, code: side.tla ?? "", group });
}
}
}
return {
id: String(m.id),
group,
stage: stageFor(m.stage, group),
matchNumber: m.matchday ?? 0,
utcDate: m.utcDate,
status: mapStatus(m.status),
minute: m.minute ?? null,
homeTeamId: m.homeTeam.id != null ? String(m.homeTeam.id) : null,
awayTeamId: m.awayTeam.id != null ? String(m.awayTeam.id) : null,
homeScore: m.score.fullTime.home,
awayScore: m.score.fullTime.away,
};
});
return { matches, teams: [...teamMap.values()] };
});
}
// ----------------------------------------------------------------------------
// Polymarket Gamma API: Wahrscheinlichkeiten pro Spiel
// ----------------------------------------------------------------------------
const PM_BASE = "https://gamma-api.polymarket.com";
interface PmMarket {
question: string;
outcomes: string; // JSON-String, z.B. "[\"Germany\",\"Draw\",\"Mexico\"]"
outcomePrices: string; // JSON-String, z.B. "[\"0.55\",\"0.25\",\"0.20\"]"
slug: string;
closed: boolean;
}
// Map: normalisierter Teamname-Schlüssel -> Wahrscheinlichkeit.
export interface OddsEntry {
outcomes: string[];
prices: number[];
question: string;
}
function safeParse<T>(s: string, fallback: T): T {
try { return JSON.parse(s) as T; } catch { return fallback; }
}
// Holt WM-bezogene Märkte über das Tag/Slug der Polymarket-WM-Kollektion.
// Hinweis: Der exakte Slug kann sich ändern; per ENV überschreibbar.
export async function fetchOdds(): Promise<OddsEntry[]> {
const slug = process.env.POLYMARKET_WC_SLUG || "world-cup-2026";
return cached("pm:odds", 120_000, async () => {
const res = await fetch(
`${PM_BASE}/events?slug=${encodeURIComponent(slug)}`,
{ cache: "no-store", headers: { "User-Agent": "wm2026-board/1.0" } },
);
if (!res.ok) throw new Error(`polymarket ${res.status}`);
const events = (await res.json()) as Array<{ markets?: PmMarket[] }>;
const entries: OddsEntry[] = [];
for (const ev of events) {
for (const mk of ev.markets ?? []) {
if (mk.closed) continue;
const outcomes = safeParse<string[]>(mk.outcomes, []);
const prices = safeParse<string[]>(mk.outcomePrices, []).map(Number);
if (outcomes.length && outcomes.length === prices.length) {
entries.push({ outcomes, prices, question: mk.question });
}
}
}
return entries;
});
}
// Verknüpft Polymarket-Märkte mit Spielen anhand der Teamnamen im Markttitel.
export function attachOdds(matches: Match[], teams: Team[], odds: OddsEntry[]): Match[] {
const nameById = new Map(teams.map((t) => [t.id, t.name.toLowerCase()]));
return matches.map((m) => {
if (!m.homeTeamId || !m.awayTeamId) return m;
const hn = nameById.get(m.homeTeamId);
const an = nameById.get(m.awayTeamId);
if (!hn || !an) return m;
const hit = odds.find(
(o) => o.question.toLowerCase().includes(hn) && o.question.toLowerCase().includes(an),
);
if (!hit) return m;
// Heuristik: 3 Outcomes = Heim/Unentschieden/Auswärts; 2 = Heim/Auswärts.
const idxHome = hit.outcomes.findIndex((o) => o.toLowerCase().includes(hn));
const idxAway = hit.outcomes.findIndex((o) => o.toLowerCase().includes(an));
const idxDraw = hit.outcomes.findIndex((o) => /draw|unentschieden|tie/i.test(o));
if (idxHome < 0 || idxAway < 0) return m;
return {
...m,
prob: {
home: hit.prices[idxHome] ?? 0,
draw: idxDraw >= 0 ? (hit.prices[idxDraw] ?? null) : null,
away: hit.prices[idxAway] ?? 0,
},
};
});
}

140
lib/resolve-bracket.ts Normal file
View File

@@ -0,0 +1,140 @@
import { GroupId, GroupTable, Match, Team, ThirdPlaceRow } from "@/lib/types";
import {
R32, LATER_ROUNDS, R32Match, BracketSlot,
ThirdAssignment, slotLabel,
} from "@/lib/bracket";
// Ein aufgelöster Teilnehmer einer K.o.-Begegnung.
export interface ResolvedSide {
teamId: string | null; // null = noch Platzhalter
label: string; // Anzeige (Teamname oder "Sieger E" etc.)
code: string;
score: number | null;
isWinner: boolean;
}
export interface ResolvedTie {
matchNumber: number;
stage: string;
home: ResolvedSide;
away: ResolvedSide;
status: string;
prob?: { home: number; draw: number | null; away: number } | null;
}
// Findet das tatsächliche Spiel (aus dem Feed) zu einer FIFA-Match-Nummer.
function feedMatch(matches: Match[], num: number): Match | undefined {
return matches.find((m) => m.matchNumber === num && m.group == null);
}
// Bestimmt den Sieger eines abgeschlossenen Spiels (Feed) als Team-ID.
function winnerOf(m: Match | undefined): string | null {
if (!m || m.status !== "FINISHED") return null;
if (m.homeScore == null || m.awayScore == null) return null;
if (m.homeScore > m.awayScore) return m.homeTeamId;
if (m.awayScore > m.homeScore) return m.awayTeamId;
return null; // Unentschieden -> Elfmeter; Feed liefert i.d.R. Sieger separat
}
function loserOf(m: Match | undefined): string | null {
if (!m || m.status !== "FINISHED") return null;
if (m.homeScore == null || m.awayScore == null) return null;
if (m.homeScore > m.awayScore) return m.awayTeamId;
if (m.awayScore > m.homeScore) return m.homeTeamId;
return null;
}
// Löst einen R32-Slot (W/R/3) zu einer Team-ID auf, sofern bereits bekannt.
function resolveR32Slot(
slot: BracketSlot,
winnerGroup: GroupId | undefined,
tables: GroupTable[],
assignment: ThirdAssignment | null,
thirds: ThirdPlaceRow[],
): string | null {
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;
}
if (slot.type === "R") {
const t = table(slot.group!);
return t?.rows.find((r) => r.rank === 2)?.teamId ?? null;
}
// 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;
}
}
return null;
}
function sideFrom(
teamId: string | null, fallbackLabel: string,
teams: Team[], feed: Match | undefined, which: "home" | "away",
): ResolvedSide {
const team = teamId ? teams.find((t) => t.id === teamId) : undefined;
const score = feed
? which === "home" ? feed.homeScore : feed.awayScore
: null;
const w = winnerOf(feed);
return {
teamId,
label: team?.name ?? fallbackLabel,
code: team?.code ?? "",
score,
isWinner: teamId != null && w === teamId,
};
}
// Baut die komplette aufgelöste Bracket-Struktur.
export function resolveBracket(
matches: Match[], teams: Team[], tables: GroupTable[],
thirds: ThirdPlaceRow[], assignment: ThirdAssignment | null,
): { 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>();
const r32: ResolvedTie[] = R32.map((rm: R32Match) => {
const feed = feedMatch(matches, rm.matchNumber);
const homeGroup = rm.home.type === "W" ? rm.home.group : undefined;
const awayGroup = rm.away.type === "W" ? rm.away.group : undefined;
// 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");
winners.set(rm.matchNumber, winnerOf(feed));
losers.set(rm.matchNumber, loserOf(feed));
return {
matchNumber: rm.matchNumber, stage: "R32",
home, away, status: feed?.status ?? "SCHEDULED", prob: feed?.prob,
};
});
const later: Record<number, ResolvedTie> = {};
for (const km of LATER_ROUNDS) {
const feed = feedMatch(matches, km.matchNumber);
const src = km.losers ? losers : winners;
const homeId = src.get(km.fromHome) ?? null;
const awayId = src.get(km.fromAway) ?? null;
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");
winners.set(km.matchNumber, winnerOf(feed));
losers.set(km.matchNumber, loserOf(feed));
later[km.matchNumber] = {
matchNumber: km.matchNumber, stage: km.stage,
home, away, status: feed?.status ?? "SCHEDULED", prob: feed?.prob,
};
}
return { r32, later };
}

131
lib/standings.ts Normal file
View File

@@ -0,0 +1,131 @@
import {
GROUP_IDS, GroupId, Match, Team,
StandingRow, GroupTable, ThirdPlaceRow,
} from "./types";
// Berechnet eine leere Tabellenzeile.
function emptyRow(teamId: string): StandingRow {
return {
teamId, played: 0, won: 0, drawn: 0, lost: 0,
goalsFor: 0, goalsAgainst: 0, goalDiff: 0, points: 0, rank: 0,
};
}
// Trägt ein abgeschlossenes Spiel in zwei Tabellenzeilen ein.
function applyMatch(rows: Map<string, StandingRow>, m: Match) {
if (m.status !== "FINISHED") return;
if (m.homeTeamId == null || m.awayTeamId == null) return;
if (m.homeScore == null || m.awayScore == null) return;
const h = rows.get(m.homeTeamId);
const a = rows.get(m.awayTeamId);
if (!h || !a) return;
h.played++; a.played++;
h.goalsFor += m.homeScore; h.goalsAgainst += m.awayScore;
a.goalsFor += m.awayScore; a.goalsAgainst += m.homeScore;
if (m.homeScore > m.awayScore) { h.won++; a.lost++; h.points += 3; }
else if (m.homeScore < m.awayScore) { a.won++; h.lost++; a.points += 3; }
else { h.drawn++; a.drawn++; h.points += 1; a.points += 1; }
}
function finalizeRow(r: StandingRow) {
r.goalDiff = r.goalsFor - r.goalsAgainst;
}
// Vergleicht zwei Teams nach FIFA-Kriterien innerhalb derselben Gruppe.
// Reihenfolge: Punkte, dann Direktvergleich (Punkte/TD/Tore in den Spielen
// der punktgleichen Teams), dann Gesamt-TD, Gesamt-Tore. Fair-Play/Ranking
// werden hier nicht abgebildet (kein Feed dafür) -> bleibt bei Gleichstand stabil.
function compareInGroup(
a: StandingRow, b: StandingRow,
matches: Match[], tiedIds: Set<string>,
): number {
if (b.points !== a.points) return b.points - a.points;
// Direktvergleich nur unter den punktgleichen Teams
if (tiedIds.size >= 2 && tiedIds.has(a.teamId) && tiedIds.has(b.teamId)) {
const mini = miniTable(matches, tiedIds);
const ma = mini.get(a.teamId);
const mb = mini.get(b.teamId);
if (ma && mb) {
if (mb.points !== ma.points) return mb.points - ma.points;
const mdA = ma.goalsFor - ma.goalsAgainst;
const mdB = mb.goalsFor - mb.goalsAgainst;
if (mdB !== mdA) return mdB - mdA;
if (mb.goalsFor !== ma.goalsFor) return mb.goalsFor - ma.goalsFor;
}
}
if (b.goalDiff !== a.goalDiff) return b.goalDiff - a.goalDiff;
if (b.goalsFor !== a.goalsFor) return b.goalsFor - a.goalsFor;
return 0;
}
// Baut eine Minitabelle aus den Spielen, in denen NUR die tiedIds gegeneinander spielten.
function miniTable(matches: Match[], tiedIds: Set<string>): Map<string, StandingRow> {
const rows = new Map<string, StandingRow>();
tiedIds.forEach((id) => rows.set(id, emptyRow(id)));
for (const m of matches) {
if (m.status !== "FINISHED") continue;
if (m.homeTeamId == null || m.awayTeamId == null) continue;
if (!tiedIds.has(m.homeTeamId) || !tiedIds.has(m.awayTeamId)) continue;
applyMatch(rows, m);
}
rows.forEach(finalizeRow);
return rows;
}
// Berechnet alle 12 Gruppentabellen aus Teams + Spielen.
export function computeGroupTables(teams: Team[], matches: Match[]): GroupTable[] {
const tables: GroupTable[] = [];
for (const g of GROUP_IDS) {
const groupTeams = teams.filter((t) => t.group === g);
const rows = new Map<string, StandingRow>();
groupTeams.forEach((t) => rows.set(t.id, emptyRow(t.id)));
const groupMatches = matches.filter((m) => m.group === g);
for (const m of groupMatches) applyMatch(rows, m);
rows.forEach(finalizeRow);
const arr = [...rows.values()];
// Gruppen punktgleicher Teams für den Direktvergleich bestimmen
const byPoints = new Map<number, string[]>();
arr.forEach((r) => {
const list = byPoints.get(r.points) ?? [];
list.push(r.teamId);
byPoints.set(r.points, list);
});
arr.sort((a, b) => {
const tied = byPoints.get(a.points) ?? [];
const tiedIds = new Set(a.points === b.points && tied.length >= 2 ? tied : []);
return compareInGroup(a, b, groupMatches, tiedIds);
});
arr.forEach((r, i) => (r.rank = i + 1));
tables.push({ group: g, rows: arr });
}
return tables;
}
// Rankt die 12 Gruppendritten gruppenübergreifend (ohne Direktvergleich).
// Kriterien: Punkte, TD, Tore. Beste 8 -> qualifies = true.
export function computeThirdPlaceTable(tables: GroupTable[]): ThirdPlaceRow[] {
const thirds: ThirdPlaceRow[] = tables
.map((t) => {
const r = t.rows.find((x) => x.rank === 3);
if (!r) return null;
return { ...r, group: t.group, qualifies: false, overallRank: 0 };
})
.filter((x): x is ThirdPlaceRow => x !== null);
thirds.sort((a, b) => {
if (b.points !== a.points) return b.points - a.points;
if (b.goalDiff !== a.goalDiff) return b.goalDiff - a.goalDiff;
if (b.goalsFor !== a.goalsFor) return b.goalsFor - a.goalsFor;
return a.group.localeCompare(b.group); // stabil
});
thirds.forEach((r, i) => {
r.overallRank = i + 1;
r.qualifies = i < 8;
});
return thirds;
}

62
lib/types.ts Normal file
View File

@@ -0,0 +1,62 @@
// Datenmodell für die WM-Seite. Bewusst entkoppelt von den Feed-Formaten:
// Adapter (siehe lib/feeds.ts) übersetzen football-data.org & Polymarket in diese Typen.
export type GroupId =
| "A" | "B" | "C" | "D" | "E" | "F"
| "G" | "H" | "I" | "J" | "K" | "L";
export const GROUP_IDS: GroupId[] = [
"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L",
];
export interface Team {
id: string; // stabile ID aus dem Feed
name: string; // Anzeigename
code: string; // 3-Buchstaben-Code, z.B. GER
group: GroupId;
}
export type MatchStatus =
| "SCHEDULED" | "LIVE" | "IN_PLAY" | "PAUSED" | "FINISHED";
export interface Match {
id: string;
group: GroupId | null; // null = K.o.-Spiel
stage: "GROUP" | "R32" | "R16" | "QF" | "SF" | "3RD" | "FINAL";
matchNumber: number; // FIFA-Spielnummer (1..104)
utcDate: string;
status: MatchStatus;
minute: number | null;
homeTeamId: string | null;
awayTeamId: string | null;
homeScore: number | null;
awayScore: number | null;
// Wahrscheinlichkeiten 0..1 (Polymarket), falls vorhanden
prob?: { home: number; draw: number | null; away: number } | null;
}
// Eine berechnete Tabellenzeile innerhalb einer Gruppe.
export interface StandingRow {
teamId: string;
played: number;
won: number;
drawn: number;
lost: number;
goalsFor: number;
goalsAgainst: number;
goalDiff: number;
points: number;
rank: number; // 1..4 innerhalb der Gruppe
}
export interface GroupTable {
group: GroupId;
rows: StandingRow[]; // sortiert, rank gesetzt
}
// Eintrag in der gruppenübergreifenden Drittplatzierten-Tabelle.
export interface ThirdPlaceRow extends StandingRow {
group: GroupId;
qualifies: boolean; // true für die besten 8
overallRank: number; // 1..12
}

5
next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

6
next.config.mjs Normal file
View File

@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone", // schlankes Docker-Image
reactStrictMode: true,
};
export default nextConfig;

943
package-lock.json generated Normal file
View File

@@ -0,0 +1,943 @@
{
"name": "wm2026-board",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wm2026-board",
"version": "1.0.0",
"dependencies": {
"next": "15.1.6",
"react": "19.0.0",
"react-dom": "19.0.0"
},
"devDependencies": {
"@types/node": "22.10.7",
"@types/react": "19.0.7",
"@types/react-dom": "19.0.3",
"typescript": "5.7.3"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
"integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.0.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
"integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.0.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
"integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
"integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
"integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
"integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
"integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
"cpu": [
"s390x"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
"integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
"integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
"integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
"integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.0.5"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
"integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.0.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
"integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
"cpu": [
"s390x"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.0.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
"integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.0.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
"integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
"integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.0.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
"integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.2.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
"integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
"integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@next/env": {
"version": "15.1.6",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.1.6.tgz",
"integrity": "sha512-d9AFQVPEYNr+aqokIiPLNK/MTyt3DWa/dpKveiAaVccUadFbhFEvY6FXYX2LJO2Hv7PHnLBu2oWwB4uBuHjr/w==",
"license": "MIT"
},
"node_modules/@next/swc-darwin-arm64": {
"version": "15.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.1.6.tgz",
"integrity": "sha512-u7lg4Mpl9qWpKgy6NzEkz/w0/keEHtOybmIl0ykgItBxEM5mYotS5PmqTpo+Rhg8FiOiWgwr8USxmKQkqLBCrw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "15.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.1.6.tgz",
"integrity": "sha512-x1jGpbHbZoZ69nRuogGL2MYPLqohlhnT9OCU6E6QFewwup+z+M6r8oU47BTeJcWsF2sdBahp5cKiAcDbwwK/lg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "15.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.1.6.tgz",
"integrity": "sha512-jar9sFw0XewXsBzPf9runGzoivajeWJUc/JkfbLTC4it9EhU8v7tCRLH7l5Y1ReTMN6zKJO0kKAGqDk8YSO2bg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "15.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.1.6.tgz",
"integrity": "sha512-+n3u//bfsrIaZch4cgOJ3tXCTbSxz0s6brJtU3SzLOvkJlPQMJ+eHVRi6qM2kKKKLuMY+tcau8XD9CJ1OjeSQQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "15.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.1.6.tgz",
"integrity": "sha512-SpuDEXixM3PycniL4iVCLyUyvcl6Lt0mtv3am08sucskpG0tYkW1KlRhTgj4LI5ehyxriVVcfdoxuuP8csi3kQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "15.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.1.6.tgz",
"integrity": "sha512-L4druWmdFSZIIRhF+G60API5sFB7suTbDRhYWSjiw0RbE+15igQvE2g2+S973pMGvwN3guw7cJUjA/TmbPWTHQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "15.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.1.6.tgz",
"integrity": "sha512-s8w6EeqNmi6gdvM19tqKKWbCyOBvXFbndkGHl+c9YrzsLARRdCHsD9S1fMj8gsXm9v8vhC8s3N8rjuC/XrtkEg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "15.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.1.6.tgz",
"integrity": "sha512-6xomMuu54FAFxttYr5PJbEfu96godcxBTRk1OhAvJq0/EnmFU/Ybiax30Snis4vdWZ9LGpf7Roy5fSs7v/5ROQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@swc/counter": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
"license": "Apache-2.0"
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@types/node": {
"version": "22.10.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.7.tgz",
"integrity": "sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.20.0"
}
},
"node_modules/@types/react": {
"version": "19.0.7",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.7.tgz",
"integrity": "sha512-MoFsEJKkAtZCrC1r6CM8U22GzhG7u2Wir8ons/aCKH6MBdD1ibV24zOSSkdZVUKqN5i396zG5VKLYZ3yaUZdLA==",
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
}
},
"node_modules/@types/react-dom": {
"version": "19.0.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.3.tgz",
"integrity": "sha512-0Knk+HJiMP/qOZgMyNFamlIjw9OFCsyC2ZbigmEEyXXixgre6IQpm/4V+r3qH4GC1JPvRJKInw+on2rV6YZLeA==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.0.0"
}
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001799",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
"integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "CC-BY-4.0"
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
"license": "MIT",
"optional": true,
"dependencies": {
"color-convert": "^2.0.1",
"color-string": "^1.9.0"
},
"engines": {
"node": ">=12.5.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT",
"optional": true
},
"node_modules/color-string": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
"license": "MIT",
"optional": true,
"dependencies": {
"color-name": "^1.0.0",
"simple-swizzle": "^0.2.2"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/is-arrayish": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
"license": "MIT",
"optional": true
},
"node_modules/nanoid": {
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/next": {
"version": "15.1.6",
"resolved": "https://registry.npmjs.org/next/-/next-15.1.6.tgz",
"integrity": "sha512-Hch4wzbaX0vKQtalpXvUiw5sYivBy4cm5rzUKrBnUB/y436LGrvOUqYvlSeNVCWFO/770gDlltR9gqZH62ct4Q==",
"deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.",
"license": "MIT",
"dependencies": {
"@next/env": "15.1.6",
"@swc/counter": "0.1.3",
"@swc/helpers": "0.5.15",
"busboy": "1.6.0",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
"styled-jsx": "5.1.6"
},
"bin": {
"next": "dist/bin/next"
},
"engines": {
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "15.1.6",
"@next/swc-darwin-x64": "15.1.6",
"@next/swc-linux-arm64-gnu": "15.1.6",
"@next/swc-linux-arm64-musl": "15.1.6",
"@next/swc-linux-x64-gnu": "15.1.6",
"@next/swc-linux-x64-musl": "15.1.6",
"@next/swc-win32-arm64-msvc": "15.1.6",
"@next/swc-win32-x64-msvc": "15.1.6",
"sharp": "^0.33.5"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
"@playwright/test": "^1.41.2",
"babel-plugin-react-compiler": "*",
"react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
"react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
"sass": "^1.3.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
},
"@playwright/test": {
"optional": true
},
"babel-plugin-react-compiler": {
"optional": true
},
"sass": {
"optional": true
}
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.6",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/react": {
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
"integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz",
"integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.25.0"
},
"peerDependencies": {
"react": "^19.0.0"
}
},
"node_modules/scheduler": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
"integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"optional": true,
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/sharp": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
"integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
"hasInstallScript": true,
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"color": "^4.2.3",
"detect-libc": "^2.0.3",
"semver": "^7.6.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.33.5",
"@img/sharp-darwin-x64": "0.33.5",
"@img/sharp-libvips-darwin-arm64": "1.0.4",
"@img/sharp-libvips-darwin-x64": "1.0.4",
"@img/sharp-libvips-linux-arm": "1.0.5",
"@img/sharp-libvips-linux-arm64": "1.0.4",
"@img/sharp-libvips-linux-s390x": "1.0.4",
"@img/sharp-libvips-linux-x64": "1.0.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
"@img/sharp-libvips-linuxmusl-x64": "1.0.4",
"@img/sharp-linux-arm": "0.33.5",
"@img/sharp-linux-arm64": "0.33.5",
"@img/sharp-linux-s390x": "0.33.5",
"@img/sharp-linux-x64": "0.33.5",
"@img/sharp-linuxmusl-arm64": "0.33.5",
"@img/sharp-linuxmusl-x64": "0.33.5",
"@img/sharp-wasm32": "0.33.5",
"@img/sharp-win32-ia32": "0.33.5",
"@img/sharp-win32-x64": "0.33.5"
}
},
"node_modules/simple-swizzle": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
"license": "MIT",
"optional": true,
"dependencies": {
"is-arrayish": "^0.3.1"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
"integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
"license": "MIT",
"dependencies": {
"client-only": "0.0.1"
},
"engines": {
"node": ">= 12.0.0"
},
"peerDependencies": {
"react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
},
"peerDependenciesMeta": {
"@babel/core": {
"optional": true
},
"babel-plugin-macros": {
"optional": true
}
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
"dev": true,
"license": "MIT"
}
}
}

22
package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "wm2026-board",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start -p 3000",
"lint": "next lint"
},
"dependencies": {
"next": "15.1.6",
"react": "19.0.0",
"react-dom": "19.0.0"
},
"devDependencies": {
"typescript": "5.7.3",
"@types/node": "22.10.7",
"@types/react": "19.0.7",
"@types/react-dom": "19.0.3"
}
}

0
public/.gitkeep Normal file
View File

21
tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}