Internationalization (i18n)
Patrimoine supports five locales: French (default), English, Spanish, German, and Italian. The frontend uses react-intl and the backend has its own translation layer for error messages.
Supported Locales
Section titled “Supported Locales”| Code | Language | BCP47 Tag | Default |
|---|---|---|---|
fr |
French | fr-FR |
Yes |
en |
English | en-US |
|
es |
Spanish | es-ES |
|
de |
German | de-DE |
|
it |
Italian | it-IT |
Frontend
Section titled “Frontend”Provider
Section titled “Provider”The I18nProvider in src/i18n/I18nProvider.tsx wraps the application and provides the react-intl context.
Each of the five catalogs is its own chunk under assets/locales/, loaded on demand through LOCALE_LOADERS. Until the first one arrives the provider shows a spinner without text, so no message id is ever painted. public/locale-init.js reads the stored language before React starts and preloads that catalog from the locale-chunks meta tag the build writes into index.html, so it downloads in parallel with the entry chunk. Its language list is a hand-kept mirror of LOCALE_LOADERS, checked by locale-init-sync.test.ts. If a catalog fails to load, the provider falls back to fr (which the service worker keeps precached), then to message ids, rather than staying on the spinner.
Locale Detection
Section titled “Locale Detection”Locale is resolved in the following priority order:
- User profile — locale saved on the user’s account (synced from backend)
- localStorage —
patrimoine-localekey - navigator.language — browser language preference
fr— fallback default
Locale Catalogs
Section titled “Locale Catalogs”Translation files live in src/locales/ (fr.json, en.json, es.json, de.json, it.json). All five catalogs contain the same keys (2952 at the time of writing); verify with python3 -c "import json; print(len(json.load(open('src/locales/en.json'))))".
A locale key-parity test (src/__tests__/locale-parity.test.ts) runs in CI to verify that all five files have identical key sets and no empty values, preventing drift between locales. A second test (src/__tests__/locale-icu-parity.test.ts) formats every English message with the real ICU runtime in each locale and fails when a translation drops one of its arguments or leaves a placeholder unresolved.
Usage in JSX
Section titled “Usage in JSX”Use <FormattedMessage> for declarative translation:
import { FormattedMessage } from 'react-intl'
<FormattedMessage id="accounts.balance" />Programmatic Access
Section titled “Programmatic Access”Use useIntl() for dynamic formatting, toast messages, and conditional logic:
import { useIntl } from 'react-intl'
const intl = useIntl()const label = intl.formatMessage({ id: 'accounts.balance' })toast.success(intl.formatMessage({ id: 'accounts.updated' }))ICU MessageFormat (Pluralization)
Section titled “ICU MessageFormat (Pluralization)”Use ICU plural syntax in locale files for count-dependent messages:
"accounts.count": "{count, plural, =0 {No accounts} =1 {1 account} other {# accounts}}"Render with values:
intl.formatMessage({ id: 'accounts.count' }, { count })Never use manual pluralization like `${count} item${count > 1 ? 's' : ''}`.
Zod Validation Messages
Section titled “Zod Validation Messages”Store the i18n key as the Zod error message, then render it with <FormattedMessage>:
const schema = z.object({ name: z.string().min(1, 'accounts.nameRequired').max(100, 'accounts.nameTooLong'),});
// In the error display:{fieldState.error?.message && ( <p className="text-body text-destructive"> <FormattedMessage id={fieldState.error.message} /> </p>)}Language Switcher
Section titled “Language Switcher”A LanguageToggle component in the sidebar footer (next to ModeToggle and SkinToggle) cycles through FR → EN → ES → DE → IT with a single click. It uses useLocaleContext() from I18nProvider to update the locale, which persists to localStorage and syncs to the backend.
A full language selector is also available on the Settings page.
Locale Sync with Backend
Section titled “Locale Sync with Backend”The useLocale hook syncs the selected locale with the backend via PATCH /api/v1/auth/me (fire-and-forget). This ensures the user’s locale preference persists across sessions and devices.
HTML lang Attribute
Section titled “HTML lang Attribute”The <html lang> attribute is updated dynamically when the locale changes, ensuring correct screen reader behavior and browser language detection.
Format Locale Sync
Section titled “Format Locale Sync”The I18nProvider calls setFormatLocale(locale) whenever the locale changes. This sets a module-level currentLocale variable in src/lib/format.ts that serves as the default locale for all formatting functions (formatCurrency, formatDateShort, formatDateLong, formatCompactCurrency).
This avoids the need to pass the locale explicitly to every formatting call — the locale is set once on mount and kept in sync on every change.
BCP47 Mapping
Section titled “BCP47 Mapping”The intlLocale() function maps internal locale codes to BCP47 tags:
| Internal | BCP47 |
|---|---|
fr |
fr-FR |
en |
en-US |
es |
es-ES |
de |
de-DE |
it |
it-IT |
Backend
Section titled “Backend”The backend has its own translation layer in app/i18n.py with a translate(key, locale, **kwargs) function. It covers the same five locales: fr, en, es, de and it.
Error Message Translation
Section titled “Error Message Translation”Backend error messages (import errors, auth errors, validation errors) are translated using the user’s locale. If a translation key is not found for the requested locale, the function falls back to French (fr).
from app.i18n import translate
message = translate("import.invalid_format", locale="en")Locale-Aware Formatting
Section titled “Locale-Aware Formatting”Formatting utilities in src/lib/format.ts use a module-level currentLocale (set via setFormatLocale() by I18nProvider) as the default locale, so callers do not need to pass the locale explicitly.
| Function | Purpose | Example |
|---|---|---|
setFormatLocale(locale) |
Sets the module-level locale for all format functions | Called by I18nProvider on locale change |
setFormatDateFormat(preset) |
Sets the module-level date format preset | Called by FormatPreferencesSync when the user’s profile loads or changes |
setFormatNumberFormat(preset) |
Sets the module-level number format preset | Called by FormatPreferencesSync when the user’s profile loads or changes |
formatCurrency(amount, currency='EUR', locale?) |
Full currency formatting | “12 500,00 €” (fr) / “$12,500.00” (en) |
formatDateShort(date, locale?) |
Compact date format | depends on the user’s date format preset, e.g. “22/07/2026” |
formatDateLong(date, locale?) |
Full date format | “22 juillet 2026” (fr) / “22 July 2026” (en) |
formatCompactCurrency(value, currency, locale?) |
Abbreviated currency | “12,5 K€” (fr) / “€12.5K” (en) |
formatPercent(value, { digits, signed, locale }) |
The only way to show a percentage (percent units: 12.5 means 12.5 %) | “12,5 %” (fr) / “12.5%” (en) |
formatPercentage(value) |
Signed percentage with one decimal; delegates to formatPercent |
“+12,5 %” (fr) / “+12.5%” (en) |
formatAmountPlaceholder(example?, locale?) |
Example amount for a money input placeholder: two decimals, no grouping | “10000,00” (fr) / “10000.00” (en) |
compareText(a, b, locale?) |
Sort comparison for display names: accent- and case-insensitive, digits as numbers | “Konto 2” before “Konto 10” |
formatRate(value) |
Rate formatting | “12.5” / “0.035” |
formatMonthLabel(monthStr, locale?) |
Month label | “Juillet 2026” / “July 2026” |
resolveNumberLocale(locale?) |
Resolves the BCP47 tag to use for number/currency formatting | currentNumberFormat’s locale if set, else intlLocale(locale) |
The locale parameter is optional on all format functions — when omitted, the module-level currentLocale is used. This is set by I18nProvider via setFormatLocale() on mount and on every locale change.
formatDateShort, formatCurrency, and formatCompactCurrency also consult the user’s independent date_format/number_format display preferences (set via setFormatDateFormat/setFormatNumberFormat, synced from the auth query by FormatPreferencesSync) before falling back to this locale-derived behavior.
These presets are separate from the UI-language locale — a user can keep the app in English while numbers render in the German 1.234,56 style, for example.
formatRate and formatMonthLabel were moved from dashboard-calc.ts to format.ts for consistency.
Chart Components
Section titled “Chart Components”Chart components must use the user’s locale (and number format preference) for number/currency formatting, not a hardcoded 'fr-FR'. Use resolveNumberLocale() from @/lib/format to get the BCP47 tag:
import { useIntl } from 'react-intl';import { type Locale } from '@/i18n';import { resolveNumberLocale } from '@/lib/format';
const intl = useIntl();const formatter = new Intl.NumberFormat( resolveNumberLocale(intl.locale as Locale), { style: 'currency', currency: 'EUR' });Or use the formatCurrency/formatCompactCurrency utilities directly, passing the current locale.
Account Type Labels
Section titled “Account Type Labels”Account types use a labelId field (an i18n key) defined in src/constants/accountTypes.ts. The label is resolved at render time via useIntl(), keeping the UI fully translatable without hardcoding display strings.
Adding New Strings
Section titled “Adding New Strings”- Add the key to all five locale files:
src/locales/fr.json,src/locales/en.json,src/locales/es.json,src/locales/de.json,src/locales/it.json - Use
<FormattedMessage id="your.key" />in components, orintl.formatMessage({ id: 'your.key' })for programmatic access - Ensure the key follows the existing naming convention (dot-separated, lowercase, namespace-prefixed)
- Write a message that carries a count as an ICU plural (
oneandotherin en and de; fr, es and it add an explicit=0branch so a zero reads naturally, such as “Aucun compte supprimé”), never with “(s)” suffixes - Name a create button after its object with the verb rule in
web/documentation/i18n/glossary.md: New for top-level objects, Add for items inside a parent