From f5ab324cca4c701f7b6779e7c02f601beb70ab9c Mon Sep 17 00:00:00 2001 From: Andreas Knuth Date: Mon, 22 Jun 2026 15:32:32 -0500 Subject: [PATCH] init --- .dockerignore | 6 + .env.example | 7 + .gitignore | 5 + Caddyfile.snippet | 7 + Dockerfile | 31 ++ README.md | 120 +++++ app/api/matches/route.ts | 44 ++ app/components/Bracket.tsx | 113 ++++ app/components/Groups.tsx | 66 +++ app/components/ThirdPlace.tsx | 52 ++ app/globals.css | 247 +++++++++ app/layout.tsx | 24 + app/page.tsx | 122 +++++ docker-compose.local.yml | 15 + docker-compose.yml | 25 + lib/annexc-data.ts | 500 ++++++++++++++++++ lib/bracket.ts | 111 ++++ lib/feeds.ts | 199 +++++++ lib/resolve-bracket.ts | 140 +++++ lib/standings.ts | 131 +++++ lib/types.ts | 62 +++ next-env.d.ts | 5 + next.config.mjs | 6 + package-lock.json | 943 ++++++++++++++++++++++++++++++++++ package.json | 22 + public/.gitkeep | 0 tsconfig.json | 21 + 27 files changed, 3024 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Caddyfile.snippet create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/api/matches/route.ts create mode 100644 app/components/Bracket.tsx create mode 100644 app/components/Groups.tsx create mode 100644 app/components/ThirdPlace.tsx create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/page.tsx create mode 100644 docker-compose.local.yml create mode 100644 docker-compose.yml create mode 100644 lib/annexc-data.ts create mode 100644 lib/bracket.ts create mode 100644 lib/feeds.ts create mode 100644 lib/resolve-bracket.ts create mode 100644 lib/standings.ts create mode 100644 lib/types.ts create mode 100644 next-env.d.ts create mode 100644 next.config.mjs create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/.gitkeep create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1e33a64 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +.next +.git +npm-debug.log +.env +.env.local diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3e77880 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd89724 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules +.next +.env +.env.local +*.log diff --git a/Caddyfile.snippet b/Caddyfile.snippet new file mode 100644 index 0000000..866be8f --- /dev/null +++ b/Caddyfile.snippet @@ -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 +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fd9e170 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..234cacd --- /dev/null +++ b/README.md @@ -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. diff --git a/app/api/matches/route.ts b/app/api/matches/route.ts new file mode 100644 index 0000000..5ec33fc --- /dev/null +++ b/app/api/matches/route.ts @@ -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 }, + ); + } +} diff --git a/app/components/Bracket.tsx b/app/components/Bracket.tsx new file mode 100644 index 0000000..28c1483 --- /dev/null +++ b/app/components/Bracket.tsx @@ -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 ( +
+ + {s.teamId ? ( + <> + {s.label} + {s.code && {s.code}} + + ) : ( + {s.label} + )} + {prob != null && prob > 0 && ( + {Math.round(prob * 100)}% + )} + + {s.score != null ? s.score : "–"} +
+ ); +} + +function Tie({ tie, isFinal }: { tie: ResolvedTie; isFinal?: boolean }) { + return ( +
+ #{tie.matchNumber} + + +
+ ); +} + +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 ( +
+
+ Annex-C-Zuordnung der Drittplatzierten: + + {annexResolved + ? "aufgelöst — die acht Dritten sind den Gruppensiegern fest zugeteilt" + : "noch offen — sobald die 8 besten Dritten feststehen, verbindet sich der Baum automatisch"} + +
+ +
+
+
+
Letzte 32
+
+ {r32.map((t) => )} +
+
+
+
Achtelfinale
+
+ {r16.map((t) => )} +
+
+
+
Viertelfinale
+
+ {qf.map((t) => )} +
+
+
+
Halbfinale
+
+ {sf.map((t) => )} +
+
+
+
Finale
+
+ {fin && } + {third && ( +
+
Spiel um Platz 3
+ +
+ )} +
+
+
+
+ +
+ Sieger / weiter + Finale + Platzhalter offen + %-Werte: Polymarket-Wahrscheinlichkeit (falls verfügbar) +
+
+ ); +} diff --git a/app/components/Groups.tsx b/app/components/Groups.tsx new file mode 100644 index 0000000..52d9b8f --- /dev/null +++ b/app/components/Groups.tsx @@ -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 ( +
+ {tables.map((t) => { + const live = liveMatchFor(t.group, matches); + return ( +
+
+ Gruppe {t.group} + + {live ? "● LIVE" : `${t.rows.reduce((a, r) => a + r.played, 0)} Spiele`} + +
+ + + + + + + + + + {t.rows.map((r) => { + const team = teamById(teams, r.teamId); + const cls = r.rank <= 2 ? `q${r.rank}` : r.rank === 3 ? "q3" : ""; + return ( + + + + + + + + + + + ); + })} + +
TeamSpSUNTore±Pkt
+ {r.rank} + {team?.name ?? r.teamId} + {team?.code && {team.code}} + {r.played}{r.won}{r.drawn}{r.lost}{r.goalsFor}:{r.goalsAgainst}{r.goalDiff > 0 ? `+${r.goalDiff}` : r.goalDiff}{r.points}
+
+ ); + })} +
+ ); +} diff --git a/app/components/ThirdPlace.tsx b/app/components/ThirdPlace.tsx new file mode 100644 index 0000000..7e5c82a --- /dev/null +++ b/app/components/ThirdPlace.tsx @@ -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 ( +
+

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

+ + + + + + + + + {rows.map((r, i) => { + const isCut = i === 8; // erste nicht-qualifizierte Zeile + return ( + + + + + + + + + + + ); + })} + +
#GruppeTeamSpPkt±ToreStatus
{r.overallRank}{r.group}{name(r.teamId)}{r.played}{r.points}{r.goalDiff > 0 ? `+${r.goalDiff}` : r.goalDiff}{r.goalsFor}:{r.goalsAgainst} + + {r.qualifies ? "weiter" : "raus"} + +
+
+ ); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..b824f9c --- /dev/null +++ b/app/globals.css @@ -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; } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..5427279 --- /dev/null +++ b/app/layout.tsx @@ -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 ( + + + + + + + {children} + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..4ff34ba --- /dev/null +++ b/app/page.tsx @@ -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(null); + const [error, setError] = useState(null); + const [tab, setTab] = useState("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 ( + <> +
+
+
+ WM 26 + USA · Kanada · Mexiko +
+ + + {error + ? "Feed offline" + : data + ? anyLive ? "Live" : `Aktualisiert ${new Date(data.updatedAt).toLocaleTimeString("de-DE")}` + : "Lade Daten…"} + +
+
+ +
+ + +
+ {error && ( +
+ Die Live-Feeds sind gerade nicht erreichbar: {error}. + Prüfe den FOOTBALL_DATA_TOKEN und die Netzwerkfreigabe des Servers. + Die Seite versucht es automatisch erneut. +
+ )} + + {!data && !error && ( +
+ {Array.from({ length: 6 }).map((_, i) =>
)} +
+ )} + + {data && tab === "groups" && ( + + )} + {data && tab === "thirds" && ( + + )} + {data && tab === "bracket" && ( + + )} +
+
+ +
+
+ Daten: football-data.org (Spiele & Tabellen) · Polymarket Gamma API (Wahrscheinlichkeiten) · + Annex-C-Zuordnung nach den FIFA-Wettbewerbsregeln WM 2026. Kein offizielles FIFA-Produkt. +
+
+ + ); +} diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000..46ad7fd --- /dev/null +++ b/docker-compose.local.yml @@ -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 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..652bb58 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/lib/annexc-data.ts b/lib/annexc-data.ts new file mode 100644 index 0000000..8c98606 --- /dev/null +++ b/lib/annexc-data.ts @@ -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 = { + "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", +}; diff --git a/lib/bracket.ts b/lib/bracket.ts new file mode 100644 index 0000000..2954f16 --- /dev/null +++ b/lib/bracket.ts @@ -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>; + +// 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("/") ?? "?"}`; +} diff --git a/lib/feeds.ts b/lib/feeds.ts new file mode 100644 index 0000000..987e1cd --- /dev/null +++ b/lib/feeds.ts @@ -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 { value: T; expires: number; } +const cache = new Map>(); + +export async function cached(key: string, ttlMs: number, fn: () => Promise): Promise { + const hit = cache.get(key) as CacheEntry | 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 { + 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(); + 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(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 { + 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(mk.outcomes, []); + const prices = safeParse(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, + }, + }; + }); +} diff --git a/lib/resolve-bracket.ts b/lib/resolve-bracket.ts new file mode 100644 index 0000000..c1d81f0 --- /dev/null +++ b/lib/resolve-bracket.ts @@ -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 } { + // Map: Match-Nummer -> Sieger-Team-ID (für Propagation in Folgerunden) + const winners = new Map(); + const losers = new Map(); + + 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 = {}; + 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 }; +} diff --git a/lib/standings.ts b/lib/standings.ts new file mode 100644 index 0000000..49470ca --- /dev/null +++ b/lib/standings.ts @@ -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, 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, +): 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): Map { + const rows = new Map(); + 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(); + 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(); + 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; +} diff --git a/lib/types.ts b/lib/types.ts new file mode 100644 index 0000000..455fec7 --- /dev/null +++ b/lib/types.ts @@ -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 +} diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 0000000..1b3be08 --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/next.config.mjs b/next.config.mjs new file mode 100644 index 0000000..52e0f35 --- /dev/null +++ b/next.config.mjs @@ -0,0 +1,6 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: "standalone", // schlankes Docker-Image + reactStrictMode: true, +}; +export default nextConfig; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0ec24e5 --- /dev/null +++ b/package-lock.json @@ -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" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7ef31fe --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/.gitkeep b/public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f5fc2f7 --- /dev/null +++ b/tsconfig.json @@ -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"] +}