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)useTextContent(uuid)
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.)

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

The active locale 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, at boot): explicit override (persisted) > server user setting > device language > en, validated against the available set from GET /app_languages.
  • Transport: apiRequest sends the active locale as Accept-Language — every read is locale-aware with no per-request parameter.
  • Switching: setLocale (packages/core/src/translations/set-locale.ts) persists the choice 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.
  • 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; each app wires a thin container that supplies the languages and calls setLocale.

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 ({ code, displayName, … }).
  • 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.