Files
parking_solution/wiki/concepts/i18n.md
T
julian 11567a417f docs(wiki): catch concept pages up to the booth-UX/shift/font-scale work
Bring three queryable pages current with the booth-UX commit (cce99aa) whose
breadth hadn't propagated:
- booth-console: Active Sessions as a real table, dropped status column/filter,
  inline live-feed rows, removed TARGE via-badge + redundant Direction filter,
  plate now searchable + backfilled via plate-recognized WS push, per-user font scale.
- shift: Z-report display simplified (shitje dropped, opening cash added) while
  the signed payload is untouched.
- i18n: users.font_scale recorded alongside language/theme as the matching
  per-user server-stored pref (migration 0014).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-29 11:43:12 +02:00

6.6 KiB

type, tags, sources, updated, status
type tags sources updated status
concept
parking
frontend
i18n
localization
2026-06-19 open

Internationalization (i18n)

The operator UI ships in two languages: Albanian (default) and English. Language is a per-user preference stored server-side and loaded on login — not a browser/localStorage setting, not a site-wide one. So an operator's choice follows their account and is restored on every login from any booth. (Decided + built 2026-06-18.)

Decisions

  • Albanian is the default and fallback. English is the second language. A missing English key falls back to Albanian.
  • Per-user, server-side preference. users.language ('sq' | 'en', default 'sq'; migration 0003). Returned from /api/auth/login and /api/auth/me, and changed via PUT /api/auth/language (self-service, any signed-in role). It is deliberately NOT in the JWT (identity/role only) — so changing language is a DB write + immediate /me, with no token refresh / re-login. See local-jwt-auth.
  • Sibling per-user UI prefs (same pattern). users.theme ('dark'|'light') and users.font_scale (percent, default 100; migration 0014, added 2026-06-28) follow language exactly — DB column, surfaced on /login + /me, self-service PUT /api/auth/theme / /api/auth/font-scale, applied after /me (applyTheme / applyFontScale), header toggles that persist. Font scale sets the root font-size over the app's rem-based type (NOT CSS zoom); none of the three is a JWT claim.
  • Library: react-i18next (i18next). Chosen over a hand-rolled t() for pluralization, interpolation, and headroom beyond two languages. The active language is applied after /me resolves (App effect on user.language); the header SQ/EN toggle switches instantly and persists.
  • Printed tickets/receipts stay Albanian. Customer-facing paper is independent of the operator's UI language — an operator reading the UI in English still prints Albanian tickets. The print strings live in the device driver's STR table (ticket-encoding, site-metadata); can become a site_config.print_language setting later if a site ever needs English receipts.

As-built (2026-06-18)

  • Backend: users.language + the three auth touch-points above (apps/server/src/routes/auth.ts).
  • Frontend: apps/web/src/lib/i18n/ — sq.ts (default/fallback), en.ts, and index.ts (init + setLanguage()). Type-safe key parity: Catalog is the shape of sq with string-typed values, so TypeScript forces en.ts to supply every key (and the build fails on a missing/typo'd key). Keys are dot-namespaced by area (common, nav, status, auth, booth, pay, shift, site, permits, tariff).
  • Translated screens: the booth (booth-console — screen, pay/exit modal, active sessions, snapshots, status), Login, ShiftControl, SiteSettings, PermitManager, TariffComposer, SetupWizard devices tab (2026-06-19 — the static chrome; driver/config-field labels still come from the backend catalog), Users/Roles managers, and the shift-history screen.

Localized ledger reasons & relative dates (2026-06-19)

Two patterns added on top of the catalog approach:

  • Reason codes (backend i18n for the signed ledger). Anomaly/payment/override reasons used to be free-text English baked into the signed payload.reason — unlocalizable at render time (the bytes are immutable). Now the ledger signs a stable reasonCode + reasonParams (plus the English reason as a fallback) from a closed set REASON_CODES in @parking/shared. The UI translates reason.<code> via the sq/en catalogs, so an Albanian operator reads Albanian from the same immutable event, and adding a language is a catalog change with no re-signing of past events. Legacy events (no code) show the signed English fallback. This narrows — but doesn't close — the "server API error strings are English" gap noted below: the ledger reasons are localized; raw HTTP error strings still aren't. See append-only-event-chain, booth-console.
  • Relative + human dates (formatRelativeDateTime). Sessions/logs/history show Sot/Today 10:48 · Dje/Yesterday 17:33 · 17 Qershor/June 10:48 instead of a bare time (a 2-day-old session previously showed only 10:48). today/yesterday come from common.today/yesterday.

    Gotcha — the appliance browser's ICU has NO Albanian locale data. Intl.DateTimeFormat("sq", {month:"long"}) silently returns English ("June", not "Qershor") on this hardware. So month names come from a common.months catalog array, NOT Intl. Any future date formatting on this box must avoid relying on Intl for Albanian or it leaks English. (Printed slips already solved this with the SQ_MONTHS table in rongta-printer.)

Open / deferred

  • SetupWizard chrome is now translated (2026-06-19); the remaining gap is the backend device-catalog (driver + config-field labels/help from device-registry, first-run-setup), still English. Localizing the catalog is the open item.
  • Server API error strings are still English (surfaced raw in the UI). The ledger reason codes are now localized (above), but raw HTTP error messages aren't. A fuller approach translates by error code, not message — the reason-code pattern is the template to follow.
  • Behaviour note (not a bug): a hard navigation (new URL) re-bootstraps the language from the user's stored preference via /me — so an un-persisted toggle resets. Correct: the stored pref wins. The toggle persists via the PUT, so it survives once saved.

Gotcha — language/theme toggle stale router-context (fixed 2026-06-19)

The header SQ/EN (and dark/light) toggles read the active value from user, which comes from the TanStack Router context (rootRoute.useRouteContext()). Router context is captured at route-resolution time and does NOT re-render on setUser — so after one switch the toggle's user.language froze, the active-button highlight stuck, and the equality guard blocked switching back until a page refresh (which re-resolved the context). Fix: drive the toggles off live state, not the stale context — language reads i18n.language (useTranslation() subscribes to i18next's languageChanged), theme uses local useState. Both still call setUser to keep the context eventually-consistent + persisted, but no longer depend on it re-rendering. General lesson: router context is not reactive React state — never read frequently-changing UI state from it.