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¶
- 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). - Add it to
locales/en/common.jsonfirst. The key becomes a TypeScript type, so a typo (t("nav.dashbord")) failspnpm typecheck. - Add it to
fr/common.jsonandit/common.json. The completeness test fails until you do. - Use it:
Outside a component (a helper, a module constant) use
const { t } = useTranslation(); <Button>{t("tx.change.apply")}</Button> <IconButton label={t("header.signOut")} /> // aria-labels, titles and placeholders tooi18n.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 withParseKeysfromi18next) and callst(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¶
{{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:
<Trans i18nKey="login.intro" components={{ code: <code className="rounded bg-surface-2 px-1" /> }} />
<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"
_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¶
- Create
web/src/locales/<code>/and copy every JSON file ofweb/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)'). - Add one entry to
LANGUAGESinweb/src/i18n/languages.ts:{ code: "pl", name: "Polski", locale: "pl-PL" }(the name in the language itself, the Intl locale it implies). - 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 oldcoach.localevalue, the browser's languages, English).web/src/test/setup.tsinitialises 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."}
codeis<area>.<name>in lower camel case: the key of the sentence inweb/src/locales/<lang>/server.json(nested:"forecast": {"lowest": "..."}).paramsare raw values, never formatted; the web formats them for the reader. Their type is given by their name, andserver_msgrefuses 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 |
textis 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
_msgsiblings: a builder they share with the API adds its messages only on request (cancellability(..., messages=True), stripped otherwise bycoach.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.pychecks 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):
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_msgin its stored payload (anomalies.payload); an alert event keepstitle_msg/body_msginside itspayloadJSON column (no migration).GET /alertslifts 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 throughhumanize()(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 Englishbodystill ends with the English wording (disclaimers.get), the message does not, and the web appends the text ofuseDisclaimers()(the key is inWEB_DISCLAIMERS). - Two param names more:
cadence(a recurring cadence code, shown withlabels.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_msgis the verification'sreason_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:
- Foundation (this page): the libraries, the language setting, the
commonnamespace, the shared components and the navigation. - 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 insrc/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). - 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. - 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.