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
- Support 10+ languages without requiring code changes per language
- Enable non-developer translators to contribute
- Localize both the React UI and server-rendered documents (invoices, timesheets)
- Handle plural forms correctly for diverse languages (CLDR plural categories)
- 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
- Translation file format: JSON vs gettext (.po) vs Fluent (.ftl)?
- Plural handling: ICU MessageFormat (in JSON) vs gettext nplurals vs Fluent selectors?
- Key naming convention: Flat (
invoice_vat) vs dot-namespaced (invoice.vat) vs nested JSON?
- Frontend library: react-i18next vs FormatJS (react-intl) vs Lingui?
- Translator workflow: Self-hosted (Weblate) vs SaaS (Crowdin/Tolgee) vs PR-only?
- Per-client language: Should contracts/clients have a language field, or is the app-level preference sufficient?
- Timesheet localization: Include in scope or defer?
- 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
Motivation
Tuttle currently localizes only invoice PDFs, using an inline Python dict (
INVOICE_LABELSinrendering.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
INVOICE_LABELSdict + Babel for dates/currencyDependencies: 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
Architecture Options
Option A: JSON namespace files + thin loader (lightweight)
tuttle/i18n.pymodule loads JSON, exposest(key, locale)function, integrates with Jinja2 filtersreact-i18nextloads the same JSON files (or a subset via lazy-loading)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)
gettextmodule orbabel.messages.pofiles per locale, compiled to.mo{% trans %}supportPros: 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)
fluent.runtimefor Python,@fluent/bundlefor JSPros: 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
babelfor date/number/currency formatting (already works)react-i18nextfor frontend, thin Python loader for backendThis is essentially Option A with the explicit acknowledgment that Babel stays for formatting.
Decisions to Make
invoice_vat) vs dot-namespaced (invoice.vat) vs nested JSON?Suggested Phased Approach
INVOICE_LABELSto external file(s), add loader module, keep same behaviorReferences