FieldOps/docs/plans/i18n-pt-en.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

176 lines
11 KiB
Markdown

# Plano — i18n (infra + extração PT-PT / EN)
> Autor: Opus 4.8 (sessão de design, 2026-05-30). Destinado a implementação pelo Sonnet.
> Pré-requisitos: MAI CALL v0.1 + Auth v0.2 + v0.3 + verificação E2E, todos implementados. Estado verificado contra o repo.
> **Motivo:** Pedro quer a app pronta para vários idiomas antes de empilhar mais módulos (o custo de extrair strings cresce com cada módulo). Começar com **PT-PT (default) + EN**. Tradução real só destas duas; a infra fica pronta para adicionar línguas com um ficheiro `.json`.
## Objetivo numa frase
Toda a UI das duas apps passa de **texto fixo em português** para **chaves de tradução** (`t('...')`), com ficheiros `pt.json` + `en.json` por app, e um seletor de idioma — sem mexer nas rotas nem partir os testes E2E.
## Estado atual (medido)
- **148 strings** hardcoded em **23 ficheiros** (59 em operator-pwa, 89 em admin-web). Inventário completo confirmado por levantamento.
- Inclui strings **dinâmicas** (`Reportado por {email}`, `há {n}m`, `{count} pedidos`) e **datas** via `toLocaleString('pt-PT')` fixo (report-view.tsx).
- `<html lang>` está **inconsistente**: operator-pwa diz `"en"` (errado, app é PT), admin-web diz `"pt"`. Ambos passam a dinâmicos.
## Decisões fixadas (não revisitar sem motivo forte)
1. **Biblioteca: `next-intl`** (padrão de facto para Next 15 App Router, suporta Server e Client Components, formatação de datas/números/plurais por locale via ICU).
2. **SEM i18n routing** (modo "without i18n routing" do next-intl). O locale **não** vai no URL (`/en/...`). Vem de um **cookie `NEXT_LOCALE`**. Justificação crítica:
- É uma app **interna** (sem SEO) — URLs por locale não trazem valor.
- **Não precisa do middleware do next-intl** → **não colide com o middleware de auth** que já existe em ambas as apps. Este é o ponto que torna a integração barata.
- Não muda nenhuma rota nem `<Link href>` existente.
3. **Default = `pt`.** Cookie ausente ou inválido → `pt`. Isto é deliberado e **protege os testes E2E**: os specs procuram texto PT exato; com PT como default, passam sem alteração — **desde que as traduções PT sejam verbatim das strings atuais** (copiar exatamente, incluindo reticências `…`, acentos, e maiúsculas).
4. **Mensagens por-app**, não um package partilhado. Cada app tem `messages/pt.json` + `messages/en.json` com namespaces internos. Só ~9 strings se repetem entre apps (estados, turnos, "Entrar") — não justifica um `@repo/i18n` partilhado e a complexidade de monorepo que traz. (Extrair um `common` partilhado fica para quando houver um 3º consumidor.)
5. **Datas/números/tempos relativos** passam a usar o **formatter do next-intl** (`useFormatter`/`getFormatter`), não `toLocaleString('pt-PT')`. O locale do formatter vem do contexto → datas ficam localizadas automaticamente.
6. **Persistência do locale: só cookie (+ seletor) nesta fase.** Guardar a preferência por-tenant ou por-utilizador na BD fica para depois (corte consciente — ver §Cortes). O cookie não fecha essa porta.
## Estrutura de ficheiros (por app, exemplo admin-web)
```
apps/admin-web/
i18n/
request.ts # getRequestConfig — lê o cookie, carrega as mensagens
locales.ts # LOCALES = ['pt','en'] as const, DEFAULT_LOCALE='pt', labels
messages/
pt.json
en.json
app/
layout.tsx # <html lang={locale}>, NextIntlClientProvider (via Providers)
language-switcher.tsx # client component: PT | EN → set cookie + router.refresh()
next.config.ts # withNextIntl(...) composto com o config existente
```
Namespaces sugeridos (chaves dentro do JSON), para manter organizado:
- `common` — botões/labels repetidos: `enter`, `cancel`, `confirm`, `back`, `loading`, `close`, estados `status.open/claimed/resolved`.
- `auth` — login/picker.
- `home` — home do operador.
- `maintenance` — fila + criar pedido.
- `report` — relatório de turno.
- `errors` — 404/500/genéricos.
## Abordagem técnica (exemplos — implementar exatamente neste padrão)
### `i18n/locales.ts`
```ts
export const LOCALES = ['pt', 'en'] as const;
export type Locale = (typeof LOCALES)[number];
export const DEFAULT_LOCALE: Locale = 'pt';
export const LOCALE_LABELS: Record<Locale, string> = { pt: 'PT', en: 'EN' };
export function isLocale(v: string | undefined): v is Locale {
return !!v && (LOCALES as readonly string[]).includes(v);
}
```
### `i18n/request.ts` (next-intl, sem routing)
```ts
import { getRequestConfig } from 'next-intl/server';
import { cookies } from 'next/headers';
import { DEFAULT_LOCALE, isLocale } from './locales';
export default getRequestConfig(async () => {
const cookie = (await cookies()).get('NEXT_LOCALE')?.value;
const locale = isLocale(cookie) ? cookie : DEFAULT_LOCALE;
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
};
});
```
### `next.config.ts`
```ts
import createNextIntlPlugin from 'next-intl/plugin';
const withNextIntl = createNextIntlPlugin('./i18n/request.ts');
// admin-web: export default withNextIntl(nextConfig);
// operator-pwa: export default withPWA(withNextIntl(nextConfig)); // compor com o PWA
```
### `app/layout.tsx`
```tsx
import { NextIntlClientProvider } from 'next-intl';
import { getLocale, getMessages } from 'next-intl/server';
export default async function RootLayout({ children }) {
const locale = await getLocale();
const messages = await getMessages();
return (
<html lang={locale}>
<body>
<NextIntlClientProvider locale={locale} messages={messages}>
<Providers>{children}</Providers>
</NextIntlClientProvider>
</body>
</html>
);
}
```
> Nota: o `NextIntlClientProvider` envolve os `Providers` existentes (client). As mensagens são passadas do server → client uma vez.
### Uso nos componentes
- **Client component:** `const t = useTranslations('report'); ... t('print')`.
- **Server component:** `const t = await getTranslations('home');`.
- **Datas/tempos:** `const format = useFormatter(); format.dateTime(d, {day:'2-digit', month:'2-digit', hour:'2-digit', minute:'2-digit'})` e `format.relativeTime(date)` para os "há 5m". Remove o `'pt-PT'` fixo.
- **Plurais** (`{count} pedido(s)`): ICU no JSON →
`"pendingCount": "{count, plural, one {# pedido por enviar} other {# pedidos por enviar}}"` (pt) / `one {# request pending} other {# requests pending}` (en).
### `language-switcher.tsx` (client)
```tsx
'use client';
// dois botões PT | EN. onClick:
// document.cookie = `NEXT_LOCALE=${l}; path=/; max-age=31536000`;
// router.refresh(); // re-render server components com o novo locale
```
Colocar: na home do operador (`app/page.tsx` header) e no header da fila (`maintenance-queue.tsx`).
## `lib/shifts.ts` — caso especial
Os labels `Manhã/Tarde/Noite` estão numa constante de lógica, não num componente. **Não traduzir no shifts.ts.** Em vez disso, manter as `ShiftKey` (`manha/tarde/noite`) e traduzir no componente: `t('report.shift.' + key)`. O `shifts.ts` perde o campo `label` (ou mantém-no só como fallback técnico não usado na UI).
## Passos de implementação (ordenados)
### Passo 1 — admin-web completo (infra + extração + seletor)
**Faz:** toda a infra (§Estrutura + §Abordagem) na admin-web; extrai as **89 strings** para `messages/pt.json` (verbatim) + traduz para `messages/en.json`; datas do report via formatter; `<html lang>` dinâmico; seletor de idioma no header da fila; trata os plurais e o `shifts.ts`.
**AC:**
- App arranca; com cookie ausente está 100% em PT, **idêntica ao atual** (texto verbatim).
- Seletor → EN traduz toda a UI da admin-web; volta a PT sem recarregar manualmente.
- `pnpm test:e2e` (a parte da admin: report + queue) **continua verde sem alterar os specs** (porque PT é default e verbatim).
- `cd apps/admin-web && npx tsc --noEmit` limpo.
### Passo 2 — operator-pwa completo
**Faz:** mesma infra (atenção: **compor `withNextIntl` com `withPWA`** no next.config); extrai as **59 strings**; trata `timeAgo`/`sync-chip`/`status-badge` (plurais ICU); corrige `<html lang="en">` → dinâmico; seletor de idioma na home (ou no picker).
**AC:**
- operator-pwa 100% PT (verbatim) + EN.
- `pnpm test:e2e` happy-path **verde sem alterar specs**; `pnpm test:e2e:auth` (login operador usa "Entrar", "PIN incorreto…") **verde** — confirmar que essas strings PT estão verbatim.
- typecheck limpo. O Service Worker (PWA) continua a funcionar (o config compõe, não substitui).
### Passo 3 — Limpeza, cortes e verificação final
**Faz:** documentar os cortes (abaixo); README ganha uma secção curta "Idiomas" (como mudar, como adicionar uma língua = novo `.json`); correr a bateria toda.
**AC:** `pnpm typecheck` (sem novos erros além do pré-existente em @repo/storage), `pnpm test:e2e` (3/3) e `pnpm test:e2e:auth` (4/4) verdes. README documenta adicionar um idioma.
## Cortes propositados — o que NÃO entra
| Cortado | Porquê | Quando volta |
|---|---|---|
| Persistir locale por-tenant/utilizador na BD | Cookie + seletor chega para PT/EN; não fecha a porta | quando um cliente pedir locale fixo por fábrica |
| Traduzir mensagens de erro do tRPC (backend) | Quase nunca chegam ao utilizador (a UI tem mensagens próprias); 1 só está em PT | se passarem a ser mostradas ao utilizador |
| Strings do `Credentials` provider em `lib/auth.ts` (`name`/`label`) | São do fluxo interno do Auth.js; usamos UI própria (picker/form), nunca se veem | se usarmos a UI default do Auth.js |
| `pages/_error.tsx` (Pages Router legacy) | Fallback que praticamente não dispara no App Router | se virar relevante |
| Conteúdo dinâmico (nomes de postos, áreas, descrições) | São dados do cliente, ficam na língua dele — não é tradução | nunca (por design) |
| Línguas além de PT/EN, RTL | Fora de âmbito agora | quando houver cliente que peça |
## Sequência crítica e riscos
- **Fazer a admin-web inteira primeiro (Passo 1)** valida a abordagem numa app sem PWA antes de a replicar. Só depois o operator-pwa (Passo 2), que tem o wrapper do PWA a compor.
- **Risco principal — partir os E2E.** Os specs procuram texto PT exato. Mitigação tripla: (a) PT é o default; (b) as traduções PT são **verbatim** (copiar, não reescrever); (c) correr ambos os E2E no fim de cada passo.
- **Risco — compor `withNextIntl` com `withPWA`** no operator. Mitigação: `withPWA(withNextIntl(nextConfig))`; testar que o SW ainda gera e que a app arranca.
- **Risco — Server vs Client.** `useTranslations` só em client components; `getTranslations` em server. O inventário marca quais são quais; em caso de dúvida, o componente que já usa `'use client'` usa o hook.
- Nada disto toca em lógica de negócio, API, ou base de dados → o risco é de UI/config, apanhável pelos E2E.
## Anexo — onde estão as 148 strings (do inventário)
**operator-pwa (59):** layout.tsx(3), page.tsx(4), error.tsx(3), not-found.tsx(3), select-operator/page.tsx(2), select-operator/operator-picker.tsx(8), sign-out-button.tsx(1), status-badge.tsx(3), sync-chip.tsx(3), maintenance/new/page.tsx(14), maintenance/sent/sent-status.tsx(5), sync-provider.tsx(2), lib/auth.ts(3 — corte), pages/_error.tsx(2 — corte).
**admin-web (89):** layout.tsx(2), login/page.tsx(2), login/login-form.tsx(7), maintenance/maintenance-queue.tsx(31), maintenance/report/report-view.tsx(35), maintenance/report/page.tsx(1), lib/shifts.ts(3 — via componente), lib/auth.ts(3 — corte), pages/_error.tsx(2 — corte).