Skip to content

Internationalisation (i18n)

All user-facing strings are externalised in src/i18n/. No hardcoded strings in handlers, use cases, or cron runners.

Supported Locales

Code Language
es Spanish (default)
en English
fr French
de German
pt Portuguese
it Italian
tr Turkish
nl Dutch
ca Catalan
ro Romanian
pl Polish
et Estonian

Architecture

src/i18n/
├── types.ts   — Locale type union + Messages interface + param types
├── index.ts   — getMessages(locale?), DEFAULT_LOCALE, catalog map
└── *.ts       — One file per locale (es.ts, en.ts, fr.ts, ...)

The Messages Interface

Every locale file exports a single object satisfying Messages. Strings with parameters use function types — no runtime template parsing, full TypeScript type-checking.

export interface Messages {
  bot: {
    error: (msg: string) => string;
    txConfirm: (p: TxConfirmParams) => string;
    onboardWelcome: (oauthUrl: string, templateUrl: string) => string;
    // ...
  };
  parser: {
    invalidAmount: (val: string) => string;
    unknownAccount: (account: string, valid: string) => string;
    // ...
  };
  daily: { ... };
  weekly: { ... };
  deferred: {
    settled: (count: number) => string;
  };
}

TypeScript enforces completeness — a missing key is a compile error.

getMessages

export function getMessages(locale: Locale = DEFAULT_LOCALE): Messages

Returns catalog for requested locale, falls back to DEFAULT_LOCALE ('es') if not found.

Default Locale

DEFAULT_LOCALE = 'es'. Used for: - Pre-auth commands (/start, /config) where no user record exists yet - Fallback in getMessages() if unknown locale passed

Per-User Locale

users.locale (D1, migration 002_add_locale.sql) stores each user's preferred language. Defaults to 'es' on onboarding.

WebhookHandler uses per-user locale for all post-auth messages and parser errors.

Known gap: SendDailyDigestUseCase and SendWeeklyDigestUseCase currently use DEFAULT_LOCALE for all users. They need to pass user.locale once per-user locale has been proven stable.

Adding a New Locale

  1. Create src/i18n/XX.ts:
    import { Messages } from './types';
    export const xx: Messages = { /* ... */ };
    
  2. Add 'xx' to the Locale union in types.ts
  3. Import and register in index.ts:
    import { xx } from './xx';
    const catalogs: Record<Locale, Messages> = { ..., xx };
    
  4. Run npx tsc --noEmit — compiler catches missing keys

Gender-Neutral Copy Conventions

Languages with grammatical gender must avoid expressions that assume the user's gender.

Language Pattern Example
Spanish Noun-based No tienes cuenta registrada
Spanish Inclusive verb Te damos la bienvenida
Catalan Inclusive verb Et donem la benvinguda
French Noun-based Pas de compte enregistré
Italian Inclusive verb Ti diamo il benvenuto
Portuguese Invariable phrase Boas-vindas
Romanian Invariable phrase Bun venit
Polish Noun-based Brak konta (neuter noun "konto")
German / Dutch / Turkish / Estonian N/A No grammatical gender issue

Rule: make the agreement target a noun (the account, the transaction), never the user.

The aiPrompt Key

Each locale's weekly.aiPrompt builds the Gemini prompt in the target language and requests the AI response in that language:

// pl.ts
aiPrompt: (...) =>
  `Przeanalizuj... Podaj 3-4 zdania analizy po polsku...`

Users receive AI analysis in their own language without post-processing.

Pluralization Notes

Locale Strategy
es, fr, it, de, nl, ca, ro, pt, tr count !== 1
ca, ro, it Full adjective agreement via ternary
pt Vowel mutation (transação / transações)
pl 3-way paucal rule: 1 / 2–4 (excluding 12–14) / 5+
et Partitive with numerals (tehing / tehingut)