diff --git a/public/data/changelog.json b/public/data/changelog.json
new file mode 100644
index 0000000..ccfd7a8
--- /dev/null
+++ b/public/data/changelog.json
@@ -0,0 +1,30 @@
+{
+ "entries": [
+ {
+ "version": "2026-06-13",
+ "date": "2026-06-13",
+ "en": {
+ "title": "Players, venues & deeper match data",
+ "items": [
+ "Player profiles: tap any name for tournament totals, a match log and a shot map.",
+ "Venue pages with each stadium's schedule, altitude and local kickoff time.",
+ "Match pages: detailed full-time stats, attack zones, and a confirmed-lineups badge.",
+ "Team pages now show the full 26-man squad and a road to the final.",
+ "Goal alerts: follow several teams at once, or every match.",
+ "Cleaner match summary — videos and the market-odds chart were removed."
+ ]
+ },
+ "de": {
+ "title": "Spieler, Stadien & mehr Spieldaten",
+ "items": [
+ "Spielerprofile: Tippe auf einen Namen für Turnierbilanz, Spielprotokoll und Schusskarte.",
+ "Stadionseiten mit Spielplan, Höhenlage und lokaler Anstoßzeit.",
+ "Spielseiten: detaillierte Statistik, Angriffszonen und ein „Bestätigt“-Badge für Aufstellungen.",
+ "Teamseiten zeigen jetzt den kompletten 26er-Kader und den Weg ins Finale.",
+ "Tor-Alarm: folge mehreren Teams gleichzeitig oder allen Spielen.",
+ "Aufgeräumte Spielübersicht — Videos und das Markt-Quoten-Diagramm wurden entfernt."
+ ]
+ }
+ }
+ ]
+}
diff --git a/src/app/RootLayout.tsx b/src/app/RootLayout.tsx
index e2747dd..b393fff 100644
--- a/src/app/RootLayout.tsx
+++ b/src/app/RootLayout.tsx
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { Link, Outlet, useRouterState } from '@tanstack/react-router';
import { ChartColumn, Database, Gauge, GitMerge, Languages, MapPin, Menu, Moon, Radio, Scale, Search, Sparkles, Sun, Swords, Table, TrendingUp, Trophy, Users } from 'lucide-react';
import { SearchPalette } from '@/components/SearchPalette';
+import { WhatsNew } from '@/components/WhatsNew';
import type { LucideIcon } from 'lucide-react';
import { useUiStore } from '@/stores/uiStore';
import { useLocaleStore } from '@/stores/localeStore';
@@ -196,6 +197,7 @@ export function RootLayout() {
return (
+ setSearchOpen(false)} />
diff --git a/src/components/OddsMovement.tsx b/src/components/OddsMovement.tsx
deleted file mode 100644
index 28a02e2..0000000
--- a/src/components/OddsMovement.tsx
+++ /dev/null
@@ -1,76 +0,0 @@
-import { useMemo } from 'react';
-import { deVig } from '@/lib/odds';
-import { useT } from '@/lib/i18n';
-import type { MatchProbs, OddsPoint } from '@/lib/types';
-
-/** Bookmaker line movement (de-vigged) vs the model's pre-match probabilities.
- * Market = solid lines drifting toward kickoff; model = dashed references.
- * Benchmark only — the model never sees these numbers. */
-export function OddsMovement({ history, model, kickoff }: {
- history: OddsPoint[];
- model: MatchProbs;
- kickoff: string;
-}) {
- const t = useT();
- const W = 600;
- const H = 200;
- const PAD = { l: 30, r: 8, t: 8, b: 20 };
-
- const data = useMemo(() => {
- const koT = new Date(kickoff).getTime();
- const pts = history
- .map((h) => ({ at: h.captured_at, probs: deVig(h.home_ml, h.draw_ml, h.away_ml) }))
- .filter((p): p is { at: number; probs: MatchProbs } => p.probs != null && p.at <= koT)
- .sort((a, b) => a.at - b.at);
- if (pts.length < 2) return null;
-
- const firstAt = pts[0]!.at;
- const span = Math.max(1, koT - firstAt);
- const x = (at: number) => PAD.l + ((at - firstAt) / span) * (W - PAD.l - PAD.r);
- const maxP = Math.min(1, Math.max(...pts.flatMap((p) => [p.probs.home, p.probs.draw, p.probs.away]), model.home, model.draw, model.away) + 0.08);
- const y = (p: number) => PAD.t + (1 - p / maxP) * (H - PAD.t - PAD.b);
- const line = (sel: (p: MatchProbs) => number) => pts.map((p) => `${x(p.at).toFixed(1)},${y(sel(p.probs)).toFixed(1)}`).join(' ');
-
- // hour ticks: kickoff plus a few nice -24h steps back
- const hoursSpan = span / 3_600_000;
- const step = hoursSpan > 96 ? 48 : hoursSpan > 36 ? 24 : hoursSpan > 12 ? 12 : 6;
- const ticks: { px: number; label: string }[] = [];
- for (let h = 0; h <= hoursSpan; h += step) {
- ticks.push({ px: x(koT - h * 3_600_000), label: h === 0 ? '0h' : `-${h}h` });
- }
- const yTicks = [0.25, 0.5, 0.75].filter((p) => p < maxP).map((p) => ({ py: y(p), label: `${Math.round(p * 100)}%` }));
- return {
- home: line((p) => p.home), draw: line((p) => p.draw), away: line((p) => p.away),
- yModel: { home: y(model.home), draw: y(model.draw), away: y(model.away) },
- ticks, yTicks,
- };
- }, [history, model, kickoff]);
-
- if (!data) return null;
-
- return (
-
-
-
{t.match.oddsMovementNote}
-
- );
-}
diff --git a/src/components/WhatsNew.tsx b/src/components/WhatsNew.tsx
new file mode 100644
index 0000000..666b556
--- /dev/null
+++ b/src/components/WhatsNew.tsx
@@ -0,0 +1,104 @@
+import { useEffect, useState } from 'react';
+import { Sparkles, X } from 'lucide-react';
+import { useLocaleStore } from '@/stores/localeStore';
+import { useT } from '@/lib/i18n';
+
+// A once-per-deploy "what's new" modal. The newest changelog entry's `version`
+// is compared against localStorage; anything the visitor hasn't seen is shown,
+// then marked seen. Brand-new visitors see the latest entry once too.
+
+const SEEN_KEY = 'cup26:changelog-seen';
+
+interface Entry {
+ version: string;
+ date: string;
+ en: { title: string; items: string[] };
+ de: { title: string; items: string[] };
+}
+
+export function WhatsNew() {
+ const t = useT();
+ const locale = useLocaleStore((s) => s.locale);
+ const [unseen, setUnseen] = useState([]);
+
+ useEffect(() => {
+ let alive = true;
+ fetch('/data/changelog.json')
+ .then((r) => (r.ok ? r.json() : Promise.reject()))
+ .then((d: { entries?: Entry[] }) => {
+ if (!alive || !Array.isArray(d.entries) || d.entries.length === 0) return;
+ const entries = d.entries;
+ let seen: string | null = null;
+ try { seen = localStorage.getItem(SEEN_KEY); } catch { /* private mode */ }
+ if (seen === entries[0]!.version) return; // already on the latest
+ // Entries are newest-first: show everything above the last-seen one,
+ // or just the latest for a first-ever / unknown-version visitor.
+ const seenIdx = seen ? entries.findIndex((e) => e.version === seen) : -1;
+ setUnseen(seenIdx > 0 ? entries.slice(0, seenIdx) : [entries[0]!]);
+ })
+ .catch(() => {});
+ return () => { alive = false; };
+ }, []);
+
+ if (unseen.length === 0) return null;
+
+ const dismiss = () => {
+ try { localStorage.setItem(SEEN_KEY, unseen[0]!.version); } catch { /* private mode */ }
+ setUnseen([]);
+ };
+
+ return (
+