Skip to content

i18n infrastructure (SPA foundation): react-i18next + resolution + string sweep (tracker) #182

Description

@mforce

Split from #45. This is the SPA i18n FOUNDATION; the API half is #45 and lands first (this needs GET /me for language and the errorCodes extension to translate validation errors). Ships English-only; first pack (Spanish) is Phase 1.5 (epic #15).

Part of epic #14 (Phase 1.1). Spec: §4.5, §5.2, §24, UC-012.

Plan hardened after a codex (repo-aware) + pi review. Corrections marked ⓡ.


📊 Status (updated 2026-07-28)

✅ SWEEP COMPLETE. All batches B0–B6b shipped (#252 merged 2026-07-28) + the login/refresh language memory fix (#253 merged). The ONLY remaining work is the native-speaker es/tl review (human translation reconciliation, not code) — this tracker stays open for it.

Foundation: shipped (#233 merged) + es/tl subset packs (#234 merged). Full string sweep: COMPLETE (#252 merged). Hardcoded-string count on src/routes+src/components: 843 → 3 (the 3 are scanner false positives — code fragments, not strings). Every client-authored SPA string is in the catalog; the only intentional survivor is index.html's <title>Cluckwork</title> (brand).

Key decision — translate-now (revised 2026-07-28). The sweep began English-first (externalize English now, defer es/tl to one native pass), but partway through we switched: every swept screen now ships machine-drafted es/tl inline, marked // machine-drafted (#182) — pending native review, and is added to TRANSLATED_NAMESPACES. So Spanish/Tagalog mode renders translated across the app now; the scheduled native-speaker review still reconciles word choices (this issue stays open for it). Rationale for the switch: the English-first fallback left most screens showing English in Spanish mode, which read as broken.

Coverage: all 29 namespaces translated (en + machine-drafted es/tl, pending native review) — the already-translated set + enums (incl. auditAction/entityType) + B4 + B5 (#248) + the B1–B3 catch-up (#249) + help prose (#251) + help glossary (#252). Spanish/Tagalog mode renders translated across the entire app.

Sweep plan: docs/superpowers/plans/2026-07-27-i18n-string-sweep.md (reviewed by 5 lenses pre-execution; findings folded).

Batch tracker

Stays open after the English sweep for

  • Native-speaker review of es/tl for all swept screens (the deferred translation pass).
  • Reconcile the temporary sales:status* duplicate into a then-translated shared enums:status. ✅ PR refactor(web): reconcile sales:status* duplicate onto shared enums:status (#182) #254, merged 2026-07-28: SalesPage's filter dropdown + voided-payment badge render via statusLabel(); the 4 sales:status* keys deleted from en/es/tl (values byte-identical to enums:status, so behavior-preserving); wiring test added; reviewed codex + 2 Claude agents + pi (unanimous approve).
  • Optional: consolidate pwa.reload/errorBoundary.reload into a shared common.reload atom if a third site appears.

Scope boundary: "all client-authored SPA copy" excludes server-authored text — uncoded validation messages render ApiError.message = server English by design; coded errors already map via errorCodes (#45).


Goal

The i18n plumbing in the SPA, so new screens add catalog keys instead of hardcoded strings, and the stored §4.5 settings finally drive display. English is the source of truth and the fallback.

ⓡ Scope of "done." The spec requires all UI strings in the catalog (specs.md:710, :2128; Sprint E retrofit). Incremental PRs are fine; incremental completion is not. So this issue is the foundation (library + resolution + convention + a representative pilot), and it stays open as the tracker for the full string sweep, which blocks Phase 1.5 (no Spanish pack can ship against half-externalized screens). The sweep may be split into numbered migration issues when scheduled.

Decisions (inherited from #45)

  • UI language (string catalog) ≠ farm locale (number/date/money formatting, §4.5, unchanged). A user reading Spanish on a en-US farm still sees $ and MM/DD/YYYY.
  • Resolution order: users.language (if a pack exists) → farm-locale's language subtag → English. Stored-but-unsupported = unset, never an error.
  • ⓡ Farm locale is available: GET /account already returns locale (AccountResponse.Locale) and the SPA Account type has it — the fallback has its source, no new API dependency.

Technical approach

  • Library: react-i18next (+ i18next). Standard, ~11 kB gz, useTranslation/<Trans>, built-in missing-key fallback. (FormatJS heavier; hand-rolled rejected per the framework-over-handrolled rule.)
  • English catalog as source of truth, namespaced by area (common, auth, dailyEntry, sales, …). ⓡ Typed keys via CustomTypeOptions module augmentation (types/i18next.d.ts, resources: typeof en) so t("missing") is a compile error — with the accepted burden of keeping the type in sync with the catalog.
  • ⓡ t() vs <Trans> convention: t() for plain strings; <Trans> only for strings interleaving JSX. Documented in a CONTRIBUTING/DESIGN note so usage stays consistent across the sweep.
  • ⓡ One coordinated authenticated bootstrap — not independent providers. After ProtectedRoute's silent-refresh gate, fetch /me and /account concurrently, resolve the language, initialise i18next with the resolved language (NOT at module load — initialising with a language before resolution causes an English→resolved flash), and only then expose the authenticated shell. A failed read settles to farm-locale/English fallback, never a permanent blank. Note the AuthContext hazard: it marks the user authenticated immediately on token receipt (AuthContext.tsx:84), so a provider reacting later in an effect could allow one English render — the bootstrap must gate BEFORE the shell renders.
  • ⓡ "Before first paint" is qualified to the AUTHENTICATED shell. /login is outside ProtectedRoute and a logged-out browser has no /me, so public/login UI is unavoidably English (or, optionally, a persisted non-sensitive local language hint for returning users). This is the honest promise; do not claim login-screen language resolution.
  • Validation errors: ⓡ parseError (api/client.ts) reads the API's errorCodes when present, maps each code to a catalog message, and falls back to the English errors message at the SAME array index when a code has no key or no code was emitted. Codes are explicit-only (i18n infrastructure (API half): validation error codes + users.language + GET/PATCH /me #45), so an uncoded field simply keeps its English message.

Scope (foundation) — shipped in #233

  • react-i18next + i18next; typed English catalog (CustomTypeOptions); missing key → English (never blank or raw key)
  • Coordinated bootstrap: concurrent /me + /account, resolution (§4.5), i18next init with resolved language, shell gated until ready, failure → fallback
  • parseError reads errorCodes → catalog message, index-aligned English fallback
  • Formatting stays farm-locale-driven regardless of UI language (guardrail + a test that mocks language and asserts money/date output is unchanged)
  • Language selector wired to PUT /me/language, hidden while English-only
  • Convention documented (t()/<Trans>, key naming, sentence casing; new screens add keys)
  • Pilot: externalize login + one dense screen (daily entry or sales) as the worked pattern
  • Vitest: catalog fallback, resolution chain + no-flash gating, a screen rendering from keys, parseError code→message mapping, formatting-independent-of-language, hidden-selector

Full string sweep (tracked here; blocks Phase 1.5) — see the Batch tracker above

ⓡ Not just route headings — includes shared components, navigation, status badges, accessibility labels, confirmation dialogs, client-side validation messages, error-boundary copy, and the Help page. Being externalized batch-by-batch (B0–B6 above); this issue stays open until the sweep AND the native-review es/tl pass are complete.

Out of scope

Sequencing

After #45. Foundation shipped (#233); string sweep in follow-up area PRs (B0–B6), tracked here.

Activity

  1. changed the title [-]i18n infrastructure (SPA half): react-i18next + string externalization + language resolution[/-] [+]i18n infrastructure (SPA foundation): react-i18next + resolution + string sweep (tracker)[/+] on Jul 24, 2026
  2. mforce commented on Jul 27, 2026

    @mforce
    OwnerAuthor

    Foundation shipped: PR #233 merged to main (ff0325b) — react-i18next + typed English catalog, coordinated no-flash bootstrap, errorCodes mapping, per-user language selector (hidden while English-only), and a Login+Sales pilot.

    PR #234 (Spanish + Tagalog packs, machine-drafted subset — login/sales/Account→Preferences/errors) is open and now rebased onto main (no longer stacked; conflicts resolved). Awaiting review/merge.

    This issue stays open to track the remaining work:

    • Full string sweep of the ~18 un-externalized screens (shared components, nav, StatusBadge/method/unit enums, a11y labels, dialogs, Help page).
    • Native-speaker review of the es/tl drafts before real users see them.
  3. mforce commented on Jul 27, 2026

    @mforce
    OwnerAuthor

    String sweep — progress update (2026-07-27)

    The full SPA string sweep is underway, executed against a plan that was reviewed by 5 independent lenses before execution (findings folded in). Key decision: English-first. Every screen is externalized to the English catalog now; es/tl for the swept screens is deferred to a single scheduled native-review pass (rather than machine-drafting ~1000 strings inline). Only the already-shipped translated set stays translated, and HelpPage (B6) is the one machine-drafted es/tl exception. This aligns with the #46 phasing (infra vs. translation) and avoids shipping an unreviewed "authoritative" corpus.

    The sweep ships as 8 area PRs (each independently reviewed + merged by a human). Hardcoded-string count on src/routes+src/components: 843 → 791 so far.

    ✅ Done / merged

    🔄 In review / in progress

    • B1 — shared chrome + primitives (PR F182 B1: i18n sweep — shared chrome + primitives (nav labelKey, AppLayout, dialogs, PWA) #238, open, awaiting merge): nav (typed labelKey model), AppLayout (+ new skip-link & catalog document.title), BottomNav, Dialog, NumberField, ErrorBoundary, ThemeToggle, useConfirm, PWA UpdatePrompt. 6 English-only namespaces; scan 843→827.
    • B2 — daily ops (in progress): DailyEntryPage externalized + under review (scan →791); Dashboard, WaterPage, GradesPage pending.

    ⏳ Pending batches

    • B3 — inventory: InventoryPage, ProductsPage, StockPage, FlocksPage.
    • B4 — finance & people: SettingsPage, UsersPage, ExpensesPage, CustomersPage, + the remainder of AccountPage (only its preferences block was done in i18n infrastructure (SPA foundation): react-i18next + resolution + string sweep (tracker) #182).
    • B5 — records & export: HistoryPage, ReportsPage (needs new render tests — 0% covered today), AuditPage, ExportPage. Also: auditAction + entityType enum displays land here (deferred from the B0 enums module — auditAction is a screen-local vocabulary, entityType isn't enumerable from SPA code).
    • B6 — Help page (2 PRs): prose + in-app glossary. The one machine-drafted es/tl exception, flagged pending native review + staleness-tracked.

    📌 Deferred / tracked (this issue stays open for)

    • Native-speaker review of es/tl for all swept screens — the scheduled translation pass English-first defers to. This is the main reason i18n infrastructure (SPA foundation): react-i18next + resolution + string sweep (tracker) #182 stays open after the English sweep completes.
    • Reconcile the temporary sales:status* duplicate into a (then-translated) shared enums:status during that pass.
    • Optional consolidation of pwa.reload/errorBoundary.reload into a shared common.reload atom if a third "Reload" site appears.

    Scope boundary: "all client-authored SPA copy" excludes server-authored text — uncoded validation messages render ApiError.message = server English by design; coded errors already map via the errorCodes path (#45).

  4. 26 remaining items

  5. mforce commented on Sep 13, 2026

    @mforce
    OwnerAuthor

    Amendment — the native-speaker es/tl review is declined (owner, 2026-09-13, during the issue cleanup).

    The body above ticks "Native-speaker review of es/tl for all swept screens" while the status block and epic #15 both described it as the one remaining item. That contradiction is now resolved in the other direction: the pass will not be done. The machine-drafted es + tl packs are what ships.

    Consequences, stated plainly so nobody re-derives them:

    The only other open item here is the optional pwa.reload / errorBoundary.reload consolidation, which is a one-line cleanup waiting on a third call site.

  6. mforce commented on Sep 13, 2026

    @mforce
    OwnerAuthor

    Closing as completed, 2026-09-13.

    What shipped

    The whole sweep. B0–B6b all merged, plus the login/refresh language memory (#253) and the sales:status* reconciliation (#254). Hardcoded strings on src/routes + src/components went 843 → 3 (the three are scanner false positives — code fragments, not strings), with index.html's <title>Cluckwork</title> the one intentional survivor. All 29 namespaces carry en plus es/tl. catalogParity guards the key sets, npm run i18n:scan guards regressions, and the typed enums module gives compile-time drift detection on the 11 closed vocabulary families.

    What is deliberately not done

    The native-speaker es/tl review is declined (owner, 2026-09-13 — see the amendment comment above). The machine-drafted packs are what ships, and the // machine-drafted (#182) — pending native review comments describe a permanent state. The consequence for #688's help-prose rule is recorded there: review is now the only check, because nothing enforces it.

    #738 (two es/tl wording items held for that pass) needs its own decision and is still open.

    The one remaining body item

    "Optional: consolidate pwa.reload/errorBoundary.reload into a shared common.reload atom if a third site appears." That is conditional on a third call site existing, and none has appeared in seven weeks. It is a note for whoever adds the third one, not scheduled work — so it does not justify holding a tier2 tracker open. Recorded here rather than lost.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions