11567a417f
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
97 lines
6.6 KiB
Markdown
97 lines
6.6 KiB
Markdown
---
|
|
type: concept
|
|
tags: [parking, frontend, i18n, localization]
|
|
sources: []
|
|
updated: 2026-06-19
|
|
status: 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.
|