FieldOps/docs/plans/i18n-pt-en.md
2026-05-30 17:09:14 +01:00

12 KiB

Plano — i18n (infra + extração PT-PT / EN)

ESTADO: IMPLEMENTADO E VERIFICADO (2026-05-30). Ambas as apps bilingues PT/EN. Verificado: paridade de chaves 85/85 (admin) + 52/52 (operator), 274 mensagens ICU OK, tsc limpo, test:e2e 3/3 + test:e2e:auth 4/4. Guia de manutenção e de adicionar línguas em docs/i18n.md.

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-intlnã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

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)

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

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

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)

'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).