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_BRe a mesma emen_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_BRque 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 oformatUtilspermanecem — 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 valorpt_BRcorrespondente, 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 dodefaultValue, que é o que aparece hoje quando a chave não existe. - RN4. O
i18nProviderdo 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álogoenUSé removido inteiro.
Sem esta regra, "remover o i18n" produziria mais inglês na tela, não menos.
- RN5. A tabela
translationse seu CRUD são removidos, com as 4 permissões (translations:list/create/update/delete) e oi18n_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 doset_languageemdependencies.pye do middleware deAccept-Languageemmain.py. - RN7. O seletor PT/EN some do header desktop (
app-layout) e do drawer mobile (mobile-shell), com os hooksuseSetLocale/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 alvodev-seed-i18ndo Makefile, e a entrada deTAGS_METADATAda tag "Traduções". - Substituir: as 110 chamadas
t("chave")em 23 arquivos pelo literalpt_BR. São quase todas mensagens de erro de negócio (BusinessRuleError,NotFoundError). NotFoundErrorusat(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 emrole_permissions.downgradesimétrico. rbac_seed: removertranslations:*do catálogo e dos papéis.- Testes:
tests/test_translations.pysai. 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álogoenUSsai inteiro.- 962 chamadas
t(...)em 48 arquivos viram literal. Onde o componente ficar sem uso det, remover também ouseTranslatee o import. - Seletor de idioma removido de
components/app-layout/index.tsxecomponents/mobile-shell/index.tsx(RN7). changeLocaledeixa de fazerPATCH /users/{id}comlanguage(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 emapps/web/srcfora doi18nProvidermínimo, e nenhuma emapps/api/app. - [x] CA2.
providers/i18n.tscontém só chaves do Refine, em pt-BR. Não existe catálogoenUS. - [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/translationsresponde 404; a tabelatranslationse a colunausers.languagenã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 testverde etsclimpo, sem apontamento novo de lint nos arquivos tocados. — parcial:make testverde (173 passed) etsclimpo;I001eF401zerados;E501regride +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 chamadast()pelos literaispt_BR - [x]
apps/api— removercore/i18n.py,locale/, model/router/service de tradução,i18n_seed.py, alvo do Makefile e tag do Swagger - [x]
apps/api— removerusers.language,set_languagee middlewareAccept-Language - [x]
apps/api— migration (DROPtabela, coluna e 4 permissões) +rbac_seed - [x]
apps/api— removertests/test_translations.py; suíte verde - [x]
apps/web— reduziri18n.tsàs chaves do Refine (RN4) - [x]
apps/web— substituir as 962 chamadas, arquivo a arquivo, removendouseTranslateonde ficar sem uso - [x]
apps/web— remover o seletor de idioma e oPATCHdelanguage - [x]
apps/e2e— ajustar o que dependa de idioma (não há spec nova) - [ ]
make testetsclimpos; 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.languageeAccept-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/descriptionguardavamroles.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 orbac_seedpassa a gravar português.audit_logs.descriptionguardavaaudit.role.create, com os parâmetros de interpolação num campo_tdentro 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.notFoundeverifyEmail.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.pyimportavaapp.db.i18n_seed, que eu havia apagado. O seed do admin inicial quebrava comImportError. Não apareceu antes porque nada no boot nem na suíte o executa — oapi-entrypoint.shengole o erro com|| echo.app/db/seed_translations.pysobreviveu: eu removii18n_seed.pysem notar que havia dois seeds de tradução. Ele era chamado peloapi-entrypoint.sh(a cada boot do container) e pelo alvoseeddoinfra/dev-remote/Makefile.- Wiki publicada em
/wiki: a páginaadmin/traducoes.mddocumentava quatro endpoints que não existem mais, referenciada notoctreedoindex.md. Mais o campolanguageemequipe.mdeadmin/usuarios.md, a linha "Traduções" emadmin/index.mde a tabelatranslationsna ordem de purge emadmin/tenants.md. - Docstrings de código vivo: a ordem de purge em
tenant_purge_service.py(duas vezes) e no routertenants.pyainda listavatranslations;schemas/permission.pydescrevia 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).
É 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 -60devolve o exit code dotail, não do Playwright. DeuEXIT=0numa 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.