Files
parking_solution/wiki/concepts/i18n.md
T
julian 0074e82a2a docs(wiki): activity-log explainability, dates/i18n, KP-300H barcode fix
Record this session's work across the affected pages + three log entries.

- ticket-encoding: id 13→11 digits (guess-resistance rationale, legacy-safe
  validation) + a barcode-geometry rule (symbol dots must fit the narrowest
  deployed printer's line — the KP-300H 72mm overflow).
- rongta-printer: KP-300H raster-garbage root cause (line overflow, not
  corruption), sendRaw graceful-close fix, Albanian human dates (formatStampSq).
- i18n: localized ledger reason codes, relative/human dates + the
  "browser ICU lacks Albanian" gotcha, toggle stale-router-context fix.
- shift: Albanian Z-report, shift-history UI + permission scoping.
- booth-console: explainable activity log (inline reasons/badges, event-detail
  modal with snapshots + audit disclosure, subscriber names, failed-snapshot
  tiles).
- index/log updated; all added wikilinks resolve.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 11:41:27 +02:00

6.1 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.
  • 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.