Skip to content

Internationalization (i18n)

EN + DE. Topic 5 lock-in.

Confidence: High — strategy and locale-source rule are both locked (rule locked 2026-04-27).

Two layers, two strategies

Layer Mechanism Source of truth for locale
UI strings (page labels, buttons, validation messages) Babel + Jinja2 {% trans %} Signed-in stringer: Stringer.default_locale. Signed-in V3 client: Person.default_locale (the Person-bound session per ADR-0006 (in-flight)). Unauthenticated request: browser Accept-Language (fallback).
PDF receipts One template file per language (receipt_en.html, receipt_de.html) order.client_profile.person.default_locale else Stringer.default_locale

Two layers because they have genuinely different audiences:

  • UI is read by stringers (and admin Stefan). Probably DE for some Swiss stringers, EN for others.
  • Receipts are read by clients — who are not necessarily the stringers. A German-speaking stringer with an English-speaking client should still send their client an English receipt.

UI string layer: Babel + Jinja2

Aspect Choice
Library Babel (the de-facto Python i18n toolkit)
Template integration Jinja2's {% trans %} block + gettext() (_() in code)
Catalogue files locales/en/LC_MESSAGES/messages.po + messages.mo (compiled), locales/de/...
Build step pybabel extract + pybabel update + pybabel compile — automated in the keystone CI template
Locale negotiation Per-request middleware sets the locale from the locked rule (below)

UI locale source: locked rule (2026-04-27)

The rule resolves the case where a stringer's account default_locale is DE but the browser sends Accept-Language: en (or vice versa). Saved account preference wins.

Request Locale source
Signed-in stringer (V2) Stringer.default_locale (EN or DE). Browser Accept-Language is ignored for authenticated stringers.
Signed-in client (V3 client portal) Person.default_locale. Browser Accept-Language is ignored for authenticated clients.
Unauthenticated request (e.g. public listing page, public receipt link if any) Browser Accept-Language is the fallback, negotiated against [en, de] with en as the default if no match.

Account record: the stringer's account row carries a default_locale field (enum: en | de). V3 clients carry the same field on the Person record (per ADR-0004Person is the platform-level identity row that the V3 client-portal session binds to).

Why account-preference-wins: stringers (and V3 clients) on a borrowed device, a colleague's laptop, or a freshly installed phone get the language they configured, not whatever the browser default happens to be. The setting is one place to change, predictable, and survives device-switch — which is in line with the "same user, two devices, same time" V2 mode in the requirements.

Override: none in V2. A per-session toggle (cookie-backed) remains a single-PR additive change later if it's ever asked for; it would not change the rule above, only layer on top.

Receipt language: locked rule

Already pinned in receipts:

receipt_locale = order.client_profile.person.default_locale ?? Stringer.default_locale

The locale field lives on Person (the platform-level identity row per ADR-0004). A Person carries one default_locale regardless of how many stringers serve them; that locale wins for any receipt addressed to them.

New-Person default-locale seeding (locked 2026-05-04): when a stringer creates a new Person (e.g. via the Add-Stringjob flow when a player is being entered for the first time), Person.default_locale is seeded from the creating stringer's Stringer.default_locale. This is a one-time write at Person-creation. The Person can later set their own preference (via the V3 client portal), at which point that Person-level value wins per the resolution rule above. The seeding rule keeps the V2 single-locale stringer experience friction-free (a German-speaking stringer with mostly German-speaking clients doesn't have to pick a locale per Person) while leaving the V3 client-self-service path open.

Per-order override is not in V2. If Stefan ever wants it, it's a single column on orders (receipt_locale_override) — small, additive, no migration risk.

Catalogue / data localization (NOT done)

We do not translate user-entered data:

  • Racket models, string SKUs, comments, player names — stored as-is, displayed as-is.
  • BYO toggles, status badges — these are translated UI, not data.

Translating user-entered data is a different beast (per-row translation tables, content moderation, fallback chains). Out of scope for V2 and V3.

Adding a third language later

If a French- or Italian-speaking stringer joins:

  1. Add Stringer.default_locale enum value fr (or it).
  2. Add Person.default_locale enum value.
  3. Add locales/fr/LC_MESSAGES/messages.popybabel init -l fr, translate, compile.
  4. Add templates/receipts/receipt_fr.html — copy receipt_en.html, translate the strings.
  5. Done.

The architecture deliberately makes "add a language" an additive change with no schema migration on existing data. Existing stringers/Persons keep their current locale.

Build and deploy implications

  • Babel .mo compile is part of the Docker image build. Stale .po files would silently ship the wrong strings — the build step asserts no fuzzy translations remain in production builds.
  • Translation reviews are a manual deliverable. At 2 languages × ~a few hundred UI strings, this is a one-afternoon task at V2 ship time and a small ongoing cost as features land.

What this chapter does NOT cover

  • Number/currency formatting (fr_CH vs de_CH for CHF) — Babel's format_currency() handles it; not architecturally significant.
  • Date format localization — same, Babel handles.
  • RTL languages — out of scope; would need CSS adjustments.