Skip to content

Translations

The web app speaks English (the source language), French and Italian. One setting, the language in the header (and in the "More" sheet on a phone), changes the interface text AND the format of dates, numbers and money (en -> en-GB, fr -> fr-FR, it -> it-IT).

This page is about the web app (web/). What is not translated is listed at the end.

The libraries, and why

Library Why
i18next The reference engine: {{variable}} interpolation, plural forms from Intl.PluralRules, fallback language, typed keys. No framework lock-in.
react-i18next useTranslation() re-renders a component when the language changes, and <Trans> puts React elements (<b>, <code>, a money amount) inside a translated sentence.

Nothing else: the translations are JSON files bundled with the app (no HTTP backend, no request, nothing leaves the machine). The language is detected by a dozen hand-written lines (detectLanguage() in web/src/i18n/index.ts): the saved choice, else the first supported language of navigator.languages, else English. The i18next-browser-languagedetector plugin would add a dependency for the same three sources.

English is imported eagerly (it is the fallback and the source of the key types). French and Italian are separate Vite chunks, loaded the first time the language is used, before the first render at start-up (initLanguage() in main.tsx), so there is no flash of English.

File layout

web/src/
  i18n/
    languages.ts        THE registry: one entry per language (code, native name, Intl locale)
    index.ts            i18next set-up, detection, setLanguage(), the lazy loading
    i18next.d.ts        the key types (from the English files)
    server.ts           server text: tServer(), serverLabel(), errorText() (see "Server text")
    translations.test.ts  the completeness test
  locales/
    en/common.json      one folder per language, one file per namespace
    fr/common.json
    it/common.json

common holds the navigation, the shared components, the dialogs of components/ and the "page not found" message. Each page area has its own namespace (a namespace is just another <namespace>.json in every language folder, see "Adding a namespace" below):

Namespace Pages
dashboard Dashboard
transactions Transactions (and the transaction list it shares)
categories Categories, a category's page, Merchants to review
budgets Budgets and savings goals
subscriptions Subscriptions & contracts: detected payments and the inventory (cancellation, usage, decisions, offers, letter)
calendar Calendar
insights Insights
coach Ask the coach
alerts Alerts
wealth Loans & net worth
rental Rental property
memory Memory (questions, proposals, history, ...)
setup Set up (first run and onboarding)
connections Connections (banks, accounts, last scheduled run, internal transfers)
household Household (top-level household.*) and Who pays what (whoPays.*)
kids Kids' money (kids.*) and the child's own home page (kidHome.*)
quality Gold set (gold.*) and AI usage (usage.*)
taxonomy The names of the category groups and leaves, by id (catLabel, groupLabel of lib/format.ts)
server Text sent by the server: its sentences by code, the labels of its fixed vocabularies, the API errors (see Server text)

A code sent by the server (a cadence, a goal status, a review reason, a decision) is translated through a key per code, with the code itself shown when it is unknown; the server's sentences follow the convention of Server text.

Rental scheme vocabulary that is a legal term of art (Micro-foncier, régime réel, Pinel, effort d'épargne) is kept as a proper noun; the explanation around it is translated.

Adding or changing a string

  1. Pick a key named area.component.meaning, lower camel case after the area: nav.dashboard, tx.change.apply, loan.lease.saveReading. Group by area (nav, scope, tx, loan, charts, itemForm, ...), not by wording: the same English word can need two translations ("Save" in a form, "Saved" in a toast).
  2. Add it to locales/en/common.json first. The key becomes a TypeScript type, so a typo (t("nav.dashbord")) fails pnpm typecheck.
  3. Add it to fr/common.json and it/common.json. The completeness test fails until you do.
  4. Use it:
    const { t } = useTranslation();
    <Button>{t("tx.change.apply")}</Button>
    <IconButton label={t("header.signOut")} />          // aria-labels, titles and placeholders too
    
    Outside a component (a helper, a module constant) use i18n.t(...) from @/i18n, at call time, never at import time. A list of labels defined at module level stores the key (label: "nav.dashboard", typed with ParseKeys from i18next) and calls t(label) when it renders.

Rules of thumb:

  • The text a person reads goes through t(): labels, hints, errors, aria-label, title, placeholder, the text of a toast. Not translated: identifiers, coach ... commands, the product names (Enable Banking), code samples and regular expressions.
  • Never build a sentence by gluing translated pieces (t("a") + " " + t("b")): word order differs between languages. Make one key with variables.
  • Amounts, dates and numbers are formatted by @/lib/format (fmtMoney, fmtDate, ...) and passed to the sentence as variables.
  • Text that comes from the server (alert titles, the "why" of a step, a verdict) is translated through its code: see Server text. What is not converted yet is listed in the plan.

Variables and markup

"tx.change.merchant": "Every \"{{entity}}\" transaction"
t("tx.change.merchant", { entity })
{{variable}} is i18next's own interpolation. escapeValue is off because React already escapes what it renders. Every {{variable}} of the English text must appear in the translation (the test checks it).

For bold text, a code sample or a component inside a sentence use <Trans> with named tags; the same tags must appear in every language:

"login.intro": "... <code>coach ui</code> prints a one-time login link ..."
<Trans i18nKey="login.intro" components={{ code: <code className="rounded bg-surface-2 px-1" /> }} />
A self-closing tag (<amount/>) puts a component without text (a money amount) at that place in the sentence.

Plurals

Give each form a suffix and pass count; i18next picks the form from Intl.PluralRules of the active language:

"loan.schedule.instalmentsLeft_one": "{{count}} instalment left",
"loan.schedule.instalmentsLeft_other": "{{count}} instalments left"
t("loan.schedule.instalmentsLeft", { count })
English needs _one and _other. French and Italian also have a _many category (millions: "1 million de ..."); give _one, _many and _other (_many is usually the same text as _other). The completeness test asks for the categories that Intl.PluralRules(language) reports, and always one and other. Do not name a key something_other, ..._one or ..._many unless it is a plural form (insurance_other was renamed insuranceOther for that reason).

Adding a language

  1. Create web/src/locales/<code>/ and copy every JSON file of web/src/locales/en/ into it. Translate the values, not the keys. Keep the {{variables}} and <tags> untouched; add the plural forms the language needs (node -e 'console.log(new Intl.PluralRules("pl").resolvedOptions().pluralCategories)').
  2. Add one entry to LANGUAGES in web/src/i18n/languages.ts: { code: "pl", name: "Polski", locale: "pl-PL" } (the name in the language itself, the Intl locale it implies).
  3. Run cd web && pnpm test. The completeness test lists what is missing, empty or different from English; the language appears in both language selects by itself.

Nothing else: no import, no switch, no second list. (Check the number and date format of the new locale in the browser: Intl support differs a little between engines.) Write the way the existing languages do: natural wording, the formal "vous" in French, the informal "tu" in Italian (as in src/coach/disclaimers.py), and no translation of coach ... commands. One exception: the child's own page (kidHome.* in kids.json) says "tu" in French too, with short and simple sentences, because it speaks to a child.

Adding a namespace

Add <namespace>.json to every language folder, then declare it in web/src/i18n/i18next.d.ts (resources: { common: ..., <namespace>: typeof <namespace> }) and use useTranslation("<namespace>"). The loader (import.meta.glob) and the completeness test pick the file up by themselves.

Tests

  • web/src/i18n/translations.test.ts: for every language of the registry and every namespace, the key set equals English's (plural forms compared by base key, with the categories the language needs), no value is empty, every {{variable}} and <tag> of the English text is in the translation.
  • web/src/i18n/language.test.tsx: choosing a language updates <html lang>, the saved value, the strings and the number format; the start-up detection (saved choice, the old coach.locale value, the browser's languages, English).
  • web/src/test/setup.ts initialises i18n in English, synchronously, before each test: tests keep looking for English text. They also keep the French number format they were written against; a test that cares about the format sets it itself.

The language is saved in the browser (localStorage, key coach.language; the former coach.locale value fr-FR / en-GB is read once as fr / en). Automatic detection saves nothing, so the browser's language keeps being followed until the person chooses one.

Server text

The API is read by the web app, but also by the CLI, the MCP finance tools (the coach, an LLM, reads English) and the stored rows (alerts, insights). So the server keeps its English and adds, next to it, what the web app needs to say it in the reader's language. Three cases:

1. A sentence: <field>_msg = {code, params, text}. Next to the English field, a sibling field with the same name and the suffix _msg (a list of sentences gets a list of messages, in the same order: notes and notes_msg). Built in Python with coach.i18n_msg.server_msg:

from coach.i18n_msg import server_msg
note = f"Lowest expected {money_str(low_c)} EUR on {low_date}."
return {"note": note, "note_msg": server_msg("forecast.lowest", note, low_amount=money_str(low_c), low_date=low_date)}
"note": "Lowest expected -12.34 EUR on 2026-10-05.",
"note_msg": {"code": "forecast.lowest", "params": {"low_amount": "-12.34", "low_date": "2026-10-05"}, "text": "Lowest expected -12.34 EUR on 2026-10-05."}

  • code is <area>.<name> in lower camel case: the key of the sentence in web/src/locales/<lang>/server.json (nested: "forecast": {"lowest": "..."}).
  • params are raw values, never formatted; the web formats them for the reader. Their type is given by their name, and server_msg refuses a value of the wrong type:
name value sent shown with
count an integer; it also picks the plural form (_one / _many / _other keys) the number
*_date ISO date YYYY-MM-DD (a date is accepted and converted) fmtDate (medium)
*_month YYYY-MM fmtMonth (long)
*_amount decimal string in EUR, money_str(cents) ("-12.34") fmtMoney
*_pct a ratio, 0.12 = 12 % fmtPct
*_num a plain decimal: a number, or a decimal string ("2.40") to keep its decimals (a rate gap in points) fmtNumber, with the decimals sent
*_category a category id group.leaf catLabel
*_group a category group id groupLabel
*_kind the kind of a memory item (mortgage, vehicle, insurance_home) holdingKindLabel
fields memory field ids joined with , ("lender, rate.nominal") fieldListLabel (labels.memoryField, dots as _)
anything else a string or a number (a pseudonym, a label, a count that is not the plural) as it is
  • text is the English sentence: the web shows it when it does not know the code.
  • A param holding text written by a person or a bank (a merchant, an account label) is passed as it is and never translated; keep the sentence's own words in the key.
  • The MCP finance tools (read by the coach) stay English only, without _msg siblings: a builder they share with the API adds its messages only on request (cancellability(..., messages=True), stripped otherwise by coach.i18n_msg.strip_msgs), or the tool picks its fields one by one (subs/tools.py); the choke point strips whatever is left (see "What a model and the CLI read" below). tests/test_i18n_subs.py checks every read-only tool output.

In the web app: tServer(msg, fallback?) from @/i18n/server (useServerText() in a component that must re-render on a language change by itself):

<p>{tServer(d.note_msg, d.note)}</p>

tests/test_i18n_msg.py fails when a code built with a literal in src/coach is missing from the English server.json; the completeness test asks for it in every language.

Coverage notes (every analytics result's coverage): pass coach.analytics.common.note(code, text, **params) (or non_eur_note(n)) in the notes of CoverageModel.info; the CoverageInfo keeps the English in notes and the message in notes_msg (same order; a plain string note gets null). The web shows them with tServerList(cov.notes, cov.notes_msg). Codes live under coverage.* in server.json.

Insight cards and alert events (api/views.build_insights and the card producers of subs/reminders.py, loans/, rental/service.py; the alert signals of alerts/signals.py, ingest/consent.py, household/kidbudgets.py, quality/usage.py): every card and every alert candidate keeps its English title / body (the CLI, the macOS notification, the digest, the external channels: unchanged) and adds title_msg / body_msg (None when there is no code). Codes under insight.*, anomaly.*, reminder.*, loanAlert.*, rentalAlert.*, alert.*. A code built from data (a budget target, a category id the household wrote) goes through server_msg_or_none: a value off the convention gives no message, and the web shows the English.

  • Stored rows. An anomaly keeps its message_msg in its stored payload (anomalies.payload); an alert event keeps title_msg / body_msg inside its payload JSON column (no migration). GET /alerts lifts them out of the payload to the event's top level. A row stored before them has none: the web shows its English, and an insight card without a message still goes through humanize() (cardText(item, {legacy}), tServerOr(msg, text, legacy)).
  • A disclaimer in a card is a key, not text: the card sends "disclaimer": "tax_short" (an alert event: in its payload); its English body still ends with the English wording (disclaimers.get), the message does not, and the web appends the text of useDisclaimers() (the key is in WEB_DISCLAIMERS).
  • Two param names more: cadence (a recurring cadence code, shown with labels.cadence.<code>) and numbers a key formats itself with i18next's {{excess_km, number}}.
  • The decision-check card's body is the decision's own reason: its body_msg is the verification's reason_msg (subs.decision.*). Not here: external channel texts and the weekly digest stay English (step 5).

Memory check issues (coach.memory.store.Issue): built with Issue.of(level, code, file, server_msg("memoryCheck.<name>", english, ...), path); the issue's code stays the stable id (by_code, the summary), the message code names the sentence (one issue code can have several sentences). Issue.to_dict(messages=True) (the API's /memory/check) adds message_msg; the CLI's --json and ValidationFailed keep the English. The previews of every memory write send warnings_msg next to warnings (api/routes/_write.py), and the invalid budget entries problems_msg next to problems (GET /budgets, codes memoryLoad.*). A Pydantic error is translated by its frame only (memoryCheck.invalidValue: Pydantic's own English is the detail param), and a coach ... command is the command param, never translated.

Generated memory questions are stored: open-questions.yaml keeps, next to question (and context), an optional question_msg (context_msg) and a topic_code, written by the generators only (memory/qgen.py, qmsg(); a question the coach proposes in its own words stays plain text). The schema checks the code and the params' types; a message whose text is no longer its question (a hand edit) is dropped when the file is read. Older files without these fields load as they are.

What a model and the CLI read: the *_msg siblings are for the web only. coach.i18n_msg.strip_msgs removes them from the redacted registry (analytics/privacy.py), at the MCP tools' choke point (mcp/tools.py, _process) and from the analytics CLI's --json; tests/test_i18n_coverage.py checks that no MCP tool output carries one.

2. A fixed vocabulary: the code is already a field. An alert kind, a subscription group, a balance type, a wizard step: the payload carries the code (kind, group, balance_type, id) and an English label. The web looks up labels.<family>.<code> in server.json and falls back to the English label, then to the code: serverLabel("alertKind", k.kind, k.label). Families: alertKind, subsGroup, balanceType, setupStep, onboardingStep, forecast (household, accountUnresolved: the forecast line with account: null, see forecastLabel()), forecastFlag, accountPurpose (purposeLabel()), cadence (the cadence param of a message), explainStep (the step_code of a transaction's decision chain, GET /transactions/detail; the English step stays what the CLI and the MCP tool read), loanField (the fields a loan card says are missing: missing_codes next to the English missing), loanOption (a prepayment option's mode), loanVerdict (the verdict_code of a prepayment option, the verdict of a renegotiation or an insurance change), rentalDocument / rentalDocumentFrom (the documents checklist of the rental tax year, by its id), bankGroup (bank_code: "manual": the file-import group of the connector health), memoryField (a field of a memory file, dots as _: fieldLabel()), questionTopic (the topic_code of a generated question). A disclaimer a result needs is sent as a key of GET /meta/disclaimers (disclaimer_key: "loan"), never as text in a locale file. A new code in a Python label map needs its label in every language (tests/test_i18n_msg.py checks the maps). The kinds of the memory items (asset, loan, contract) use the item form's options of common (holdingKindLabel()). A flag is a code, or code:value when it carries one value (accounts_without_balance:2): flagLabel(family, flag) passes the value as {{value}} and, when it is a number, as {{count}}.

3. An API error: by its code. {"error": {"code", "message"}} is shown with errorText(e, fallback): error.<code> of server.json for the generic codes (session, CSRF, rate limit, not found, validation, migration pending, ...), else the server's message, which explains a domain rule (a rejected change, a missing configuration) in English.

Not a translation: legal texts (the AI-generated label, the advice banner, every disclaimer). Their wording lives only in src/coach/disclaimers.py; the web asks for it with GET /meta/disclaimers?lang=<language> (useDisclaimers()), and the coach's answers carry theirs in the answer's language. Never copy them into a locale file. A payload that shows a disclaimer sends its KEY next to the English wording (the cancellation panel: disclaimer_key: "contract", verify_key: "contract_verify"); the key must be one of WEB_DISCLAIMERS (api/routes/core.py), and the web shows useDisclaimers()[key], the English wording being the fallback. Other shapes of the same rule: a field disclaimer_key next to the English disclaimer (the rental tax year: tax, the rental indicators: loan), or "disclaimer": "<key>" inside a message whose English ends with the line (a rental reading: general_advice; the message's text is then the sentence without it). The English fields the CLI and the tools read stay as they were.

Category and group names come from the taxonomy namespace by id (group.<group>, cat.<group>.<leaf>; withGroup keeps "Transfer: internal" for income and transfers); a leaf the household added keeps its title-cased id. catLabel / groupLabel call i18n.t at call time, so they can be used outside a component.

What is NOT translated

  • The command line (uv run coach ...), its messages and --help.
  • The README, the other docs, the changelog and the code comments.
  • The coach's answers (the LLM): they follow the language of the question. The prompts themselves are written in English.
  • Legal and compliance texts (disclaimers, the "this is general information, not financial advice" line): their wording lives only in src/coach/disclaimers.py.
  • The server's sentences that do not carry a code yet (see the plan): they are shown in English.

Plan

The migration of the web app is split in five steps:

  1. Foundation (this page): the libraries, the language setting, the common namespace, the shared components and the navigation.
  2. and 3. The pages (web/src/pages/*, done): one namespace per page area, in two batches. Step 2: Dashboard, Transactions, Categories, a category's page, Merchants to review, Budgets, Subscriptions (with the inventory), Calendar, Insights, Ask the coach, page not found. Step 3: Alerts, Wealth, Rental, Memory, Setup, Connections, Household, Who pays what, Kids, the child's home page, Gold set and AI usage. The EU AI Act label is not a translation key (its wording lives only in src/coach/disclaimers.py): the full label comes from the server in the answer's language (since 4a the short badge and the fallback too, see Server text).
  3. Server-sent text to codes: next to its English sentences (alert titles, the steps of a "why", verdicts, category labels) the API sends codes plus parameters that the web app translates. In chunks: 4a (done): the convention below, the label maps (alert kinds, subscription groups, balance types, setup and onboarding steps, account purposes, the household forecast line and its flags), the category names, the API errors, the AI label served by GET /meta/disclaimers. 4b (done): the coverage notes of the analytics results (CoverageInfo.notes_msg). 4e (done): subscriptions and cancellability (rules, conditions, methods, decisions, usage, offers, contract drafts). 4c (done): insight cards, anomalies and alert events (codes stored in the existing JSON payloads, old rows fall back to English). 4d (done): the "why" of a transaction (decision chain, annotation and attribution reasons, category-edit warnings). 4f (done): loans and net worth (unknowns, schedule, scenarios, inference, lease). 4g (done): rental property (scheme, tax year, reduction, indicators). 4i (done): the memory check issues and the validation warnings of memory writes and budgets. 4h (done): household, onboarding, set-up wizard, connections and health, sync results, the calendar and the generated memory questions. Step 4 is complete.
  4. The Python side: the texts that the CLI and the API still produce, and what the web app should tell the coach about the language.