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) | useTextContent(uuid) |
| 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.)
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
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 fromGET /app_languages. - Transport:
apiRequestsends the active locale asAccept-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 everyt(...)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 callssetLocale.
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 ({ code, displayName, … }).- 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.