From 05010771a39e08ba59071c1b2ab2cb4d4838976b Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Fri, 12 Jun 2026 00:20:07 +0200 Subject: [PATCH] German language option + a full wording-clarity pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tiny typed i18n layer (no library): en.ts is the single source of UI copy and the clarity rewrite lives there — shorter sentences, plain words, the dense Methodology/Scoreboard copy untangled. de.ts mirrors its shape exactly, enforced by the compiler, with placeholder parity covered by tests. Every page now reads from the dictionary via useT(); dates flip locale through useFormat() (en-GB/de-DE); raw server values (status, outcome, stage) map through total records. The header gains an EN/DE toggle (persisted, defaults from the browser language); stays in sync. Co-Authored-By: Claude Fable 5 --- src/app/NotFound.tsx | 6 +- src/app/RootLayout.tsx | 62 ++- src/components/GroupTable.tsx | 14 +- src/components/MatchCard.tsx | 29 +- src/components/OddsTable.tsx | 31 +- src/components/ReliabilityDiagram.tsx | 17 +- src/components/WinProbBar.tsx | 12 +- src/components/ui/SectionTitle.tsx | 11 + src/components/ui/Term.tsx | 29 +- src/features/bracket/BracketPage.tsx | 31 +- src/features/data/DataPage.tsx | 71 ++-- src/features/groups/GroupsPage.tsx | 12 +- src/features/live/LivePage.tsx | 22 +- src/features/match/MatchPreviewPage.tsx | 68 ++-- src/features/methodology/MethodologyPage.tsx | 86 ++-- src/features/predictions/PredictionsPage.tsx | 26 +- src/features/scoreboard/ScoreboardPage.tsx | 84 ++-- src/features/story/StoryPage.tsx | 41 +- src/features/team/TeamProfilePage.tsx | 36 +- src/features/teams/TeamsPage.tsx | 20 +- src/lib/format.ts | 48 ++- src/lib/i18n/de.ts | 389 ++++++++++++++++++ src/lib/i18n/en.ts | 390 +++++++++++++++++++ src/lib/i18n/i18n.test.ts | 67 ++++ src/lib/i18n/index.ts | 70 ++++ src/stores/localeStore.ts | 33 ++ 26 files changed, 1382 insertions(+), 323 deletions(-) create mode 100644 src/components/ui/SectionTitle.tsx create mode 100644 src/lib/i18n/de.ts create mode 100644 src/lib/i18n/en.ts create mode 100644 src/lib/i18n/i18n.test.ts create mode 100644 src/lib/i18n/index.ts create mode 100644 src/stores/localeStore.ts diff --git a/src/app/NotFound.tsx b/src/app/NotFound.tsx index d1396e6..e122986 100644 --- a/src/app/NotFound.tsx +++ b/src/app/NotFound.tsx @@ -1,13 +1,15 @@ import { Link } from '@tanstack/react-router'; +import { useT } from '@/lib/i18n'; export function NotFound() { + const t = useT(); return (

404

-

Off the pitch — that page doesn't exist.

+

{t.notFound.message}

- Back to Live + {t.notFound.backToLive}
diff --git a/src/app/RootLayout.tsx b/src/app/RootLayout.tsx index 6bbecda..20b8423 100644 --- a/src/app/RootLayout.tsx +++ b/src/app/RootLayout.tsx @@ -1,54 +1,58 @@ import { useEffect } from 'react'; import { Link, Outlet } from '@tanstack/react-router'; -import { Gauge, GitMerge, Moon, Radio, Scale, Sparkles, Sun, Table, TrendingUp, Trophy } from 'lucide-react'; +import { Gauge, GitMerge, Languages, Moon, Radio, Scale, Sparkles, Sun, Table, TrendingUp, Trophy } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { useUiStore } from '@/stores/uiStore'; +import { useLocaleStore } from '@/stores/localeStore'; import { useTournamentStore } from '@/stores/tournamentStore'; +import { useT, type Dict } from '@/lib/i18n'; import { cn } from '@/lib/cn'; -const NAV: { to: string; label: string; exact: boolean; icon: LucideIcon }[] = [ - { to: '/', label: 'Live', exact: true, icon: Radio }, - { to: '/groups', label: 'Groups', exact: false, icon: Table }, - { to: '/bracket', label: 'Bracket', exact: false, icon: GitMerge }, - { to: '/predict', label: 'Predict', exact: false, icon: TrendingUp }, - { to: '/scoreboard', label: 'vs Market', exact: false, icon: Scale }, - { to: '/methodology', label: 'Model', exact: false, icon: Gauge }, - { to: '/story', label: 'Story', exact: false, icon: Sparkles }, +const NAV: { to: string; label: (t: Dict) => string; exact: boolean; icon: LucideIcon }[] = [ + { to: '/', label: (t) => t.nav.live, exact: true, icon: Radio }, + { to: '/groups', label: (t) => t.nav.groups, exact: false, icon: Table }, + { to: '/bracket', label: (t) => t.nav.bracket, exact: false, icon: GitMerge }, + { to: '/predict', label: (t) => t.nav.predict, exact: false, icon: TrendingUp }, + { to: '/scoreboard', label: (t) => t.nav.vsMarket, exact: false, icon: Scale }, + { to: '/methodology', label: (t) => t.nav.model, exact: false, icon: Gauge }, + { to: '/story', label: (t) => t.nav.story, exact: false, icon: Sparkles }, ]; function ConnectionStatus() { + const t = useT(); const status = useTournamentStore((s) => s.status); const source = useTournamentStore((s) => s.snapshot?.source); if (status === 'live') { return ( - Live + {t.common.liveNow} ); } return ( - {status === 'connecting' ? '…' : 'Offline'} + {status === 'connecting' ? '…' : t.common.offline} ); } function ThemeToggle() { + const t = useT(); const theme = useUiStore((s) => s.theme); const toggle = useUiStore((s) => s.toggleTheme); return ( + ); +} + export function RootLayout() { + const t = useT(); useEffect(() => { useTournamentStore.getState().init(); }, []); @@ -82,12 +103,13 @@ export function RootLayout() { '[&.active]:bg-accent-glow [&.active]:text-accent', )} > - {item.label} + {item.label(t)} ))} -
+
+
@@ -110,16 +132,16 @@ export function RootLayout() { )} > - {item.label} + {item.label(t)} ))}

- Cup26 · World Cup 2026 · all the data ·{' '} - how the model works · - model odds, not betting advice + Cup26 · {t.footer.worldCup} · {t.footer.allData} ·{' '} + {t.footer.howModelWorks} ·{' '} + {t.footer.disclaimer}

diff --git a/src/components/GroupTable.tsx b/src/components/GroupTable.tsx index c02c9ba..cc8abb6 100644 --- a/src/components/GroupTable.tsx +++ b/src/components/GroupTable.tsx @@ -1,23 +1,25 @@ import { Link } from '@tanstack/react-router'; import { cn } from '@/lib/cn'; import { teamFlag } from '@/lib/teams'; +import { fmt, useT } from '@/lib/i18n'; import type { StandingRow } from '@/lib/types'; /** One group's standings. Top 2 qualify directly; 3rd may advance as a best-third. */ export function GroupTable({ group, rows }: { group: string; rows: StandingRow[] }) { + const t = useT(); return (
- Group {group} - Pld · Pts + {fmt(t.common.group, { group })} + {t.groups.tableHeaderPldPts}
- - - - + + + + diff --git a/src/components/MatchCard.tsx b/src/components/MatchCard.tsx index 7a9161b..ccb3d90 100644 --- a/src/components/MatchCard.tsx +++ b/src/components/MatchCard.tsx @@ -2,12 +2,13 @@ import { Link } from '@tanstack/react-router'; import { cn } from '@/lib/cn'; import { teamFlag } from '@/lib/teams'; import type { Fixture } from '@/lib/types'; -import { kickoffTime, relativeKickoff } from '@/lib/format'; +import { fmt, useFormat, useT, type Dict } from '@/lib/i18n'; import { useTournamentStore } from '@/stores/tournamentStore'; import { TeamLabel } from './TeamLabel'; /** The model's pre-match lean for scheduled matches — the "expectation" hint. */ function ModelHint({ num }: { num: number }) { + const t = useT(); const pred = useTournamentStore((s) => s.model?.matches.find((m) => m.num === num)); if (!pred) return null; const { home, draw, away } = pred.probs; @@ -17,43 +18,35 @@ function ModelHint({ num }: { num: number }) { const p = drawLikely ? draw : Math.max(home, away); return (
- model: {side ? <>{teamFlag(side)} {side} : 'draw '} + {t.match.modelPrefix} {side ? <>{teamFlag(side)} {side} : `${t.common.draw} `} {Math.round(p * 100)}%
); } -const STAGE_LABEL: Record = { - group: 'Group', - r32: 'Round of 32', - r16: 'Round of 16', - qf: 'Quarter-final', - sf: 'Semi-final', - third: 'Third place', - final: 'Final', -}; - -function tag(f: Fixture): string { - if (f.stage === 'group') return `Group ${f.group}`; - return STAGE_LABEL[f.stage]; +function tag(f: Fixture, t: Dict): string { + if (f.stage === 'group') return fmt(t.common.group, { group: f.group ?? '' }); + return t.stage[f.stage]; } function StatusPill({ f }: { f: Fixture }) { + const { t, relativeKickoff } = useFormat(); if (f.status === 'live') { return ( - {f.minute ? `${f.minute}'` : 'Live'} + {f.minute ? fmt(t.live.minute, { n: f.minute }) : t.status.live} ); } if (f.status === 'finished') { - return FT; + return {t.common.fullTimeShort}; } return {relativeKickoff(f.kickoff)}; } export function MatchCard({ f }: { f: Fixture }) { + const { t, kickoffTime } = useFormat(); const hasScore = f.status === 'live' || f.status === 'finished'; return (
- {tag(f)} + {tag(f, t)}
diff --git a/src/components/OddsTable.tsx b/src/components/OddsTable.tsx index 4ca85de..0495b40 100644 --- a/src/components/OddsTable.tsx +++ b/src/components/OddsTable.tsx @@ -1,17 +1,19 @@ import { Link } from '@tanstack/react-router'; import { teamFlag } from '@/lib/teams'; import { cn } from '@/lib/cn'; +import { useT } from '@/lib/i18n'; import type { TeamOdds } from '@/lib/types'; -const cols: { key: keyof Omit; label: string }[] = [ - { key: 'winGroup', label: 'Win grp' }, - { key: 'qualify', label: 'Qualify' }, - { key: 'reachR16', label: 'R16' }, - { key: 'reachQF', label: 'QF' }, - { key: 'reachSF', label: 'SF' }, - { key: 'reachFinal', label: 'Final' }, - { key: 'champion', label: 'Champion' }, -]; +// Column order; labels come from the dictionary (t.predict.oddsTable.*). +const colKeys = [ + 'winGroup', + 'qualify', + 'reachR16', + 'reachQF', + 'reachSF', + 'reachFinal', + 'champion', +] as const satisfies readonly (keyof Omit)[]; const pct = (x: number) => (x >= 0.995 ? '>99%' : x < 0.005 ? '—' : `${Math.round(x * 100)}%`); @@ -28,14 +30,15 @@ function Cell({ x, accent = false }: { x: number; accent?: boolean }) { } export function OddsTable({ odds }: { odds: TeamOdds[] }) { + const t = useT(); return (
TeamPGDPts{t.groups.colTeam}{t.groups.colPlayed}{t.groups.colGoalDiff}{t.groups.colPoints}
- - {cols.map((c) => ( - + + {colKeys.map((key) => ( + ))} @@ -48,8 +51,8 @@ export function OddsTable({ odds }: { odds: TeamOdds[] }) { {o.team} - {cols.map((c) => ( - + {colKeys.map((key) => ( + ))} ))} diff --git a/src/components/ReliabilityDiagram.tsx b/src/components/ReliabilityDiagram.tsx index 44550f6..f57e79d 100644 --- a/src/components/ReliabilityDiagram.tsx +++ b/src/components/ReliabilityDiagram.tsx @@ -1,4 +1,5 @@ import { scaleLinear } from 'd3-scale'; +import { fmt, fmtSplit, useT } from '@/lib/i18n'; import type { BacktestReport } from '@/lib/types'; const W = 360; @@ -8,6 +9,7 @@ const M = 34; /** Calibration plot: predicted probability (x) vs observed frequency (y). Points * on the diagonal = perfectly calibrated. Dot size ∝ sample count. */ export function ReliabilityDiagram({ reliability, ece }: { reliability: BacktestReport['reliability']; ece: number }) { + const t = useT(); const sx = scaleLinear().domain([0, 1]).range([M, W - 8]); const sy = scaleLinear().domain([0, 1]).range([H - M, 8]); const maxCount = Math.max(...reliability.map((b) => b.count), 1); @@ -34,15 +36,20 @@ export function ReliabilityDiagram({ reliability, ece }: { reliability: Backtest /> {pts.map((b, i) => ( - {`predicted ${(b.predicted * 100).toFixed(0)}% → happened ${(b.observed * 100).toFixed(0)}% (${b.count})`} + {fmt(t.methodology.calibration.pointTooltip, { predicted: (b.predicted * 100).toFixed(0), observed: (b.observed * 100).toFixed(0), count: b.count })} ))} - predicted probability - actually happened + {t.methodology.calibration.xAxis} + {t.methodology.calibration.yAxis}

- Points hug the diagonal — when the model says 30%, it happens about 30% of the time. - Calibration error (ECE) is just {ece.toFixed(2)} (lower is better; under 0.05 is excellent). + {fmtSplit(t.methodology.calibration.caption).map((p, i) => + typeof p === 'string' + ? p + : p.key === 'ece' + ? {ece.toFixed(2)} + : null, + )}

); diff --git a/src/components/WinProbBar.tsx b/src/components/WinProbBar.tsx index 7d9a35a..a8d5542 100644 --- a/src/components/WinProbBar.tsx +++ b/src/components/WinProbBar.tsx @@ -1,4 +1,5 @@ import { teamFlag } from '@/lib/teams'; +import { fmt, useT } from '@/lib/i18n'; import type { MatchProbs } from '@/lib/types'; const pct = (x: number) => `${Math.round(x * 100)}%`; @@ -16,6 +17,7 @@ export function WinProbBar({ probs: MatchProbs; topScore?: { home: number; away: number }; }) { + const t = useT(); return (
@@ -24,7 +26,7 @@ export function WinProbBar({ {home} {topScore && ( - + {topScore.home}–{topScore.away} )} @@ -34,13 +36,13 @@ export function WinProbBar({
-
-
-
+
+
+
{pct(probs.home)} - draw {pct(probs.draw)} + {fmt(t.common.drawPct, { pct: pct(probs.draw) })} {pct(probs.away)}
diff --git a/src/components/ui/SectionTitle.tsx b/src/components/ui/SectionTitle.tsx new file mode 100644 index 0000000..37f2249 --- /dev/null +++ b/src/components/ui/SectionTitle.tsx @@ -0,0 +1,11 @@ +import type { ReactNode } from 'react'; + +/** Consistent section heading: smallcaps label with optional right-side extra. */ +export function SectionTitle({ children, extra }: { children: ReactNode; extra?: ReactNode }) { + return ( +
+

{children}

+ {extra != null &&
{extra}
} +
+ ); +} diff --git a/src/components/ui/Term.tsx b/src/components/ui/Term.tsx index 85fa8fb..89d0815 100644 --- a/src/components/ui/Term.tsx +++ b/src/components/ui/Term.tsx @@ -1,25 +1,30 @@ import type { ReactNode } from 'react'; +import { useT, type Dict } from '@/lib/i18n'; -// Plain-language explainers for every metric, attached where the metric appears. -const GLOSSARY: Record = { - RPS: 'Ranked Probability Score — how far the forecast probabilities were from what actually happened, respecting that home win / draw / away win are ordered. 0 is perfect; lower is better.', - Brier: 'Mean squared error of the probabilities across the three outcomes. 0 is perfect; lower is better.', - ECE: 'Expected Calibration Error — the average gap between predicted probability and observed frequency. Under 0.05 is excellent.', - Elo: 'A strength rating updated after every match: beat a stronger team, gain more points. A 100-point gap ≈ 64% expected score.', - xG: 'Expected goals — the probability a shot becomes a goal, judged from historical shots like it. Total xG measures chance quality, not luck.', - 'de-vig': "Bookmaker odds include a profit margin, so their implied probabilities sum past 100%. De-vigging rescales them to a fair 100% — the bookmaker's true opinion.", - ensemble: 'Two independent goal models (Elo-based and team attack/defence) blended by a weight chosen on validation data — the blend beat both members out-of-sample.', +// Plain-language explainers live in the dictionary (t.glossary.*); this maps +// the legacy keys onto the dictionary entries. +const GLOSSARY_KEY: Record = { + RPS: 'rps', + Brier: 'brier', + ECE: 'ece', + Elo: 'elo', + xG: 'xg', + 'de-vig': 'deVig', + ensemble: 'ensemble', }; /** A dotted-underline term with a plain-language tooltip from the glossary. */ -export function Term({ k, children }: { k: keyof typeof GLOSSARY | string; children?: ReactNode }) { +export function Term({ k, children }: { k: string; children?: ReactNode }) { + const t = useT(); + const dictKey = GLOSSARY_KEY[k]; + const entry = dictKey ? t.glossary[dictKey] : undefined; return ( - {children ?? k} + {children ?? entry?.term ?? k} ); } diff --git a/src/features/bracket/BracketPage.tsx b/src/features/bracket/BracketPage.tsx index 7adff9e..ac773bc 100644 --- a/src/features/bracket/BracketPage.tsx +++ b/src/features/bracket/BracketPage.tsx @@ -3,17 +3,11 @@ import { Link } from '@tanstack/react-router'; import { PageHeader } from '@/components/ui/PageHeader'; import { useTournamentStore } from '@/stores/tournamentStore'; import { teamFlag } from '@/lib/teams'; -import { kickoffDay, kickoffTime } from '@/lib/format'; import { cn } from '@/lib/cn'; +import { fmt, useFormat, useT } from '@/lib/i18n'; import type { Fixture, MatchProbs, Stage, TeamSlot } from '@/lib/types'; -const COLUMNS: { stage: Stage; label: string }[] = [ - { stage: 'r32', label: 'Round of 32' }, - { stage: 'r16', label: 'Round of 16' }, - { stage: 'qf', label: 'Quarter-finals' }, - { stage: 'sf', label: 'Semi-finals' }, - { stage: 'final', label: 'Final' }, -]; +const COLUMN_STAGES = ['r32', 'r16', 'qf', 'sf', 'final'] as const; function Side({ slot, score, winner }: { slot: TeamSlot; score: number | null; winner: boolean }) { return ( @@ -34,10 +28,11 @@ function Side({ slot, score, winner }: { slot: TeamSlot; score: number | null; w } function AdvanceBar({ probs }: { probs: MatchProbs }) { + const t = useT(); // Advance probability = regulation win + half of draws (shootout ≈ coin flip). const home = Math.round((probs.home + probs.draw / 2) * 100); return ( -
+
{home}%
@@ -49,6 +44,7 @@ function AdvanceBar({ probs }: { probs: MatchProbs }) { } function BracketMatch({ f, probs }: { f: Fixture; probs?: MatchProbs | undefined }) { + const { t, kickoffDay, kickoffTime } = useFormat(); const decided = f.status === 'finished' && f.homeScore !== null && f.awayScore !== null; const showProbs = !!probs && !!f.home.team && !!f.away.team && f.status !== 'finished'; return ( @@ -58,13 +54,14 @@ function BracketMatch({ f, probs }: { f: Fixture; probs?: MatchProbs | undefined f.homeScore!} /> {showProbs && }
- M{f.num} · {f.status === 'finished' ? 'FT' : `${kickoffDay(f.kickoff)} ${kickoffTime(f.kickoff)}`} + {fmt(t.common.matchNumberShort, { num: f.num })} · {f.status === 'finished' ? t.common.fullTimeShort : `${kickoffDay(f.kickoff)} ${kickoffTime(f.kickoff)}`}
); } export function BracketPage() { + const t = useT(); const snapshot = useTournamentStore((s) => s.snapshot); const model = useTournamentStore((s) => s.model); const probsByNum = useMemo( @@ -84,15 +81,15 @@ export function BracketPage() { return (
- +
- {COLUMNS.map((col) => ( -
-

{col.label}

- {byStage[col.stage].length - ? byStage[col.stage].map((f) => ) + {COLUMN_STAGES.map((stage) => ( +
+

{t.stage[stage]}

+ {byStage[stage].length + ? byStage[stage].map((f) => ) :
}
))} @@ -101,7 +98,7 @@ export function BracketPage() { {third && (
-

Third-place play-off

+

{t.bracket.thirdPlacePlayoff}

)} diff --git a/src/features/data/DataPage.tsx b/src/features/data/DataPage.tsx index 904c6ac..0d79ff8 100644 --- a/src/features/data/DataPage.tsx +++ b/src/features/data/DataPage.tsx @@ -3,6 +3,7 @@ import { Database, HardDrive, Radio, Scale } from 'lucide-react'; import { PageHeader } from '@/components/ui/PageHeader'; import { Card, CardBody, CardHeader } from '@/components/ui/Card'; import { Badge } from '@/components/ui/Badge'; +import { fmt as tfmt, plural, useT, type Dict } from '@/lib/i18n'; interface LakeStats { bytes: number; events: number; matchesParsed: number; players: number; @@ -19,10 +20,10 @@ interface DataStats { const fmt = (n: number) => n.toLocaleString(); const gb = (b: number) => `${(b / 1e9).toFixed(2)} GB`; -const ago = (t: number | null) => { - if (!t) return 'never'; +const ago = (t: number | null, d: Dict) => { + if (!t) return d.common.never; const m = Math.round((Date.now() - t) / 60000); - return m < 1 ? 'just now' : m < 60 ? `${m} min ago` : `${Math.round(m / 60)} h ago`; + return m < 1 ? d.common.justNow : m < 60 ? tfmt(d.common.minAgo, { n: m }) : tfmt(d.common.hoursAgo, { n: Math.round(m / 60) }); }; function Big({ value, label }: { value: string; label: string }) { @@ -44,6 +45,7 @@ function Counter({ label, value }: { label: string; value: string }) { } export function DataPage() { + const t = useT(); const [stats, setStats] = useState(null); useEffect(() => { @@ -58,28 +60,26 @@ export function DataPage() { return (
{lake && ( - Event-data lake - StatsBomb open data + {t.data.lake.heading} + {tfmt(t.data.lake.openDataBadge, { provider: 'StatsBomb' })}
- - - - + + + +

- Every pass, shot and duel from {lake.competitionSeasons} competition-seasons (eight World Cups among them), - processed into the style fingerprints ({lake.nationalTeamsCovered} of 48 nations covered) and the - score-state analysis shown on the methodology page. + {tfmt(t.data.lake.summary, { seasons: lake.competitionSeasons, covered: lake.nationalTeamsCovered, total: 48 })}

@@ -89,33 +89,33 @@ export function DataPage() { - Historical archive + {t.data.archive.heading} {stats.archive && ( <> - - - + + + )} - {stats.squadValues != null && } - {stats.fingerprints != null && } + {stats.squadValues != null && } + {stats.fingerprints != null && } - Live collection (this tournament) + {t.data.live.heading} - - - - - - + + + + + +
@@ -123,7 +123,7 @@ export function DataPage() { - Source health + {t.data.sources.heading} {stats.sources.length ? stats.sources.map((s) => ( @@ -131,20 +131,19 @@ export function DataPage() { {s.source} {s.open_until > Date.now() ? ( - paused (circuit open) + {t.data.sources.paused} ) : s.consec_fail > 0 ? ( - {s.consec_fail} recent failure{s.consec_fail > 1 ? 's' : ''} + {plural(t.data.sources.recentFailures, s.consec_fail)} ) : ( - healthy + {t.data.sources.healthy} )} - last OK {ago(s.last_ok)} + {tfmt(t.data.sources.lastOk, { time: ago(s.last_ok, t) })}
- )) :

No live sources polled yet.

} + )) :

{t.data.sources.empty}

}

- Sources: openfootball (fixtures) · ESPN (live scores, form, lineups, bookmaker lines) · football-data.org (fallback) · - martj42 results archive · StatsBomb open data · Transfermarkt (squad values). All scraping is server-side, cached and - rate-limited; a blocked source degrades to stale data, never a broken page. + {t.data.sources.footer.label} openfootball ({t.data.sources.footer.roleFixtures}) · ESPN ({t.data.sources.footer.roleLiveScores}) · football-data.org ({t.data.sources.footer.roleFallback}) · + martj42 ({t.data.sources.footer.roleResultsArchive}) · StatsBomb ({t.data.sources.footer.roleOpenData}) · Transfermarkt ({t.data.sources.footer.roleSquadValues}). {t.data.sources.footer.note}

diff --git a/src/features/groups/GroupsPage.tsx b/src/features/groups/GroupsPage.tsx index f1e901e..676c96c 100644 --- a/src/features/groups/GroupsPage.tsx +++ b/src/features/groups/GroupsPage.tsx @@ -3,8 +3,10 @@ import { Users } from 'lucide-react'; import { PageHeader } from '@/components/ui/PageHeader'; import { GroupTable } from '@/components/GroupTable'; import { useTournamentStore } from '@/stores/tournamentStore'; +import { useT } from '@/lib/i18n'; export function GroupsPage() { + const t = useT(); const snapshot = useTournamentStore((s) => s.snapshot); const tables = snapshot?.tables ?? {}; const groups = Object.keys(tables).sort(); @@ -12,11 +14,11 @@ export function GroupsPage() { return (
- All 48 teams + {t.groups.allTeams} } /> @@ -36,10 +38,10 @@ export function GroupsPage() {
- Top 2 — advance + {t.groups.legendTop2} - 3rd — best-third race + {t.groups.legendThird}
diff --git a/src/features/live/LivePage.tsx b/src/features/live/LivePage.tsx index 2f353de..86b7a28 100644 --- a/src/features/live/LivePage.tsx +++ b/src/features/live/LivePage.tsx @@ -4,6 +4,7 @@ import { PageHeader } from '@/components/ui/PageHeader'; import { MatchCard } from '@/components/MatchCard'; import { useTournamentStore } from '@/stores/tournamentStore'; import { dayKeyOf, isToday, kickoffDay } from '@/lib/format'; +import { fmt, useFormat } from '@/lib/i18n'; import type { Fixture } from '@/lib/types'; function Section({ title, fixtures }: { title: string; fixtures: Fixture[] }) { @@ -20,6 +21,7 @@ function Section({ title, fixtures }: { title: string; fixtures: Fixture[] }) { export function LivePage() { const snapshot = useTournamentStore((s) => s.snapshot); + const { t, locale } = useFormat(); const groups = useMemo(() => { const fixtures = snapshot?.fixtures ?? []; @@ -40,13 +42,13 @@ export function LivePage() { .sort((a, b) => b.kickoff.localeCompare(a.kickoff)) .slice(0, 6); - return { live, today, nextDay, nextDayLabel: upcoming[0] ? kickoffDay(upcoming[0].kickoff) : '', recent }; - }, [snapshot]); + return { live, today, nextDay, nextDayLabel: upcoming[0] ? kickoffDay(upcoming[0].kickoff, locale) : '', recent }; + }, [snapshot, locale]); if (!snapshot) { return (
- +
{Array.from({ length: 4 }).map((_, i) => (
@@ -61,21 +63,21 @@ export function LivePage() { return (
-
-
+
+
{nothingToday && ( -
+
)} -
+
{nothingToday && !groups.nextDay.length && !groups.recent.length && (
-

The schedule is loaded — matches will appear here as the tournament unfolds.

+

{t.live.empty}

)}
diff --git a/src/features/match/MatchPreviewPage.tsx b/src/features/match/MatchPreviewPage.tsx index 5c4fb84..d0cec5c 100644 --- a/src/features/match/MatchPreviewPage.tsx +++ b/src/features/match/MatchPreviewPage.tsx @@ -5,22 +5,11 @@ import { Card, CardBody, CardHeader } from '@/components/ui/Card'; import { WinProbBar } from '@/components/WinProbBar'; import { FormChips } from '@/components/FormChips'; import { teamFlag } from '@/lib/teams'; -import { kickoffDay, kickoffTime } from '@/lib/format'; +import { fmt, useFormat, useT } from '@/lib/i18n'; import { inPlayProbs } from '@/lib/model/inplay'; import { useTournamentStore } from '@/stores/tournamentStore'; import type { Fixture, KeyPlayer, MatchPreview } from '@/lib/types'; -const STAGE: Record = { - group: 'Group', r32: 'Round of 32', r16: 'Round of 16', qf: 'Quarter-final', sf: 'Semi-final', third: 'Third place', final: 'Final', -}; -const STAT_ROWS: [string, string][] = [ - ['possessionPct', 'Possession'], - ['totalShots', 'Shots'], - ['shotsOnTarget', 'On target'], - ['wonCorners', 'Corners'], - ['foulsCommitted', 'Fouls'], - ['saves', 'Saves'], -]; const num = (s: string | undefined) => (s ? Number(s.replace('%', '')) || 0 : 0); function eventIcon(type: string): string { @@ -50,6 +39,7 @@ function LiveStatRow({ label, home, away }: { label: string; home: number; away: } function ScorerList({ team, players }: { team: string; players: KeyPlayer[] }) { + const t = useT(); return (
{teamFlag(team)} {team}
@@ -58,11 +48,11 @@ function ScorerList({ team, players }: { team: string; players: KeyPlayer[] }) { {players.slice(0, 6).map((p) => (
  • {p.name} - {p.goals}{p.pens ? ` (${p.pens}p)` : ''} + {p.goals}{p.pens ? ` (${fmt(t.common.pensShort, { n: p.pens })})` : ''}
  • ))} - ) :

    no data

    } + ) :

    {t.common.noData}

    }
    ); } @@ -88,6 +78,7 @@ function LineupCol({ team, players }: { team: string; players: { name: string; p export function MatchPreviewPage() { const { num: numParam } = useParams({ strict: false }) as { num: string }; + const { t, kickoffDay, kickoffTime } = useFormat(); const [preview, setPreview] = useState(null); const [failed, setFailed] = useState(false); @@ -117,7 +108,16 @@ export function MatchPreviewPage() { return inPlayProbs(preview.prediction.lambdaHome, preview.prediction.lambdaAway, f.minute ?? 0, f.homeScore, f.awayScore); }, [preview, f]); - if (failed) return
    Couldn't load that match. Back to Live
    ; + const statRows = useMemo<[string, string][]>(() => [ + ['possessionPct', t.match.stats.possessionPct], + ['totalShots', t.match.stats.totalShots], + ['shotsOnTarget', t.match.stats.shotsOnTarget], + ['wonCorners', t.match.stats.wonCorners], + ['foulsCommitted', t.match.stats.foulsCommitted], + ['saves', t.match.stats.saves], + ], [t]); + + if (failed) return
    {t.match.loadError} {t.match.backToLive}
    ; if (!preview || !f) return
    ; const hasScore = f.status === 'live' || f.status === 'finished'; @@ -127,13 +127,13 @@ export function MatchPreviewPage() { return (
    - Back + {t.common.back} {/* hero */}
    - {f.group ? `Group ${f.group}` : STAGE[f.stage]} · {kickoffDay(f.kickoff)} · {kickoffTime(f.kickoff)} · {preview.venue} + {f.group ? fmt(t.common.group, { group: f.group }) : t.stage[f.stage]} · {kickoffDay(f.kickoff)} · {kickoffTime(f.kickoff)} · {preview.venue}
    @@ -142,8 +142,8 @@ export function MatchPreviewPage() {
    {hasScore ?
    {f.homeScore ?? 0}–{f.awayScore ?? 0}
    :
    {kickoffTime(f.kickoff)}
    } - {isLive &&
    {f.minute ? `${f.minute}'` : 'Live'}
    } - {finished &&
    Full time
    } + {isLive &&
    {f.minute ? fmt(t.live.minute, { n: f.minute }) : t.common.liveNow}
    } + {finished &&
    {t.common.fullTime}
    }
    {f.away.team ? teamFlag(f.away.team) : '⚽'} @@ -158,11 +158,11 @@ export function MatchPreviewPage() { - Live win probability + {t.match.liveWinProbability} -

    Updates with the score and clock — remaining goals modelled from each side's pre-match expectation.

    +

    {t.match.inPlayHelp}

    )} @@ -170,9 +170,9 @@ export function MatchPreviewPage() { {/* LIVE/FT: stats */} {preview.liveStats && ( - Match stats + {t.match.statsTitle} - {STAT_ROWS.filter(([k]) => preview.liveStats!.home[k] != null || preview.liveStats!.away[k] != null).map(([k, label]) => ( + {statRows.filter(([k]) => preview.liveStats!.home[k] != null || preview.liveStats!.away[k] != null).map(([k, label]) => ( ))} @@ -182,12 +182,12 @@ export function MatchPreviewPage() { {/* LIVE/FT: timeline */} {preview.events.length > 0 && ( - Timeline + {t.match.timeline}
      {preview.events.map((e, i) => (
    • - {e.minute != null ? `${e.minute}'` : ''} + {e.minute != null ? fmt(t.live.minute, { n: e.minute }) : ''} {eventIcon(e.type)} {e.text || e.type}
    • @@ -200,10 +200,10 @@ export function MatchPreviewPage() { {/* model expectation */} {preview.prediction && ( - {hasScore ? 'Pre-match model' : 'Model expectation'} + {hasScore ? t.match.preMatchModel : t.match.modelExpectation} -

      Expected goals: {preview.prediction.lambdaHome.toFixed(2)} – {preview.prediction.lambdaAway.toFixed(2)} · model odds, not betting advice

      +

      {fmt(t.match.expectedGoalsLine, { home: preview.prediction.lambdaHome.toFixed(2), away: preview.prediction.lambdaAway.toFixed(2) })}

      )} @@ -211,7 +211,7 @@ export function MatchPreviewPage() { {/* form */} {(preview.form.home.length > 0 || preview.form.away.length > 0) && ( - Recent form + {t.match.recentForm}
      {f.home.team && teamFlag(f.home.team)} {homeName}
      {f.away.team && teamFlag(f.away.team)} {awayName}
      @@ -222,15 +222,15 @@ export function MatchPreviewPage() { {/* head to head */} {preview.h2h && ( - Head to head + {t.match.headToHead}
      -
      {preview.h2h.homeWins}
      {homeName} wins
      -
      {preview.h2h.draws}
      draws · {preview.h2h.games} total
      -
      {preview.h2h.awayWins}
      {awayName} wins
      +
      {preview.h2h.homeWins}
      {fmt(t.match.h2h.teamWins, { team: homeName })}
      +
      {preview.h2h.draws}
      {fmt(t.match.h2h.drawsTotal, { games: preview.h2h.games })}
      +
      {preview.h2h.awayWins}
      {fmt(t.match.h2h.teamWins, { team: awayName })}
      -
      Recent meetings
      +
      {t.match.h2h.recentMeetings}
        {preview.h2h.last.map((m, i) => (
      • {m.date.slice(0, 10)}{m.home} {m.homeScore}–{m.awayScore} {m.away}
      • @@ -243,7 +243,7 @@ export function MatchPreviewPage() { {/* lineups or key players */} - {preview.lineups ? 'Lineups' : 'Key players (all-time scorers)'} + {preview.lineups ? t.match.lineups : t.match.keyPlayers} {preview.lineups ? ( <> diff --git a/src/features/methodology/MethodologyPage.tsx b/src/features/methodology/MethodologyPage.tsx index c68938d..b987193 100644 --- a/src/features/methodology/MethodologyPage.tsx +++ b/src/features/methodology/MethodologyPage.tsx @@ -1,19 +1,16 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, type ReactNode } from 'react'; import { Activity, Dices, Gauge, TrendingUp } from 'lucide-react'; import { PageHeader } from '@/components/ui/PageHeader'; import { Card, CardBody, CardHeader } from '@/components/ui/Card'; import { Term } from '@/components/ui/Term'; import { ReliabilityDiagram } from '@/components/ReliabilityDiagram'; +import { fmt, fmtSplit, useFormat, useT } from '@/lib/i18n'; import type { BacktestReport } from '@/lib/types'; -const STEPS = [ - { icon: TrendingUp, title: 'Elo ratings', body: '150 years of international results (49,000 matches) build a strength rating for every nation, updated after each game and weighted by match importance.' }, - { icon: Activity, title: 'Two goal models', body: 'Elo-based goal expectations are blended with an independent attack/defence Dixon-Coles model (time-decayed, fit on the last 15 years) — an ensemble that beat both members in testing.' }, - { icon: Gauge, title: 'Dixon-Coles scorelines', body: 'A bivariate-Poisson scoreline model (with the Dixon-Coles low-score correction) turns the blended expectations into the full distribution of scorelines and win/draw/loss.' }, - { icon: Dices, title: 'Monte Carlo', body: 'The whole 48-team tournament is simulated 20,000 times — sampling every remaining match — to produce championship and advancement odds that update after each result.' }, -]; +const STEP_ICONS = [TrendingUp, Activity, Gauge, Dices] as const; function RpsBar({ label, rps, best, highlight }: { label: string; rps: number; best: number; highlight?: boolean }) { + const t = useT(); // shorter bar = better (lower RPS). Scale relative to the worst (uniform ~0.24). const width = Math.min(100, (rps / 0.26) * 100); return ( @@ -23,7 +20,7 @@ function RpsBar({ label, rps, best, highlight }: { label: string; rps: number; b
        {rps.toFixed(3)} - {rps === best && best} + {rps === best && {t.methodology.backtest.bestBadge}}
      ); } @@ -38,6 +35,7 @@ function Stat({ value, label }: { value: string; label: string }) { } export function MethodologyPage() { + const { t, locale } = useFormat(); const [bt, setBt] = useState(null); useEffect(() => { @@ -48,17 +46,44 @@ export function MethodologyPage() { const bestRps = bt ? Math.min(bt.model.rps, bt.baselines.uniform.rps, bt.baselines.baseRate.rps, bt.baselines.eloOnly.rps) : 0; + const steps = [t.methodology.steps.elo, t.methodology.steps.goalModels, t.methodology.steps.scorelines, t.methodology.steps.monteCarlo] + .map((s, i) => ({ ...s, icon: STEP_ICONS[i] ?? TrendingUp })); + + const introParts = bt + ? fmtSplit( + fmt( + bt.validation + ? t.methodology.backtest.intro + : t.methodology.backtest.intro.replace(/\{valFrom\}.*?\{valTo\}/, t.methodology.backtest.validationFallback), + { + trainEnd: bt.trainEnd.slice(0, 4), + testFrom: (bt.testFrom ?? bt.trainEnd).slice(0, 4), + testTo: bt.testTo.slice(0, 4), + ...(bt.validation ? { valFrom: bt.validation.from.slice(0, 4), valTo: bt.validation.to.slice(0, 4) } : {}), + }, + ), + ) + : []; + + const metricsParts = fmtSplit(t.methodology.backtest.metricsLine); + const metricsNodes: Record = { + rps: , + brier: , + ece: , + ensemble: , + }; + return (
      - +
      - {STEPS.map((s, i) => ( + {steps.map((s, i) => (
      - STEP {i + 1} + {fmt(t.methodology.stepLabel, { n: i + 1 })}

      {s.title}

      {s.body}

      @@ -68,30 +93,35 @@ export function MethodologyPage() {
      - How good is it? (out-of-sample backtest) + {t.methodology.backtest.heading} {bt ? ( <>

      - Strict three-way split: parameters fit before {bt.trainEnd.slice(0, 4)}, ensemble settings tuned on {bt.validation ? `${bt.validation.from.slice(0, 4)}–${bt.validation.to.slice(0, 4)}` : 'a validation window'}, and the numbers below come from{' '} - {bt.tested.toLocaleString()} untouched matches ({(bt.testFrom ?? bt.trainEnd).slice(0, 4)}–{bt.testTo.slice(0, 4)}) — each predicted walk-forward using only prior data. + {introParts.map((p, i) => + typeof p === 'string' + ? p + : p.key === 'tested' + ? {bt.tested.toLocaleString(locale === 'de' ? 'de-DE' : 'en-GB')} + : null, + )}

      - - - + + +

      - Metrics: · · · the shipped model is an . + {metricsParts.map((p, i) => (typeof p === 'string' ? p : {metricsNodes[p.key] ?? null}))}

      -
      Ranked Probability Score — lower is better, vs candidates & baselines
      +
      {t.methodology.backtest.rpsChartHeading}
      - {(bt.variants ?? [{ name: 'This model', ...bt.model }]).map((v, i) => ( + {(bt.variants ?? [{ name: t.methodology.backtest.thisModel, ...bt.model }]).map((v, i) => ( ))} - - + +
      @@ -102,20 +132,20 @@ export function MethodologyPage() {
      - Is it calibrated? + {t.methodology.calibration.heading} {bt ? :
      } - Honest limits + {t.methodology.limits.heading}
        -
      • • These are model probabilities, not betting advice. We don't use bookmaker odds, so we make no claim to beat the market — sharp betting markets remain the most accurate forecaster there is.
      • -
      • • International expected-goals and event data are sparse, so per-match tactical breakdowns are richer for some teams than others, and improve as live data accumulates during the tournament.
      • -
      • • Football is high-variance: a 60% favourite still loses plenty. Calibration means our 60% really is ~60% — not that the favourite always wins.
      • -
      • • Everything is transparent and reproducible from public data (results, ESPN, StatsBomb) — that openness is the point.
      • +
      • • {t.methodology.limits.notAdvice}
      • +
      • • {t.methodology.limits.sparseData}
      • +
      • • {t.methodology.limits.variance}
      • +
      • • {t.methodology.limits.openData}
      diff --git a/src/features/predictions/PredictionsPage.tsx b/src/features/predictions/PredictionsPage.tsx index 4a2797d..74c4cf1 100644 --- a/src/features/predictions/PredictionsPage.tsx +++ b/src/features/predictions/PredictionsPage.tsx @@ -9,7 +9,7 @@ import { WinProbBar } from '@/components/WinProbBar'; // Recharts is heavy and only used here — load it as a separate on-demand chunk. const OddsChart = lazy(() => import('@/components/OddsChart').then((m) => ({ default: m.OddsChart }))); import { teamFlag } from '@/lib/teams'; -import { kickoffDay, kickoffTime } from '@/lib/format'; +import { fmt, useFormat } from '@/lib/i18n'; import { useTournamentStore } from '@/stores/tournamentStore'; function ChampionBoard({ odds }: { odds: { team: string; champion: number }[] }) { @@ -38,6 +38,7 @@ function ChampionBoard({ odds }: { odds: { team: string; champion: number }[] }) } export function PredictionsPage() { + const { t, locale, kickoffDay, kickoffTime } = useFormat(); const model = useTournamentStore((s) => s.model); const snapshot = useTournamentStore((s) => s.snapshot); @@ -54,7 +55,7 @@ export function PredictionsPage() { if (!model) { return (
      - +
      ); @@ -63,11 +64,11 @@ export function PredictionsPage() { return (
      - How & how accurate + {t.predict.methodologyLink} } /> @@ -76,7 +77,7 @@ export function PredictionsPage() { - Who lifts the trophy? + {t.predict.trophy.title} @@ -86,7 +87,7 @@ export function PredictionsPage() { - Title race over time + {t.predict.titleRace.title} {model.history.length >= 2 ? ( @@ -95,30 +96,27 @@ export function PredictionsPage() { ) : (
      -

      - The title-race chart fills in as results come in — each completed match re-rates - the teams and re-runs the simulation. -

      +

      {t.predict.titleRace.empty}

      )}
      -

      Full odds — group, knockout & title

      +

      {t.predict.fullOddsHeading}

      {upcoming.length > 0 && ( <> -

      Next match predictions

      +

      {t.predict.nextMatchesHeading}

      {upcoming.map(({ m, f }) => (
      - {f.group ? `Group ${f.group}` : f.round} + {f.group ? fmt(t.common.group, { group: f.group }) : f.round} {kickoffDay(f.kickoff)} · {kickoffTime(f.kickoff)}
      diff --git a/src/features/scoreboard/ScoreboardPage.tsx b/src/features/scoreboard/ScoreboardPage.tsx index aeb7970..4217ce7 100644 --- a/src/features/scoreboard/ScoreboardPage.tsx +++ b/src/features/scoreboard/ScoreboardPage.tsx @@ -1,11 +1,11 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, type ReactNode } from 'react'; import { Link } from '@tanstack/react-router'; import { Scale } from 'lucide-react'; import { PageHeader } from '@/components/ui/PageHeader'; import { Card, CardBody, CardHeader } from '@/components/ui/Card'; import { Term } from '@/components/ui/Term'; import { teamFlag } from '@/lib/teams'; -import { kickoffDay, kickoffTime } from '@/lib/format'; +import { fmt, fmtSplit, plural, useFormat, useT } from '@/lib/i18n'; import { cn } from '@/lib/cn'; import type { MatchProbs, ScoreboardData, ScoreboardRow } from '@/lib/types'; @@ -29,9 +29,11 @@ function ProbTriple({ label, probs, tone }: { label: string; probs: MatchProbs; } function Row({ r }: { r: ScoreboardRow }) { + const { t, kickoffDay, kickoffTime } = useFormat(); + const { scored } = r; const closer = - r.scored && r.scored.marketRps != null - ? r.scored.modelRps < r.scored.marketRps ? 'model' : r.scored.marketRps < r.scored.modelRps ? 'market' : 'tie' + scored && scored.marketRps != null + ? scored.modelRps < scored.marketRps ? 'model' : scored.marketRps < scored.modelRps ? 'market' : 'tie' : null; return ( @@ -39,28 +41,34 @@ function Row({ r }: { r: ScoreboardRow }) {
      {teamFlag(r.home)} {r.home} - {r.status !== 'scheduled' && r.homeScore != null ? `${r.homeScore}–${r.awayScore}` : 'vs'} + {r.status !== 'scheduled' && r.homeScore != null ? `${r.homeScore}–${r.awayScore}` : t.common.vs} {r.away} {teamFlag(r.away)} - {r.group ? `Group ${r.group} · ` : ''}{kickoffDay(r.kickoff)} {kickoffTime(r.kickoff)} - {r.late && ' · snapshot late — excluded from totals'} + {r.group ? `${fmt(t.common.group, { group: r.group })} · ` : ''}{kickoffDay(r.kickoff)} {kickoffTime(r.kickoff)} + {r.late && ` · ${t.scoreboard.row.lateNote}`}
      - + {r.market ? ( ) : ( -

      no bookmaker line captured before kickoff

      +

      {t.scoreboard.row.noMarketLine}

      )} - {r.scored && ( + {scored && (
      - result: {r.scored.outcome} - model RPS {r.scored.modelRps} - {r.scored.marketRps != null && market RPS {r.scored.marketRps}} + + {fmtSplit(t.scoreboard.row.result).map((p, i) => + typeof p === 'string' ? p : p.key === 'outcome' ? ( + {t.outcome[scored.outcome]} + ) : null, + )} + + {fmt(t.scoreboard.row.modelRps, { rps: scored.modelRps })} + {scored.marketRps != null && {fmt(t.scoreboard.row.marketRps, { rps: scored.marketRps })}} {closer && closer !== 'tie' && ( - {closer === 'model' ? 'model closer' : 'market closer'} + {closer === 'model' ? t.scoreboard.row.modelCloser : t.scoreboard.row.marketCloser} )}
      @@ -71,6 +79,7 @@ function Row({ r }: { r: ScoreboardRow }) { } export function ScoreboardPage() { + const t = useT(); const [data, setData] = useState(null); useEffect(() => { @@ -81,48 +90,53 @@ export function ScoreboardPage() { return () => { alive = false; clearInterval(id); }; }, []); - const t = data?.totals ?? null; - const lead = t ? (t.modelRps < t.marketRps ? 'model' : t.marketRps < t.modelRps ? 'market' : 'tie') : null; + const totals = data?.totals ?? null; + const lead = totals ? (totals.modelRps < totals.marketRps ? 'model' : totals.marketRps < totals.modelRps ? 'market' : 'tie') : null; return (
      - Running head-to-head + {t.scoreboard.headToHead.title} - {t ? ( + {totals ? (
      -
      {t.modelRps.toFixed(4)}
      -
      our model · RPS
      -
      closer {t.modelCloser}×
      +
      {totals.modelRps.toFixed(4)}
      +
      {t.scoreboard.headToHead.modelLabel}
      +
      {fmt(t.scoreboard.headToHead.closerTimes, { n: totals.modelCloser })}
      - {t.n} match{t.n === 1 ? '' : 'es'} scored -
      {lead === 'tie' ? 'dead level' : lead === 'model' ? 'model ahead' : 'market ahead'}
      + {plural(t.scoreboard.headToHead.matchesScored, totals.n)} +
      {lead === 'tie' ? t.scoreboard.headToHead.tie : lead === 'model' ? t.scoreboard.headToHead.modelAhead : t.scoreboard.headToHead.marketAhead}
      -
      {t.marketRps.toFixed(4)}
      -
      bookmaker · RPS
      -
      closer {t.marketCloser}×
      +
      {totals.marketRps.toFixed(4)}
      +
      {t.scoreboard.headToHead.marketLabel}
      +
      {fmt(t.scoreboard.headToHead.closerTimes, { n: totals.marketCloser })}
      ) : ( -

      - The head-to-head starts with the first final whistle — forecasts are frozen before each kickoff and scored when the match ends. -

      +

      {t.scoreboard.headToHead.empty}

      )}

      - Honest rules: both forecasts frozen pre-kickoff; bookmaker margin removed (); scored with the{' '} - Ranked Probability Score; late snapshots excluded. Beating the market over a small sample is - noise — watch the gap, not the lead. + {fmtSplit(t.scoreboard.rules.text).map((p, i) => + typeof p === 'string' + ? p + : ( + { + devig: , + rps: {t.scoreboard.rules.rpsTerm}, + } as Record + )[p.key] ?? null, + )}

      @@ -130,7 +144,7 @@ export function ScoreboardPage() { {data ? (
      {data.rows.length === 0 && ( -

      No frozen forecasts yet — the first snapshots are taken 15 minutes before kickoff.

      +

      {fmt(t.scoreboard.emptyRows, { minutes: 15 })}

      )} {data.rows.map((r) => )}
      diff --git a/src/features/story/StoryPage.tsx b/src/features/story/StoryPage.tsx index e657691..02a024f 100644 --- a/src/features/story/StoryPage.tsx +++ b/src/features/story/StoryPage.tsx @@ -7,6 +7,7 @@ import { ShotMap } from '@/components/viz/ShotMap'; import { XgRace } from '@/components/viz/XgRace'; import { PassNetwork } from '@/components/viz/PassNetwork'; import { teamFlag } from '@/lib/teams'; +import { fmt, fmtSplit, useT } from '@/lib/i18n'; import type { StoryViz } from '@/lib/types'; function StatRow({ label, home, away, fmt = (n: number) => String(n) }: { @@ -29,6 +30,7 @@ function StatRow({ label, home, away, fmt = (n: number) => String(n) }: { } export function StoryPage() { + const t = useT(); const [viz, setViz] = useState(null); const [failed, setFailed] = useState(false); @@ -44,9 +46,11 @@ export function StoryPage() { if (failed) { return (
      - + }> - The story dataset isn't available. Run npm run data:viz to generate it. + {fmtSplit(t.story.datasetMissing).map((p, i) => + typeof p === 'string' ? p : npm run data:viz, + )}
      ); @@ -55,7 +59,7 @@ export function StoryPage() { if (!viz) { return (
      - +
      ); @@ -64,7 +68,7 @@ export function StoryPage() { return (
      @@ -83,57 +87,52 @@ export function StoryPage() {

      - Argentina and France traded six goals across 120 minutes — Messi twice, Di María, and a - Mbappé hat-trick — before penalties. Here's what the event data says about how it happened. + {t.story.intro}

      {/* totals */} - By the numbers + {t.story.byTheNumbers} - n.toFixed(2)} /> - - - + n.toFixed(2)} /> + + + {/* shot map */} - Every shot, sized by xG + {t.story.shotMapTitle}

      - Argentina ({viz.totals.home.xg.toFixed(2)} xG) created the bigger chances; France's - comeback was built on a flurry of high-value shots once Mbappé sparked into life. Each - dot is a shot — bigger means a better chance; filled dots are goals. + {fmt(t.story.shotMapCaption, { xg: viz.totals.home.xg.toFixed(2) })}

      {/* xG race */} - The xG race + {t.story.xgRaceTitle}

      - Argentina built a commanding expected-goals lead through 80 minutes — then France's - two goals in 97 seconds dragged the line sharply upward. Steep steps are big chances. + {t.story.xgRaceCaption}

      {/* pass networks */} - Passing shape (starting XI) + {t.story.passNetworkTitle}

      - Each node is a player at their average pass position; links are the completed passes - between a pair, up to the first substitution. Bigger nodes saw more of the ball. + {t.story.passNetworkCaption}

      diff --git a/src/features/team/TeamProfilePage.tsx b/src/features/team/TeamProfilePage.tsx index 4a1de9c..1740971 100644 --- a/src/features/team/TeamProfilePage.tsx +++ b/src/features/team/TeamProfilePage.tsx @@ -4,34 +4,36 @@ import { ArrowLeft } from 'lucide-react'; import { Card, CardBody, CardHeader } from '@/components/ui/Card'; import { Badge } from '@/components/ui/Badge'; import { teamFlag } from '@/lib/teams'; -import { kickoffDay, kickoffTime } from '@/lib/format'; +import { fmt, useFormat, useT } from '@/lib/i18n'; import type { Fixture, TeamProfile } from '@/lib/types'; const pct = (x: number | null) => (x == null ? '—' : `${(x * 100).toFixed(1)}%`); function FixtureRow({ f, team }: { f: Fixture; team: string }) { + const { t, kickoffDay, kickoffTime } = useFormat(); const isHome = f.home.team === team; const opp = isHome ? f.away : f.home; const done = f.status === 'finished' || f.status === 'live'; const us = isHome ? f.homeScore : f.awayScore; const them = isHome ? f.awayScore : f.homeScore; - const res = done && us != null && them != null ? (us > them ? 'W' : us < them ? 'L' : 'D') : null; + const res = done && us != null && them != null ? (us > them ? 'win' : us < them ? 'loss' : 'draw') : null; return ( - {f.group ? `Grp ${f.group}` : f.round.replace('Round of', 'R')} - {isHome ? 'vs' : '@'} + {f.group ? fmt(t.team.groupShort, { group: f.group }) : f.stage !== 'group' ? t.team.stageShort[f.stage] : f.round} + {isHome ? t.common.vs : t.team.awayIndicator} {opp.team ? teamFlag(opp.team) : '⚽'} {opp.label} {done && us != null ? {us}–{them} : `${kickoffDay(f.kickoff)} ${kickoffTime(f.kickoff)}`} - {res && {res}} + {res && {t.team.resultShort[res]}} ); } export function TeamProfilePage() { const { name } = useParams({ strict: false }) as { name: string }; + const t = useT(); const [profile, setProfile] = useState(null); const [failed, setFailed] = useState(false); @@ -46,14 +48,14 @@ export function TeamProfilePage() { return () => { alive = false; }; }, [name]); - if (failed) return
      Unknown team. All teams
      ; + if (failed) return
      {t.team.unknown} {t.team.allTeams}
      ; if (!profile) return
      ; const s = profile.standing; return (
      - All teams + {t.team.allTeams} @@ -62,16 +64,16 @@ export function TeamProfilePage() {

      {profile.team}

      - {profile.group && Group {profile.group}} - Elo {profile.elo} - Title {pct(profile.championOdds)} - Advance {pct(profile.qualifyOdds)} + {profile.group && {fmt(t.common.group, { group: profile.group })}} + {fmt(t.team.eloBadge, { rating: profile.elo })} + {fmt(t.team.titleOddsBadge, { pct: pct(profile.championOdds) })} + {fmt(t.team.advanceOddsBadge, { pct: pct(profile.qualifyOdds) })}
      {s && (
      -
      Group standing
      -
      #{s.rank} · {s.points} pts · {s.won}-{s.drawn}-{s.lost} · GD {s.gd > 0 ? `+${s.gd}` : s.gd}
      +
      {t.team.groupStanding}
      +
      {fmt(t.team.standingLine, { rank: s.rank, points: s.points, won: s.won, drawn: s.drawn, lost: s.lost, gd: s.gd > 0 ? `+${s.gd}` : s.gd })}
      )} @@ -79,14 +81,14 @@ export function TeamProfilePage() {
      - Fixtures + {t.team.fixtures} {profile.fixtures.map((f) => )} - All-time top scorers + {t.team.allTimeTopScorers} {profile.keyPlayers.length ? (
        @@ -95,11 +97,11 @@ export function TeamProfilePage() { {i + 1} {p.name} {p.goals} - {p.pens ? {p.pens}p : null} + {p.pens ? {fmt(t.common.pensShort, { n: p.pens })} : null} ))}
      - ) :

      No data.

      } + ) :

      {t.common.noData}

      }
      diff --git a/src/features/teams/TeamsPage.tsx b/src/features/teams/TeamsPage.tsx index d600a96..e1afd57 100644 --- a/src/features/teams/TeamsPage.tsx +++ b/src/features/teams/TeamsPage.tsx @@ -3,10 +3,12 @@ import { Link } from '@tanstack/react-router'; import { PageHeader } from '@/components/ui/PageHeader'; import { teamFlag } from '@/lib/teams'; import { useTournamentStore } from '@/stores/tournamentStore'; +import { fmt, useT } from '@/lib/i18n'; const pct = (x: number) => (x >= 0.995 ? '>99%' : x < 0.005 ? '—' : `${(x * 100).toFixed(x < 0.1 ? 1 : 0)}%`); export function TeamsPage() { + const t = useT(); const model = useTournamentStore((s) => s.model); const snapshot = useTournamentStore((s) => s.snapshot); @@ -18,25 +20,25 @@ export function TeamsPage() { return (
      - + {teams.length ? (
      - {teams.map((t, i) => ( + {teams.map((team, i) => ( {i + 1} - {teamFlag(t.team)} + {teamFlag(team.team)}
      -
      {t.team}
      -
      {t.group ? `Group ${t.group}` : ''}
      +
      {team.team}
      +
      {team.group ? fmt(t.common.group, { group: team.group }) : ''}
      -
      {pct(t.champion)}
      -
      advance {pct(t.qualify)}
      +
      {pct(team.champion)}
      +
      {fmt(t.teams.advanceShort, { pct: pct(team.qualify) })}
      ))} diff --git a/src/lib/format.ts b/src/lib/format.ts index a3c33db..1064163 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -1,18 +1,31 @@ -// Date/score formatting in the viewer's local timezone. +// Date/score formatting in the viewer's local timezone, in the app locale. +import type { Locale } from '@/stores/localeStore'; -const time = new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }); -const dayLong = new Intl.DateTimeFormat(undefined, { weekday: 'short', day: 'numeric', month: 'short' }); +const TAG: Record = { en: 'en-GB', de: 'de-DE' }; +const cache = new Map(); +function dtf(locale: Locale, kind: 'time' | 'day'): Intl.DateTimeFormat { + const key = `${locale}:${kind}`; + let f = cache.get(key); + if (!f) { + f = new Intl.DateTimeFormat( + TAG[locale], + kind === 'time' ? { hour: '2-digit', minute: '2-digit' } : { weekday: 'short', day: 'numeric', month: 'short' }, + ); + cache.set(key, f); + } + return f; +} const dayKey = new Intl.DateTimeFormat('en-CA', { year: 'numeric', month: '2-digit', day: '2-digit' }); -export function kickoffTime(iso: string): string { - return time.format(new Date(iso)); +export function kickoffTime(iso: string, locale: Locale = 'en'): string { + return dtf(locale, 'time').format(new Date(iso)); } -export function kickoffDay(iso: string): string { - return dayLong.format(new Date(iso)); +export function kickoffDay(iso: string, locale: Locale = 'en'): string { + return dtf(locale, 'day').format(new Date(iso)); } -/** Stable local-day key (YYYY-MM-DD) for grouping fixtures by date. */ +/** Stable local-day key (YYYY-MM-DD) for grouping fixtures by date — locale-independent. */ export function dayKeyOf(iso: string): string { return dayKey.format(new Date(iso)); } @@ -21,12 +34,17 @@ export function isToday(iso: string): boolean { return dayKeyOf(iso) === dayKey.format(new Date()); } -/** "in 2h", "live", "today 19:00", "Thu 11 Jun" — a compact when-label. */ -export function relativeKickoff(iso: string): string { +/** "in 2m", "today 19:00", "Thu 11 Jun · 19:00" — a compact when-label. + * `rel` carries the localized templates (common.inMinutes / common.todayAt). */ +export function relativeKickoff( + iso: string, + locale: Locale = 'en', + rel: { inMinutes: string; todayAt: string } = { inMinutes: 'in {n}m', todayAt: 'today {time}' }, +): string { const diffMin = Math.round((new Date(iso).getTime() - Date.now()) / 60000); - if (diffMin < 0) return kickoffTime(iso); - if (diffMin < 60) return `in ${diffMin}m`; - if (isToday(iso)) return `today ${kickoffTime(iso)}`; - if (diffMin < 60 * 24 * 7) return `${kickoffDay(iso)} · ${kickoffTime(iso)}`; - return kickoffDay(iso); + if (diffMin < 0) return kickoffTime(iso, locale); + if (diffMin < 60) return rel.inMinutes.replace('{n}', String(diffMin)); + if (isToday(iso)) return rel.todayAt.replace('{time}', kickoffTime(iso, locale)); + if (diffMin < 60 * 24 * 7) return `${kickoffDay(iso, locale)} · ${kickoffTime(iso, locale)}`; + return kickoffDay(iso, locale); } diff --git a/src/lib/i18n/de.ts b/src/lib/i18n/de.ts new file mode 100644 index 0000000..8ab9d83 --- /dev/null +++ b/src/lib/i18n/de.ts @@ -0,0 +1,389 @@ +import type { Dict } from './en'; + +// Deutsch — neutral-informeller Sport-Ton, Schlüssel 1:1 wie en.ts. +export const de: Dict = { + nav: { + live: "Live", + groups: "Gruppen", + bracket: "Turnierbaum", + predict: "Prognose", + vsMarket: "vs. Markt", + model: "Modell", + story: "Story", + teams: "Teams", + data: "Daten", + more: "Mehr", + }, + common: { + liveNow: "Live", + offline: "Offline", + connecting: "Verbinde…", + connected: "Verbunden", + liveVia: "Live · {source}", + offlineShowingSchedule: "Offline — Spielplan wird angezeigt", + switchToLightMode: "Zum hellen Modus wechseln", + switchToDarkMode: "Zum dunklen Modus wechseln", + draw: "Unentschieden", + fullTime: "Abpfiff", + fullTimeShort: "Ende", + inMinutes: "in {n} min", + todayAt: "heute {time}", + group: "Gruppe {group}", + matchNumberShort: "Sp. {num}", + vs: "vs.", + mostLikelyScore: "Wahrscheinlichstes Ergebnis", + teamWinPct: "{team} gewinnt {pct}", + drawPct: "Unentschieden {pct}", + never: "nie", + justNow: "gerade eben", + minAgo: "vor {n} Min.", + hoursAgo: "vor {n} Std.", + back: "Zurück", + noData: "Keine Daten", + pensShort: "{n}E", + }, + stage: { + group: "Gruppenphase", + r32: "Sechzehntelfinale", + r16: "Achtelfinale", + qf: "Viertelfinale", + sf: "Halbfinale", + third: "Spiel um Platz 3", + final: "Finale", + }, + status: { + scheduled: "Angesetzt", + live: "Live", + finished: "Beendet", + }, + live: { + minute: "{n}'", + title: "Live", + loadingSubtitle: "Turnier wird geladen…", + subtitle: "{season} · 48 Teams · 104 Spiele", + liveNowHeading: "Jetzt live", + todayHeading: "Spiele heute", + nextUp: "Als Nächstes", + nextUpDay: "Als Nächstes · {day}", + comingUp: "Demnächst", + latestResults: "Letzte Ergebnisse", + empty: "Der Spielplan ist geladen. Sobald das Turnier läuft, tauchen die Spiele hier auf.", + }, + match: { + modelPrefix: "Modell:", + loadError: "Dieses Spiel konnte nicht geladen werden.", + backToLive: "Zurück zu Live", + liveWinProbability: "Live-Siegwahrscheinlichkeit", + inPlayHelp: "Aktualisiert sich mit Spielstand und Spielminute. Die noch zu erwartenden Tore werden aus der Vor-Anstoß-Prognose beider Teams geschätzt.", + statsTitle: "Spielstatistik", + stats: { + possessionPct: "Ballbesitz", + totalShots: "Schüsse", + shotsOnTarget: "Aufs Tor", + wonCorners: "Ecken", + foulsCommitted: "Fouls", + saves: "Paraden", + }, + timeline: "Spielverlauf", + preMatchModel: "Modell vor Anstoß", + modelExpectation: "Modell-Prognose", + expectedGoalsLine: "Erwartete Tore (xG): {home} – {away} · Modell-Wahrscheinlichkeiten, keine Wettempfehlung", + recentForm: "Aktuelle Form", + headToHead: "Direkter Vergleich", + h2h: { + teamWins: "Siege {team}", + drawsTotal: "Unentschieden · {games} Spiele insgesamt", + recentMeetings: "Letzte Duelle", + }, + lineups: "Aufstellungen", + keyPlayers: "Schlüsselspieler (beste Torschützen aller Zeiten)", + }, + notFound: { + message: "Ball im Aus — diese Seite gibt es nicht.", + backToLive: "Zurück zu Live", + }, + footer: { + worldCup: "WM 2026", + allData: "Alle Daten", + howModelWorks: "So funktioniert das Modell", + disclaimer: "Modell-Quoten — keine Wettempfehlung", + }, + glossary: { + rps: { + term: "RPS", + definition: "Ranked Probability Score — misst, wie weit die Prognose-Wahrscheinlichkeiten vom tatsächlichen Ergebnis entfernt lagen. Heimsieg, Unentschieden und Auswärtssieg zählen als geordnete Ausgänge: Wer nur um einen Schritt danebenliegt, wird weniger bestraft. 0 ist perfekt; je niedriger, desto besser.", + }, + brier: { + term: "Brier", + definition: "Der mittlere quadratische Fehler der Prognose-Wahrscheinlichkeiten über die drei Ausgänge (Heimsieg, Unentschieden, Auswärtssieg). 0 ist perfekt; je niedriger, desto besser.", + }, + ece: { + term: "ECE", + definition: "Expected Calibration Error — die durchschnittliche Lücke zwischen vorhergesagter Wahrscheinlichkeit und tatsächlicher Häufigkeit. Beispiel: Was mit 70 % vorhergesagt wird, sollte auch in rund 70 % der Fälle eintreten. Unter 0,05 ist hervorragend.", + }, + elo: { + term: "Elo", + definition: "Eine Stärkewertung, die nach jedem Spiel aktualisiert wird. Schlägst du ein stärkeres Team, gibt es mehr Punkte. Ein Abstand von 100 Punkten bedeutet etwa 64 % erwartetes Ergebnis für das stärkere Team.", + }, + xg: { + term: "xG", + definition: "Erwartete Tore (xG) — für jeden Schuss die Wahrscheinlichkeit, dass daraus ein Tor wird, geschätzt aus tausenden ähnlichen Schüssen. Die xG-Summe misst die Qualität der Chancen, nicht das Glück.", + }, + deVig: { + term: "De-Vig", + definition: "Buchmacherquoten enthalten eine Gewinnmarge, deshalb ergeben die impliziten Wahrscheinlichkeiten zusammen mehr als 100 %. De-Viggen entfernt diese Marge und skaliert auf genau 100 % — die ehrliche Einschätzung des Buchmachers.", + }, + ensemble: { + term: "Ensemble", + definition: "Eine Mischung aus zwei unabhängigen Tormodellen: eines auf Elo-Basis, eines auf Basis von Angriff und Abwehr jedes Teams. Das Mischverhältnis wurde auf Validierungsdaten abgestimmt — dort schlug die Kombination beide Einzelmodelle.", + }, + }, + groups: { + title: "Gruppen", + subtitle: "Alle 12 Gruppen. Die Top 2 jeder Gruppe kommen weiter, dazu die 8 besten Gruppendritten.", + allTeams: "Alle 48 Teams", + legendTop2: "Top 2 — kommen weiter", + legendThird: "Platz 3 — im Rennen um die besten Dritten", + tableHeaderPldPts: "Sp. · Pkt.", + colTeam: "Team", + colPlayed: "Sp", + colGoalDiff: "TD", + colPoints: "Pkt", + }, + bracket: { + title: "Turnierbaum", + subtitle: "Die K.-o.-Runden — vom Sechzehntelfinale bis zum Finale.", + thirdPlacePlayoff: "Spiel um Platz 3", + advanceTooltip: "Modell: Chance jedes Teams aufs Weiterkommen", + }, + teams: { + title: "Teams", + subtitle: "Alle 48 Teams, sortiert nach den Titelchancen des Modells. Tipp auf ein Team für sein Profil.", + advanceShort: "Weiterkommen {pct}", + }, + outcome: { + home: "Heimsieg", + draw: "Unentschieden", + away: "Auswärtssieg", + }, + predict: { + title: "Prognose", + loadingSubtitle: "Simulation läuft…", + subtitle: "Elo + Dixon-Coles, {iterations} Mal simuliert (Monte Carlo) · Ratings vom {date}", + methodologyLink: "So funktioniert's & so genau ist es", + trophy: { + title: "Wer holt den Pokal?", + }, + titleRace: { + title: "Titelrennen im Zeitverlauf", + empty: "Diese Grafik füllt sich, sobald Ergebnisse reinkommen. Nach jedem beendeten Spiel werden die Teams neu bewertet und die Simulation läuft erneut.", + }, + fullOddsHeading: "Alle Chancen — Gruppe, K.o.-Runde & Titel", + nextMatchesHeading: "Spiel-Prognosen", + filters: { + next3Days: "Nächste 3 Tage", + allUpcoming: "Alle anstehenden", + allStages: "Alle", + groupStage: "Gruppenphase", + knockout: "K.-o.-Runde", + }, + oddsTable: { + team: "Team", + winGroup: "Gruppensieg", + qualify: "Weiter", + reachR16: "AF", + reachQF: "VF", + reachSF: "HF", + reachFinal: "Finale", + champion: "Titel", + }, + }, + scoreboard: { + title: "Modell vs. Markt", + subtitle: "Vor jedem Spiel frieren wir unsere Prognose ein — daneben die Buchmacherquoten (ohne Marge). Nach Abpfiff werden beide bewertet: Die niedrigere Zahl lag näher am Ergebnis.", + headToHead: { + title: "Direkter Vergleich", + modelLabel: "unser Modell · RPS", + marketLabel: "Buchmacher · RPS", + closerTimes: "{n}× näher dran", + matchesScored: { + one: "{n} Spiel gewertet", + other: "{n} Spiele gewertet", + }, + tie: "Gleichstand", + modelAhead: "Modell vorn", + marketAhead: "Markt vorn", + empty: "Der direkte Vergleich startet mit dem ersten Abpfiff. Jede Prognose wird vor dem Anstoß eingefroren und nach Spielende gewertet.", + }, + rules: { + text: "Faire Regeln: Beide Prognosen werden vor dem Anstoß eingefroren. Die Buchmachermarge wird entfernt ({devig}). Gewertet wird mit dem {rps}. Verspätete Snapshots zählen nicht. Den Markt über wenige Spiele zu schlagen, ist meist Zufall — schau auf den Abstand, nicht auf die Führung.", + rpsTerm: "Ranked Probability Score", + }, + row: { + modelLabel: "Modell", + lateNote: "Snapshot zu spät — zählt nicht in die Gesamtwertung", + noMarketLine: "vor dem Anstoß keine Buchmacherquoten erfasst", + result: "Ergebnis: {outcome}", + modelRps: "Modell-RPS {rps}", + marketRps: "Markt-RPS {rps}", + modelCloser: "Modell näher dran", + marketCloser: "Markt näher dran", + }, + emptyRows: "Noch keine eingefrorenen Prognosen — der erste Snapshot entsteht {minutes} Minuten vor Anstoß.", + }, + methodology: { + title: "Methodik", + subtitle: "Wie das Modell funktioniert — und ehrlich gemessen, wie gut es wirklich ist.", + stepLabel: "SCHRITT {n}", + steps: { + elo: { + title: "Elo-Ratings", + body: "Jede Nation bekommt ein Stärke-Rating (Elo), aufgebaut aus 150 Jahren Länderspiel-Ergebnissen — rund 49.000 Spielen. Das Rating wird nach jedem Spiel aktualisiert, wichtige Spiele zählen mehr. Ein zweites, schneller reagierendes Rating verfolgt die aktuelle Form (neue Ergebnisse wirken dreimal so stark) — beide werden gemischt.", + }, + goalModels: { + title: "Zwei Tor-Modelle", + body: "Die erwarteten Tore pro Spiel schätzen wir auf zwei Wegen: aus den Elo-Ratings und aus einem eigenen Angriff/Abwehr-Modell (Dixon-Coles, gefittet auf den letzten 15 Jahren, neuere Spiele zählen mehr). Das Angriff/Abwehr-Modell trägt den größeren Teil der Mischung und wird im Turnier jede Nacht neu gefittet — aktuelle Form zählt mehr als ferne Vergangenheit.", + }, + scorelines: { + title: "Spielstände (Dixon-Coles)", + body: "Ein Spielstand-Modell macht aus den Tor-Schätzungen eine Wahrscheinlichkeit für jedes mögliche Ergebnis — und daraus Sieg, Unentschieden und Niederlage. Es nutzt eine bivariate Poisson-Verteilung mit der Dixon-Coles-Korrektur für torarme Spiele.", + }, + monteCarlo: { + title: "Monte Carlo", + body: "Wir simulieren das komplette 48-Team-Turnier 20.000-mal — jedes noch ausstehende Spiel wird dabei durchgespielt. So entstehen für jedes Team die Chancen auf den Titel und jede Runde, aktualisiert nach jedem Ergebnis.", + }, + }, + backtest: { + heading: "Wie gut ist es? (Backtest auf ungesehenen Spielen)", + intro: "Wir testen das Modell an Spielen, die es nie gesehen hat. Die Parameter wurden an Spielen vor {trainEnd} gefittet. Die Misch-Einstellungen wurden auf {valFrom}–{valTo} abgestimmt. Die Zahlen unten stammen aus {tested} unberührten Spielen ({testFrom}–{testTo}) — jedes nur mit Daten vorhergesagt, die vor Anstoß verfügbar waren (Walk-Forward).", + validationFallback: "einen Validierungszeitraum", + stats: { + outcomeAccuracy: "Trefferquote (Ergebnis)", + rps: "Ranked Probability Score", + worldCupAccuracy: "WM-Trefferquote ({n})", + }, + metricsLine: "Metriken: {rps} · {brier} · {ece} — das eingesetzte Modell ist ein {ensemble}.", + rpsChartHeading: "Ranked Probability Score (RPS) — niedriger ist besser. Unser Modell gegen einfachere Vergleichsmodelle.", + thisModel: "Dieses Modell", + baseRates: "Basisraten", + coinFlip: "Münzwurf", + bestBadge: "beste", + }, + calibration: { + heading: "Ist es kalibriert?", + xAxis: "vorhergesagte Wahrscheinlichkeit", + yAxis: "tatsächlich eingetreten", + pointTooltip: "vorhergesagt {predicted}% → eingetreten {observed}% ({count})", + caption: "Die Punkte liegen nah an der Diagonale. Das heißt, das Modell ist ehrlich: Sagt es 30%, tritt das Ergebnis in rund 30% der Fälle ein. Der Kalibrierungsfehler (ECE) liegt bei {ece} — niedriger ist besser, unter 0,05 ist hervorragend.", + }, + limits: { + heading: "Ehrliche Grenzen", + notAdvice: "Das sind Modell-Wahrscheinlichkeiten, keine Wettempfehlung. Das Modell nutzt keine Buchmacherquoten, also behaupten wir nicht, den Markt zu schlagen. Scharfe Wettmärkte sind nach wie vor die besten Prognosen, die es gibt.", + sparseData: "Detaildaten im Länderspiel-Fußball (erwartete Tore, Spiel-Events) sind dünn gesät. Manche Teams bekommen deshalb reichere Taktik-Analysen als andere. Die Abdeckung wird besser, je mehr Live-Daten im Turnier dazukommen.", + variance: "Fußball ist launisch. Ein 60%-Favorit verliert trotzdem oft. Kalibriert heißt: Unsere 60% treffen wirklich in rund 60% der Fälle ein — nicht, dass der Favorit immer gewinnt.", + openData: "Alles ist transparent und lässt sich aus öffentlichen Daten nachbauen. Genau diese Offenheit ist der Punkt.", + }, + }, + data: { + title: "Die Daten", + subtitle: "Alles, womit diese Seite arbeitet — mit Größe, Quelle und Zeitstempel. Keine Blackbox.", + lake: { + heading: "Event-Daten-Lake", + openDataBadge: "Open Data von {provider}", + parsed: "Event-Daten verarbeitet", + events: "Aktionen auf dem Platz", + matches: "Spiele", + players: "Spieler", + summary: "Jeder Pass, Schuss und Zweikampf aus {seasons} Wettbewerbs-Saisons — darunter acht Weltmeisterschaften. Daraus bauen wir die Stil-Fingerabdrücke der Teams ({covered} von {total} Nationen abgedeckt) und die Spielstand-Analyse auf der Methodik-Seite.", + }, + archive: { + heading: "Historisches Archiv", + internationalResults: "Länderspiel-Ergebnisse (1872 → heute)", + h2hPairings: "Direkte Vergleiche vorberechnet", + ratingsThrough: "Ratings aktuell bis", + squadValues: "Kader-Marktwerte ({provider})", + fingerprints: "Stil-Fingerabdrücke der Teams (Event-Daten)", + teamsCount: { + one: "{n} Team", + other: "{n} Teams", + }, + }, + live: { + heading: "Live-Erfassung (dieses Turnier)", + oddsLines: "Buchmacherquoten erfasst", + oddsFixtures: "Spiele mit Marktquoten", + frozenForecasts: "Prognosen vor Anstoß eingefroren", + enrichedMatches: "Spiele angereichert (Form, Direkter Vergleich, Aufstellungen)", + cachedResponses: "API-Antworten im Cache", + ingestRuns: "Daten-Importläufe protokolliert", + }, + sources: { + heading: "Quellen-Status", + healthy: "läuft", + paused: "pausiert (zu viele Fehler)", + recentFailures: { + one: "{n} Fehlversuch zuletzt", + other: "{n} Fehlversuche zuletzt", + }, + lastOk: "zuletzt OK {time}", + empty: "Noch keine Live-Quellen abgefragt.", + footer: { + label: "Quellen:", + roleFixtures: "Spielplan", + roleLiveScores: "Live-Ergebnisse, Form, Aufstellungen, Buchmacherquoten", + roleFallback: "Ausweichquelle", + roleResultsArchive: "Ergebnisarchiv", + roleOpenData: "Open Data", + roleSquadValues: "Kader-Marktwerte", + note: "Alle Daten holen wir serverseitig, mit Cache und Rate-Limit. Blockt eine Quelle oder fällt sie aus, siehst du etwas ältere Daten — nie eine kaputte Seite.", + }, + }, + }, + story: { + title: "Story", + subtitle: "Ein Rückblick in Daten.", + loadingSubtitle: "Die Daten-Story wird geladen …", + datasetMissing: "Der Story-Datensatz ist nicht verfügbar. Führe {command} aus, um ihn zu erzeugen.", + heroTitle: "Das größte Finale aller Zeiten — in Daten", + intro: "Argentinien und Frankreich erzielten in 120 Minuten zusammen sechs Tore — zwei von Messi, eins von Di María und ein Hattrick von Mbappé — ehe es ins Elfmeterschießen ging. Hier zeigen die Event-Daten, wie es dazu kam.", + byTheNumbers: "Die Zahlen zum Spiel", + stats: { + expectedGoals: "Erwartete Tore (xG)", + shots: "Schüsse", + passes: "Pässe", + passAccuracy: "Passquote %", + }, + shotMapTitle: "Jeder Schuss, skaliert nach xG", + shotMapCaption: "Argentinien ({xg} xG) hatte die größeren Chancen. Frankreichs Comeback kam durch eine Serie hochkarätiger Schüsse, als Mbappé heiß lief. Jeder Punkt ist ein Schuss — je größer, desto besser die Chance. Gefüllte Punkte sind Tore.", + xgRaceTitle: "Das xG-Rennen", + xgRaceCaption: "80 Minuten lang baute Argentinien einen klaren Vorsprung bei den erwarteten Toren auf. Dann traf Frankreich zweimal in 97 Sekunden, und ihre Linie schoss steil nach oben. Steile Stufen bedeuten große Chancen.", + passNetworkTitle: "Passstruktur (Startelf)", + passNetworkCaption: "Jeder Kreis ist ein Spieler an seiner durchschnittlichen Passposition. Linien zeigen die angekommenen Pässe zwischen zwei Spielern, gezählt bis zur ersten Auswechslung. Je größer der Kreis, desto mehr hatte der Spieler den Ball.", + }, + team: { + unknown: "Unbekanntes Team.", + allTeams: "Alle Teams", + eloBadge: "Elo {rating}", + titleOddsBadge: "Titel {pct}", + advanceOddsBadge: "Weiterkommen {pct}", + groupStanding: "Platz in der Gruppe", + standingLine: "#{rank} · {points} Pkt. · {won}-{drawn}-{lost} · TD {gd}", + fixtures: "Spielplan", + allTimeTopScorers: "Beste Torschützen aller Zeiten", + groupShort: "Gr. {group}", + awayIndicator: "bei", + resultShort: { + win: "S", + draw: "U", + loss: "N", + }, + stageShort: { + r32: "1/16", + r16: "AF", + qf: "VF", + sf: "HF", + third: "Pl. 3", + final: "F", + }, + }, +}; diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts new file mode 100644 index 0000000..65673d2 --- /dev/null +++ b/src/lib/i18n/en.ts @@ -0,0 +1,390 @@ +// The single source of truth for UI copy — the clarity pass lives here. +// Keys are mirrored exactly by de.ts (the compiler enforces it via Dict). +export const en = { + nav: { + live: "Live", + groups: "Groups", + bracket: "Bracket", + predict: "Predict", + vsMarket: "vs Market", + model: "Model", + story: "Story", + teams: "Teams", + data: "Data", + more: "More", + }, + common: { + liveNow: "Live", + offline: "Offline", + connecting: "Connecting…", + connected: "Connected", + liveVia: "Live · {source}", + offlineShowingSchedule: "Offline — showing the schedule", + switchToLightMode: "Switch to light mode", + switchToDarkMode: "Switch to dark mode", + draw: "Draw", + fullTime: "Full time", + fullTimeShort: "FT", + inMinutes: "in {n}m", + todayAt: "today {time}", + group: "Group {group}", + matchNumberShort: "M{num}", + vs: "vs", + mostLikelyScore: "Most likely scoreline", + teamWinPct: "{team} win {pct}", + drawPct: "Draw {pct}", + never: "never", + justNow: "just now", + minAgo: "{n} min ago", + hoursAgo: "{n} h ago", + back: "Back", + noData: "No data", + pensShort: "{n}p", + }, + stage: { + group: "Group stage", + r32: "Round of 32", + r16: "Round of 16", + qf: "Quarter-final", + sf: "Semi-final", + third: "Third place", + final: "Final", + }, + status: { + scheduled: "Scheduled", + live: "Live", + finished: "Finished", + }, + live: { + minute: "{n}'", + title: "Live", + loadingSubtitle: "Loading the tournament…", + subtitle: "{season} · 48 teams · 104 matches", + liveNowHeading: "Live now", + todayHeading: "Today's matches", + nextUp: "Next up", + nextUpDay: "Next up · {day}", + comingUp: "Coming up", + latestResults: "Latest results", + empty: "The schedule is ready. Matches will appear here once the tournament kicks off.", + }, + match: { + modelPrefix: "Model:", + loadError: "Couldn't load that match.", + backToLive: "Back to Live", + liveWinProbability: "Live win probability", + inPlayHelp: "Updates with the score and the clock. The goals still to come are estimated from each team's pre-match expectation.", + statsTitle: "Match stats", + stats: { + possessionPct: "Possession", + totalShots: "Shots", + shotsOnTarget: "On target", + wonCorners: "Corners", + foulsCommitted: "Fouls", + saves: "Saves", + }, + timeline: "Timeline", + preMatchModel: "Pre-match model", + modelExpectation: "Model expectation", + expectedGoalsLine: "Expected goals: {home} – {away} · model probabilities, not betting advice", + recentForm: "Recent form", + headToHead: "Head to head", + h2h: { + teamWins: "{team} wins", + drawsTotal: "draws · {games} matches in total", + recentMeetings: "Recent meetings", + }, + lineups: "Lineups", + keyPlayers: "Key players (all-time top scorers)", + }, + notFound: { + message: "Off the pitch — this page doesn't exist.", + backToLive: "Back to Live", + }, + footer: { + worldCup: "World Cup 2026", + allData: "All the data", + howModelWorks: "How the model works", + disclaimer: "Model odds — not betting advice", + }, + glossary: { + rps: { + term: "RPS", + definition: "Ranked Probability Score — how far the forecast probabilities were from the actual result. Home win, draw and away win count as ordered outcomes: missing by one step costs less than missing by two. 0 is perfect; lower is better.", + }, + brier: { + term: "Brier", + definition: "The average squared error of the forecast probabilities across the three outcomes (home win, draw, away win). 0 is perfect; lower is better.", + }, + ece: { + term: "ECE", + definition: "Expected Calibration Error — the average gap between predicted probability and how often it really happened. Example: outcomes given 70% should occur about 70% of the time. Below 0.05 is excellent.", + }, + elo: { + term: "Elo", + definition: "A team strength rating that updates after every match. Beat a stronger team and you gain more points. A 100-point gap means roughly a 64% expected score for the stronger side.", + }, + xg: { + term: "xG", + definition: "Expected goals — for each shot, the chance it becomes a goal, based on thousands of similar past shots. Total xG measures the quality of chances, not luck.", + }, + deVig: { + term: "de-vig", + definition: "Bookmaker odds include a profit margin, so their implied probabilities add up to more than 100%. De-vigging removes that margin and rescales them to exactly 100% — the bookmaker's honest opinion.", + }, + ensemble: { + term: "ensemble", + definition: "A blend of two independent goal models: one built on Elo ratings, one on each team's attack and defence. The blend weight was tuned on validation data, where the combination beat both single models.", + }, + }, + groups: { + title: "Groups", + subtitle: "All 12 groups. The top 2 in each group advance, plus the 8 best third-placed teams.", + allTeams: "All 48 teams", + legendTop2: "Top 2 — advance", + legendThird: "3rd — still in the race for best third", + tableHeaderPldPts: "Pld · Pts", + colTeam: "Team", + colPlayed: "P", + colGoalDiff: "GD", + colPoints: "Pts", + }, + bracket: { + title: "Bracket", + subtitle: "The knockout rounds — from the Round of 32 to the Final.", + thirdPlacePlayoff: "Third-place play-off", + advanceTooltip: "Model: each team's chance to advance", + }, + teams: { + title: "Teams", + subtitle: "All 48 teams, ranked by the model's title odds. Tap a team to open its profile.", + advanceShort: "advance {pct}", + }, + outcome: { + home: "Home win", + draw: "Draw", + away: "Away win", + }, + predict: { + title: "Predict", + loadingSubtitle: "Running the simulation…", + subtitle: "Elo + Dixon-Coles, simulated {iterations} times (Monte Carlo) · ratings as of {date}", + methodologyLink: "How it works & how accurate it is", + trophy: { + title: "Who lifts the trophy?", + }, + titleRace: { + title: "Title race over time", + empty: "This chart fills in as results come in. After every finished match, the teams are re-rated and the simulation runs again.", + }, + fullOddsHeading: "Full odds — group, knockout & title", + nextMatchesHeading: "Match predictions", + filters: { + next3Days: "Next 3 days", + allUpcoming: "All upcoming", + allStages: "All", + groupStage: "Group stage", + knockout: "Knockout", + }, + oddsTable: { + team: "Team", + winGroup: "Win group", + qualify: "Advance", + reachR16: "R16", + reachQF: "QF", + reachSF: "SF", + reachFinal: "Final", + champion: "Champion", + }, + }, + scoreboard: { + title: "Model vs Market", + subtitle: "Before each match we lock in our forecast next to the bookmaker's odds (margin removed). After full time, both are scored — the lower number was closer to what happened.", + headToHead: { + title: "Running head-to-head", + modelLabel: "our model · RPS", + marketLabel: "bookmaker · RPS", + closerTimes: "closer {n}×", + matchesScored: { + one: "{n} match scored", + other: "{n} matches scored", + }, + tie: "level", + modelAhead: "model ahead", + marketAhead: "market ahead", + empty: "The head-to-head starts when the first match ends. Each forecast is frozen before kickoff and scored at full time.", + }, + rules: { + text: "Honest rules: both forecasts are frozen before kickoff. The bookmaker's margin is removed ({devig}). Both are scored with the {rps}. Late snapshots don't count. Beating the market over a few matches is mostly noise — watch the gap, not the lead.", + rpsTerm: "Ranked Probability Score", + }, + row: { + modelLabel: "Model", + lateNote: "snapshot late — not counted in the totals", + noMarketLine: "no bookmaker odds captured before kickoff", + result: "result: {outcome}", + modelRps: "model RPS {rps}", + marketRps: "market RPS {rps}", + modelCloser: "model closer", + marketCloser: "market closer", + }, + emptyRows: "No frozen forecasts yet — the first snapshot is taken {minutes} minutes before kickoff.", + }, + methodology: { + title: "Methodology", + subtitle: "How the model works — and an honest measure of how good it really is.", + stepLabel: "STEP {n}", + steps: { + elo: { + title: "Elo ratings", + body: "Every nation gets a strength rating (Elo), built from 150 years of international results — about 49,000 matches. The rating updates after every game, and important matches count more. A second, faster-reacting rating tracks current form (recent results move it three times as hard), and the two are blended.", + }, + goalModels: { + title: "Two goal models", + body: "We estimate each match's expected goals in two ways: from the Elo ratings, and from a separate attack/defence model (Dixon-Coles, fit on the last 15 years, recent games weighted more). The attack/defence model carries most of the blend and is refit every night during the tournament — current form counts for more than distant history.", + }, + scorelines: { + title: "Scorelines (Dixon-Coles)", + body: "A scoreline model turns those goal estimates into a probability for every possible score — and from that, win, draw and loss. It uses a bivariate Poisson distribution with the Dixon-Coles fix for low-scoring games.", + }, + monteCarlo: { + title: "Monte Carlo", + body: "We simulate the whole 48-team tournament 20,000 times, playing out every remaining match. That gives each team's odds of winning the title or reaching each round — refreshed after every result.", + }, + }, + backtest: { + heading: "How good is it? (backtest on unseen matches)", + intro: "We test the model on matches it has never seen. Parameters were fit on matches before {trainEnd}. The blend settings were tuned on {valFrom}–{valTo}. The numbers below come from {tested} untouched matches ({testFrom}–{testTo}) — each predicted using only data available before kickoff (walk-forward).", + validationFallback: "a validation window", + stats: { + outcomeAccuracy: "outcome accuracy", + rps: "ranked probability score", + worldCupAccuracy: "World Cup accuracy ({n})", + }, + metricsLine: "Metrics: {rps} · {brier} · {ece} — the live model is an {ensemble}.", + rpsChartHeading: "Ranked Probability Score (RPS) — lower is better. Our model vs. simpler baselines.", + thisModel: "This model", + baseRates: "Base rates", + coinFlip: "Coin flip", + bestBadge: "best", + }, + calibration: { + heading: "Is it calibrated?", + xAxis: "predicted probability", + yAxis: "actually happened", + pointTooltip: "predicted {predicted}% → happened {observed}% ({count})", + caption: "The points hug the diagonal. That means the model is honest: when it says 30%, the outcome happens about 30% of the time. The calibration error (ECE) is {ece} — lower is better, and under 0.05 is excellent.", + }, + limits: { + heading: "Honest limits", + notAdvice: "These are model probabilities, not betting advice. The model does not use bookmaker odds, so we make no claim to beat the market. Sharp betting markets are still the best forecasters there are.", + sparseData: "Detailed data for international football (expected goals, match events) is sparse. Some teams get richer tactical breakdowns than others. Coverage improves as live data comes in during the tournament.", + variance: "Football is high-variance. A 60% favourite still loses a lot. Calibrated means our 60% really happens about 60% of the time — not that the favourite always wins.", + openData: "Everything is transparent and can be rebuilt from public data. That openness is the point.", + }, + }, + data: { + title: "The data", + subtitle: "Everything this site runs on — with its size, its source and its last update. No black boxes.", + lake: { + heading: "Event-data lake", + openDataBadge: "{provider} open data", + parsed: "event data parsed", + events: "on-pitch events", + matches: "matches", + players: "players", + summary: "Every pass, shot and duel from {seasons} competition seasons — eight World Cups among them. We turn it into the team style fingerprints ({covered} of {total} nations covered) and the score-state analysis on the methodology page.", + }, + archive: { + heading: "Historical archive", + internationalResults: "International results (1872 → now)", + h2hPairings: "Head-to-head records precomputed", + ratingsThrough: "Ratings updated through", + squadValues: "Squad market values ({provider})", + fingerprints: "Team style fingerprints (event data)", + teamsCount: { + one: "{n} team", + other: "{n} teams", + }, + }, + live: { + heading: "Live collection (this tournament)", + oddsLines: "Bookmaker lines captured", + oddsFixtures: "Fixtures with market odds", + frozenForecasts: "Forecasts locked before kickoff", + enrichedMatches: "Matches enriched (form, head-to-head, lineups)", + cachedResponses: "API responses cached", + ingestRuns: "Data ingestion runs logged", + }, + sources: { + heading: "Source health", + healthy: "healthy", + paused: "paused (too many failures)", + recentFailures: { + one: "{n} recent failure", + other: "{n} recent failures", + }, + lastOk: "last OK {time}", + empty: "No live sources polled yet.", + footer: { + label: "Sources:", + roleFixtures: "fixtures", + roleLiveScores: "live scores, form, lineups, bookmaker lines", + roleFallback: "fallback", + roleResultsArchive: "results archive", + roleOpenData: "open data", + roleSquadValues: "squad values", + note: "All data is fetched on our servers, cached, and rate-limited. If a source blocks us or goes down, you see slightly older data — never a broken page.", + }, + }, + }, + story: { + title: "Story", + subtitle: "A data-driven look back.", + loadingSubtitle: "Loading the data story…", + datasetMissing: "The story dataset isn't available. Run {command} to generate it.", + heroTitle: "The greatest final ever, in data", + intro: "Argentina and France scored six goals between them across 120 minutes — two from Messi, one from Di María, and a Mbappé hat-trick — before it went to penalties. Here's what the event data says about how it happened.", + byTheNumbers: "By the numbers", + stats: { + expectedGoals: "Expected goals", + shots: "Shots", + passes: "Passes", + passAccuracy: "Pass accuracy %", + }, + shotMapTitle: "Every shot, sized by xG", + shotMapCaption: "Argentina ({xg} xG) created the bigger chances. France's comeback was built on a burst of high-quality shots once Mbappé caught fire. Each dot is one shot — the bigger the dot, the better the chance. Filled dots are goals.", + xgRaceTitle: "The xG race", + xgRaceCaption: "For 80 minutes, Argentina built a clear lead in expected goals. Then France scored twice in 97 seconds, and their line jumped sharply upward. Steep steps mean big chances.", + passNetworkTitle: "Passing shape (starting XI)", + passNetworkCaption: "Each circle is a player, placed at his average passing position. Lines show the completed passes between two players, counted up to the first substitution. Bigger circles mean a player saw more of the ball.", + }, + team: { + unknown: "Unknown team.", + allTeams: "All teams", + eloBadge: "Elo {rating}", + titleOddsBadge: "Title {pct}", + advanceOddsBadge: "Advance {pct}", + groupStanding: "Group standing", + standingLine: "#{rank} · {points} pts · {won}-{drawn}-{lost} · GD {gd}", + fixtures: "Fixtures", + allTimeTopScorers: "All-time top scorers", + groupShort: "Grp {group}", + awayIndicator: "@", + resultShort: { + win: "W", + draw: "D", + loss: "L", + }, + stageShort: { + r32: "R32", + r16: "R16", + qf: "QF", + sf: "SF", + third: "3rd", + final: "F", + }, + }, +}; + +export type Dict = typeof en; diff --git a/src/lib/i18n/i18n.test.ts b/src/lib/i18n/i18n.test.ts new file mode 100644 index 0000000..c097818 --- /dev/null +++ b/src/lib/i18n/i18n.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { en } from './en'; +import { de } from './de'; +import { fmt, fmtSplit, plural } from './index'; + +describe('fmt', () => { + it('interpolates named vars', () => { + expect(fmt('Ratings as of {date}', { date: '2026-06-10' })).toBe('Ratings as of 2026-06-10'); + }); + it('leaves unknown placeholders intact', () => { + expect(fmt('{a} {b}', { a: 1 })).toBe('1 {b}'); + }); +}); + +describe('plural', () => { + const p = { one: '{n} match scored', other: '{n} matches scored' }; + it('uses one at exactly 1', () => expect(plural(p, 1)).toBe('1 match scored')); + it('uses other elsewhere', () => { + expect(plural(p, 0)).toBe('0 matches scored'); + expect(plural(p, 7)).toBe('7 matches scored'); + }); +}); + +describe('fmtSplit', () => { + it('splits around placeholders preserving order', () => { + expect(fmtSplit('scored with the {rps}. Done.')).toEqual([ + 'scored with the ', { key: 'rps' }, '. Done.', + ]); + }); + it('handles adjacent and leading placeholders', () => { + expect(fmtSplit('{a}{b} tail')).toEqual([{ key: 'a' }, { key: 'b' }, ' tail']); + }); +}); + +describe('en/de dictionary parity', () => { + type Node = string | { [k: string]: Node }; + const walk = (a: Node, b: Node, path: string, problems: string[]): void => { + if (typeof a === 'string' || typeof b === 'string') { + if (typeof a !== typeof b) problems.push(`type mismatch at ${path}`); + if (typeof b === 'string' && b.trim() === '') problems.push(`empty translation at ${path}`); + return; + } + for (const k of Object.keys(a)) { + if (!(k in b)) { problems.push(`missing in de: ${path}.${k}`); continue; } + walk(a[k]!, b[k]!, `${path}.${k}`, problems); + } + for (const k of Object.keys(b)) if (!(k in a)) problems.push(`extra in de: ${path}.${k}`); + }; + + it('de mirrors en exactly, with no empty leaves', () => { + const problems: string[] = []; + walk(en as unknown as Node, de as unknown as Node, '$', problems); + expect(problems).toEqual([]); + }); + + it('placeholders agree between en and de', () => { + const collect = (n: Node, path: string, into: Map): void => { + if (typeof n === 'string') { into.set(path, [...n.matchAll(/\{(\w+)\}/g)].map((m) => m[1]).sort().join(',')); return; } + for (const k of Object.keys(n)) collect(n[k]!, `${path}.${k}`, into); + }; + const a = new Map(); const b = new Map(); + collect(en as unknown as Node, '$', a); + collect(de as unknown as Node, '$', b); + const diff = [...a].filter(([k, v]) => b.get(k) !== v).map(([k]) => k); + expect(diff).toEqual([]); + }); +}); diff --git a/src/lib/i18n/index.ts b/src/lib/i18n/index.ts new file mode 100644 index 0000000..5160d96 --- /dev/null +++ b/src/lib/i18n/index.ts @@ -0,0 +1,70 @@ +// Tiny typed i18n — no library. en.ts is the source of truth; de.ts mirrors +// its shape (compiler-enforced via Dict). Components call useT() and read +// nested keys with full autocomplete: t.live.todayHeading. +import { en, type Dict } from './en'; +import { de } from './de'; +import { useLocaleStore, type Locale } from '@/stores/localeStore'; +import { kickoffDay, kickoffTime, relativeKickoff } from '@/lib/format'; + +export type { Dict } from './en'; + +const dicts: Record = { en, de }; + +/** Reactive dictionary for components — re-renders on locale switch. */ +export function useT(): Dict { + return dicts[useLocaleStore((s) => s.locale)]; +} + +/** Non-hook access (event handlers, module-level helpers). */ +export function getDict(): Dict { + return dicts[useLocaleStore.getState().locale]; +} + +/** "{n} of {total}" interpolation. */ +export function fmt(tpl: string, vars: Record): string { + return tpl.replace(/\{(\w+)\}/g, (m, k: string) => (k in vars ? String(vars[k]) : m)); +} + +export interface Plural { + one: string; + other: string; +} + +/** English/German share the one/other cardinal split. */ +export function plural(p: Plural, n: number): string { + return fmt(n === 1 ? p.one : p.other, { n }); +} + +/** Split a template around {placeholders} so React nodes (e.g. glossary + * chips) can be spliced in: fmtSplit("a {x} b") → ["a ", {key:"x"}, " b"]. */ +export function fmtSplit(tpl: string): (string | { key: string })[] { + const out: (string | { key: string })[] = []; + const re = /\{(\w+)\}/g; + let last = 0; + for (let m = re.exec(tpl); m; m = re.exec(tpl)) { + if (m.index > last) out.push(tpl.slice(last, m.index)); + out.push({ key: m[1]! }); + last = m.index + m[0].length; + } + if (last < tpl.length) out.push(tpl.slice(last)); + return out; +} + +/** Locale-bound date formatters + the dictionary, in one hook. */ +export function useFormat(): { + t: Dict; + locale: Locale; + kickoffTime: (iso: string) => string; + kickoffDay: (iso: string) => string; + relativeKickoff: (iso: string) => string; +} { + const locale = useLocaleStore((s) => s.locale); + const t = dicts[locale]; + return { + t, + locale, + kickoffTime: (iso) => kickoffTime(iso, locale), + kickoffDay: (iso) => kickoffDay(iso, locale), + relativeKickoff: (iso) => relativeKickoff(iso, locale, { inMinutes: t.common.inMinutes, todayAt: t.common.todayAt }), + }; +} diff --git a/src/stores/localeStore.ts b/src/stores/localeStore.ts new file mode 100644 index 0000000..d040d36 --- /dev/null +++ b/src/stores/localeStore.ts @@ -0,0 +1,33 @@ +import { create } from 'zustand'; + +export type Locale = 'en' | 'de'; + +const STORAGE_KEY = 'cup26:locale'; + +function readLocale(): Locale { + try { + const v = localStorage.getItem(STORAGE_KEY); + if (v === 'en' || v === 'de') return v; + } catch { /* SSR / tests / private mode */ } + try { + if (navigator.language?.toLowerCase().startsWith('de')) return 'de'; + } catch { /* no navigator */ } + return 'en'; +} + +interface LocaleState { + locale: Locale; + setLocale: (locale: Locale) => void; +} + +export const useLocaleStore = create((set) => ({ + locale: readLocale(), + setLocale: (locale) => { + try { localStorage.setItem(STORAGE_KEY, locale); } catch { /* private mode */ } + if (typeof document !== 'undefined') document.documentElement.lang = locale; + set({ locale }); + }, +})); + +// keep in sync from the start +if (typeof document !== 'undefined') document.documentElement.lang = readLocale();
    Team{c.label}{t.predict.oddsTable.team}{t.predict.oddsTable[key]}