Skip to main content

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:

SystemContentReference (identity)EndpointStoreResolver
A — server-entity text (exercise/metric names)backend-managed entitiesthe 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 pipelinea content-hash key from the source textGET /ui_text_contents (raw admin array)ui-text-content/store.tst("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.

EntityResolverWhere
any named entity (tag, metric, equipment, variation, muscle)displayName(entity, fallback) / useDisplayNametext-content/display-name.ts
an exerciseexerciseDisplayName(exercise, byDefinition, fallback) / useExerciseDisplayNameexercises/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​

  1. 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.

  2. npm run i18n:extract scans every t(...) call and regenerates the bundled baseline (packages/core/src/translations/baseline/en.json) and keys.ts, plus i18n.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.

  3. npm run i18n:push upserts the source strings into the backend / admin tool (idempotent by key). New strings appear as "needs translation" with their usage descriptions shown alongside.

  4. 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:

SourceRole
userAppSettings.localeAuthoritative. 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.languageBootstrap for a device that has never loaded settings.
enLast 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 every t(...) output updates without a backend call. It is idempotent, which is load-bearing: syncLocale runs on every settings load and the settings drawer reloads the settings on every open.
  • On logout the locale is deliberately not reset. auth-guard.tsx calls logout() straight from the enode:unauthorized listener, 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 the LanguageRow in packages/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 UUID id. Locale via Accept-Language (default en_001), device-type filters 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, translations lists only the locales that exist. (GET /ui_text_contents/app is 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 — carries locale, the authoritative display language (see "Locale resolution and switching" above). The PUT is a full replace, so locale is carried through on every save from every surface.
  • Admin/ingest endpoints (used by the admin tool / i18n:push) upsert by key, carry context plus the auto-derived usages descriptions, and apply the deprecation marking.

Automation​

  • .githooks/pre-push runs i18n:extract:check when apps/ or packages/ui/ changed and blocks on a stale baseline.
  • .githooks/post-commit ingests the source strings (i18n:push) after any commit that changed apps/, packages/ui/, or i18n.catalog.json, then prints a reminder to translate new strings in the admin app. Non-blocking. The push targets https://translations.enode.ai/api/ingest (a Cloudflare Worker behind Cloudflare Access; host overridable via ENODE_I18N_BASE_URL) and reads its secrets from the environment or the untracked .env.i18n.local: ENODE_API_TOKEN (Bearer, equals the Worker's ENODE_INGEST_SECRET) plus CF_ACCESS_CLIENT_ID / CF_ACCESS_CLIENT_SECRET (Access service token, checked at the edge before the Worker). Without ENODE_API_TOKEN it 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.yml is the authoritative check on PRs and, on push to main, runs i18n:push (skipped when the ENODE_API_TOKEN secret is unset; also needs the CF_ACCESS_CLIENT_ID / CF_ACCESS_CLIENT_SECRET secrets).

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.