FieldOps/docs/i18n.md
Pedro Gomes 35e7027881 localization support
O que mudou
Infra (por app):

i18n/locales.ts — lista de locales (pt, en), default pt, labels para o seletor
i18n/request.ts — lê o cookie NEXT_LOCALE, carrega as mensagens
messages/pt.json + messages/en.json — todas as strings extraídas
next.config.ts — envolvido com withNextIntl (operator-pwa: withPWA(withNextIntl(...)))
app/layout.tsx — <html lang={locale}> dinâmico, NextIntlClientProvider
app/language-switcher.tsx — seletor PT | EN (cookie + router.refresh())
23 ficheiros de UI atualizados — todos os textos visíveis agora usam t('...') ou getTranslations.

Datas no relatório passaram de toLocaleString('pt-PT') fixo para useFormatter() do next-intl — localizam-se automaticamente.

Plurais em ICU no sync-chip: {count, plural, one {# pedido...} other {# pedidos...}}.

Resultado dos testes:

pnpm test:e2e — 3/3 ✓
pnpm test:e2e:auth — 4/4 ✓
tsc --noEmit em ambas as apps — limpo ✓
Para adicionar uma língua futura: criar messages/<locale>.json + adicionar o locale a i18n/locales.ts em cada app. O seletor aparece automaticamente.
2026-05-30 16:46:07 +01:00

6.0 KiB
Raw Blame History

Internationalization (i18n)

FieldOps ships in Portuguese (PT, default) and English (EN). Both apps (operator-pwa and admin-web) are fully translated. This doc explains how it works and how to add a new language.

How it works

  • Library: next-intl (Next.js 15 App Router).
  • No locale in the URL. This is an internal app (no SEO), so we use next-intl's "without i18n routing" mode. The locale comes from a NEXT_LOCALE cookie, not a /en/... path prefix. This keeps every route unchanged and avoids any clash with the auth middleware.
  • Default is pt. A missing or invalid cookie falls back to Portuguese.
  • Dates, numbers and relative times are formatted by next-intl's useFormatter, so they follow the active locale automatically (no hardcoded pt-PT).
  • Translations live per app (not in a shared package) — the two apps share only a handful of strings, not enough to justify a shared package yet.

File structure (same in each app)

apps/<app>/
  i18n/
    locales.ts        # LOCALES, DEFAULT_LOCALE, LOCALE_LABELS, isLocale()
    request.ts        # reads the NEXT_LOCALE cookie, loads the messages file
  messages/
    pt.json           # Portuguese (default)
    en.json           # English
  app/
    layout.tsx        # <html lang={locale}>, NextIntlClientProvider
    language-switcher.tsx   # the PT | EN pill
  next.config.ts      # wrapped with withNextIntl(...)

Messages are organised into namespaces (common, auth, maintenance, report, home, sync, errors, metadata) — see the JSON files.

Changing language (end user)

Click the PT | EN pill in the app header. The choice is saved in the NEXT_LOCALE cookie (1-year expiry); server components re-render via router.refresh(). No account or DB change involved.

The switcher currently appears on the authenticated screens (operator home, maintenance queue), not on the login/picker pages. A first-time user sees the initial login in PT; once they switch, the cookie persists across logins.

Adding a new language

Example: adding French (fr) to the admin-web. Repeat for operator-pwa.

  1. Create the messages file. Copy apps/admin-web/messages/en.json to apps/admin-web/messages/fr.json and translate every value. Keep every key identical — only translate the values. Leave ICU placeholders ({email}, {count}) and plural structure ({count, plural, one {…} other {…}}) intact.

  2. Register the locale in apps/admin-web/i18n/locales.ts:

    export const LOCALES = ['pt', 'en', 'fr'] as const;
    export const LOCALE_LABELS: Record<Locale, string> = { pt: 'PT', en: 'EN', fr: 'FR' };
    
  3. Repeat steps 12 for operator-pwa (copy its en.json, add fr to its locales.ts).

  4. Verify (see below). The FR button then appears in the switcher automatically — no other code changes needed.

Translating the values needs someone who speaks the language. Everything else is mechanical.

Keeping translations healthy

Two failure modes are NOT caught by tsc or the E2E tests (the E2E only run in the default PT), so check them whenever you touch translations:

1. Key parity — every locale file must have exactly the same keys, or a missing key renders as raw text (or throws) when a user switches to it:

node -e '
const fs=require("fs");
function flat(o,p=""){let k=[];for(const key in o){const np=p?p+"."+key:key;if(typeof o[key]==="object"&&o[key]!==null)k=k.concat(flat(o[key],np));else k.push(np);}return k;}
for(const app of ["admin-web","operator-pwa"]){
  const files=fs.readdirSync(`apps/${app}/messages`).filter(f=>f.endsWith(".json"));
  const sets=files.map(f=>[f,new Set(flat(JSON.parse(fs.readFileSync(`apps/${app}/messages/${f}`))))]);
  const base=sets[0][1];
  for(const [f,s] of sets){
    const missing=[...base].filter(k=>!s.has(k));
    const extra=[...s].filter(k=>!base.has(k));
    console.log(`[${app}] ${f}: ${s.size} keys`, missing.length||extra.length?`MISSING ${missing} EXTRA ${extra}`:"OK");
  }
}'

2. ICU syntax — a malformed plural (e.g. a missing brace) only throws when that exact message renders, which the PT-only E2E may never hit:

node -e '
const fs=require("fs"),path=require("path");
const dir=fs.readdirSync("node_modules/.pnpm").find(d=>d.startsWith("intl-messageformat@"));
const {IntlMessageFormat}=require(path.resolve("node_modules/.pnpm",dir,"node_modules/intl-messageformat"));
function flat(o,p=""){let r={};for(const k in o){const np=p?p+"."+k:k;if(typeof o[k]==="object"&&o[k]!==null)Object.assign(r,flat(o[k],np));else r[np]=o[k];}return r;}
let errors=0;
for(const app of ["admin-web","operator-pwa"]){
  for(const f of fs.readdirSync(`apps/${app}/messages`).filter(f=>f.endsWith(".json"))){
    const loc=f.replace(".json","");
    const msgs=flat(JSON.parse(fs.readFileSync(`apps/${app}/messages/${f}`)));
    for(const [key,val] of Object.entries(msgs)){
      try{new IntlMessageFormat(val,loc);}catch(e){console.log(`ICU ERROR [${app}/${loc}] ${key}: ${e.message}`);errors++;}
    }
  }
}
console.log(errors?`${errors} ERRORS`:"ALL ICU OK");'

Both should report OK before shipping a new language.

What is intentionally NOT translated

  • Dynamic content — workstation names, areas, request descriptions. These are customer data, kept in whatever language the user typed.
  • Backend tRPC error messages — almost never surface to the user (the UI has its own messages).
  • The Credentials provider labels in lib/auth.ts (internal to Auth.js; the custom login UI is what users actually see).

Future improvements (not done yet)

  • Persist the locale per tenant/user in the DB (currently cookie-only).
  • Show the language switcher on the login/picker pages too.
  • A small E2E that switches to EN and asserts a translated string (today the EN path is covered only by the parity + ICU checks above).
  • next-intl strict message-key typing (autocomplete + compile-time error on unknown keys).