German language option + a full wording-clarity pass

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); <html lang> stays in sync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 00:20:07 +02:00
parent 92fdfdf398
commit 05010771a3
26 changed files with 1382 additions and 323 deletions
+389
View File
@@ -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",
},
},
};
+390
View File
@@ -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;
+67
View File
@@ -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<string, string>): 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<string, string>(); const b = new Map<string, string>();
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([]);
});
});
+70
View File
@@ -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<Locale, Dict> = { 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, string | number>): 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 <Term>
* 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 }),
};
}