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

139 lines
6.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`](https://next-intl.dev) (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`:
```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:
```sh
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:
```sh
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).