Skip to content

Internationalization: scalable i18n architecture for multi-language support #348

Description

@clstaudt

Motivation

Tuttle currently localizes only invoice PDFs, using an inline Python dict (INVOICE_LABELS in rendering.py) with ~30 keys for 3 languages (en, de, es). This approach has served us well for the initial scope but will not scale as we aim to support many major languages across both the backend and the React UI.

Recent issues (#336, #337) exposed friction: hardcoded strings, key mismatches, and no tooling to prevent translation drift between locales.

Current State

Layer i18n status
Invoice PDFs (active templates) Partial — INVOICE_LABELS dict + Babel for dates/currency
Invoice PDFs (legacy templates) English hardcoded
Timesheets English hardcoded, no language param
React UI English only, no i18n library
E-invoicing (XRechnung) Uses UN/ECE codes, locale-independent

Dependencies: Only babel (date/currency formatting). No gettext, no .po files, no react-i18next.

Language setting: App-level preference (preferred_language), not per-client or per-contract.

Goals

  1. Support 10+ languages without requiring code changes per language
  2. Enable non-developer translators to contribute
  3. Localize both the React UI and server-rendered documents (invoices, timesheets)
  4. Handle plural forms correctly for diverse languages (CLDR plural categories)
  5. Incremental migration — no big-bang rewrite

Architecture Options

Option A: JSON namespace files + thin loader (lightweight)

locales/
  en/
    invoice.json
    ui.json
    timesheet.json
  de/
    invoice.json
    ui.json
    ...
  • Backend: A tuttle/i18n.py module loads JSON, exposes t(key, locale) function, integrates with Jinja2 filters
  • Frontend: react-i18next loads the same JSON files (or a subset via lazy-loading)
  • Plurals: ICU MessageFormat strings within JSON values
  • Tooling: CI check that all locales have the same keys; optional translation platform (Weblate/Tolgee)

Pros: Simple, no heavy deps, JSON is universal, shared between Python and JS
Cons: ICU plural syntax can be verbose; no compile-time type safety for keys

Option B: gettext (.po/.mo files)

  • Standard Python approach via gettext module or babel.messages
  • .po files per locale, compiled to .mo
  • Jinja2 has native {% trans %} support
  • Frontend would need a separate solution (or compile .po → JSON for react-i18next)

Pros: Battle-tested, excellent tooling (Poedit, Weblate), handles plurals natively
Cons: Binary .mo files in repo; separate toolchain for React UI; extraction tooling needed

Option C: Project Fluent (.ftl files)

  • Mozilla's modern i18n format with built-in plural/gender/selector support
  • fluent.runtime for Python, @fluent/bundle for JS
  • Natural-language-friendly syntax

Pros: Most expressive format; great plural/gender handling; unified across Python + JS
Cons: Less mainstream than gettext or JSON; fewer translation platform integrations; smaller ecosystem

Option D: Hybrid — JSON for UI, keep Babel for formatting

  • Keep babel for date/number/currency formatting (already works)
  • JSON files only for translatable string labels
  • react-i18next for frontend, thin Python loader for backend
  • Translation platform for community contributions

This is essentially Option A with the explicit acknowledgment that Babel stays for formatting.

Decisions to Make

  1. Translation file format: JSON vs gettext (.po) vs Fluent (.ftl)?
  2. Plural handling: ICU MessageFormat (in JSON) vs gettext nplurals vs Fluent selectors?
  3. Key naming convention: Flat (invoice_vat) vs dot-namespaced (invoice.vat) vs nested JSON?
  4. Frontend library: react-i18next vs FormatJS (react-intl) vs Lingui?
  5. Translator workflow: Self-hosted (Weblate) vs SaaS (Crowdin/Tolgee) vs PR-only?
  6. Per-client language: Should contracts/clients have a language field, or is the app-level preference sufficient?
  7. Timesheet localization: Include in scope or defer?
  8. Migration strategy: Extract current dict in-place first, or start fresh with new structure?

Suggested Phased Approach

Phase Scope Rough effort
1 Extract INVOICE_LABELS to external file(s), add loader module, keep same behavior 1 day
2 Add CI lint (all locales have same keys), connect translation platform 1 day
3 Add react-i18next to UI, extract English strings 2–3 days
4 Adopt ICU MessageFormat for plurals where needed 1 day
5 Onboard community translators, add languages Ongoing

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions