7c. Internationalization (i18n)
This section makes the localization model of the application a first-class architectural concern — peer to authentication (§7a) and the MCP integration (§7b) — so that "what is and is not translated" is decided once for the whole stack and never redebated on a per-page basis.
The chosen approach is react-i18next with statically imported
JSON namespace catalogues bundled into web/dist. There is no
runtime translation backend: the Go server stays language-neutral
and ships every user-facing message as a stable code that the
frontend renders.
7c.1 Goals
- Two languages at launch, room for at most three. Polish
(
pl) is the primary locale because the operational vocabulary (maszynista,nastawniczy,nastawnia,skład,centralka,makieta,szablon pojazdu) originates in Polish railway tradition (see §0 Terminology). English (en) is the secondary locale. A third locale (e.g. German for cross-border clubs) may be added later without touching any non-i18n code. - Backend stays language-neutral. The HTTP / WebSocket / MCP
surfaces never return a localized human string. They return
stable machine codes (e.g.
invalid_credentials,STOPPED_AT_SIGNAL_READY_TO_ENTER,vehicle.leased,loconet_serial). The language preference is purely a frontend concern. - Translations bundled into the SPA. Catalogue JSONs are
imported statically by Vite, end up tree-shaken into hashed
bundle chunks under
web/dist, and are served by the same Go process via the existingembed.FSpipeline (§7.1 Single binary serving the frontend too). No separate translation server, noAccept-Languageround-trip, no extra fetch on cold start. - Type-safe message keys. Missing or misspelled keys are
caught by
npm run lint(tsc -b --noEmit) thanks to a generated.d.tsfrom the catalogue files. A typo never reaches production. - History is never retranslated. Strings already persisted as
denormalized snapshots (audit-log
user_nameandobject_nameper §3a.5; radio-messagenotefree-text per §3a.10) are rendered verbatim in every locale. Translation must not rewrite history.
7c.2 What is translated, what is not
The contract with the backend is the single most important thing this section pins down. The frontend translates codes, never user-entered or historical strings.
| Source on the wire / in the DB | Treatment | i18n namespace + key shape |
|---|---|---|
ApiError.code (REST + WS error envelopes) |
translate | errors.<code> (e.g. errors.invalid_credentials) |
RadioPhrase enum (§3a.1) |
translate | radio.phrase.<PHRASE> (e.g. radio.phrase.STOPPED_AT_SIGNAL_READY_TO_ENTER) |
FunctionIcon closed catalogue (§3a.8 / §goal 16) |
translate (label only; SVG asset is locale-independent) | function.icon.<slug> (e.g. function.icon.headlight); default PL strings in 08-function-icon-catalogue.md |
Role (driver / signalman / admin) |
translate | role.<role> |
CommandStationConnectionType (loconet_serial / z21 / loconet_tcp) |
translate | command_station.connection.<type> |
AuditAction strings (vehicle.leased, session.emergency_executed, system.radio_stop, …) |
translate | audit.action.<action_with_dots_to_underscores> |
| Radio Stop UI (throttle overlay button, confirm overlay „Uruchom radiostop” / „Anuluj”, toast on receipt, §4.6) | translate | throttle.radioStop.<key> |
| Fullscreen toggle on the throttle left toolbar (§6.3b) | translate | throttle.fullscreen.<key> (enter / exit) |
TakeoverState (requested / granted / rejected / cancelled / auto_granted) |
translate | takeover.state.<state> |
| Static UI chrome (buttons, dialogs, navbar, form labels) | translate | per-page namespace (e.g. auth.login.title) |
| Plurals & counts (e.g. "N pojazdów") | translate via ICU plural forms | <ns>.<key>_one / _few / _many / _other |
Vehicle.Name, Train.Name, Layout.Name, CommandStation.Name, DccFunction.Name, Script.Name |
render verbatim | n/a — user input |
Audit user_name, object_name (denormalized snapshots, §3a.5) |
render verbatim | n/a — historical record |
RadioMessage.Note (optional free-text capped at 80 chars, §3a.1) |
render verbatim | n/a — user input |
Script.Source (JavaScript source code) |
render verbatim | n/a — code |
| Numbers, dates, times | format via Intl.NumberFormat / Intl.DateTimeFormat keyed off the active locale |
n/a (platform API) |
Anything not on this table that ends up rendered to the user must land on it before the PR adding it can be merged.
7c.3 Stack and dependencies
Frontend additions to web/package.json:
| Package | Purpose |
|---|---|
i18next |
Core engine: catalogue store, plural-rule resolver, interpolation. |
react-i18next |
React bindings: useTranslation, <Trans>, I18nextProvider. |
i18next-browser-languagedetector |
Detection chain (localStorage → navigator → fallback). |
i18next-resources-for-ts (dev only) |
Generates web/src/i18n/resources.d.ts from the JSON catalogues so t("…") is typed. |
No i18next-http-backend: catalogues are imported statically. No
ICU plugin: i18next's built-in _one / _few / _many / _other
suffixes (driven by Intl.PluralRules) cover Polish plural forms
without an extra dependency. If a future requirement needs full
ICU MessageFormat (e.g. {gender, select, …}), add
i18next-icu then; this is forward-compatible.
7c.4 File layout
web/src/
├── i18n/
│ ├── index.ts # i18next.init({...}); imported once from main.tsx
│ ├── resources.d.ts # GENERATED by i18next-resources-for-ts (ignored by git)
│ └── locales/
│ ├── pl/
│ │ ├── common.json # buttons, generic words, navbar, errors-of-last-resort
│ │ ├── auth.json # LoginPage, logout dialog, session expiry banner
│ │ ├── errors.json # ApiError.code → human message (full surface)
│ │ ├── role.json # driver / signalman / admin
│ │ ├── radio.json # RadioPhrase → human phrase + short tooltip
│ │ ├── function.json # FunctionIcon labels
│ │ ├── audit.json # AuditAction labels + activity-log header strings
│ │ ├── takeover.json # TakeoverState labels + countdown copy
│ │ ├── command_station.json # CommandStationConnectionType labels + centralka CRUD copy
│ │ ├── layout.json # makieta CRUD copy (admin /admin/layouts page), login-form layout dropdown labels (incl. `system_default_label`), lock/unlock + attached-stations strings
│ │ ├── home.json # dashboard tables, "Show my vehicles", "Add my vehicle to layout", online-users column headers
│ │ ├── interlocking.json # interlocking view: Occupy/Leave labels, displacement confirm, leave-on-navigate dialog, vacant/occupied badges
│ │ ├── trainAnnouncements.json # PA message labels (keys referenced by config/trainAnnouncements.ts)
│ │ ├── script.json # Scripts page copy (editor, console, attachment)
│ │ ├── sudo.json # sudo elevation flow (§7a.7): AppBar lock + signalman icons, PIN dialog, countdown badge, layout-settings PIN field copy
│ │ └── vehicle.json # vehicle CRUD copy, throttle screen
│ └── en/ # mirror of pl/, same keys, English values
└── pages/
└── …
Rules for namespace boundaries:
- One namespace per top-level UI feature, not per page. The
Scripts page, the throttle's script buttons, and the script log
console all share
script.json, because they share vocabulary ("attachment", "deadline", "interrupt"). - One namespace per backend enum family (
role,radio,function,audit,takeover,command_station.connection). These are cited from many pages and must stay 1:1 with the Go constants — a separate file makes the mapping reviewable. common.jsonis for genuinely generic copy only (Save, Cancel, OK, Loading…, Retry, "Network error"). It is not a dumping ground for "I didn't know where to put this".
7c.5 Bootstrapping (web/src/i18n/index.ts and main.tsx)
// web/src/i18n/index.ts
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import plCommon from "./locales/pl/common.json";
import plAuth from "./locales/pl/auth.json";
import plErrors from "./locales/pl/errors.json";
import plRole from "./locales/pl/role.json";
import plRadio from "./locales/pl/radio.json";
import plFunction from "./locales/pl/function.json";
import plAudit from "./locales/pl/audit.json";
import plTakeover from "./locales/pl/takeover.json";
import plCommandStation from "./locales/pl/command_station.json";
import plLayout from "./locales/pl/layout.json";
import plScript from "./locales/pl/script.json";
import plSudo from "./locales/pl/sudo.json";
import plVehicle from "./locales/pl/vehicle.json";
import enCommon from "./locales/en/common.json";
// …mirror imports for en/…
export const SUPPORTED_LOCALES = ["pl", "en"] as const;
export type Locale = (typeof SUPPORTED_LOCALES)[number];
void i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: "pl",
supportedLngs: SUPPORTED_LOCALES,
defaultNS: "common",
ns: [
"common", "auth", "errors", "role", "radio",
"function", "audit", "takeover", "command_station",
"layout", "script", "sudo", "vehicle",
],
resources: {
pl: {
common: plCommon, auth: plAuth, errors: plErrors,
role: plRole, radio: plRadio, function: plFunction,
audit: plAudit, takeover: plTakeover, command_station: plCommandStation,
layout: plLayout, script: plScript, sudo: plSudo,
vehicle: plVehicle,
},
en: { /* mirror */ },
},
detection: {
// Order matters: an explicit user choice (localStorage) beats
// the browser's locale, which beats the fallback.
order: ["localStorage", "navigator"],
lookupLocalStorage: "bigfred.locale",
caches: ["localStorage"],
},
interpolation: { escapeValue: false }, // React already escapes
returnNull: false,
});
export default i18n;
// web/src/main.tsx — extend the existing root with I18nextProvider
import "./i18n"; // side-effect init
import { I18nextProvider } from "react-i18next";
import i18n from "./i18n";
// …existing imports (ThemeProvider, QueryClientProvider, App)…
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<I18nextProvider i18n={i18n}>
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={theme}>
<CssBaseline />
<App />
</ThemeProvider>
</QueryClientProvider>
</I18nextProvider>
</React.StrictMode>,
);
I18nextProvider is the outermost application provider. This
matters because TanStack Query uses message keys for error toasts,
and MUI's LocalizationProvider (when added for date pickers in
M3+) reads the active locale via useTranslation().i18n.language.
7c.6 Use in components
Replace inline switch statements over backend codes with a
namespace lookup. The motivating example is the existing
LoginPage.tsx:
// BEFORE — string switch baked into the component
const errMessage = (() => {
if (loginMut.error instanceof ApiError) {
switch (loginMut.error.code) {
case "invalid_credentials": return "Nieprawidłowy login lub hasło.";
case "missing_credentials": return "Wpisz login i hasło.";
default: return `Błąd: ${loginMut.error.code}`;
}
}
return "Nie udało się połączyć z serwerem.";
})();
// AFTER — single i18n lookup, locale-agnostic
const { t } = useTranslation(["errors", "common"]);
const errMessage = (() => {
if (loginMut.error instanceof ApiError) {
return t(`errors:${loginMut.error.code}`, {
defaultValue: t("common:errors.unknown", { code: loginMut.error.code }),
});
}
return t("common:errors.network");
})();
errors:<code> exists for every code the backend can emit; the
defaultValue only fires for a code the frontend hasn't been
updated to know about — visible enough to flag in QA, harmless to
a user.
7c.7 Polish plurals
Polish has four plural forms (one / few / many / other),
selected by the value of n mod 10 and n mod 100. Intl.PluralRules
on every supported browser implements the correct rule set out of
the box, and i18next consumes its result.
// web/src/i18n/locales/pl/vehicle.json (excerpt)
{
"count_one": "{{count}} pojazd",
"count_few": "{{count}} pojazdy",
"count_many": "{{count}} pojazdów",
"count_other": "{{count}} pojazdu"
}
const { t } = useTranslation("vehicle");
<Typography>{t("count", { count: vehicles.length })}</Typography>
This pattern is mandatory for any user-visible counter. Hand-written
ternaries (n === 1 ? "pojazd" : "pojazdy") are wrong for Polish
and are a review-blocker.
7c.8 Locale selection & persistence
Three layers, in increasing priority:
navigator.language— applied on first visit before the user has any preference.localStorage["bigfred.locale"]— overwrites (1); persisted across reloads and tabs.User.LocalePref(deferred, optional) — a future nullable column onusers. When non-null and the user is logged in, it pre-seedslocalStorageon first paint afteruseMe()resolves. Persisting the preference per account means a phone and a desktop show the same language without the user setting it twice. This column is not a backend translation switch: the server still never renders human strings; it just stores the preference.
A locale toggle lives in AppShell.tsx as an MUI Menu next to the
existing user menu. Switching language calls
i18n.changeLanguage(loc); React re-renders, localStorage is
updated by the detector's caches configuration, and TanStack
Query keys that contain locale-sensitive copy (only the audit-log
list and the activity feed, in practice) are invalidated by a
small subscriber registered next to the i18n init:
i18n.on("languageChanged", () => {
queryClient.invalidateQueries({ queryKey: ["audit-log"] });
queryClient.invalidateQueries({ queryKey: ["activity-feed"] });
});
Most queries are locale-independent because the payloads are codes, not text.
7c.9 Type safety, lint, codegen
- Add
npm run gen:i18n-typesthat callsi18next-resources-for-ts toc -i src/i18n/locales/pl -o src/i18n/resources.d.ts. Run it fromprebuildandpredevinpackage.jsonsoweb/src/i18n/resources.d.tsis always current. - The generated declaration teaches TypeScript every namespace and
every key in the
plsource-of-truth catalogue. Callingt("errors:typo_here")becomes a compile-time error. tsc -b --noEmit(already wired asnpm run lintper the M1 scaffold) is the only thing that has to pass — no separate linter for translations is needed.- A small unit test in
web/src/i18n/__tests__/coverage.test.tswalks every key in the Polish catalogue and asserts that each English JSON has a matching key (and vice versa). This catches drift the moment a key is added in only one language.
7c.10 Build & deployment
- Vite resolves the
importstatements inweb/src/i18n/index.tsat build time. Each language's namespaces become a hashed JS chunk underweb/dist/assets/. The total payload at two languages is in the order of a few KB gzipped — comfortably smaller than a single MUI icon set. web/distis embedded into the Go binary the same way the rest of the SPA is (§7.1 Single binary serving the frontend too). No new server-side code is required to ship i18n.- Cache busting is handled by the existing Vite hashing; no new cache-control rules.
- Lazy-loading per language is intentionally not enabled at
this size. When a third locale ships and the catalogue grows
past ~50 KB gzipped per language, replace the static
importblock withi18next-http-backendand nothing else — neither components nor backend change.
7c.11 Backend implications (none, by design)
To make the "no backend changes" claim auditable, here is the explicit list of conventions the backend must honour for the frontend's i18n contract to keep working. None of these are new — they already follow from §3a.1 (entities), §4 (communication protocol) and §3a.5 (audit log); §7c.11 just captures the i18n angle.
- Errors expose
code, not human strings. Every error envelope (REST4xx/5xx, WS error frame) carries a stablecodefield. New codes go on the table in §7c.2 and intoweb/src/i18n/locales/*/errors.jsonin the same PR. - Enums stay enums on the wire.
RadioPhrase,FunctionIcon,Role,CommandStationConnectionType,TakeoverState,AuditActionare emitted as the Go constant value. Renaming a constant is a wire-protocol break and a coordinated FE+BE change. - Audit log writes denormalized strings as captured.
audit.user_nameandaudit.object_nameare written in whatever spelling the user chose at the moment of the event (§3a.5). The activity-log UI renders them verbatim regardless of active locale. Only theaudit.action_typecolumn is translated, viaaudit:action.<action>keys. - MCP tool descriptions are English-only. The MCP surface (§7b.3) is consumed by AI assistants, not humans, and follows protocol conventions: tool names, descriptions and parameter docstrings stay in English regardless of the operator's UI locale. Strings the tool returns (e.g. a radio phrase broadcast) follow rule (2): they are emitted as codes.
7c.12 Delivery slot
i18n is incremental and lands alongside whichever milestone first introduces user-visible copy beyond the bootstrap login screen:
- M2 — install the four NPM packages, scaffold
web/src/i18n/, shippl/common.json,pl/auth.json,pl/errors.jsonand English mirrors. MigrateLoginPage.tsxtouseTranslation(per §7c.6). Add the locale toggle toAppShell.tsx. - M3 — fill in
pl/role.json,pl/audit.json,pl/function.json,pl/vehicle.jsonas the role-management, audit-log and function-list UIs are added. - M4 — fill in
pl/command_station.json,pl/layout.jsonandpl/sudo.json(the AppBar lock + signalman icons and the layout-settings PIN field, §7a.7). - M5 — fill in
pl/radio.jsonandpl/takeover.json. - M5.1 — fill in
pl/trainAnnouncements.json(labels referenced byweb/src/config/trainAnnouncements.ts). - M6 / M7 —
pl/script.jsonand any new error codes that ship with API keys + scripts.
There is no dedicated i18n milestone. Every PR that adds a new backend code or a new UI screen ships its translation keys for both locales in the same PR; the unit test in §7c.9 prevents regressions.