-
Notifications
You must be signed in to change notification settings - Fork 2
ADR 021 Internationalization Architecture
Accepted
Cornerstone is used by German-speaking homeowners managing construction projects. The application needs to support multiple display languages (starting with English and German) and a configurable display currency. Key considerations:
- User base: 1-5 users per instance, all likely sharing the same language, but individuals may prefer different languages.
- Content scope: All UI text is static (labels, buttons, messages, status names). User-generated content (work item names, notes, diary entries) is NOT translated.
- Currency: The display currency is a deployment-level setting (all users in one instance use the same currency), not a per-user preference.
-
Existing patterns: The codebase already has a
ThemeContext+user_preferencespattern for per-user settings with localStorage hydration and server sync. Formatters inclient/src/lib/formatters.tshardcode'en-US'locale and'EUR'currency. -
Server errors: Error responses use machine-readable
ErrorCodestrings (e.g.,NOT_FOUND,VALIDATION_ERROR). Human-readable messages are for debugging, not display. - Dependency policy: No native binary dependencies on the client side (rules out SWC-based tooling).
| Option | Pros | Cons |
|---|---|---|
| react-i18next | Industry standard, React hooks/components, namespace support, interpolation, pluralization, active maintenance, pure JS | Adds ~40KB (gzipped ~12KB) to bundle |
| react-intl (FormatJS) | Strong ICU MessageFormat support, built-in number/date formatting | Heavier bundle, more complex API for simple string translations, ICU syntax learning curve |
| Custom solution | Zero dependencies, full control | Reinvents pluralization, interpolation, namespace loading; maintenance burden |
Use react-i18next (built on i18next) for client-side internationalization with the following design:
react-i18next is the most widely adopted React i18n library. It provides:
-
useTranslation()hook for function components -
Transcomponent for JSX interpolation (bold text, links in translated strings) - Namespace support to split translations by feature domain
- Built-in interpolation (
{{count}},{{name}}) and pluralization - Pure JavaScript -- no native binaries
Translations are imported statically and bundled with the application. With only 2 languages and approximately 800 keys per language, the total payload is roughly 20KB gzipped. This eliminates:
- HTTP requests for translation files at runtime
- Loading states / race conditions between i18n init and first render
- Need for a translation file server endpoint
One JSON file per feature domain per language:
client/src/i18n/
index.ts # i18next initialization
en/
common.json # Shared: nav, buttons, status labels, validation messages
auth.json # Login, setup, session
dashboard.json # Dashboard cards, headings
workItems.json # Work item CRUD, statuses, fields
householdItems.json # Household item CRUD, statuses, fields
budget.json # Budget lines, invoices, sources, subsidies
schedule.json # Gantt, calendar, milestones, dependencies
diary.json # Diary entries, types, export
documents.json # Paperless integration, document links
settings.json # Profile, user management, manage page
errors.json # Client-side translations of ErrorCode values
de/
(same structure)
- Flat dot-notation within each namespace:
nav.project,button.save,status.in_progress - Prefixes by UI context:
nav.*,button.*,label.*,placeholder.*,status.*,toast.*,heading.*,empty.*,confirm.* - Pluralization uses i18next's built-in
_one/_othersuffix convention - Interpolation:
{{variableName}}syntax
Detection order (i18next languageDetector):
- Server preference (via existing
user_preferencesAPI, keylocale) -
localStoragekeylocale(instant hydration before server response) - Browser
navigator.language(mapped:de*->de, everything else ->en) - Fallback:
en
Storage: Mirrors the ThemeContext pattern exactly:
-
LocaleContextprovideslocale,resolvedLocale,setLocale(),syncWithServer() -
localStoragefor instant hydration on page load (no flash of wrong language) - Server sync via existing preference API (
key: 'locale',value: 'en' | 'de' | 'system') - On auth,
LocaleServerSynccomponent (parallelingThemeServerSync) callssyncWithServer()
The locale preference uses the same tri-state pattern as theme:
| Value | Meaning |
|---|---|
'en' |
Force English |
'de' |
Force German |
'system' |
Auto-detect from browser navigator.language
|
Currency is a deployment-level setting, not a per-user preference:
-
Environment variable:
CURRENCY(default:EUR) - Validation: Must be a valid 3-letter ISO 4217 currency code
-
Exposed via:
GET /api/config(public endpoint, no auth required) -
Client consumption: Fetched once at app startup, stored in
LocaleContext, passed toIntl.NumberFormat
All formatters in client/src/lib/formatters.ts gain locale and currency parameters:
// Before
formatCurrency(amount: number): string
formatDate(dateStr: string | null | undefined, fallback?: string): string
// After
formatCurrency(amount: number, locale: string, currency: string): string
formatDate(dateStr: string | null | undefined, locale: string, fallback?: string): stringA useFormatters() hook wraps these with the current locale and currency from LocaleContext, so components call formatCurrency(1234) without passing locale/currency explicitly.
Server error messages remain in English (they are developer-facing debug strings). The client translates errors for display:
- API error responses contain
error.code(e.g.,NOT_FOUND,VALIDATION_ERROR) - The
errors.jsonnamespace maps eachErrorCodeto a translated message - A
translateError(code: ErrorCode, t: TFunction)utility function looks up the translation - Toast notifications and error banners use the translated message
GET /api/config
Returns deployment-level configuration that the client needs but that is not sensitive:
{
"currency": "EUR"
}This endpoint is public (no auth required) because the client needs the currency before login (e.g., the setup page may show currency-formatted values in the future). It is added to the unprotected routes list alongside /api/health.
Locale is stored using the existing user_preferences table (key: locale, value: 'en' | 'de' | 'system'). The PreferenceKey type in shared/src/types/preference.ts gains the 'locale' union member.
- Adding new languages: create a new directory under
client/src/i18n/with translated JSON files, add the locale code to the supported locales list - Consistent formatting: all date/currency/number formatting flows through locale-aware
IntlAPIs - Currency flexibility: deploying for CHF, USD, or any other currency requires only setting
CURRENCY=CHF - Error messages: users see translated error messages instead of English developer strings
- Every new UI string must be added to both
en/andde/translation files (cannot just hardcode text) - Formatter calls gain complexity (though the
useFormatters()hook hides this) - Bundle size increases by approximately 20KB gzipped (translations) + 12KB gzipped (react-i18next)
- Testing: components that render translated text need i18next initialized in the test environment
- Additional languages: add a new directory, no code changes needed
- Server-side translations (e.g., PDF export in user's language): i18next can run on Node.js, but this is deferred
- RTL languages: would require CSS adjustments but i18next supports RTL detection