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