Slim match summary + per-deploy "What's new" modal
- Removed the Videos feed and the "Market movement vs the model" card from the match Summary tab (and the now-orphaned OddsMovement component, the odds-history fetch, and the dead i18n keys). - New WhatsNew modal (src/components/WhatsNew.tsx, mounted in RootLayout): on first visit after a deploy it shows the changelog entries the visitor hasn't seen yet, then records the version in localStorage. Bilingual, driven by public/data/changelog.json (en+de per entry); silent once the visitor is current. Bump changelog.json's top entry each deploy. Gates: 170 tests, build, typecheck:server — all green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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."
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex min-h-full flex-col">
|
||||
<WhatsNew />
|
||||
<SearchPalette open={searchOpen} onClose={() => setSearchOpen(false)} />
|
||||
<header className="sticky top-0 z-30 border-b border-line bg-surface/85 backdrop-blur">
|
||||
<div className="mx-auto flex h-14 max-w-6xl items-center gap-4 px-4">
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" role="img" aria-label={t.match.oddsMovement}>
|
||||
<rect x={PAD.l} y={PAD.t} width={W - PAD.l - PAD.r} height={H - PAD.t - PAD.b} className="fill-[var(--app-elevated)]" rx={4} />
|
||||
{data.yTicks.map((yt) => (
|
||||
<g key={yt.label}>
|
||||
<line x1={PAD.l} x2={W - PAD.r} y1={yt.py} y2={yt.py} className="stroke-[var(--app-line)]" strokeWidth={1} />
|
||||
<text x={PAD.l - 4} y={yt.py + 3} textAnchor="end" className="fill-[var(--app-faint)] text-[10px]">{yt.label}</text>
|
||||
</g>
|
||||
))}
|
||||
{/* model references (dashed) */}
|
||||
<line x1={PAD.l} x2={W - PAD.r} y1={data.yModel.home} y2={data.yModel.home} className="stroke-[var(--app-accent)]" strokeDasharray="5 4" strokeWidth={1.5} opacity={0.7} />
|
||||
<line x1={PAD.l} x2={W - PAD.r} y1={data.yModel.draw} y2={data.yModel.draw} className="stroke-[var(--app-line-strong)]" strokeDasharray="5 4" strokeWidth={1.5} opacity={0.9} />
|
||||
<line x1={PAD.l} x2={W - PAD.r} y1={data.yModel.away} y2={data.yModel.away} className="stroke-[var(--app-info)]" strokeDasharray="5 4" strokeWidth={1.5} opacity={0.7} />
|
||||
{/* market lines */}
|
||||
<polyline points={data.home} fill="none" className="stroke-[var(--app-accent)]" strokeWidth={2.5} />
|
||||
<polyline points={data.draw} fill="none" className="stroke-[var(--app-line-strong)]" strokeWidth={2.5} />
|
||||
<polyline points={data.away} fill="none" className="stroke-[var(--app-info)]" strokeWidth={2.5} />
|
||||
{data.ticks.map((tk) => (
|
||||
<text key={tk.label} x={tk.px} y={H - 6} textAnchor="middle" className="fill-[var(--app-faint)] text-[10px]">{tk.label}</text>
|
||||
))}
|
||||
</svg>
|
||||
<p className="mt-1.5 text-xs text-faint">{t.match.oddsMovementNote}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Entry[]>([]);
|
||||
|
||||
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 (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-end justify-center bg-black/50 p-4 sm:items-center"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t.whatsNew.title}
|
||||
onClick={dismiss}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md overflow-hidden rounded-2xl border border-line bg-surface shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-line bg-panel px-4 py-3">
|
||||
<Sparkles size={16} className="shrink-0 text-accent" />
|
||||
<span className="font-display font-bold text-ink">{t.whatsNew.title}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
aria-label={t.whatsNew.dismiss}
|
||||
className="ml-auto grid h-7 w-7 place-items-center rounded-md text-faint transition-colors hover:text-ink"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-[60vh] space-y-4 overflow-y-auto px-4 py-4">
|
||||
{unseen.map((e) => {
|
||||
const c = locale === 'de' ? e.de : e.en;
|
||||
return (
|
||||
<div key={e.version}>
|
||||
<div className="mb-1.5 font-semibold text-ink">{c.title}</div>
|
||||
<ul className="space-y-1.5">
|
||||
{c.items.map((it, i) => (
|
||||
<li key={i} className="flex gap-2 text-sm text-ink-soft">
|
||||
<span aria-hidden className="mt-1.5 h-1 w-1 shrink-0 rounded-full bg-accent" />
|
||||
<span>{it}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="border-t border-line px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
className="w-full rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-surface transition-opacity hover:opacity-90"
|
||||
>
|
||||
{t.whatsNew.dismiss}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<MatchPreview | null>(null);
|
||||
const [timeline, setTimeline] = useState<TimelinePoint[]>([]);
|
||||
const [oddsHistory, setOddsHistory] = useState<OddsPoint[]>([]);
|
||||
const [rich, setRich] = useState<MatchRich | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('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() {
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
{preview.prediction && (hasOddsChart || closingLine) && (
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">{t.match.oddsMovement}</span></CardHeader>
|
||||
<CardBody>
|
||||
{hasOddsChart && <OddsMovement history={oddsHistory} model={preview.prediction.probs} kickoff={f.kickoff} />}
|
||||
{closingLine && (
|
||||
<p className={`text-xs text-faint${hasOddsChart ? ' mt-2' : ''}`}>
|
||||
{[
|
||||
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}
|
||||
</p>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
{preview.videos.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="flex items-center gap-2">
|
||||
<Clapperboard size={16} className="text-accent" />
|
||||
<span className="font-display font-bold text-ink">{t.match.videos}</span>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{preview.videos.map((v) => (
|
||||
<a
|
||||
key={v.href}
|
||||
href={v.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="group flex gap-3 rounded-lg border border-line p-2 transition-colors hover:border-line-strong hover:bg-panel-2"
|
||||
>
|
||||
{v.thumbnail && <img src={v.thumbnail} alt="" loading="lazy" className="h-14 w-24 shrink-0 rounded object-cover" />}
|
||||
<span className="min-w-0">
|
||||
<span className="line-clamp-2 text-sm font-medium text-ink group-hover:text-accent">{v.headline}</span>
|
||||
{v.duration != null && (
|
||||
<span className="mt-0.5 block text-[11px] text-faint">
|
||||
{Math.floor(v.duration / 60)}:{String(v.duration % 60).padStart(2, '0')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-faint">{t.match.videosNote}</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
+4
-8
@@ -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",
|
||||
|
||||
+4
-8
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user