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 ( -
- - - {data.yTicks.map((yt) => ( - - - {yt.label} - - ))} - {/* model references (dashed) */} - - - - {/* market lines */} - - - - {data.ticks.map((tk) => ( - {tk.label} - ))} - -

{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 ( +
+
e.stopPropagation()} + > +
+ + {t.whatsNew.title} + +
+
+ {unseen.map((e) => { + const c = locale === 'de' ? e.de : e.en; + return ( +
+
{c.title}
+
    + {c.items.map((it, i) => ( +
  • + + {it} +
  • + ))} +
+
+ ); + })} +
+
+ +
+
+
+ ); +} diff --git a/src/features/match/MatchPreviewPage.tsx b/src/features/match/MatchPreviewPage.tsx index 3ef1be3..3aa046b 100644 --- a/src/features/match/MatchPreviewPage.tsx +++ b/src/features/match/MatchPreviewPage.tsx @@ -1,11 +1,10 @@ import { useEffect, useMemo, useState } from 'react'; import { Link, useParams } from '@tanstack/react-router'; -import { ArrowLeft, ArrowRight, BadgeCheck, Ban, Clapperboard, Goal, Shield, Shirt, Stethoscope } from 'lucide-react'; +import { ArrowLeft, ArrowRight, BadgeCheck, Ban, Goal, Shield, Shirt, Stethoscope } from 'lucide-react'; import { Badge } from '@/components/ui/Badge'; import { Card, CardBody, CardHeader } from '@/components/ui/Card'; import { WinProbBar } from '@/components/WinProbBar'; import { WinProbTimeline, type TimelinePoint } from '@/components/WinProbTimeline'; -import { OddsMovement } from '@/components/OddsMovement'; import { FormChips } from '@/components/FormChips'; import { XgRace } from '@/components/XgRace'; import { ShotMap } from '@/components/ShotMap'; @@ -19,7 +18,7 @@ import { redCards } from '@/lib/discipline'; import { inPlayProbs } from '@/lib/model/inplay'; import { useTournamentStore } from '@/stores/tournamentStore'; import { PlayerLink } from '@/components/PlayerLink'; -import type { Fixture, KeyPlayer, MatchPreview, MatchRich, OddsPoint } from '@/lib/types'; +import type { Fixture, KeyPlayer, MatchPreview, MatchRich } from '@/lib/types'; const num = (s: string | undefined) => (s ? Number(s.replace('%', '')) || 0 : 0); @@ -98,7 +97,6 @@ export function MatchPreviewPage() { const { t, locale, kickoffDay, kickoffTime } = useFormat(); const [preview, setPreview] = useState(null); const [timeline, setTimeline] = useState([]); - const [oddsHistory, setOddsHistory] = useState([]); const [rich, setRich] = useState(null); const [failed, setFailed] = useState(false); const [activeTab, setActiveTab] = useState('summary'); @@ -133,11 +131,6 @@ export function MatchPreviewPage() { void load(); void loadTimeline(); void loadRich(); - // line history changes on a 3h sweep — once per visit is plenty - fetch(`/api/odds/${numParam}`) - .then((r) => (r.ok ? r.json() : Promise.reject())) - .then((d: { history: OddsPoint[] }) => alive && setOddsHistory(d.history)) - .catch(() => {}); const id = setInterval(() => { if (useTournamentStore.getState().snapshot?.fixtures.find((f) => f.num === Number(numParam))?.status === 'live') { void load(); @@ -175,12 +168,6 @@ export function MatchPreviewPage() { const homeName = f.home.label; const awayName = f.away.label; const finished = f.status === 'finished'; - const koT = new Date(f.kickoff).getTime(); - const hasOddsChart = oddsHistory.filter((o) => o.home_ml != null && o.draw_ml != null && o.away_ml != null && o.captured_at <= koT).length >= 2; - // Latest pre-kickoff totals/handicap line (display only, like all odds here). - const closingLine = oddsHistory - .filter((o) => o.captured_at <= koT && (o.over_under != null || o.spread != null)) - .sort((a, b) => b.captured_at - a.captured_at)[0] ?? null; // Hero extras from the rich capture: kickoff-hour weather (pre-match only) // and post-match official chips (attendance, referee, player of the match). @@ -430,54 +417,6 @@ export function MatchPreviewPage() { )} - {preview.prediction && (hasOddsChart || closingLine) && ( - - {t.match.oddsMovement} - - {hasOddsChart && } - {closingLine && ( -

- {[ - closingLine.over_under != null ? fmt(t.match.oddsExtras.ou, { n: closingLine.over_under }) : null, - closingLine.spread != null ? fmt(t.match.oddsExtras.spread, { n: `${closingLine.spread > 0 ? '+' : ''}${closingLine.spread}` }) : null, - ].filter(Boolean).join(' · ')} · {closingLine.provider} -

- )} -
-
- )} - {preview.videos.length > 0 && ( - - - - {t.match.videos} - - - -

{t.match.videosNote}

-
-
- )}
)} diff --git a/src/lib/i18n/de.ts b/src/lib/i18n/de.ts index cc6d2a0..5066c83 100644 --- a/src/lib/i18n/de.ts +++ b/src/lib/i18n/de.ts @@ -97,8 +97,6 @@ export const de: Dict = { }, timeline: "Spielverlauf", momentum: "So schwankte die Siegwahrscheinlichkeit", - oddsMovement: "Marktbewegung vs. Modell", - oddsMovementNote: "Durchgezogene Linien: die Buchmacherquoten ohne Marge, im Verlauf bis zum Anstoß. Gestrichelt: die Wahrscheinlichkeiten unseres Modells. Der Markt ist nur Vergleichsmaßstab — das Modell sieht ihn nie.", preMatchModel: "Modell vor Anstoß", modelExpectation: "Modell-Prognose", modelHonestyNote: "Modell-Wahrscheinlichkeiten — keine Wettempfehlung.", @@ -114,8 +112,6 @@ export const de: Dict = { unavailable: "Verletzt oder fraglich: {players}", shootoutTitle: "Elfmeterschießen", shootoutNote: "Schützen in Reihenfolge aus dem Live-Feed — Punkt antippen für den Namen.", - videos: "Videos", - videosNote: "Spiel-Clips — öffnen bei ESPN.", keyPlayers: "Schlüsselspieler (beste Torschützen aller Zeiten)", xgRace: "xG-Rennen", shotMap: "Schusskarte", @@ -147,10 +143,6 @@ export const de: Dict = { aerialDuels: "Gewonnene Kopfballduelle", dribbles: "Erfolgreiche Dribblings", }, - oddsExtras: { - ou: "Über/Unter {n} Tore", - spread: "Handicap {n}", - }, confirmedLineups: "Bestätigt", whoScores: { title: "Wer trifft?", @@ -233,6 +225,10 @@ export const de: Dict = { howModelWorks: "So funktioniert das Modell", disclaimer: "Modell-Quoten — keine Wettempfehlung", }, + whatsNew: { + title: "Was ist neu", + dismiss: "Verstanden", + }, glossary: { rps: { term: "RPS", diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index 80aaadc..e7023c1 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -96,8 +96,6 @@ export const en = { }, timeline: "Timeline", momentum: "How the win probability swung", - oddsMovement: "Market movement vs the model", - oddsMovementNote: "Solid lines: the bookmaker's odds with the margin removed, drifting toward kickoff. Dashed lines: our model's probabilities. The market is a benchmark — the model never sees it.", preMatchModel: "Pre-match model", modelExpectation: "Model expectation", modelHonestyNote: "Model probabilities — not betting advice.", @@ -113,8 +111,6 @@ export const en = { unavailable: "Unavailable or doubtful: {players}", shootoutTitle: "Penalty shootout", shootoutNote: "Kicks in order from the live feed — hover a dot for the taker.", - videos: "Videos", - videosNote: "Match clips — they open on ESPN.", keyPlayers: "Key players (all-time top scorers)", xgRace: "xG race", shotMap: "Shot map", @@ -146,10 +142,6 @@ export const en = { aerialDuels: "Aerial duels won", dribbles: "Dribbles completed", }, - oddsExtras: { - ou: "Over/under {n} goals", - spread: "spread {n}", - }, confirmedLineups: "Confirmed", whoScores: { title: "Who scores?", @@ -232,6 +224,10 @@ export const en = { howModelWorks: "How the model works", disclaimer: "Model odds — not betting advice", }, + whatsNew: { + title: "What's new", + dismiss: "Got it", + }, glossary: { rps: { term: "RPS",