Internationalization (i18n)
The translation engine lets the apps add or fix a language over-the-air — without an app-store release — and keeps translations consistent across the tracking app and the portal. It replaces the old Google-Sheet → JSON → DB workflow. Authoring is friction-free: write the text inline in code, and it registers itself for translation automatically.
Two independent translation systems
There are two semantically different translation sources, kept in separate stores that share no map and no selector — different identity types and different lifecycles:
| System | Content | Reference (identity) | Endpoint | Store | Resolver |
|---|---|---|---|---|---|
| A — server-entity text (exercise/metric names) | backend-managed entities | the entity's UUID (nameTextContentID) | GET /text_contents (If-Modified-Since, Accept-Language, device-type) | text-content/store.ts (byId) | useDisplayName(entity) / useTextContent(uuid) — see Naming a server entity |
| B — UI strings (buttons, labels, hints) | the automated i18n pipeline | a content-hash key from the source text | GET /ui_text_contents (raw admin array) | ui-text-content/store.ts | t("Save") → resolveUiText(key) |
System A is unchanged. System B is how translated UI strings reach the apps: the
i18n publisher writes keys + source texts + translations into the backend, and
each app loads the full raw catalogue (GET /ui_text_contents) and resolves the
active locale locally.
The two stores never merge. System A is keyed by UUID and changes with backend
entity data; System B is keyed by a hash string and changes with frontend
releases. t() reads only the UI store; the UUID lookup reads only the entity
store. (The entity store still carries a dead byKey index from the earlier
single-endpoint design; the resolver no longer consults it.)
Naming a server entity
A named entity carries BOTH an own name (a user's rename, stored on the
entity) and a nameTextContentID (the stock, localizable name). Resolving one
before the other is not a detail: a coach who renames "Squat" to "Comp Squat"
must see the rename everywhere.
The order is always own name → localized text content → fallback, never the
reverse. Do not hand-roll it — getTextContent(e.nameTextContentID) ?? e.name
is the inverted form and silently discards renames.
| Entity | Resolver | Where |
|---|---|---|
| any named entity (tag, metric, equipment, variation, muscle) | displayName(entity, fallback) / useDisplayName | text-content/display-name.ts |
| an exercise | exerciseDisplayName(exercise, byDefinition, fallback) / useExerciseDisplayName | exercises/display-name.ts |
An exercise takes one extra step in the middle: own name → the catalogue
exercise behind its exerciseDefinitionID → text content → fallback. A
session's or live feed's exercise is the athlete's per-definition instance and
carries neither the coach's rename nor the fetched equipment, so it has to be
joined back to the root user's catalogue. useExercisesByDefinition() builds
that index (memoized per catalogue snapshot); useCatalogueExercise() returns
the resolved entity for non-name metadata, which is what useExerciseSubtitle
uses.
Components use the hooks so the name re-resolves when the catalogue or the
text-content table lands. Bulk paths that can't call a hook per item (a
sortAccessor, a search haystack) use the pure functions with one shared index,
so sort order and rendered names never disagree.
Why the apps read the raw admin endpoint
GET /ui_text_contents returns the full array — every key with sourceText,
context, namespace, deprecatedSince, and only the locales that actually
exist (no source fallback baked in, deprecated entries included). Both apps use
this raw variant rather than the app-token GET /ui_text_contents/app (which is
OTA-resolved per locale with a source fallback) so a locale switch resolves
locally and deprecated markings stay visible. Local locale match: exact
(normalized) → primary subtag (en-US satisfies en) → English sourceText.
(The /app variant — a smaller per-locale payload — is an optional future
optimization.)
Two parallel preloads
Each app's root layout mounts two independent loaders — <TextContentLoader/>
(System A) and <UiTextContentLoader/> (System B) — that fail open separately.
The UI endpoint has no If-Modified-Since, so each load replaces the whole
catalogue; the loader also re-fetches on window focus for live admin edits.
Loads coalesce in the store, so a focus burst is one request.
Authoring flow — write text, translate later
-
Write the text inline. The source text is the value, the fallback, and (via its content-hash) the key:
const { t } = useTranslation();t("Save");t("{count, plural, one {# rep} other {# reps}}", { count });t({ text: "Save", context: "Set editor primary action" });It renders immediately — no key to invent, no pre-registration.
-
npm run i18n:extractscans everyt(...)call and regenerates the bundled baseline (packages/core/src/translations/baseline/en.json) andkeys.ts, plusi18n.catalog.json(key + text + context + namespace + locations + usages) for the push. Identical text and context dedup to one key; differing context splits.Each entry also carries auto-derived usage descriptions — plain-language phrases for translators saying what kind of text the string is ("Button label (call to action)", "Placeholder text shown inside an empty input field", "Short status notification (toast)…"). The extractor classifies the AST surroundings (attribute, element, enclosing call) into these phrases; deliberately no code details (no component names, tags, or file paths) leak into them. Usages are informational only: they do NOT feed the content-hash key, so refactors that move a string never invalidate its translations. Use an explicit
t({ text, context })only when the same English text needs different translations. -
npm run i18n:pushupserts the source strings into the backend / admin tool (idempotent bykey). New strings appear as "needs translation" with their usage descriptions shown alongside. -
Translate later, decoupled, in the admin tool (see the admin-tool guide) — then publish. The apps pick it up on the next launch. No release needed, no pre-release translation work.
The content-hash key is the single source of truth for identity. It lives in
packages/core/src/translations/contenthash.ts and is duplicated (and
golden-tested) in scripts/i18n/extract.mjs; both must agree.
Deprecation, not orphaning
Changing an English source text changes its key. The push marks the old key
deprecated_since=<version> instead of deleting it, so older app versions still
in the field keep their text; the admin tool lists it as "unused since vX" and
can delete it once no supported version references it.
Runtime fallback chain
A string is resolved, never blocking and never showing a raw key:
UI catalogue (active locale → backend sourceText) → bundled en baseline → the inline source text
A not-yet-translated string therefore shows its English original everywhere
until translations land. A key that is absent from the loaded catalogue is a
pipeline gap (the publisher hasn't shipped it): the resolver logs a one-time dev
warning and still falls back to the bundled baseline / inline source — in
production it stays silent. ICU plurals/interpolation are handled by
packages/core/src/translations/format.ts (intl-messageformat), which fails
open (returns the raw message on any parse/format error).
Locale resolution and switching
userAppSettings.locale is the source of truth for the display language.
It is a server-stored, per-user language+region string, so the choice follows the
user to their other browsers and to iOS. See
docs/decisions/0008-server-stored-locale.md for why.
The active locale itself is a plain BCP-47 string held in the dependency-free
leaf packages/core/src/locale/active-locale.ts, so both the stores and
apiRequest can read it without an import cycle.
Resolution
resolveLocale precedence — user setting > cold-start mirror > device
language > en:
| Source | Role |
|---|---|
userAppSettings.locale | Authoritative. Applied by syncLocale on every settings load/save. |
localStorage["enode.locale"] | A mirror of the last known server value, not a user override. Read only before the settings exist; always overwritten by them. |
navigator.language | Bootstrap for a device that has never loaded settings. |
en | Last resort. |
The mirror exists purely to break a chicken-and-egg: Accept-Language is sent
on the very request that fetches the settings, so something has to answer
first. Without it, a cold start fetches all reference data in the wrong language
and re-fetches once the settings land.
Candidates are validated against the available set from GET /app_languages —
but only when that set is non-empty. It is empty for the whole window before
that endpoint answers, which includes the first settings load; rejecting there
would stamp every user down to English. AppLanguagesLoader calls
reresolveActiveLocale() once the list lands to correct a stored locale the
server doesn't actually offer.
Validation matches on the primary language subtag and returns the user's own
tag, region intact: /app_languages serves bare codes (en, de, ja, zh),
so canonicalizing de-AT to de would throw away the region that Intl and the
TTS voice select on.
Sanitization
Every read and write of a locale string goes through sanitizeLocale /
sanitizeLocaleOrDefault (same module), which canonicalize to
language[-Script][-REGION], accept _ or - and any casing, drop variant and
extension subtags, and return null for anything unusable. Applied at the
settings read, the PUT /user_app_settings body, the localStorage mirror,
navigator.language, the /app_languages codes, and the picker's onSelect.
Comparisons use normalizeLocale / primaryLanguage rather than ===.
Transport
apiRequest sends the active locale as Accept-Language — every read is
locale-aware with no per-request parameter.
Live-verified against backdev (tests/live/auth/70-accept-language.live.test.ts):
the backend matches on the primary language subtag only, case-insensitively,
treating de, de-DE, de_DE, DE and de-AT identically and falling back to
English for an unknown or empty value. So the canonical hyphenated form is sent
as-is — no iOS-style underscore conversion is needed, despite the en_001
default the endpoint documents.
Switching
A user changes their language by saving userAppSettings.locale — there is
no separate "switch the language" call in app code. The settings drawer's
language row stages the pick into its pending record, exactly like the Metric
System toggle beside it; dismissing the drawer discards it. The full chain on
Save is:
pick → onPatch({ locale }) → Save → PUT /user_app_settings
→ setUserAppSettings → syncLocale → applyLocale → stores re-fetch
applyLocale(packages/core/src/translations/set-locale.ts) is the internal apply and never writes the server. It sets the active locale and re-fetches the entity-side locale-dependent stores (/text_contents, names, …). The UI-string store is not re-fetched —t()re-resolves locally from the already-loaded catalogue, so everyt(...)output updates without a backend call. It is idempotent, which is load-bearing:syncLocaleruns on every settings load and the settings drawer reloads the settings on every open.- On logout the locale is deliberately not reset.
auth-guard.tsxcallslogout()straight from theenode:unauthorizedlistener, so re-resolving there would fire nine unauthenticated GETs and an error toast mid-teardown. - Available languages come from the languages store
(
packages/core/src/languages/store.ts,GET /app_languages), so a new language is selectable without a rebuild. - UI: the presentational
LanguagePicker(packages/ui/src/language-picker.tsx, flag + endonym) is shared by both apps, wired by theLanguageRowinpackages/ui/src/settings/settings-drawer.tsx.
Not covered by the active locale: date and number formatting. Roughly forty call
sites use toLocaleDateString(undefined, …) (the browser locale) and a few pin
"en-US"/"en-GB" deliberately. Changing the app language does not change them.
formatTimeAgo and the TTS voice are the exceptions — they take the active
locale explicitly.
One deliberate bypass: apps/portal/src/app/dashboard/debug/auth/api.ts hardcodes
Accept-Language: en; it is a debug harness that sidesteps apiRequest on purpose.
Backend contract
GET /text_contents(System A) — returns{ id, value, key? }per row; entity content is referenced by its UUIDid. Locale viaAccept-Language(defaulten_001),device-typefilters by environment, If-Modified-Since.GET /ui_text_contents(System B) — the raw admin catalogue: an array of{ key, sourceText, context, namespace, deprecatedSince, translations: [{ locale, value }] }. User-token with admin read, no If-Modified-Since, includes deprecated entries,translationslists only the locales that exist. (GET /ui_text_contents/appis the app-token, OTA-resolved variant; the portal does not use it.)GET /app_languages— the available languages. Live-verified wire shape:{ locale: "en", name: "english", nameTextContentID, id }— bare language codes, no regions. The DTO reader (api/dtos/app-languages.ts) stays tolerant of the other plausible spellings.GET/PUT /user_app_settings— carrieslocale, the authoritative display language (see "Locale resolution and switching" above). The PUT is a full replace, solocaleis carried through on every save from every surface.- Admin/ingest endpoints (used by the admin tool /
i18n:push) upsert bykey, carrycontextplus the auto-derivedusagesdescriptions, and apply the deprecation marking.
Automation
.githooks/pre-pushrunsi18n:extract:checkwhenapps/orpackages/ui/changed and blocks on a stale baseline..githooks/post-commitingests the source strings (i18n:push) after any commit that changedapps/,packages/ui/, ori18n.catalog.json, then prints a reminder to translate new strings in the admin app. Non-blocking. The push targetshttps://translations.enode.ai/api/ingest(a Cloudflare Worker behind Cloudflare Access; host overridable viaENODE_I18N_BASE_URL) and reads its secrets from the environment or the untracked.env.i18n.local:ENODE_API_TOKEN(Bearer, equals the Worker'sENODE_INGEST_SECRET) plusCF_ACCESS_CLIENT_ID/CF_ACCESS_CLIENT_SECRET(Access service token, checked at the edge before the Worker). WithoutENODE_API_TOKENit skips the push and only reminds. A redirect or HTML answer means Access blocked the request (bad/missing service token); a JSON 401 means the Bearer secret is wrong..github/workflows/i18n-check.ymlis the authoritative check on PRs and, on push tomain, runsi18n:push(skipped when theENODE_API_TOKENsecret is unset; also needs theCF_ACCESS_CLIENT_ID/CF_ACCESS_CLIENT_SECRETsecrets).
Admin tool
Translations are authored in a small separate admin project (same tech stack)
that syncs into the backend text_contents table. See
the admin-tool guide for setup and the connection model.