rongta-printer still named the cashino driver id (→ escpos + migration 0023 note); tariff-time-tiers listed the composer price preview as deferred (→ delivered by the lab fee breakdown); tariff.md lab section gained the breakdown + composer increment-guard paragraph; i18n.md now records the "25 Qer 14:30" date standard + never-toLocaleString-for- dates rule; fleet-deployment-komodo gained the park-lab stack + tier table (the park-lab addition had also slipped the log — both fixed). Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
7.4 KiB
type, tags, sources, updated, status
| type | tags | sources | updated | status | ||||
|---|---|---|---|---|---|---|---|---|
| concept |
|
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/loginand/api/auth/me, and changed viaPUT /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') andusers.font_scale(percent, default 100; migration 0014, added 2026-06-28) follow language exactly — DB column, surfaced on/login+/me, self-servicePUT /api/auth/theme//api/auth/font-scale, applied after/me(applyTheme/applyFontScale), header toggles that persist. Font scale sets the rootfont-sizeover the app's rem-based type (NOT CSSzoom); 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/meresolves (App effect onuser.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
STRtable (ticket-encoding, site-metadata); can become asite_config.print_languagesetting 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, andindex.ts(init +setLanguage()). Type-safe key parity:Catalogis the shape ofsqwith string-typed values, so TypeScript forcesen.tsto 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 stablereasonCode+reasonParams(plus the Englishreasonas a fallback) from a closed setREASON_CODESin@parking/shared. The UI translatesreason.<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 showSot/Today 10:48·Dje/Yesterday 17:33·17 Qershor/June 10:48instead of a bare time (a 2-day-old session previously showed only10:48). today/yesterday come fromcommon.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 acommon.monthscatalog array, NOTIntl. Any future date formatting on this box must avoid relying onIntlfor Albanian or it leaks English. (Printed slips already solved this with theSQ_MONTHStable in rongta-printer.)
UI-wide date standard — "25 Qer 14:30" (2026-07-06)
Dates were a mix of catalog-formatted "25 Qershor 20:01" and browser-locale "7/6/2026, 9:34 AM"
(raw toLocaleString in ~20 call sites) — operator called it out. The standard now: short
month from the catalog (common.monthsShort: Jan/Shk/…/Qer/Korr/…/Dhj), 24h clock, year
only when ≠ current. Helpers in lib/format.ts: formatDate ("25 Qer"), formatDateTime
("25 Qer 14:30", optional seconds — the event-detail modal keeps them), formatClock ("HH:mm");
formatRelativeDateTime keeps Sot/Dje and uses the same short months beyond that. Rule for new
code: never call toLocale*String for a DATE — catalog months exist because the appliance
browser's ICU may lack Albanian data; number formatting (thousand separators on money) still uses
the locale. Swept everywhere 2026-07-06.
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.