Pular para conteúdo

0009 — Remoção do i18n

Status CONCLUÍDA
Branch spec/0009-remocao-do-i18n
Criada em 2026-08-01
Apps afetados api · web · e2e

Status: RASCUNHO → APROVADA → EM IMPLEMENTAÇÃO → CONCLUÍDA

1. Problema

O Garantora carrega uma infraestrutura completa de internacionalização para atender a um único idioma. A solução é 100% brasileira: garantidoras, imobiliárias e inquilinos brasileiros, contrato regido pela lei brasileira, valores em real. Não há e não está previsto público em outro idioma.

O custo dessa infraestrutura é cobrado todo dia:

  • Todo texto novo exige duas escritas — a chave em pt_BR e a mesma em en_US. Nas specs 0007 e 0008 isso significou 23 e 25 chaves duplicadas, respectivamente.
  • O catálogo EN apodrece em silêncio. A spec 0007 encontrou 25 linhas com artefato de rename mecânico ("No creditAnalyses found", "This creditAnalysis has…") — texto que ninguém lê, porque ninguém usa a interface em inglês.
  • Ler o código fica pior. t("creditAnalyses.detail.agencyNoAgencyDesc") não diz o que aparece na tela; é preciso abrir um arquivo de 1.967 linhas para descobrir.
  • A cadeia de resolução é desproporcional: o t() da API consulta cache de tenant → cache de sistema → JSON de fallback, para sempre devolver português.

Alcance medido:

Web API
Chamadas t(...) 962 em 48 arquivos 110 em 23 arquivos
Catálogo providers/i18n.ts, 1.967 linhas (PT + EN) locale/pt_BR.json + en_US.json, 187 linhas cada
Extra seletor PT/EN no header e no drawer mobile tabela translations, CRUD, 4 permissões, users.language, middleware Accept-Language, i18n_seed.py

2. Objetivo

Texto de interface passa a ser literal, no ponto onde aparece. Sem catálogo, sem chave, sem segundo idioma — e sem que nada na tela mude para quem usa.

3. Fora de escopo

  • Traduzir ou revisar textos. Cada chave vira exatamente o valor pt_BR que já existe. Se um texto está ruim hoje, continua ruim — corrigir é outra conversa, e misturar as duas coisas tornaria impossível revisar este diff.
  • Mudar comportamento. Nenhuma tela, rota, permissão ou regra muda além da remoção do i18n em si.
  • Formatação de número, moeda e data. Intl.NumberFormat("pt-BR"), toLocaleDateString("pt-BR") e o formatUtils permanecem — são formatação brasileira, não tradução.
  • As 126 chaves do próprio Refine. Ver RN4: elas ficam.

4. Regras de negócio

  • RN1. Toda chamada t("chave") é substituída pelo valor pt_BR correspondente, literal, no ponto de uso. Nada de constante intermediária: o texto fica onde é lido.
  • RN2. Chamadas com interpolação viram template literal. t("creditAnalyses.months", { count: 12 }) → `${meses} meses`. São 91 chaves com {{...}} e 72 chamadas com segundo argumento.
  • RN3. Chamadas com defaultValue (t("x", { defaultValue: "Aguarde..." })) viram o literal do defaultValue, que é o que aparece hoje quando a chave não existe.
  • RN4. O i18nProvider do Refine sobrevive, enxuto. O Refine lê ~126 chaves próprias (buttons.*, notifications.*, pages.login.*, table.*, documentTitle). Removê-lo faria a UI dele cair no inglês embutido — "Save", "Successfully created", paginação em inglês — o oposto do objetivo. O provider fica com apenas essas chaves, em pt-BR, e o catálogo enUS é removido inteiro.

Sem esta regra, "remover o i18n" produziria mais inglês na tela, não menos.

  • RN5. A tabela translations e seu CRUD são removidos, com as 4 permissões (translations:list/create/update/delete) e o i18n_seed.py. Consequência assumida: a garantidora perde a capacidade de personalizar rótulos para o vocabulário dela — renomear "Análise de Crédito" para "Ficha Cadastral" passa a ser mudança de código.
  • RN6. users.language é removida (migration), junto do set_language em dependencies.py e do middleware de Accept-Language em main.py.
  • RN7. O seletor PT/EN some do header desktop (app-layout) e do drawer mobile (mobile-shell), com os hooks useSetLocale/useGetLocale.
  • RN8. Nenhum texto visível muda. O critério de sucesso é a tela ficar idêntica — esta spec é invisível para quem usa. Qualquer diferença de texto é bug, não melhoria.
  • RN9. Permissões: só a remoção das 4 de translations. Nenhum papel muda de alcance em nada além disso.

5. Impacto por app

apps/api

  • Remover: app/core/i18n.py, app/locale/*.json, app/models/translation.py, app/api/v1/routers/translations.py (+ service/repo), app/db/i18n_seed.py, o alvo dev-seed-i18n do Makefile, e a entrada de TAGS_METADATA da tag "Traduções".
  • Substituir: as 110 chamadas t("chave") em 23 arquivos pelo literal pt_BR. São quase todas mensagens de erro de negócio (BusinessRuleError, NotFoundError).
  • NotFoundError usa t(resource) para traduzir o nome da entidade — passa a receber o nome já em português, direto.
  • Migration: DROP TABLE translations, DROP COLUMN users.language, e remoção das 4 permissões com seus vínculos em role_permissions. downgrade simétrico.
  • rbac_seed: remover translations:* do catálogo e dos papéis.
  • Testes: tests/test_translations.py sai. O restante não deveria mudar — se um teste quebrar por texto, é sinal de que o literal ficou diferente do que era (RN8).

apps/web

  • providers/i18n.ts: de 1.967 para ~140 linhas — só as chaves do Refine em pt-BR (RN4). O catálogo enUS sai inteiro.
  • 962 chamadas t(...) em 48 arquivos viram literal. Onde o componente ficar sem uso de t, remover também o useTranslate e o import.
  • Seletor de idioma removido de components/app-layout/index.tsx e components/mobile-shell/index.tsx (RN7).
  • changeLocale deixa de fazer PATCH /users/{id} com language (RN6).
  • data-testid: nenhum muda. É o que permite ao e2e existente servir de rede.
  • Responsividade & PWA: sem impacto — nenhum layout muda. Textos são os mesmos, então não há risco novo de quebra em 375px. O PWA serve bundle novo normalmente.

apps/e2e

  • Sem spec nova. Não há comportamento novo a cobrir; a suíte existente é justamente a rede que prova a RN8 — se um texto mudou onde não devia, um teste que casa por texto quebra.
  • Ajustar apenas o que porventura dependa do seletor de idioma ou de language.

6. Contrato de API

Removido:

GET    /api/v1/translations
POST   /api/v1/translations
PATCH  /api/v1/translations/{id}
DELETE /api/v1/translations/{id}

Alterado: PATCH /api/v1/users/{id} deixa de aceitar language; GET /users/{id} deixa de devolvê-lo.

Inalterado: todo o resto. Mensagens de erro mantêm code e message — o texto do message passa a vir de literal em vez do catálogo, com o mesmo conteúdo.

Sem alias nem período de compatibilidade: o único cliente é o apps/web, versionado junto (ver "Estágio do produto" no CLAUDE.md da raiz).

7. Critérios de aceite

  • [x] CA1. Nenhuma chamada t( resta em apps/web/src fora do i18nProvider mínimo, e nenhuma em apps/api/app.
  • [x] CA2. providers/i18n.ts contém só chaves do Refine, em pt-BR. Não existe catálogo enUS.
  • [x] CA3. Os textos do Refine seguem em português: salvar, cancelar, notificação de sucesso, paginação e tela de login. Nenhum texto em inglês na interface.
  • [x] CA4. O seletor PT/EN não existe mais, no desktop nem no mobile.
  • [x] CA5. GET /api/v1/translations responde 404; a tabela translations e a coluna users.language não existem.
  • [x] CA6. As permissões translations:* não existem, e nenhum papel perdeu outro acesso.
  • [x] CA7. As telas ficam visualmente idênticas ao estado anterior — conferido por screenshot antes/depois em desktop e mobile (RN8).
  • [ ] CA8. make test verde e tsc limpo, sem apontamento novo de lint nos arquivos tocados. — parcial: make test verde (173 passed) e tsc limpo; I001 e F401 zerados; E501 regride +43 mesmo com o limite em 120. Ver "Lint: o que melhorou, o que piorou, e o limite de linha em 120".

8. Plano de implementação

Ordem obrigatória: api → web → e2e.

  • [x] apps/api — substituir as 110 chamadas t() pelos literais pt_BR
  • [x] apps/api — remover core/i18n.py, locale/, model/router/service de tradução, i18n_seed.py, alvo do Makefile e tag do Swagger
  • [x] apps/api — remover users.language, set_language e middleware Accept-Language
  • [x] apps/api — migration (DROP tabela, coluna e 4 permissões) + rbac_seed
  • [x] apps/api — remover tests/test_translations.py; suíte verde
  • [x] apps/web — reduzir i18n.ts às chaves do Refine (RN4)
  • [x] apps/web — substituir as 962 chamadas, arquivo a arquivo, removendo useTranslate onde ficar sem uso
  • [x] apps/web — remover o seletor de idioma e o PATCH de language
  • [x] apps/e2e — ajustar o que dependa de idioma (não há spec nova)
  • [ ] make test e tsc limpos; lint sem apontamento novo
  • [x] Screenshot antes/depois em desktop e mobile confirmando telas idênticas

9. Decisões em aberto

Resolvidas com o usuário (2026-08-01):

  • [x] Override de texto por tenant — removido junto (RN5), ciente de que a garantidora perde a personalização de rótulos.
  • [x] users.language e Accept-Language — removidos, coluna e middleware (RN6). Sem produção, dropar coluna não tem custo.
  • [x] Textos do próprio Refine — provider mínimo com as ~126 chaves em pt-BR (RN4). Remover tudo faria a UI do Refine falar inglês.
  • [x] Fatiamento — uma spec só, API e web juntos: é remoção mecânica, o erro aparece na tela na hora e dividir deixaria um meio-termo com API sem i18n e web com chave.

Nenhuma decisão em aberto.

10. Registro

Resultado (2026-08-01)

95 arquivos, +1.308 / −4.325. Nenhuma chamada t( resta na API nem no web fora do provider mínimo. providers/i18n.ts: 1.967 → 93 linhas.

Planejado Real
Substituições no web 962 891 automáticas + 71 manuais (dinâmicas, buttons.*, t(k, "fallback"))
Substituições na API 110 109 automáticas + 1 sem catálogo
Chaves do Refine mantidas ~126 65

O que a spec não previu

Chaves montadas em runtime não viram literal. A spec assumia que toda chamada era t("literal"). Havia oito lugares em que a chave é calculada — t(f"permissions.resource.{perm.resource}"), t(\caDocuments.area.${area}`),t(role.display_name),t(log.description),t(cond ? "a" : "b")`. Não existe "literal no ponto de uso" para esses: viraram mapas estáticos próximos de onde são usados, com comentário explicando por quê. Foi a maior divergência da RN1.

Duas tabelas guardavam chaves i18n como dado. Descoberto durante a implementação:

  • roles.display_name/description guardavam roles.system.VIEWER.display_name. Sem o catálogo, a tela mostraria a chave crua como nome do perfil. A migration converte os dados para o texto final, e o rbac_seed passa a gravar português.
  • audit_logs.description guardava audit.role.create, com os parâmetros de interpolação num campo _t dentro do payload. As descrições passam a ser gravadas já formatadas. Não houve dado a migrar (0 de 323 linhas tinham descrição).

Nenhuma das duas aparecia na spec, e ambas quebrariam a interface em silêncio.

Só 65 chaves do Refine, não ~126. A conta de 126 na spec incluía prefixos que o catálogo não usava. O provider ficou com o que o Refine realmente lê.

Bugs pré-existentes que a remoção expôs

Duas chaves eram usadas sem existir em catálogo nenhum — o t() devolve a própria chave nesse caso, então a tela já mostrava o identificador cru:

  • API: contract.settings_not_found → virava a mensagem de erro literal "contract.settings_not_found" ao simular contrato sem parâmetros configurados.
  • Web: common.notFound e verifyEmail.yourEmail.

Preservá-las ao pé da letra cumpriria a RN8, mas seria manter um defeito visível de propósito. Escrevi texto de verdade nos três — divergência consciente da RN8, cujo propósito é impedir mudança acidental de texto, não congelar bug.

make test: 172 passed / 1 failed durante a spec, 173 passed no fecho

test_operations_center.py::test_financial_summary_default_rate falha porque cria uma cobrança vencida em hoje − 5 dias e espera que ela conte no mês corrente. Hoje é dia 1º: hoje − 5 cai em julho. O teste falha nos dias 1 a 5 de qualquer mês.

Verifiquei fazendo git stash e rodando contra o código original: falha igual. Não foi a remoção do i18n.

Corrigido a pedido do usuário, depois de fechada a implementação. O resumo agrupa por mês-calendário do due_date (_monthly_financial(tenant_id, y, m)), então as duas cobranças do teste precisam cair no mês corrente; a vencida passou a usar today.replace(day=1), que por construção está sempre no mês atual e nunca no futuro. tests/test_operations_center.py: 10 passed, e a suíte inteira contra a imagem reconstruída: 173 passed, 0 failed.

O today - timedelta(days=10) do test_alerts_critical_overdue_billing foi conferido e não tem o mesmo defeito: aquela regra é de janela móvel (dias desde o vencimento), não de mês-calendário.

Lint: o que melhorou, o que piorou, e o limite de linha em 120

Comparação feita com o próprio ruff nos dois lados (git archive HEAD num diretório separado, mesma pyproject.toml), sobre app + tests. Números anteriores desta seção mediam só os arquivos tocados e por isso não batiam; estes são o escopo inteiro.

Regra HEAD spec 0009
I001 (imports) 16 0 zerado, inclusive o pré-existente
F401 (import morto) 5 0 idem
E501 (limite 100) 614 669 +55
E501 (limite 120) 120 163 +43
F821, F841, E741, N817 — — inalterados, pré-existentes

line-length subiu de 100 para 120 (apps/api/pyproject.toml), a pedido do usuário. Justifica-se por si: derruba os apontamentos de E501 na árvore de 874 para 206 (app + tests + alembic), 76% deles pré-existentes e sem relação com esta spec. 120 é valor padrão reconhecível e ainda pega a linha genuinamente ilegível.

O que subir o limite não fez: cumprir o CA8. A regressão relativa continua, apenas menor — +55 em 100, +43 em 120. Nenhum limite defensável a zera; seria preciso ir a 180 (+2), e aí a regra deixa de valer para alguma coisa. A causa é o próprio objetivo da spec: frase em português inline é mais longa que t("chave"). Quebrei 49 raise Erro("CODE", "msg") em várias linhas, o que resolveu só 15 — o resto é a frase em si. A divergência do CA8 permanece registrada, agora com o custo medido em vez de estimado.

Segunda varredura: o que a primeira passagem deixou para trás

A primeira passagem seguiu os t() e os imports — e por isso só pegou quem chamava o i18n, não quem o invocava por nome de módulo ou apenas o descrevia. Uma varredura por i18n|translation|locale|idioma no repositório inteiro achou o resto:

  • app/db/init_db.py importava app.db.i18n_seed, que eu havia apagado. O seed do admin inicial quebrava com ImportError. Não apareceu antes porque nada no boot nem na suíte o executa — o api-entrypoint.sh engole o erro com || echo.
  • app/db/seed_translations.py sobreviveu: eu removi i18n_seed.py sem notar que havia dois seeds de tradução. Ele era chamado pelo api-entrypoint.sh (a cada boot do container) e pelo alvo seed do infra/dev-remote/Makefile.
  • Wiki publicada em /wiki: a página admin/traducoes.md documentava quatro endpoints que não existem mais, referenciada no toctree do index.md. Mais o campo language em equipe.md e admin/usuarios.md, a linha "Traduções" em admin/index.md e a tabela translations na ordem de purge em admin/tenants.md.
  • Docstrings de código vivo: a ordem de purge em tenant_purge_service.py (duas vezes) e no router tenants.py ainda listava translations; schemas/permission.py descrevia os campos como "nome traduzido no idioma do usuário".

Nenhum desses aparecia no plano. A lição é que remover uma infraestrutura pede duas varreduras com critérios diferentes: uma pelas chamadas, outra pelo nome. make test e tsc passavam limpos com o init_db quebrado.

Depois da segunda varredura: 172 passed, 1 failed (a mesma falha de calendário), imports de init_db/permission_service/tenant_purge_service verificados no container.

Suíte e2e: executada, 107 passed

Rodada a pedido do usuário antes do merge, contra o dev-remote com o container web reconstruído (o bundle antigo testaria o código anterior).

107 passed, 4 skipped, 0 failed   (chromium + firefox, 5.0 min)

É a rede que sustenta a RN8: os specs casam por texto visível (credit-analyses, credit-analysis-documents, pacote-locaticio, mobile-shell, renters), então qualquer literal que tivesse ficado diferente do valor pt_BR original quebraria aqui. Nenhum quebrou.

O webkit não rodou. Só o chromium estava instalado na VPS; firefox subiu depois de libxcursor1 + libgtk-3-0t64, mas o webkit exige ~40 pacotes a mais (GTK4, GStreamer, flite) e o usuário optou por não instalar. Fica registrado: o gate desta spec foi de dois motores (Blink e Gecko), não três.

Duas armadilhas de método na primeira tentativa, que quase viraram falso verde:

  • npx playwright test | tail -60 devolve o exit code do tail, não do Playwright. Deu EXIT=0 numa corrida que havia abortado.
  • "42 did not run" não é "42 pulados": o Playwright abortou a corrida porque firefox e webkit não existiam. Sem ler o começo do output, passaria por suíte verde.

Imagem da API reconstruída

Durante a implementação o container rodou com o código entregue por docker cp, o que é frágil justamente pelo motivo documentado em apps/api/CLAUDE.md. A imagem foi reconstruída (docker compose build api + up -d api) e o container recriado a partir dela. Conferido depois: sem aninhamento /app/app/app, app/db/ sem i18n_seed.py nem seed_translations.py, e o boot passando pelo init_db sem erro — que é onde o ImportError teria aparecido. Smoke test contra dev.garantora.com.br: login 200, /credit-analyses 200, /permissions 200, /translations 404.

Nota de método

Sobre os 33 F821: eles existem e são pré-existentes — forward-refs Mapped["User"] do SQLAlchemy em app/models/, resolvidos em runtime pelo registry (o mesmo padrão que o CLAUDE.md da raiz descreve para o mypy). O que foi artefato de método foi eu ter atribuído a mim: conferido arquivo a arquivo, só dois deles a spec tocou, e o único fora do padrão (RoleResponse em permission_service.py) é idêntico no HEAD.

Duas vezes o docker cp app container:/... aninhou o pacote (/app/app/app) e fez o teste rodar contra código antigo — na primeira, um import ok falso; na segunda, quase me levou a concluir que a falha do calendário era minha. O que resolveu foi conferir a estrutura no container antes de confiar no resultado, e não o próprio resultado.