From 75cdea38b4c8a09074e78705117605a9ca4f4c01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Sun, 26 Jul 2026 23:35:49 -0300 Subject: [PATCH] Register the B8 F2 review debts as B8+. The three /simplify reviewers reported after the merge, so their findings became a fill-in instead of a fix. The lot is anchored on a measurement that contradicts "small mesh, small artifact": the 19 Salvador zones carry 9,120 arc points against 9,139 for ALL 417 Bahia municipalities, because both simplify constants are relative to the file's own bbox and were copied verbatim from the sibling script -- the same "1e4 grid" is ~103 m statewide and ~3.9 m in a city 26x narrower, on a map that stops at maxZoom 10. That measurement inverts one reviewer's recommendation, which is why it is written down rather than left to be re-derived: extracting the shared constants as they stand would cement the defect and give it the authority of a policy, so what gets shared between the two build scripts is the ground scale. The other three: nothing runs featureMapKey over a real feature outside the e2e, so the three spellings of the map key can drift and paint grey with no error anywhere; Promise.all over both meshes plus the memoized rejection turned a 19-polygon chunk into a single point of failure for the whole map, where the municipal mesh alone used to suffice; and the "approximate, not the official TSE boundary" caveat hangs off the zone LIST's render condition, so it vanishes in comparison mode while the approximate mesh is still painted. Also records what this delivery orphaned (getTerritoryFeature lost its last production caller when fitMapToHighlights stopped consulting geometry modules, and knip cannot see it because specs are entry points), and bumps B5 F2's call-site counter to five. --- .cursor/rules/projects/nucleos-eleitorais.mdc | 2 +- docs/plans/escala-dry-pos-b2.md | 2 +- docs/plans/escala-dry-pos-b8f2.md | 120 ++++++++++++++++++ docs/plans/poligonos-pracas-zona.md | 4 + docs/roadmap.md | 5 +- 5 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 docs/plans/escala-dry-pos-b8f2.md diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index 8f2afdd1..ba9f8fd4 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -7,7 +7,7 @@ alwaysApply: false ## Status -- **B8 F2 entregue em código (2026-07-26):** as 19 ZE de Salvador ganharam polígono próprio e o coroplético deixou de ser keyed por `codarea`. Antes, Salvador era **um** polígono carregando os votos da cidade inteira, então o mapa não respondia "qual pedaço de Salvador está fraco / é meu" — a única visão por zona era a lista textual do `zoneBreakdown`. **Fase 0 (prep S1+S3):** `municipalityZoneNeighborhoods` passou a autorar só `{ municipalitySlug, source, neighborhoods }` e hidrata `city`/`zoneNumber` de `getMunicipalityCatalogEntry` no `entry()` (slug desconhecido ou não-`zona` falha no import); int novo de ordem de slugs vs catálogo. S2 (gerar TS do JSON) segue cortado — a curadoria ficou no script, não no catálogo. **Fase 1–2 (artefato):** `scripts/build-municipality-zone-geometries.mjs` (`pnpm build:municipality-zone-geometries`, `--report` só reconcilia) baixa/cacheia `BA_bairros_CD2022.zip` do geoftp do IBGE, parseia com **`shpjs`** (devDependency nova — reprojeta o SIRGAS 2000, o IBGE não publica GeoJSON de bairro), filtra `CD_MUN === '2927408'` (**170 polígonos**) e dissolve por zona pela RA 02/2017 do TRE-BA → `src/lib/geometries/bahia-municipality-zones.topo.json` (**72.310 bytes**, ~20 KB gzip, 19 features keyed por `municipalitySlug`), com submódulo `bahiaMunicipalityZoneGeometries.ts` + `loadMunicipalityZoneGeometryModule()` no padrão memoizado de B5 F1. **Reconciliação = 3 tabelas curadas no cabeçalho do script, não regex mais esperta:** `IBGE_TO_TRE_NEIGHBORHOOD` (7 aliases — compostos "Beiru/Tancredo Neves", CAB, ilhas), `TRE_NEIGHBORHOODS_MERGED_INTO` (Jardim Cruzeiro dentro de Vila Ruy Barbosa; o build **recusa** um merge que atravesse zonas) e `SPLIT_NEIGHBORHOOD_ZONES` (Periperi, que a resolução corta na Rua das Pedrinhas, vai **inteiro** para a ZE 17 — o bairro é o átomo geométrico). Atribuição em dois passes: nome da resolução, depois **maior fronteira compartilhada** para os 5 bairros que a RA nunca nomeia (Ilha Amarela, Dois de Julho, Chame-Chame, Horto Florestal, Mirantes de Periperi) — a regra foi **validada** contra os 4 que a resolução nomeia sob outro rótulo e reproduz a zona oficial nos 4. O build morre se sobrar polígono órfão ou bairro TRE sem polígono (hoje 170/170 → 19/19). **Fase 3 (re-keying):** map key = `kind === 'zona' ? slug : ibgeCode` (`mapKeyForMunicipality` + `buildMunicipalitiesByMapKey` em `municipalityMapNavigation.ts`); o bundle inteiro re-keyed (`territorialClassByMapKey`, `projectedValidVotesByMapKey`, `municipalitiesByMapKey`, `valuesByYear`, `values2026ByScenario`, `validVotesByYear`, `diffByYear`); os rollups pararam de acumular com `+=` porque cada map key agora é 1:1; `computeAggregateTerritorialClass` deu lugar a `computeMunicipalityTerritorialClass` (um slug por chave); `resolveMunicipalityMapNavigation` virou 1:1 e o ramo `kind: 'zones'` morreu junto com o scroll-anchor e a copy "toque de novo" do readout. **`competitiveRank` continua por cidade** (o artefato TSE só tem `federalRankByIbgeCode`): as 19 zonas dividem a posição da cidade, o que a legenda já afirmava — int novo pina que as 19 têm **uma** posição só. Per-ZE segue fora (gatilho: produto pedir posição dentro da zona). `buildMunicipalitiesByIbgeCode` sobrevive intacto — o href de lista do B14 ✓ é por cidade. **Fase 4 (mapa):** `BahiaMap` monta a camada de município a partir de **duas** malhas via `loadLayerFeatures`, e o polígono municipal de Salvador fica desenhado **por baixo** como base sem dado e sem pointer (`baseKeys`), para que um sliver de desacordo entre duas malhas simplificadas independentemente não abra buraco no contorno do estado; `keyPropertyForMode`/`featureKeyFromProperties` deram lugar a `featureMapKey` (`municipalitySlug ?? codarea ?? code` — as três nunca coexistem, então o argumento de modo sumiu); `fitMapToHighlights` passou a medir os **paths desenhados** (`pathByKeyRef`) em vez do módulo de geometria, que já não sabe sozinho de qual malha veio a chave, e `geometryModuleRef` foi apagado. Copy do painel: a linha morta "Zonas não têm polígono oficial — Salvador e **Camaçari** aparecem agregadas" (Camaçari saiu do modelo de zona na M1) virou o disclaimer de aproximação. **Dois fatos medidos, para ninguém re-derivar:** o polígono municipal de Salvador tem **~690 km²** e a soma das 19 zonas **~324 km²** — a diferença é a água da baía que os limites municipais encerram e a malha de bairros não desenha, então o int de área compara contra a área de **terra** (280–360 km²) e não contra o município; e centróide-dentro-de-outra-zona **não** é teste de sobreposição válido aqui, porque a ZE 16 é concava e envolve a ZE 14 (o centróide da 16 cai na 14) — a garantia de não-sobreposição é estrutural (cada bairro é atribuído a exatamente uma zona) mais a soma de áreas. Testes: int de contrato (19 features, slugs == catálogo, budget 600 KB, centróide de cada zona dentro do polígono municipal), unit de `featureMapKey`, e2e `campaignZoneMap.e2e.spec.ts` (436 paths / 435 interativos — o único não-interativo é a base de Salvador — readout "Salvador — ZE N" e clique abrindo `/campanha/municipios/salvador-ze-N`). **`/simplify`:** comentário de `NearbyMunicipalityResolution.zoneCity` que dizia "sem polígonos de zona (B8 F2)" corrigido (os polígonos passaram a existir nesta entrega; o card **deliberadamente** continua na lista filtrada — débito aberto, não efeito colateral); duas asserções do int de bairros que viraram tautologia com a hidratação da Fase 0 (comparavam `city`/`zoneNumber` com o catálogo de onde foram copiados) removidas, apontando para o teste de fixture que checa contra a transcrição independente; e o script parou de re-indexar geometrias simplificadas por **nome** de bairro (o irmão `build-bahia-geometries.mjs` usa `codarea`, único por construção) — hoje são 170 nomes distintos em 170 polígonos, mas um nome repetido em malha futura colapsaria dois polígonos silenciosamente num artefato commitado; passou a haver `id` próprio, e o rebuild saiu **byte-idêntico** (sha256 `1dd0d629…`). Sem migration, sem collection, sem action, sem Consent. **Gate:** tsc/lint/format/knip/`check:cycles`/unit+int/e2e/build rodados; **Aikido não fechou** — o scanner MCP responde Opengrep exit code 2 em **qualquer** entrada (inclusive um arquivo-probe trivial de uma linha), então os "4 findings" que ele reportou são artefato da execução quebrada e não achados atribuíveis; o feed do Aikido não tem nada deste repo. SCA do `shpjs` também pendente. Plano: [`poligonos-pracas-zona.md`](../../../docs/plans/poligonos-pracas-zona.md). +- **B8 F2 entregue em código (2026-07-26):** as 19 ZE de Salvador ganharam polígono próprio e o coroplético deixou de ser keyed por `codarea`. Antes, Salvador era **um** polígono carregando os votos da cidade inteira, então o mapa não respondia "qual pedaço de Salvador está fraco / é meu" — a única visão por zona era a lista textual do `zoneBreakdown`. **Fase 0 (prep S1+S3):** `municipalityZoneNeighborhoods` passou a autorar só `{ municipalitySlug, source, neighborhoods }` e hidrata `city`/`zoneNumber` de `getMunicipalityCatalogEntry` no `entry()` (slug desconhecido ou não-`zona` falha no import); int novo de ordem de slugs vs catálogo. S2 (gerar TS do JSON) segue cortado — a curadoria ficou no script, não no catálogo. **Fase 1–2 (artefato):** `scripts/build-municipality-zone-geometries.mjs` (`pnpm build:municipality-zone-geometries`, `--report` só reconcilia) baixa/cacheia `BA_bairros_CD2022.zip` do geoftp do IBGE, parseia com **`shpjs`** (devDependency nova — reprojeta o SIRGAS 2000, o IBGE não publica GeoJSON de bairro), filtra `CD_MUN === '2927408'` (**170 polígonos**) e dissolve por zona pela RA 02/2017 do TRE-BA → `src/lib/geometries/bahia-municipality-zones.topo.json` (**72.310 bytes**, ~20 KB gzip, 19 features keyed por `municipalitySlug`), com submódulo `bahiaMunicipalityZoneGeometries.ts` + `loadMunicipalityZoneGeometryModule()` no padrão memoizado de B5 F1. **Reconciliação = 3 tabelas curadas no cabeçalho do script, não regex mais esperta:** `IBGE_TO_TRE_NEIGHBORHOOD` (7 aliases — compostos "Beiru/Tancredo Neves", CAB, ilhas), `TRE_NEIGHBORHOODS_MERGED_INTO` (Jardim Cruzeiro dentro de Vila Ruy Barbosa; o build **recusa** um merge que atravesse zonas) e `SPLIT_NEIGHBORHOOD_ZONES` (Periperi, que a resolução corta na Rua das Pedrinhas, vai **inteiro** para a ZE 17 — o bairro é o átomo geométrico). Atribuição em dois passes: nome da resolução, depois **maior fronteira compartilhada** para os 5 bairros que a RA nunca nomeia (Ilha Amarela, Dois de Julho, Chame-Chame, Horto Florestal, Mirantes de Periperi) — a regra foi **validada** contra os 4 que a resolução nomeia sob outro rótulo e reproduz a zona oficial nos 4. O build morre se sobrar polígono órfão ou bairro TRE sem polígono (hoje 170/170 → 19/19). **Fase 3 (re-keying):** map key = `kind === 'zona' ? slug : ibgeCode` (`mapKeyForMunicipality` + `buildMunicipalitiesByMapKey` em `municipalityMapNavigation.ts`); o bundle inteiro re-keyed (`territorialClassByMapKey`, `projectedValidVotesByMapKey`, `municipalitiesByMapKey`, `valuesByYear`, `values2026ByScenario`, `validVotesByYear`, `diffByYear`); os rollups pararam de acumular com `+=` porque cada map key agora é 1:1; `computeAggregateTerritorialClass` deu lugar a `computeMunicipalityTerritorialClass` (um slug por chave); `resolveMunicipalityMapNavigation` virou 1:1 e o ramo `kind: 'zones'` morreu junto com o scroll-anchor e a copy "toque de novo" do readout. **`competitiveRank` continua por cidade** (o artefato TSE só tem `federalRankByIbgeCode`): as 19 zonas dividem a posição da cidade, o que a legenda já afirmava — int novo pina que as 19 têm **uma** posição só. Per-ZE segue fora (gatilho: produto pedir posição dentro da zona). `buildMunicipalitiesByIbgeCode` sobrevive intacto — o href de lista do B14 ✓ é por cidade. **Fase 4 (mapa):** `BahiaMap` monta a camada de município a partir de **duas** malhas via `loadLayerFeatures`, e o polígono municipal de Salvador fica desenhado **por baixo** como base sem dado e sem pointer (`baseKeys`), para que um sliver de desacordo entre duas malhas simplificadas independentemente não abra buraco no contorno do estado; `keyPropertyForMode`/`featureKeyFromProperties` deram lugar a `featureMapKey` (`municipalitySlug ?? codarea ?? code` — as três nunca coexistem, então o argumento de modo sumiu); `fitMapToHighlights` passou a medir os **paths desenhados** (`pathByKeyRef`) em vez do módulo de geometria, que já não sabe sozinho de qual malha veio a chave, e `geometryModuleRef` foi apagado. Copy do painel: a linha morta "Zonas não têm polígono oficial — Salvador e **Camaçari** aparecem agregadas" (Camaçari saiu do modelo de zona na M1) virou o disclaimer de aproximação. **Dois fatos medidos, para ninguém re-derivar:** o polígono municipal de Salvador tem **~690 km²** e a soma das 19 zonas **~324 km²** — a diferença é a água da baía que os limites municipais encerram e a malha de bairros não desenha, então o int de área compara contra a área de **terra** (280–360 km²) e não contra o município; e centróide-dentro-de-outra-zona **não** é teste de sobreposição válido aqui, porque a ZE 16 é concava e envolve a ZE 14 (o centróide da 16 cai na 14) — a garantia de não-sobreposição é estrutural (cada bairro é atribuído a exatamente uma zona) mais a soma de áreas. Testes: int de contrato (19 features, slugs == catálogo, budget 600 KB, centróide de cada zona dentro do polígono municipal), unit de `featureMapKey`, e2e `campaignZoneMap.e2e.spec.ts` (436 paths / 435 interativos — o único não-interativo é a base de Salvador — readout "Salvador — ZE N" e clique abrindo `/campanha/municipios/salvador-ze-N`). **`/simplify`:** comentário de `NearbyMunicipalityResolution.zoneCity` que dizia "sem polígonos de zona (B8 F2)" corrigido (os polígonos passaram a existir nesta entrega; o card **deliberadamente** continua na lista filtrada — débito aberto, não efeito colateral); duas asserções do int de bairros que viraram tautologia com a hidratação da Fase 0 (comparavam `city`/`zoneNumber` com o catálogo de onde foram copiados) removidas, apontando para o teste de fixture que checa contra a transcrição independente; e o script parou de re-indexar geometrias simplificadas por **nome** de bairro (o irmão `build-bahia-geometries.mjs` usa `codarea`, único por construção) — hoje são 170 nomes distintos em 170 polígonos, mas um nome repetido em malha futura colapsaria dois polígonos silenciosamente num artefato commitado; passou a haver `id` próprio, e o rebuild saiu **byte-idêntico** (sha256 `1dd0d629…`). Sem migration, sem collection, sem action, sem Consent. **Gate:** tsc/lint/format/knip/`check:cycles`/unit+int/e2e/build rodados; **Aikido não fechou** — o scanner MCP responde Opengrep exit code 2 em **qualquer** entrada (inclusive um arquivo-probe trivial de uma linha), então os "4 findings" que ele reportou são artefato da execução quebrada e não achados atribuíveis; o feed do Aikido não tem nada deste repo. SCA do `shpjs` também pendente. **Relatórios do `/simplify` chegaram depois do merge** e o que sobrou virou o fill-in **B8+** ([`escala-dry-pos-b8f2.md`](../../../docs/plans/escala-dry-pos-b8f2.md)), aberto por uma medição que contradiz a intuição de "malha pequena, artefato pequeno": as 19 zonas têm **9.120** pontos de arco contra **9.139** da malha de **todos** os 417 municípios da Bahia (480 vs 22 pontos/feature; Salvador é um `Polygon` de **35** pontos na malha municipal). A causa não é a malha de bairros ser detalhada — é que `QUANTIZE_DIGITS = 1e4` e `SIMPLIFY_QUANTILE = 0.35` foram copiados verbatim do script irmão e **os dois são relativos à própria topologia**: a quantização é uma grade sobre o bbox do arquivo, e o de Salvador é ~26× mais estreito, o que dá **~3,9 m** de resolução contra ~103 m no estado, num mapa que para em `maxZoom: 10`. Consequência para quem for consertar: o que deve ficar compartilhado entre os dois scripts é a **escala de chão**, não a contagem de dígitos — extrair as constantes como estão (o que o revisor de reuse pediu, sem essa medição) cimentaria o defeito e lhe daria autoridade de política. Os outros três: nada roda `featureMapKey` sobre um feature **real** fora do e2e, então as três escritas da map key podem divergir e pintar cinza sem erro em lugar nenhum; o `Promise.all` das duas malhas somado à memoização da rejeição fez de um chunk de 19 polígonos um ponto único de falha do mapa inteiro (antes a malha municipal sozinha bastava); e a ressalva "é aproximado, não é o limite oficial do TSE" está pendurada na condição de render da **lista** (`!comparisonActive`), então desaparece no modo comparação enquanto a malha aproximada continua pintada. Plano: [`poligonos-pracas-zona.md`](../../../docs/plans/poligonos-pracas-zona.md). - **B39–B42 registrados (2026-07-26, lote roadmap-items):** sessão "Lembrar de mim" (**B39**, ~0,5d, não cortável — default hoje 2 h); login biométrico WebAuthn (**B40**, soft B39, cortável); scroll horizontal + sticky coluna Município (**B41**, espelha TerritoryList/B21); polimento mobile lista municípios (**B42** — Drawer Tendência/Assessores no padrão B26 + card compacto). Planos: `lembrar-de-mim-sessao-campanha.md`, `login-biometrico-campanha.md`, `scroll-horizontal-lista-municipios.md`, `polimento-mobile-lista-municipios.md`. Dedup do lote: drawers + card mobile mesclados em B42; auth fatiado B39/B40. - **E12 entregue em código (2026-07-26):** camada TI — rollups MAUP + benchmark intra-TI. Estende `territoryOverview.ts` (`computeTerritoryE12Rollup`: cobertura agregada E8 cenário `central`, captura regional como razão dos agregados, mediana/amplitude, município crítico e farol) e `loadTerritoryOverview.ts` (passa `user`, `loadMunicipalityGoalCoverageBundle`, `computeAggregateTerritorialClass` por TI e sub-linhas do Metropolitano). `/campanha/territorios`: colunas Cobertura/Captura/Classe, sorts e descrições B22 em `territoryListUrl.ts` / `territoryListLabels.ts` / `TerritoryListColumns.tsx`. Detalhe: `territoryIntraCaptureBenchmark.ts` + linha T4 no `MunicipalityGoalAccountCard`. Conceitos E18 `captura-regional` e `benchmark-intra-ti`. Cortes v1: sem agrupamento na lista de municípios, sem mapa TI. `sanityCheckSuggestedGoalsByTerritory` segue sem UI. Gate: tsc/lint/format/check:cycles/533 unit/406 int/build (com `PAYLOAD_SECRET` local); knip P3 pré-existente em `payload.config.ts`; Aikido 0 findings. Plano: [`camada-territorios-identidade.md`](../../../docs/plans/camada-territorios-identidade.md). - **B36 entregue em código (2026-07-26):** coluna "Lideranças" em `/campanha/dobradinhas` — chips nomeados (link `/campanha/liderancas/[id]`) + Popover+Command + auto-save por delta, reusando **sem alteração** a mutação/schema/lock do **B31 ✓** (`setLeadershipStateDeputyMembership`, `leadership-state-deputies:{id}`, `nextStateDeputyIdsAfterMembership`). **Auditoria do plano** (escrito antes do B31 as-built) corrigiu duas defasagens: (1) sem `RelationOptionsProvider`/Context — B31 já tinha trocado Context por columns factory no `/simplify`; (2) casca `dobradinhas/formActions.ts` própria (não import cross-rota de `liderancas/formActions`). **Extração paga aqui:** `LeadershipStateDeputiesCell` (domínio) → `shared/LeadershipStateDeputyRelationCell` bidirecional (`direction: 'fromLeadership' | 'fromStateDeputy'`, `measureOverflow` default true — clamp 3 linhas + "Ver mais…" no lado do deputado; `liderancas` passa `false`). `loadLeadershipOptions`; `StateDeputyRowViewModel.leadershipCount` → `leaderships: {id,name}[]` na mesma query batch. Gate: tsc/lint/format/knip/cycles/unit 530 + int (flakes de timeout re-rodados verdes)/build; Aikido 0 findings. Plano: [`chips-liderancas-lista-dobradinhas.md`](../../../docs/plans/chips-liderancas-lista-dobradinhas.md). diff --git a/docs/plans/escala-dry-pos-b2.md b/docs/plans/escala-dry-pos-b2.md index 053a5e8c..36165b28 100644 --- a/docs/plans/escala-dry-pos-b2.md +++ b/docs/plans/escala-dry-pos-b2.md @@ -10,7 +10,7 @@ Responsável: — O B2 ([mapa-bahia-geometrias.md](mapa-bahia-geometrias.md) Fase 1) entregou a fundação estática do mapa: `*.topo.json` de municípios e Territórios de Identidade, `bahiaMunicipalityCodes`, helpers em `bahiaGeometries.ts`, script `pnpm build:geometries`. A passagem `/simplify` de 2026-07-18 limpou dead code e churns baratos no script/codegen, mas **deixou de fora** dois follow-ups que os revisores (performance / reuse) marcaram como importantes e maiores que cleanup pontual: 1. **Carregamento eager de geometrias no cliente.** `src/lib/bahiaGeometries.ts` importa os dois TopoJSON, roda `topojson-client` `feature()` em 417+27 polígonos e monta índices no load do módulo — custo de bundle/memória antes de qualquer superfície Leaflet (B3). -2. **Cache de download duplicado nos CLIs.** `ensureCachedJson` em `scripts/build-bahia-geometries.mjs` é estruturalmente o mesmo que `ensureCachedZip` em `scripts/seed-tse-results.mjs` (mkdir → hit → `downloadToBuffer` → SHA-256 → write), só muda extensão/parse. **Pós-A7 F2:** `scripts/build-bahia-tse-city-codes.mjs` (`pnpm build:tse-city-codes`) clona o mesmo padrão `ensureCachedZip` — terceiro call site. **Pós-A8 (`capture-review-debts` 2026-07-20):** `scripts/build-bahia-demographics.mjs` (`pnpm build:demographics`) clona `ensureCachedJson` — quarto call site; reforça prioridade da Fase 2 sem novo ID de roadmap. +2. **Cache de download duplicado nos CLIs.** `ensureCachedJson` em `scripts/build-bahia-geometries.mjs` é estruturalmente o mesmo que `ensureCachedZip` em `scripts/seed-tse-results.mjs` (mkdir → hit → `downloadToBuffer` → SHA-256 → write), só muda extensão/parse. **Pós-A7 F2:** `scripts/build-bahia-tse-city-codes.mjs` (`pnpm build:tse-city-codes`) clona o mesmo padrão `ensureCachedZip` — terceiro call site. **Pós-A8 (`capture-review-debts` 2026-07-20):** `scripts/build-bahia-demographics.mjs` (`pnpm build:demographics`) clona `ensureCachedJson` — quarto call site; reforça prioridade da Fase 2 sem novo ID de roadmap. **Pós-B8 F2 (`capture-review-debts` 2026-07-26):** `scripts/build-municipality-zone-geometries.mjs` clona o padrão como `ensureCachedBinary` (devolve o buffer cru, porque a entrada é um SHP e não um JSON) — **quinto** call site, e o primeiro que valida a decisão de `cacheDir` por chamada com um segundo consumidor real da **mesma** pasta (`GEOMETRIES_CACHE_DIR`, default `data/geometries/`). O bloco todo (`die`/`log`/`sha256`/`cacheDir`/`writeJson`) veio junto, então a Fase 2 tem hoje 5 cópias a colapsar, não 4. 3. **Factory duplicada mun/TI pós-B3 F1.** `bahiaMunicipalityGeometries.ts` e `bahiaTerritoryGeometries.ts` repetem o mesmo pipeline `topojson.feature` → array → `Map` por chave → export do módulo indexado — só mudam topology object, propriedade de chave (`codarea` vs `code`) e getter. Este plano é o registro canônico desses follow-ups. Sem ele, o B3 pode embarcar o mapa com payload desnecessário no path default, e o próximo script de dado aberto volta a clonar o cache. diff --git a/docs/plans/escala-dry-pos-b8f2.md b/docs/plans/escala-dry-pos-b8f2.md new file mode 100644 index 00000000..4ae83bf8 --- /dev/null +++ b/docs/plans/escala-dry-pos-b8f2.md @@ -0,0 +1,120 @@ +# Escala e DRY pós-B8 F2 (polígonos das ZE de Salvador) + +Status: rascunho +Atualizado em: 2026-07-26 +Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins abertos, **B8+**, fill-in de engenharia) +Impeccable: A — N/A (nenhuma superfície nova; F3 muda um parágrafo de copy e um estado de falha, o resto preserva o comportamento visível do B8 F2) +Appetite: ~0,75–1 dia eng; quatro fases independentes, nenhuma com migration, collection ou `Consent`; só F1 reescreve o artefato commitado +Responsável: — + +## Dados → decisão → apresentação + +- **Vou apresentar dados?** Não (N/A) — o lote é custo, coerência e honestidade de dados já entregues pelo B8 F2. F3 muda **um parágrafo** (a ressalva de aproximação, que hoje desaparece no modo comparação enquanto a malha aproximada continua pintada), não a métrica nem a forma. +- **Anti-goals de dado:** nenhuma escala nova, nenhuma métrica nova, nenhum rank por ZE (esse tem gatilho próprio no [plano do B8](poligonos-pracas-zona.md)). Se uma fase começar a propor métrica, ela virou outro item. + +## Contexto + +**B8 F2** ([poligonos-pracas-zona.md](poligonos-pracas-zona.md), entregue 2026-07-26) deu polígono próprio às 19 ZE de Salvador e re-keyed o coroplético por **map key** (`municipalitySlug` na zona, `ibgeCode` no resto). O `/simplify` da entrega (três revisores em paralelo — quality / performance / reuse) devolveu relatório **depois** do merge; o cleanup barato entrou antes (`id` no re-index do script, comentário obsoleto, asserções tautológicas), e os achados maiores que cleanup estão aqui. + +O lote abre com custo porque o achado de performance é o único **medido e já commitado**: o artefato novo tem quase exatamente a mesma contagem de vértices que a malha de **todos** os 417 municípios da Bahia, gasta em 3,8% da largura do mapa. Verificado nos artefatos deste repo, não estimado: + +| artefato | features | pontos de arco | pontos/feature | bbox | passo da grade | +| ------------------------------ | -------- | -------------- | -------------- | --------------- | ------------------ | +| `bahia-municipalities` | 417 | 9.139 | 21,9 | 9,236° × 9,804° | ~103 m × 108 m | +| `bahia-identity-territories` | 27 | 1.210 | 44,8 | 9,227° × 9,804° | ~103 m × 108 m | +| **`bahia-municipality-zones`** | **19** | **9.120** | **480,0** | 0,353° × 0,275° | **~3,9 m × 3,0 m** | + +A comparação que fecha o caso: **Salvador é um `Polygon` de 35 pontos na malha municipal e 9.120 pontos na malha de zonas.** A causa não é a malha de bairros ser detalhada — é que `QUANTIZE_DIGITS = 1e4` e `SIMPLIFY_QUANTILE = 0.35` foram copiados verbatim do script irmão, e **os dois são relativos à própria topologia**: a quantização é uma grade sobre o bbox do arquivo (o de Salvador é ~26× mais estreito que o da Bahia, daí 30× mais resolução no chão) e o quantil de `topojson-simplify` é um percentil dos pesos de triângulo **daquele** arquivo (manter 65% num mosaico de bairros corta um limiar de área absoluta muito menor que num mosaico municipal estadual). O detalhe só se torna visível por volta do zoom 15; `fitMapToHighlights` para em `maxZoom: 10`. + +**Já resolvido no simplify (não reabrir):** o re-index das geometrias simplificadas passou de `properties.name` para um `id` único (um nome repetido em malha futura colapsaria dois polígonos em silêncio dentro de um artefato commitado — o irmão usa `codarea`, único por construção), com rebuild byte-idêntico como prova; o comentário de `zoneCity` que afirmava não existirem polígonos de zona; e as duas asserções que a hidratação da Fase 0 tornou tautológicas (comparavam `city`/`zoneNumber` com o catálogo de onde foram copiados). Os revisores também confirmaram que o commit **acertou** três coisas que não devem regredir: `fitMapToHighlights` ficou mais rápido que antes (lê o `_bounds` já cacheado dos paths desenhados em vez de construir um `L.geoJSON` descartável sobre os 417 features), o desenho do B6 sobreviveu intacto (efeito de geometria só em `[mode]`, `restyleFeature` O(2), `featureMapKey` roda 2× por feature na construção e nunca por frame), e o re-keying deixou o servidor **mais barato** (`computeMunicipalityTerritorialClass` é memoizado, `computeAggregateTerritorialClass` não era e re-somava as 19 baselines de Salvador por request). + +## Objetivos + +- As duas malhas commitadas da Bahia são simplificadas na **mesma escala de chão**, e um teste falha se uma delas voltar a ser cortada na sua própria escala relativa. +- A regra da chave do mapa é exercida ponta a ponta pelo menos uma vez fora do e2e: artefato → `featureMapKey` → `mapKeyForMunicipality`. +- Uma falha de chunk nas 19 zonas de Salvador não derruba o mapa inteiro da sessão. +- Nenhum número/rótulo aproximado aparece sem a ressalva, em nenhum modo do painel. +- O que a entrega orfanou está deletado (regra "dead code dies immediately" do `engineering-standards.mdc`), inclusive o que o knip não vê. +- Guardrails: sem migration, sem collection, sem `Consent`, sem server action; contrato de URL, escalas e o catálogo TRE **inalterados**; `overrideAccess: false` preservado em toda leitura com `user`. + +## Decisões travadas + +- **F1 calibra por resolução de chão, e é isso que fica compartilhado entre os dois scripts — não a contagem de dígitos.** O revisor de reuse pediu extrair `simplifyTopology` + as duas constantes (hoje byte-idênticas nos dois scripts) para um módulo comum; a medição acima mostra que compartilhar **os dígitos** é exatamente o defeito, porque `quantize(1e4)` significa 103 m na Bahia e 3,9 m em Salvador. O que merece nome é a política — "as duas malhas commitadas da Bahia são cortadas na mesma escala de chão" —, então o módulo compartilhado expõe um alvo em metros (ou um `minWeight` absoluto em graus²) e cada script deriva seus dígitos do próprio bbox. Fonte: `/simplify` B8 F2 (2026-07-26), medido nos artefatos. **Rejeitado:** extrair as constantes como estão (cimenta o bug e dá a ele a autoridade de uma política); baixar só o `QUANTIZE_DIGITS` das zonas e deixar os dois scripts divergindo em silêncio (a próxima pessoa "conserta" de volta para o valor do irmão por simetria); manter como está porque "só aparece no zoom 15" (o custo não é visual, é vértice reprojetado a cada `zoomend`/pan e +19 KB gzip no chunk do mapa). +- **F1 troca o tripwire de bytes por um de densidade.** `MAX_TOPO_BYTES = 600 * 1024` foi calibrado para pegar uma malha **estadual** sem simplificação; uma malha de 19 features a 3,8 KB/feature passa folgada. O guard que teria pego isso é pontos-por-feature (ou bytes-por-feature), que é a grandeza que a política de F1 controla. **Rejeitado:** apertar o teto de bytes (um número global não distingue "19 features densas" de "435 features magras"); confiar na revisão humana (foi ela que deixou passar). +- **F2 pina o contrato com duas asserções no laço que já existe, não com uma abstração.** A regra da chave está escrita em três formas — `mapKeyForMunicipality` (registro do catálogo), `featureMapKey`/`MAP_KEY_PROPERTIES` (saco de propriedades GeoJSON) e o `baseKeys = zones.map(z => z.properties.ibgeCode)` do `BahiaMap` (a identidade entre as duas). As três precisam concordar ou o coroplético pinta cinza **sem erro em lugar nenhum**. Cada ponta já está pinada contra o `municipalityCatalog`, mas nada roda `featureMapKey` sobre um feature **real**: o unit novo alimenta literais escritos à mão, então o artefato pode deixar de casar com o literal e os dois testes continuam verdes. Só o e2e fecha o laço, e ele pede dev server + banco. **Rejeitado:** unificar as três num helper (o server lê Payload/catálogo e o cliente lê GeoJSON — um helper que aceite os dois é um wrapper raso, reprovado pelo depth check); confiar no e2e (a suíte mais lenta e mais flaky do repo como única rede de um modo de falha silencioso). +- **F3 degrada, não esconde.** `loadLayerFeatures` exige as duas malhas num `Promise.all`, e `loadMunicipalityZoneGeometryModule` memoiza a **rejeição** (comportamento deliberado, documentado no B14). Somados, um ChunkLoadError nos 19 polígonos de Salvador mata o mapa inteiro pelo resto da sessão — antes do B8 F2 a malha municipal sozinha bastava. Isto é um ponto único de falha **novo**, introduzido pela entrega. **Rejeitado:** remover a memoização da rejeição (é ela que dá ao B14 um estado de falha acionável em vez de um spinner eterno); deixar como está porque chunk raramente falha (a regressão é de disponibilidade e o custo do `.catch` é três linhas). +- **A ressalva de aproximação pertence à malha, não à lista.** O parágrafo "é aproximado, não é o limite oficial do TSE" está dentro do gate `bundle.zoneBreakdown.length > 0 && !comparisonActive`, mas o mapa divergente da comparação pinta Salvador zona por zona a partir da **mesma** malha aproximada. A ressalva foi pendurada na condição de render da lista em vez de na presença da malha na tela — e o rabbit hole que o próprio plano do B8 nomeia é "tratar polígono derivado como limite oficial". **Rejeitado:** duplicar a frase nos dois braços (duas cópias da mesma afirmação jurídica divergem na primeira revisão de copy). +- **i18n e naming** seguem o AGENTS.md: identificadores em inglês, strings visíveis em pt-BR. + +## Questões em aberto + +- **Qual resolução de chão para as duas malhas?** **Opções:** (a) ~100 m nas duas, igualando a malha municipal de hoje (`QUANTIZE_DIGITS ≈ 380` nas zonas); (b) ~25 m nas zonas, aceitando nitidez até ~zoom 14 (`≈ 1500`), e 100 m no estado; (c) ~25 m nas duas (encarece a malha estadual, que é a que todo mundo baixa). **Recomendação:** (b) — o zoom útil de uma ZE é maior que o de um município do interior, e mesmo assim corta a densidade atual várias vezes; (a) é o corte máximo e vale se a medição pós-mudança mostrar que 1500 ainda dobra a camada. +- **Deletar os dois acessores órfãos ou declará-los contrato do módulo?** **Opções:** (a) deletar `getMunicipalityZoneFeature` e `getTerritoryFeature`, com os specs montando o índice em uma linha; (b) manter e documentar como contrato uniforme dos três módulos de malha. **Recomendação:** (a) — o `getTerritoryFeature` perdeu o último consumidor de produção **neste commit** (quando `fitMapToHighlights` parou de consultar os módulos de geometria) e o knip não vê nenhum dos dois porque specs são entry points; a regra do repo é deletar o que a mudança orfanou. Se o produto quiser o contrato uniforme, ele volta de graça no 4º mesh. + +## Abordagem proposta + +```mermaid +flowchart LR + F1["F1 escala de chão
+ guard de densidade"] --> Custo["Chunk do mapa e vértices por frame"] + F2["F2 contrato da map key
artefato -> featureMapKey"] --> Coerencia["Coroplético não pinta cinza em silêncio"] + F3["F3 degradar sem a malha
+ ressalva na comparação"] --> Confianca["Disponibilidade e honestidade do painel"] + F4["F4 deletar o orfanado
+ colapsar formas mortas"] --> Custo +``` + +Componentes: + +- **F1 — escala de chão + guard de densidade.** Novo `scripts/lib/topology.mjs` (ou nome irmão) expondo o alvo de resolução e um `simplifyTopology`/`quantizeToGroundScale` que derivam os dígitos do bbox da topologia recebida; `scripts/build-municipality-zone-geometries.mjs:52-55,139-145` e `scripts/build-bahia-geometries.mjs:53-57,96-101` passam a consumi-lo. Rebuild de `bahia-municipality-zones.topo.json` (o dissolve e o `quantize` final rodam em espaço de topologia com arcos compartilhados, então o snap move os dois lados de uma fronteira igualmente e **não** abre sliver entre zonas vizinhas). `tests/int/bahiaGeometries.int.spec.ts:29` ganha a asserção de pontos-por-feature ao lado de `MAX_TOPO_BYTES`; a janela de 280–360 km² e o centróide-dentro-da-cidade têm folga para o novo corte. Como F1 já reescreve o artefato, remove no mesmo passe a propriedade `zoneNumber` (script `:382`), que ninguém consome e é a terceira cópia de um valor derivável do slug. +- **F2 — contrato da map key.** Duas asserções dentro do laço que já existe em `tests/int/bahiaGeometries.int.spec.ts:149`: `featureMapKey(zoneFeature.properties) === entry.slug` e `featureMapKey(municipalFeature.properties) === entry.ibgeCode`. Resolve junto a contradição entre o comentário de `bahiaMapStyle.ts:15` ("as três nunca coexistem num feature") e o unit de precedência em `tests/unit/bahiaMapStyle.unit.spec.ts:51-55`, que pina uma forma `{ municipalitySlug, codarea }` que nenhuma malha produz: se o invariante vale, cai o teste de precedência e o comentário de ordenação; se não vale, o comentário do invariante é que está errado. +- **F3 — degradar + ressalva.** `BahiaMap.tsx:81` passa a `.catch()` a metade das zonas e seguir com a malha municipal (Salvador volta a ser um polígono agregado, o que é pior que zonas e muito melhor que mapa nenhum); `MunicipalityMapPanel.tsx:594-601` tira o parágrafo da ressalva de dentro do braço `!comparisonActive`, mantendo-o atrelado à presença da malha de zonas. +- **F4 — deletar e colapsar.** `MunicipalityMapNavigation` (`municipalityMapNavigation.ts:18,64-70`) virou `{kind:'none'} | {kind:'navigate';slug}`, três estados para um `string | undefined`: colapsa em `municipalitiesByMapKey[key]?.slug` nos dois call sites (`MunicipalityMapPanel.tsx:680-683,696-702`), deletando o tipo, o helper, o `useMemo` que só existe porque o helper aloca objeto por chamada, o widening a `| null` em `MapFeatureReadout.tsx:24,108` e três unit tests. `MunicipalityMapSlugEntry.name` não é lido no índice 1:1 (o rótulo do readout vem de `feature.name`), então `MunicipalitiesByMapKey` vira `Record` — são 435 strings mortas no payload RSC de toda visita ao Início. Deleta os dois acessores órfãos (ver Questões em aberto) e, com eles, `city`/`zoneNumber` de `MunicipalityZoneNeighborhoodEntry` (verificado: nem o card nem o script os leem — o script só usa `municipalitySlug` e `neighborhoods`), o que também apaga o filtro `entry.city === 'Salvador'` provavelmente-sempre-verdadeiro no int. Exporta `polygonRingsOf` de `municipalityProximity.ts:109` (3º call site: o spec novo e `tests/helpers/featureBounds.ts:16` reescrevem o mesmo branch Polygon/MultiPolygon) e alinha `featureBounds.ts` ao `BahiaGeometryFeature` — hoje é o único ainda tipado em `BahiaMunicipalityFeature`. Troca `BahiaGeometryFeature` (que carrega `GeoJsonProperties`, ou seja `any`) por um `type PolygonalFeature = { geometry: Polygon | MultiPolygon }` que declara o requisito real dos três helpers, e introduz `BahiaFeature

` para as 4 grafias de `Feature` em `bahiaGeometriesTypes.ts`. No script: a chave resolvida por alias é calculada duas vezes com o mesmo fallback (`:329-330` e `:355-360`) e o `die` do guard é construído sobre a **segunda** — uma divergência faria o guard passar sobre um artefato que o primeiro passe montou errado; calcular uma vez num `Map` acima do passe 1. Fecha com `polygonCount` (`:156`, dois branches inalcançáveis depois do `die`), `[await shp(buffer)].flat()` (`:248`, lê como erro), o `url` que `ensureCachedBinary` devolve e ninguém desestrutura, e a referência morta ao branch `none|navigate|zones` em `docs/plans/municipio-mais-proximo.md:141`. +- **Migration**: sem migration, sem collection, sem server action. F1 reescreve um artefato commitado (esperado e revisável no diff); F2–F4 não tocam dado. + +## Dependências + +- Nenhuma dura de outro item aberto. F1 é dependência **suave** de **B5 F2** (cache/factory dos scripts de geometria): quem fizer F1 cria o primeiro módulo compartilhado em `scripts/lib/`, e o B5 F2 deve pousar nele em vez de abrir um segundo. +- Reusa: `scripts/build-bahia-geometries.mjs` (o irmão que define o padrão), `bahiaGeometriesTypes.ts`, `municipalityProximity.ts`, `municipalityCatalog.ts`, o laço de contrato de `tests/int/bahiaGeometries.int.spec.ts`. + +## Não escopo + +- **Rank competitivo por ZE** e **resolver a ZE exata no card "Onde estou"** — os dois débitos de produto do B8 F2, com gatilho no [plano do B8](poligonos-pracas-zona.md). Este lote é custo e coerência, não capacidade nova. +- **Bloco de helpers de CLI dos scripts** (`die`, `sha256`, `cacheDir`, `ensureCached*`, `writeJson` — hoje na 4ª–6ª cópia) — **B5 F2** ([escala-dry-pos-b2.md](escala-dry-pos-b2.md)), cujo contador de call sites já foi atualizado com este script (5º de `ensureCached*`, e o primeiro segundo consumidor real de `GEOMETRIES_CACHE_DIR`, o que valida a decisão de `cacheDir` por chamada que aquele plano tomou). O `ensureCachedBinary` daqui devolve o buffer cru em vez de `JSON.parse` — a variante que faltava para `ensureCachedDownload` ser superconjunto das quatro. +- **Genérico para o trio `*GeometryModule`** — adiado com gatilho abaixo; renomear um acessor público a três malhas não paga. +- **`normalizeNeighborhood` e `IBGE_TO_TRE_NEIGHBORHOOD` no script** — ficam locais de propósito: a regra do parêntese é o que transforma `"Periperi (parte ao norte da Rua das Pedrinhas)"` em `PERIPERI`, e `normalizeMunicipalityKey` não faz isso. É chave de bairro, não de município, e o script é o único consumidor. + +## Rabbit holes + +- **"Já que estou mexendo na simplificação, melhoro a qualidade das duas malhas."** Se alguém "só completar": os três artefatos são rebuildados, o diff fica ilegível e a malha estadual (que todo mundo baixa) engorda. **Mitigação:** F1 tem critério binário — pontos/feature das zonas cai para a ordem do irmão, e `bahia-municipalities.topo.json` sai **byte-idêntico** ou com mudança justificada no plano. +- **"Já que a chave está em três lugares, crio um resolvedor único."** Se alguém "só completar": o resolvedor precisa aceitar registro de catálogo **e** saco de propriedades GeoJSON, e vira wrapper raso sobre um `??`. **Mitigação:** F2 é só teste; mudança de runtime aqui exige evidência nova. +- **"Já que estou no `loadLayerFeatures`, limpo o modo `territory` morto."** O branch não tem consumidor de produção (`MunicipalityMapPanel` fixa `mode="municipality"` e o B21 tirou o painel de TI do Início), mas o corte v1 do **E12** nomeia "sem modo de mapa TI" como adiado, ou seja, o artefato tem consumidor futuro nomeado. **Mitigação:** F4 deleta os **acessores** órfãos, não a malha nem o modo; a decisão sobre o modo `territory` pertence ao E12. + +## Adiado com gatilho + +- **`rankZonesBySharedBoundary` é O(U × N)** — reconstrói `arcIndexes(other.arcs)` para cada vizinho candidato de cada polígono não atribuído. Medido como irrelevante: centenas de milhares de inserts em `Set`, milissegundos, ofuscados pelo download e pelo `shp()` da malha estadual. Revisitar quando: uma segunda cidade entrar no dissolve (o `U × N` cresce com o mosaico, não com as zonas). Hoje, mexer nisso é queimar entrega em ganho zero. +- **Helper `createCampaignUser` para os e2e** — a criação inline de `campaignUser` está na 19ª cópia (`campaignZoneMap.e2e.spec.ts:1618-1629` é a mais nova), e o int já tem o helper (`tests/helpers/campaignFixtures.ts:542-559`). Pré-existente em `main` e inerte, portanto fora deste lote. Revisitar quando: o próximo spec e2e nascer — ele paga o helper em `campaignE2EFixtures.ts` e migra os vizinhos que tocar. +- **Genérico para `MunicipalityZoneGeometryModule`/`MunicipalityGeometryModule`/`TerritoryGeometryModule`** com `getFeature(key)` uniforme. Revisitar quando: **a 4ª malha** entrar — a três, o rename atravessa `municipalityProximity.ts`, dois int specs e um e2e por ganho estético. +- **Um único laço sobre `municipalities` em `municipalityMapData.ts`** (hoje quatro, cada um chamando `mapKeyForMunicipality`). Revisitar quando: o bundle ganhar um 5º campo por município — abaixo disso, juntar o laço histórico com o de cenários custa legibilidade e economiza microssegundos. + +## Explicitamente fora (descartes deste triage) + +- **`geometriesByZone.set(slug, [...anterior, geometry])` copia o array a cada insert** (`:375`, O(K²) com K ≈ 30) — `push` tem o mesmo tamanho e não é quadrático, mas o custo é indistinguível de zero; entra de graça se alguém já estiver naquela linha. +- **O laço de rank olhando Salvador 19 vezes** (`municipalityMapData.ts:144`, ~54 lookups extra por request) e **`mapKeyForMunicipality` chamado 2× por município por ano** — os próprios revisores concluíram que não vale tocar. +- **`@types/shpjs`** — `tsconfig.json` só inclui `**/*.ts(x)`, então `scripts/*.mjs` nunca é type-checked e o pacote compra hover no editor. Há precedente idêntico (`@types/topojson-server`); é consistência, não defeito. +- **`as unknown as MunicipalityZoneTopology`** em `bahiaMunicipalityZoneGeometries.ts:10` — byte a byte o padrão dos dois módulos irmãos (import de JSON que o TS não estreita). +- **`baseKeys` ser cinto-e-suspensório com `interactiveKeys`** — hoje `scopedKeys` nunca contém `2927408`, então o polígono-base já seria não-interativo pelo caminho antigo. Mantido de propósito: `baseKeys` codifica um invariante que o **componente** possui ("polígono de malha-base nunca é endereçável") em vez de um que o chamador por acaso fornece, e é o que mantém a base fora de `pathByKeyRef`, que o `fitMapToHighlights` novo lê. O que falta é uma asserção dizendo **qual** dos dois mecanismos é load-bearing; se F2 sobrar tempo, é uma linha no e2e (`total: 436, interactive: 435` passa sob qualquer um dos dois). +- **`tests/int/municipalityMapData.int.spec.ts:73` reescrevendo a regra da chave à mão** em vez de chamar `mapKeyForMunicipality` — é o trabalho do teste: expectativa independente. +- **Shoelace duplicado no int spec** (`bahiaGeometries.int.spec.ts:45-66`) — `featureCentroid` precisa da área **assinada** e dos momentos no mesmo passe, então compartilhar custaria um segundo passe ou uma forma de retorno estranha. F4 compartilha só o `polygonRingsOf`; a soma de áreas fica duplicada de propósito. + +## Referências + +- `docs/roadmap.md` (Fill-ins abertos — **B8+**) +- [poligonos-pracas-zona.md](poligonos-pracas-zona.md) — o pai do lote (B8 F2 ✓), incl. os dois débitos de produto com gatilho +- [escala-dry-pos-b2.md](escala-dry-pos-b2.md) — B5 F2, dono do bloco de helpers de CLI que F1 encosta +- [mapa-bahia-geometrias.md](mapa-bahia-geometrias.md) — B2, dono do padrão de artefato estático +- [municipio-mais-proximo.md](municipio-mais-proximo.md) — B14 ✓, dono da memoização de rejeição que F3 preserva (e da referência morta que F4 corrige) +- `scripts/build-municipality-zone-geometries.mjs` + `scripts/build-bahia-geometries.mjs` — F1, F4 +- `tests/int/bahiaGeometries.int.spec.ts` (`:29` budget, `:149` laço de contrato) — F1, F2 +- `src/lib/bahiaMapStyle.ts` (`:15`, `:95-109`) + `tests/unit/bahiaMapStyle.unit.spec.ts` — F2 +- `src/components/campaign/map/BahiaMap.tsx` (`:73-90` duas malhas) + `MunicipalityMapPanel.tsx` (`:594-601` ressalva) — F3 +- `src/utilities/municipalityMapNavigation.ts` + `src/lib/municipalityProximity.ts` + `src/lib/bahiaGeometriesTypes.ts` + `tests/helpers/featureBounds.ts` — F4 +- AGENTS.md — geometrias B2/B8 F2, gate por entrega, `overrideAccess: false` com `user` +- `.cursor/rules/engineering-standards.mdc` — "dead code dies immediately" (F4), depth check (F1/F2) diff --git a/docs/plans/poligonos-pracas-zona.md b/docs/plans/poligonos-pracas-zona.md index 597a0348..a1f9c426 100644 --- a/docs/plans/poligonos-pracas-zona.md +++ b/docs/plans/poligonos-pracas-zona.md @@ -170,6 +170,10 @@ Atribuição em dois passes: (1) nome citado pela resolução; (2) **maior front - **S9 — Double gate** `view.kind === 'zona'` na página + card `null`. Defesa em profundidade; não simplificar só por DRY. - **S10 — `key={neighborhood}` no card.** Seguro com exclusividade Salvador testada. +## Débitos do `/simplify` da F2 → B8+ + +Os relatórios dos três revisores chegaram **depois** do merge; o cleanup barato entrou antes (ver "Já resolvido"), e os quatro achados maiores viraram o fill-in **B8+**: [escala-dry-pos-b8f2.md](escala-dry-pos-b8f2.md) — a malha de 19 zonas com a densidade de vértices da malha estadual (as duas constantes de simplificação são relativas ao bbox), o buraco de teste da map key, a falha de chunk das zonas derrubando o mapa inteiro (mais a ressalva de aproximação sumindo no modo comparação), e o que a entrega orfanou. + ## Adiado com gatilho - **S6 — CSS compartilhado lista zona** (`MunicipalityMapPanel` zone breakdown ↔ `MunicipalityZoneNeighborhoodsCard`). **Gatilho disparou** na F2 (o painel foi alterado) e foi reavaliado: as duas listas continuam pequenas e com estrutura diferente, então extrair peça compartilhada agora seria abstração por coincidência visual. Segue adiado, agora com gatilho único: **R6** citar a duplicação em ≥2 superfícies de zona. diff --git a/docs/roadmap.md b/docs/roadmap.md index ba728fd6..a29ac2a5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-26 (**B8 F2 ✓** polígonos das 19 ZE de Salvador — artefato `bahia-municipality-zones.topo.json` (72 KB) por dissolve bairro→zona do `BA_bairros_CD2022` do IBGE contra a RA 02/2017 do TRE-BA, com curadoria explícita e um passe de maior fronteira compartilhada validado; o coroplético passou a ser keyed por **map key** (`slug` na zona, `ibgeCode` no resto) e o polígono municipal de Salvador ficou como base sem interação por baixo, porque duas malhas simplificadas independentemente não fecham na mesma costa; `competitiveRank` segue por cidade (o artefato TSE só tem `federalRankByIbgeCode`) com int pinando o fato; desambiguar a ZE no card "Onde estou" do B14 ✓ virou débito com gatilho, não dependência — [plano](plans/poligonos-pracas-zona.md); **B39–B42** registrados: sessão "Lembrar de mim" + login biométrico WebAuthn + scroll horizontal/sticky na lista de municípios + polimento mobile da mesma lista — [B39](plans/lembrar-de-mim-sessao-campanha.md), [B40](plans/login-biometrico-campanha.md), [B41](plans/scroll-horizontal-lista-municipios.md), [B42](plans/polimento-mobile-lista-municipios.md); **E12 ✓** camada TI — rollups MAUP, colunas Cobertura/Captura/Classe em `/campanha/territorios`, benchmark intra-TI no `MunicipalityGoalAccountCard`; [plano](plans/camada-territorios-identidade.md); **B36 ✓** coluna "Lideranças" em `/campanha/dobradinhas` — chips + Popover+Command + delta reuse da mutação B31; `LeadershipStateDeputyRelationCell` compartilhada (`direction`/`measureOverflow`); columns factory (não Context); casca `dobradinhas/formActions.ts`; `loadLeadershipOptions` + `leaderships[]` no row; [plano](plans/chips-liderancas-lista-dobradinhas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` (`UserPlusIcon`) na coluna `actions` ao lado do contato do B28 ✓; Popover two-step reusa `createCampaignInvite`/`autopreenchimento`; disable por `normalizeBrazilianPhone`; [plano](plans/convite-whatsapp-lista-liderancas.md); **B31 ✓** coluna de dobradinhas na lista de lideranças entregue — chips clicáveis → `/campanha/dobradinhas/` em `/campanha/liderancas`, edição por Popover+`Command` (mecânica do **B27 ✓**) com escrita por **server action direta do cliente** (`useTransition`, padrão do B19 ✓) em vez do endpoint JSON do B27, porque aqui há derivados a revalidar (`leadershipCount`, a ficha do deputado); `setLeadershipStateDeputyMembership(Record)` em transação + lock `leadership-state-deputies:{id}` + `revalidatePath`; helper puro `nextStateDeputyIdsAfterMembership` espelha `nextAdvisorIdsAfterMembership`; catálogo entregue uma vez por tabela via `leadershipColumns(stateDeputyOptions)` (columns factory, mesmo precedente de `municipalityListColumns`) em vez de Context; é o **2º call site** do padrão Popover+Command+chips — extração para `shared/` continua esperando o 3º (B34/B36/B37, que agora consomem a mutação/lock em vez de correr para desenhá-los); [plano](plans/dobradinhas-lista-liderancas.md); **fill-in ✓** limitar pan/zoom do mapa à Bahia — `BahiaMap` com `maxBounds`/`maxBoundsViscosity: 1` sobre `BAHIA_BOUNDS.pad(0.1)` e `minZoom` via `getBoundsZoom` com o mesmo padding 16px do `fitBounds`, recalculado no `ResizeObserver` existente; único arquivo; [plano](plans/limitar-mapa-a-bahia.md); (**B14 ✓** município mais próximo entregue — card cliente "Onde estou" no Início staff resolvendo **point-in-polygon** sobre o chunk de geometria que o mapa do dashboard já carrega (a premissa "polígonos explodem o bundle" do plano de 24/07 caiu na medição: 41 KB gzip já estão no browser, então o artefato de centroides e seu script saíram de escopo), com fallback nomeando onde o assessor está e o município mais próximo **da carteira** dentro de 150 km; a posição nunca sai do aparelho (sem action, sem `Consent`); First Load JS de `/campanha` 265 → 268 kB depois de mover o href das zonas de Salvador para o servidor (`zoneCityHrefs`) — importar `buildMunicipalityListHref` no cliente custava **21 kB** por um link, porque arrasta `bahiaTerritories` + `municipalityCatalog`; [plano](plans/municipio-mais-proximo.md); ~~**B38**~~ ✓ sidebar recolhido em tablet entregue — offcanvas + trigger md+ + cookie/matchMedia; [plano](plans/sidebar-recolhido-tablet.md); **B33+** novo fill-in de engenharia registrado no fechamento do B33 — `StateDeputyFilters` é o 3º call site do scaffold de navegação/debounce que `TerritoryFilters`/`MunicipalityFilters` já tinham, achado do `/simplify` rodada 2, [plano](plans/escala-dry-pos-b33.md); **B28 ✓** e-mail + celular na lista de lideranças entregue — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp, no padrão de `/campanha/assessores` (B19 ✓); `shared/CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`, `loadLeadershipDetail` deduplicado; [plano](plans/email-celular-lista-liderancas.md); **B24 ✓** auto-save + justificativa no popover de Tendência de `/campanha/municipios` — sem botão "Salvar", `POST /campanha/municipios/political-trend`, [plano](plans/autosave-tendencia-lista-municipios.md); **B13 ✓** escala relativa no mapa do Início entregue — quantis como novo default, padrão próprio/LQ nas âncoras do E10 e posição no município a partir do **artefato TSE v3** (`federalRankByIbgeCode`), mais bolhas opcionais por válidos projetados coloridas pela classe; `computeAggregateTerritorialClass` nasce aqui e o **E12** deve herdá-lo em vez de escrever um rollup próprio; `TERRITORIAL_CLASS_ANCHORS` mudou de casa para `src/lib/territorialClassAnchors.ts`; [plano](plans/escala-relativa-mapa.md); **B13+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B13 — teste de concordância entre as duas escritas do LQ (mapa cliente × classificador server-only, a rede de segurança do **E15**), escopo geográfico declarado no readout (posição é da cidade inteira, classe é do escopo do usuário), uma varredura da fatia TSE por ano no script de agregados em vez de três, e a tupla no `competitiveRankByYear` que corta ~metade dos ~63,6 KB somados ao payload RSC · [plano](plans/escala-dry-pos-b13.md); **débito de teste** afinado no ledger ([TECH-DEBT.md](TECH-DEBT.md), P3 → P2): `campaignMunicipalities`/`campaign-pwa` são flaky com 2 workers **em `main`** também — clique no checkbox de consentimento antes da hidratação e regex de fixture em que "Conde" casa com "Condeúba"; **B27 ✓** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` entregue — a checkbox list + botão "Salvar assessores" virou chips removíveis + `Command` com gravação por delta, `nextAdvisorIdsAfterMembership` movido para `src/lib/`, endpoint `POST /campanha/municipios/advisors` espelho do `expected-votes/`, [plano](plans/combobox-assessores-lista-municipios.md); **B23 ✓** tooltip de conteúdo nas células das listas entregue — a mecânica genérica (`cellTooltip`, `CampaignCellTooltip`, `CampaignHoverTooltip` em `shared/`) já tinha saído de graça no E10 ✓; este item fechou `openOnTouch`/`disabled` no primitivo (mais um terceiro comportamento do `/simplify`: `content` falsy renderiza o trigger sem wrapper) e os dois consumidores de `/campanha/municipios` (nomes de assessores + justificativa da tendência), [plano](plans/tooltip-celulas-listas.md); **B22 ✓** explicação por coluna no header das listas entregue — `description?: ReactNode` em `CampaignTableHead`/`MunicipalitySortableHead` (rename de `tooltip`, sublinhado pontilhado), `campaignConceptOneLiner` como fonte única para colunas cobertas pelo E18 ✓, `municipalityColumnDescriptions` cobrindo as 11 colunas de `/campanha/municipios`; sem migration/action, [plano](plans/explicacao-colunas-header-listas.md); 2026-07-25: **E10 ✓** classificação territorial relativa entregue — Reduto/Expansão/Manutenção/Marginal/Sem base sobre o artefato TSE, coluna+sort+filtro na lista, badge no card do E8 e na capa do dossiê, e o seam genérico `cellTooltip` da `CampaignTable` (núcleo do B23) com `CampaignHoverTooltip` promovido para `shared/`; **B13** liberado; **E10+** novo fill-in de engenharia com os quatro débitos do `/simplify` do E10 — a lista lendo `municipality` duas vezes por request (o que **dispara o "Adiado com gatilho" do A11**: `classe` é o 4º path derivado no loader), memo de processo dos potenciais por município, `TooltipProvider` único no layout e os multi-filtros do mobile saindo das definitions · [plano](plans/escala-dry-pos-e10.md); **C8 F4b** novo fill-in — o guard de `formActions` só vê arquivos chamados exatamente `formActions.ts`, achado do `/simplify` do C13; **B26 ✓** registrar sinal na célula "Último sinal" — [plano](plans/registrar-sinal-lista-municipios.md); **C13 ✓** "Plano de Ação" → **"Atividade"** renomeado em toda a vertical — collection `activity`, rota `/campanha/atividades`, sidebar "Atividades", migration data-preserving escrita à mão (4 tabelas, 3 enums, 2 colunas, 39 índices, 14 FKs, 2 sequences + os 8 índices fósseis `%plaza%` da remodelagem) e guard de vocabulário table-driven varrendo `src`+`tests`+`scripts`; [plano](plans/renomear-plano-acao-para-atividade.md); **B37** chips editáveis de municípios na lista de dobradinhas — coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`municipalityCount`) por chips com link (município) / território (`MapPinIcon`) e o mesmo Popover+combobox do **B34** (busca por município/TI/ZE, lote de território/zona), mas escrevendo do lado invertido (`municipality.stateDeputies`, sem piso mínimo, lock por município em vez de por dobradinha, sem `assertMunicipalitiesWithinScope` porque `canUpdateMunicipality` já escopa o assessor); reaproveita/promove a mesma lib do B19/B34 (quem chegar primeiro faz a renomeação); mesmo conflito registrado com o pedido literal (toggle "Editar" de tabela inteira) que B28/B31/B32/B34/B36 já resolveram na direção do Popover por célula — decisão marcada como _proposta, a validar com produto_; sem migration, [plano](plans/chips-municipios-lista-dobradinhas.md); **B36** coluna "Lideranças" em `/campanha/dobradinhas` com a mesma UI/mecânica da coluna "Municípios" de `/campanha/assessores` (B19 ✓) — chips + Popover + `Command` + auto-save por delta + "Ver mais…" (clamp de 3 linhas por `ResizeObserver`, necessário aqui porque um deputado popular pode acumular muito mais lideranças do que uma liderança acumula dobradinhas); pedido explícito de não duplicar lógica — reusa **sem alteração** a mutação/schema/lock que o **B31** desenha para o lado inverso da mesma relação `leadership.stateDeputies`, promovendo a célula desse item para um componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`) em vez de um segundo componente-irmão; corrida de extração com o B31 (quem entrar primeiro paga a peça compartilhada); sem migration, [plano](plans/chips-liderancas-lista-dobradinhas.md); **B34** chips editáveis de municípios na lista de lideranças — coluna "Municípios" de `/campanha/liderancas` ganha chips com link (município) / território (`MapPinIcon`), edição por **Popover disparado por célula** com chips removíveis + combobox `Command` (busca por município/TI/ZE, lote de território/zona), gravação **por delta** direto em `leadership.municipalities` com lock `leadership-municipalities:{id}` (piso de 1 e teto de 30 respeitados) e `revalidatePath` (`liderancas`, `liderancas/[id]`, `municipios/[slug]` dos tocados); promove a lógica pura de `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros, sem portar a UI com `ResizeObserver`; **conflito registrado com o pedido literal** (pede um toggle "Editar" de tabela inteira, que B28/B31/B32 já travaram como anti-goal nesta mesma tabela — este plano segue o precedente e marca a decisão como _proposta, a validar com produto_); sem migration, [plano](plans/chips-municipios-lista-liderancas.md); ~~**B33**~~ ✓ ordenação e filtro no header da lista de dobradinhas (entregue 2026-07-26) — `?sort=`/`?dir=` por Nome/Partido + Popover multi-seleção de Partido com "Sem partido", consumindo o chrome compartilhado do **B21 ✓** (a corrida de ordem de chegada com B29 foi encerrada); paridade mobile (`StateDeputyFilters`) entrou por decisão desta sessão; ordem dos nulos de `party` pinada por int test; contagens derivadas (`municipalityCount`/`leadershipCount`) seguem sem sort/filtro, como travado, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` na coluna `actions` (entregue 2026-07-26), [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); ~~**B28**~~ ✓ e-mail + celular na lista de `/campanha/liderancas` (entregue 2026-07-26) — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp como em `/campanha/assessores` (B19 ✓); `CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`; edição in-list adiada; sem migration, [plano](plans/email-celular-lista-liderancas.md); ~~**B27**~~ ✓ combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/combobox-assessores-lista-municipios.md); ~~**B26**~~ ✓ registrar sinal na célula "Último sinal" (entregue 2026-07-25), [plano](plans/registrar-sinal-lista-municipios.md); **fill-in** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); glossário/KPI strips intactos; B16 ✓ liberou o nome ao remover "Assessoria", [plano](plans/renomear-coluna-cobertura-meta.md); ~~**B25**~~ ✓ link do território na lista de municípios (entregue 2026-07-26) — coluna "Território" + card mobile → `/campanha/territorios#ti-`; `territoryAnchor.ts` + `TerritoryLink`; seam `rowId` em `CampaignTable`; [plano](plans/link-territorio-lista-municipios.md); ~~**B24**~~ ✓ auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/autosave-tendencia-lista-municipios.md); **fill-in** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtro por Salvador cobre as zonas; drop também do filtro/sort/`?kind=` na URL da lista, schema `kind` intacto, [plano](plans/remover-coluna-tipo-municipios.md); ~~**B23**~~ ✓ tooltip de conteúdo nas células das listas (entregue 2026-07-26), [plano](plans/tooltip-celulas-listas.md); **fill-in** opções selecionadas no topo nos Popovers de filtro do header — helper puro reutilizável pelas demais listas, [plano](plans/selecionados-no-topo-filtros-header.md); ~~**B22**~~ ✓ explicação por coluna no header das listas (entregue 2026-07-26), [plano](plans/explicacao-colunas-header-listas.md); **B21** página própria dos Territórios de Identidade — rota `/campanha/territorios` com overview + tabela no sistema de listas (sort/filtro no header), sem migration, [plano](plans/pagina-territorios-identidade.md); **B20 ✓** Municípios prioritários no Início entregue 2026-07-25 — atalho ranqueado por déficit + markup sem bullets, [plano](plans/municipios-prioritarios-inicio.md); **Pass 2 ✓** consolidação de engenharia — listas genéricas/B16+ absorvido/B17-B18 triviais/E10 limpo/C8 F4 ✓/D3 consent WhatsApp; **B16 ✓**; **E16 ✓**; **E9 ✓**; **E18 ✓**; **B19 ✓**; **E8 ✓**; C12 ✓; B18; B17; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14 ✓; janela 1: smoke, R6)Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc). +Atualizado em: 2026-07-26 (**B8 F2 ✓** polígonos das 19 ZE de Salvador — artefato `bahia-municipality-zones.topo.json` (72 KB) por dissolve bairro→zona do `BA_bairros_CD2022` do IBGE contra a RA 02/2017 do TRE-BA, com curadoria explícita e um passe de maior fronteira compartilhada validado; o coroplético passou a ser keyed por **map key** (`slug` na zona, `ibgeCode` no resto) e o polígono municipal de Salvador ficou como base sem interação por baixo, porque duas malhas simplificadas independentemente não fecham na mesma costa; `competitiveRank` segue por cidade (o artefato TSE só tem `federalRankByIbgeCode`) com int pinando o fato; desambiguar a ZE no card "Onde estou" do B14 ✓ virou débito com gatilho, não dependência — [plano](plans/poligonos-pracas-zona.md); **B8+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B8 F2, cujos relatórios chegaram depois do merge — a malha de 19 zonas ficou com a mesma contagem de vértices da malha dos 417 municípios (as duas constantes de simplificação são relativas ao bbox e foram copiadas verbatim, o que vale ~3,9 m de grade em Salvador contra ~103 m no estado), a regra da map key está em três formas sem nenhum teste ligando artefato → `featureMapKey` → catálogo, uma falha de chunk nas zonas passou a derrubar o mapa inteiro, a ressalva de aproximação desaparece no modo comparação, e `getTerritoryFeature` perdeu o último consumidor de produção neste commit · [plano](plans/escala-dry-pos-b8f2.md); **B39–B42** registrados: sessão "Lembrar de mim" + login biométrico WebAuthn + scroll horizontal/sticky na lista de municípios + polimento mobile da mesma lista — [B39](plans/lembrar-de-mim-sessao-campanha.md), [B40](plans/login-biometrico-campanha.md), [B41](plans/scroll-horizontal-lista-municipios.md), [B42](plans/polimento-mobile-lista-municipios.md); **E12 ✓** camada TI — rollups MAUP, colunas Cobertura/Captura/Classe em `/campanha/territorios`, benchmark intra-TI no `MunicipalityGoalAccountCard`; [plano](plans/camada-territorios-identidade.md); **B36 ✓** coluna "Lideranças" em `/campanha/dobradinhas` — chips + Popover+Command + delta reuse da mutação B31; `LeadershipStateDeputyRelationCell` compartilhada (`direction`/`measureOverflow`); columns factory (não Context); casca `dobradinhas/formActions.ts`; `loadLeadershipOptions` + `leaderships[]` no row; [plano](plans/chips-liderancas-lista-dobradinhas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` (`UserPlusIcon`) na coluna `actions` ao lado do contato do B28 ✓; Popover two-step reusa `createCampaignInvite`/`autopreenchimento`; disable por `normalizeBrazilianPhone`; [plano](plans/convite-whatsapp-lista-liderancas.md); **B31 ✓** coluna de dobradinhas na lista de lideranças entregue — chips clicáveis → `/campanha/dobradinhas/` em `/campanha/liderancas`, edição por Popover+`Command` (mecânica do **B27 ✓**) com escrita por **server action direta do cliente** (`useTransition`, padrão do B19 ✓) em vez do endpoint JSON do B27, porque aqui há derivados a revalidar (`leadershipCount`, a ficha do deputado); `setLeadershipStateDeputyMembership(Record)` em transação + lock `leadership-state-deputies:{id}` + `revalidatePath`; helper puro `nextStateDeputyIdsAfterMembership` espelha `nextAdvisorIdsAfterMembership`; catálogo entregue uma vez por tabela via `leadershipColumns(stateDeputyOptions)` (columns factory, mesmo precedente de `municipalityListColumns`) em vez de Context; é o **2º call site** do padrão Popover+Command+chips — extração para `shared/` continua esperando o 3º (B34/B36/B37, que agora consomem a mutação/lock em vez de correr para desenhá-los); [plano](plans/dobradinhas-lista-liderancas.md); **fill-in ✓** limitar pan/zoom do mapa à Bahia — `BahiaMap` com `maxBounds`/`maxBoundsViscosity: 1` sobre `BAHIA_BOUNDS.pad(0.1)` e `minZoom` via `getBoundsZoom` com o mesmo padding 16px do `fitBounds`, recalculado no `ResizeObserver` existente; único arquivo; [plano](plans/limitar-mapa-a-bahia.md); (**B14 ✓** município mais próximo entregue — card cliente "Onde estou" no Início staff resolvendo **point-in-polygon** sobre o chunk de geometria que o mapa do dashboard já carrega (a premissa "polígonos explodem o bundle" do plano de 24/07 caiu na medição: 41 KB gzip já estão no browser, então o artefato de centroides e seu script saíram de escopo), com fallback nomeando onde o assessor está e o município mais próximo **da carteira** dentro de 150 km; a posição nunca sai do aparelho (sem action, sem `Consent`); First Load JS de `/campanha` 265 → 268 kB depois de mover o href das zonas de Salvador para o servidor (`zoneCityHrefs`) — importar `buildMunicipalityListHref` no cliente custava **21 kB** por um link, porque arrasta `bahiaTerritories` + `municipalityCatalog`; [plano](plans/municipio-mais-proximo.md); ~~**B38**~~ ✓ sidebar recolhido em tablet entregue — offcanvas + trigger md+ + cookie/matchMedia; [plano](plans/sidebar-recolhido-tablet.md); **B33+** novo fill-in de engenharia registrado no fechamento do B33 — `StateDeputyFilters` é o 3º call site do scaffold de navegação/debounce que `TerritoryFilters`/`MunicipalityFilters` já tinham, achado do `/simplify` rodada 2, [plano](plans/escala-dry-pos-b33.md); **B28 ✓** e-mail + celular na lista de lideranças entregue — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp, no padrão de `/campanha/assessores` (B19 ✓); `shared/CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`, `loadLeadershipDetail` deduplicado; [plano](plans/email-celular-lista-liderancas.md); **B24 ✓** auto-save + justificativa no popover de Tendência de `/campanha/municipios` — sem botão "Salvar", `POST /campanha/municipios/political-trend`, [plano](plans/autosave-tendencia-lista-municipios.md); **B13 ✓** escala relativa no mapa do Início entregue — quantis como novo default, padrão próprio/LQ nas âncoras do E10 e posição no município a partir do **artefato TSE v3** (`federalRankByIbgeCode`), mais bolhas opcionais por válidos projetados coloridas pela classe; `computeAggregateTerritorialClass` nasce aqui e o **E12** deve herdá-lo em vez de escrever um rollup próprio; `TERRITORIAL_CLASS_ANCHORS` mudou de casa para `src/lib/territorialClassAnchors.ts`; [plano](plans/escala-relativa-mapa.md); **B13+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B13 — teste de concordância entre as duas escritas do LQ (mapa cliente × classificador server-only, a rede de segurança do **E15**), escopo geográfico declarado no readout (posição é da cidade inteira, classe é do escopo do usuário), uma varredura da fatia TSE por ano no script de agregados em vez de três, e a tupla no `competitiveRankByYear` que corta ~metade dos ~63,6 KB somados ao payload RSC · [plano](plans/escala-dry-pos-b13.md); **débito de teste** afinado no ledger ([TECH-DEBT.md](TECH-DEBT.md), P3 → P2): `campaignMunicipalities`/`campaign-pwa` são flaky com 2 workers **em `main`** também — clique no checkbox de consentimento antes da hidratação e regex de fixture em que "Conde" casa com "Condeúba"; **B27 ✓** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` entregue — a checkbox list + botão "Salvar assessores" virou chips removíveis + `Command` com gravação por delta, `nextAdvisorIdsAfterMembership` movido para `src/lib/`, endpoint `POST /campanha/municipios/advisors` espelho do `expected-votes/`, [plano](plans/combobox-assessores-lista-municipios.md); **B23 ✓** tooltip de conteúdo nas células das listas entregue — a mecânica genérica (`cellTooltip`, `CampaignCellTooltip`, `CampaignHoverTooltip` em `shared/`) já tinha saído de graça no E10 ✓; este item fechou `openOnTouch`/`disabled` no primitivo (mais um terceiro comportamento do `/simplify`: `content` falsy renderiza o trigger sem wrapper) e os dois consumidores de `/campanha/municipios` (nomes de assessores + justificativa da tendência), [plano](plans/tooltip-celulas-listas.md); **B22 ✓** explicação por coluna no header das listas entregue — `description?: ReactNode` em `CampaignTableHead`/`MunicipalitySortableHead` (rename de `tooltip`, sublinhado pontilhado), `campaignConceptOneLiner` como fonte única para colunas cobertas pelo E18 ✓, `municipalityColumnDescriptions` cobrindo as 11 colunas de `/campanha/municipios`; sem migration/action, [plano](plans/explicacao-colunas-header-listas.md); 2026-07-25: **E10 ✓** classificação territorial relativa entregue — Reduto/Expansão/Manutenção/Marginal/Sem base sobre o artefato TSE, coluna+sort+filtro na lista, badge no card do E8 e na capa do dossiê, e o seam genérico `cellTooltip` da `CampaignTable` (núcleo do B23) com `CampaignHoverTooltip` promovido para `shared/`; **B13** liberado; **E10+** novo fill-in de engenharia com os quatro débitos do `/simplify` do E10 — a lista lendo `municipality` duas vezes por request (o que **dispara o "Adiado com gatilho" do A11**: `classe` é o 4º path derivado no loader), memo de processo dos potenciais por município, `TooltipProvider` único no layout e os multi-filtros do mobile saindo das definitions · [plano](plans/escala-dry-pos-e10.md); **C8 F4b** novo fill-in — o guard de `formActions` só vê arquivos chamados exatamente `formActions.ts`, achado do `/simplify` do C13; **B26 ✓** registrar sinal na célula "Último sinal" — [plano](plans/registrar-sinal-lista-municipios.md); **C13 ✓** "Plano de Ação" → **"Atividade"** renomeado em toda a vertical — collection `activity`, rota `/campanha/atividades`, sidebar "Atividades", migration data-preserving escrita à mão (4 tabelas, 3 enums, 2 colunas, 39 índices, 14 FKs, 2 sequences + os 8 índices fósseis `%plaza%` da remodelagem) e guard de vocabulário table-driven varrendo `src`+`tests`+`scripts`; [plano](plans/renomear-plano-acao-para-atividade.md); **B37** chips editáveis de municípios na lista de dobradinhas — coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`municipalityCount`) por chips com link (município) / território (`MapPinIcon`) e o mesmo Popover+combobox do **B34** (busca por município/TI/ZE, lote de território/zona), mas escrevendo do lado invertido (`municipality.stateDeputies`, sem piso mínimo, lock por município em vez de por dobradinha, sem `assertMunicipalitiesWithinScope` porque `canUpdateMunicipality` já escopa o assessor); reaproveita/promove a mesma lib do B19/B34 (quem chegar primeiro faz a renomeação); mesmo conflito registrado com o pedido literal (toggle "Editar" de tabela inteira) que B28/B31/B32/B34/B36 já resolveram na direção do Popover por célula — decisão marcada como _proposta, a validar com produto_; sem migration, [plano](plans/chips-municipios-lista-dobradinhas.md); **B36** coluna "Lideranças" em `/campanha/dobradinhas` com a mesma UI/mecânica da coluna "Municípios" de `/campanha/assessores` (B19 ✓) — chips + Popover + `Command` + auto-save por delta + "Ver mais…" (clamp de 3 linhas por `ResizeObserver`, necessário aqui porque um deputado popular pode acumular muito mais lideranças do que uma liderança acumula dobradinhas); pedido explícito de não duplicar lógica — reusa **sem alteração** a mutação/schema/lock que o **B31** desenha para o lado inverso da mesma relação `leadership.stateDeputies`, promovendo a célula desse item para um componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`) em vez de um segundo componente-irmão; corrida de extração com o B31 (quem entrar primeiro paga a peça compartilhada); sem migration, [plano](plans/chips-liderancas-lista-dobradinhas.md); **B34** chips editáveis de municípios na lista de lideranças — coluna "Municípios" de `/campanha/liderancas` ganha chips com link (município) / território (`MapPinIcon`), edição por **Popover disparado por célula** com chips removíveis + combobox `Command` (busca por município/TI/ZE, lote de território/zona), gravação **por delta** direto em `leadership.municipalities` com lock `leadership-municipalities:{id}` (piso de 1 e teto de 30 respeitados) e `revalidatePath` (`liderancas`, `liderancas/[id]`, `municipios/[slug]` dos tocados); promove a lógica pura de `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros, sem portar a UI com `ResizeObserver`; **conflito registrado com o pedido literal** (pede um toggle "Editar" de tabela inteira, que B28/B31/B32 já travaram como anti-goal nesta mesma tabela — este plano segue o precedente e marca a decisão como _proposta, a validar com produto_); sem migration, [plano](plans/chips-municipios-lista-liderancas.md); ~~**B33**~~ ✓ ordenação e filtro no header da lista de dobradinhas (entregue 2026-07-26) — `?sort=`/`?dir=` por Nome/Partido + Popover multi-seleção de Partido com "Sem partido", consumindo o chrome compartilhado do **B21 ✓** (a corrida de ordem de chegada com B29 foi encerrada); paridade mobile (`StateDeputyFilters`) entrou por decisão desta sessão; ordem dos nulos de `party` pinada por int test; contagens derivadas (`municipalityCount`/`leadershipCount`) seguem sem sort/filtro, como travado, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` na coluna `actions` (entregue 2026-07-26), [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); ~~**B28**~~ ✓ e-mail + celular na lista de `/campanha/liderancas` (entregue 2026-07-26) — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp como em `/campanha/assessores` (B19 ✓); `CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`; edição in-list adiada; sem migration, [plano](plans/email-celular-lista-liderancas.md); ~~**B27**~~ ✓ combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/combobox-assessores-lista-municipios.md); ~~**B26**~~ ✓ registrar sinal na célula "Último sinal" (entregue 2026-07-25), [plano](plans/registrar-sinal-lista-municipios.md); **fill-in** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); glossário/KPI strips intactos; B16 ✓ liberou o nome ao remover "Assessoria", [plano](plans/renomear-coluna-cobertura-meta.md); ~~**B25**~~ ✓ link do território na lista de municípios (entregue 2026-07-26) — coluna "Território" + card mobile → `/campanha/territorios#ti-`; `territoryAnchor.ts` + `TerritoryLink`; seam `rowId` em `CampaignTable`; [plano](plans/link-territorio-lista-municipios.md); ~~**B24**~~ ✓ auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/autosave-tendencia-lista-municipios.md); **fill-in** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtro por Salvador cobre as zonas; drop também do filtro/sort/`?kind=` na URL da lista, schema `kind` intacto, [plano](plans/remover-coluna-tipo-municipios.md); ~~**B23**~~ ✓ tooltip de conteúdo nas células das listas (entregue 2026-07-26), [plano](plans/tooltip-celulas-listas.md); **fill-in** opções selecionadas no topo nos Popovers de filtro do header — helper puro reutilizável pelas demais listas, [plano](plans/selecionados-no-topo-filtros-header.md); ~~**B22**~~ ✓ explicação por coluna no header das listas (entregue 2026-07-26), [plano](plans/explicacao-colunas-header-listas.md); **B21** página própria dos Territórios de Identidade — rota `/campanha/territorios` com overview + tabela no sistema de listas (sort/filtro no header), sem migration, [plano](plans/pagina-territorios-identidade.md); **B20 ✓** Municípios prioritários no Início entregue 2026-07-25 — atalho ranqueado por déficit + markup sem bullets, [plano](plans/municipios-prioritarios-inicio.md); **Pass 2 ✓** consolidação de engenharia — listas genéricas/B16+ absorvido/B17-B18 triviais/E10 limpo/C8 F4 ✓/D3 consent WhatsApp; **B16 ✓**; **E16 ✓**; **E9 ✓**; **E18 ✓**; **B19 ✓**; **E8 ✓**; C12 ✓; B18; B17; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14 ✓; janela 1: smoke, R6)Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc). ## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026) @@ -58,7 +58,7 @@ Textos provisórios de Consent + `/privacidade` auto-provisionados ([onda-0.md]( - **B38 (2026-07-26)** — sidebar recolhido em tablet: `CampaignSidebar` com `collapsible="offcanvas"`, `SidebarTrigger` no inset `md+`, `defaultOpen` a partir do cookie `sidebar_state`, colapso padrão 768–1023 via `CampaignSidebarViewportDefault` quando não há cookie; mobile Sheet inalterado. Plano: [sidebar-recolhido-tablet.md](plans/sidebar-recolhido-tablet.md). - **B13 (2026-07-26)** — escala relativa no mapa do Início: o coropleto era quase monocromático porque 0–100% dos válidos comprime uma disputa cujo topo estadual é 4,71%. Três escalas relativas em classes discretas — **quantis** (novo default, degradando para 3 classes com escopo < 10 municípios e declarando isso na legenda), **padrão próprio (LQ)** nas mesmas âncoras do E10 e **posição no município** — mais uma camada opcional de bolhas por válidos projetados, coloridas pela classe do E10. "Posição no município" não existia em lugar nenhum: entrou no **artefato TSE commitado (v3)** como `federalRankByIbgeCode` (colocação entre todos os candidatos com votos na geografia, 2014/2018/2022; 534 → 623 KB, orçamento 700 KB), não em query de request. `computeAggregateTerritorialClass` classifica um grupo de slugs como um território só (o mapa é por `ibgeCode` e Salvador são 19 slugs) somando insumos e rodando o **mesmo** classificador — o E12 herda o helper. As âncoras do E10 saíram para `src/lib/territorialClassAnchors.ts` para não arrastar o artefato ao bundle do cliente; a matemática das escalas vive em `src/lib/mapScaleClasses.ts`. Legenda discreta com faixas reais, readout que nunca mostra a classe sem o porquê, dois conceitos novos em `/campanha/conceitos`. Rank indisponível em 2026 (dado TSE); sem migration; bundle client de `/campanha` inalterado (o payload RSC cresceu ~63,6 KB — **B13+ F4**). Plano: [escala-relativa-mapa.md](plans/escala-relativa-mapa.md); débitos do `/simplify`: [escala-dry-pos-b13.md](plans/escala-dry-pos-b13.md). -- **B8 F2 (2026-07-26)** — polígonos das 19 ZE de Salvador e re-keying do coroplético. Salvador era **um** polígono carregando os votos da cidade, então o mapa não respondia "qual pedaço de Salvador está fraco / é meu". Novo artefato commitado `bahia-municipality-zones.topo.json` (72 KB, ~20 KB gzip, 19 features) gerado por `pnpm build:municipality-zone-geometries` a partir do `BA_bairros_CD2022` do IBGE (`shpjs` como devDependency — o recorte só existe em SHP), dissolvendo bairro→zona pela **RA 02/2017 do TRE-BA**: 170/170 polígonos atribuídos, 19/19 zonas, curadoria em 3 tabelas explícitas no script (aliases, bairro sem polígono próprio, bairro que a resolução parte) mais um passe de **maior fronteira compartilhada** para os 5 que a resolução nunca nomeia — regra validada contra os 4 que ela nomeia sob outro rótulo. A chave do mapa virou **map key** (`slug` na zona, `ibgeCode` no resto): bundle, rollups, navegação e classe territorial re-keyed, e o ramo `kind: 'zones'` do readout morreu. O polígono municipal de Salvador ficou desenhado por baixo como **base sem dado e sem pointer**, porque duas malhas simplificadas independentemente não fecham na mesma costa. `competitiveRank` continua por cidade (o artefato só tem `federalRankByIbgeCode`, o que a legenda já dizia — int pina que as 19 dividem uma posição). Sem migration, sem collection, sem action. Plano: [poligonos-pracas-zona.md](plans/poligonos-pracas-zona.md). +- **B8 F2 (2026-07-26)** — polígonos das 19 ZE de Salvador e re-keying do coroplético. Salvador era **um** polígono carregando os votos da cidade, então o mapa não respondia "qual pedaço de Salvador está fraco / é meu". Novo artefato commitado `bahia-municipality-zones.topo.json` (72 KB, ~20 KB gzip, 19 features) gerado por `pnpm build:municipality-zone-geometries` a partir do `BA_bairros_CD2022` do IBGE (`shpjs` como devDependency — o recorte só existe em SHP), dissolvendo bairro→zona pela **RA 02/2017 do TRE-BA**: 170/170 polígonos atribuídos, 19/19 zonas, curadoria em 3 tabelas explícitas no script (aliases, bairro sem polígono próprio, bairro que a resolução parte) mais um passe de **maior fronteira compartilhada** para os 5 que a resolução nunca nomeia — regra validada contra os 4 que ela nomeia sob outro rótulo. A chave do mapa virou **map key** (`slug` na zona, `ibgeCode` no resto): bundle, rollups, navegação e classe territorial re-keyed, e o ramo `kind: 'zones'` do readout morreu. O polígono municipal de Salvador ficou desenhado por baixo como **base sem dado e sem pointer**, porque duas malhas simplificadas independentemente não fecham na mesma costa. `competitiveRank` continua por cidade (o artefato só tem `federalRankByIbgeCode`, o que a legenda já dizia — int pina que as 19 dividem uma posição). Sem migration, sem collection, sem action. Plano: [poligonos-pracas-zona.md](plans/poligonos-pracas-zona.md); débitos do `/simplify` (relatórios chegaram depois do merge): [escala-dry-pos-b8f2.md](plans/escala-dry-pos-b8f2.md). ## Próximos — Campanha (`/campanha`) @@ -136,6 +136,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re - ~~**B16+** escala/DRY pós-B16~~ — **absorvido pelo Pass 2 W1-D1 (2026-07-25)**: `useOptimistic`, facet por slugs, hrefs via serializador canônico, facets no mesmo `Promise.all` · [plano](plans/escala-dry-pos-b16.md) - **E10+** escala/DRY pós-E10 — quatro fases do `/simplify` da classificação territorial: **F1** a lista lê `municipality` 2× por request (`listQuery` + `loadMunicipalityScope`, mesmo `where`, select superset) — **fecha o "Adiado com gatilho" do A11**, cujo gatilho ("3º path derivado no loader") disparou com `classe` sendo o 4º (`votos`, `deficit`, `frescor`); **F2** `computeAllMunicipalityPotentials()` recomputa 435 linhas puras do artefato por request (memo de processo, como `bahiaElectionAggregates`/`municipalityVoteRank`); **F3** um `TooltipProvider` por tooltip (~30 na lista) anula o agrupamento de delay do Radix — hoist para o layout de `(app)`; **F4** os 4 multi-filtros do mobile saem da mesma `municipalityFilterDefinitions` que os single-select vizinhos já usam · ~0,75–1d eng · Impeccable A · sem migration · cortável · [plano](plans/escala-dry-pos-e10.md) - **B13+** escala/DRY pós-B13 — quatro fases do `/simplify` da escala relativa no mapa, as duas primeiras de **coerência** e não de custo: **F1** a razão do LQ é escrita duas vezes (painel cliente `:210` × classificador `server-only` `:108`, separação **deliberada** — o classificador arrasta o artefato de 600 KB) sem nenhum teste travando a concordância, que é a rede de segurança do **E15**; **F2** no readout do mapa a posição vale para a cidade inteira enquanto a classe é calculada sobre o escopo do usuário, então dois assessores veem classes diferentes para o mesmo polígono de Salvador — declarar a geografia, sem mudar o cálculo; **F3** `pnpm build:election-aggregates` varre a mesma fatia estadual do TSE 3× por ano (~225k docs extras) porque `loadFederalVotesByCityZoneAndCandidate` só precisa de `party` no `select` para ser superconjunto estrito dos outros dois; **F4** `competitiveRankByYear` soma ~57 dos ~63,6 KB que o B13 pôs no payload RSC, pagos mesmo por quem nunca abre a escala — tupla `[rank, candidates]` corta ~metade e responde ao "profiling antes de agir" que o ledger pediu em 2026-07-23 · ~0,5–0,75d eng · Impeccable A · sem migration · cortável · [plano](plans/escala-dry-pos-b13.md) +- **B8+** escala/DRY pós-B8 F2 — quatro fases do `/simplify` dos polígonos das ZE, aberta pelo único achado **medido e já commitado**: a malha das 19 zonas tem 9.120 pontos de arco contra 9.139 da malha de **todos** os 417 municípios da Bahia (480 vs 22 pontos/feature), porque `QUANTIZE_DIGITS = 1e4` e `SIMPLIFY_QUANTILE = 0.35` foram copiados verbatim do script irmão e **os dois são relativos à própria topologia** — o bbox de Salvador é ~26× mais estreito, então a mesma "grade de 10⁴" vale ~3,9 m no chão contra ~103 m no estado (Salvador é um `Polygon` de 35 pontos na malha municipal); **F1** calibra as duas malhas pela mesma escala de **chão** — e é a escala, não a contagem de dígitos, que fica compartilhada entre os scripts, porque extrair as constantes como estão cimentaria o defeito — trocando o tripwire de bytes (`MAX_TOPO_BYTES`, calibrado para malha estadual) por um de pontos/feature; **F2** fecha o buraco de teste da **map key**, escrita em três formas (`mapKeyForMunicipality` sobre o catálogo × `featureMapKey` sobre propriedades GeoJSON × a identidade `baseKeys` do `BahiaMap`) que, se discordarem, pintam cinza **sem erro em lugar nenhum** — hoje nada roda `featureMapKey` sobre um feature real fora do e2e; **F3** para de derrubar o mapa inteiro quando o chunk das zonas falha (`Promise.all` + memoização da rejeição = ponto único de falha novo, quando antes a malha municipal sozinha bastava) e tira a ressalva "é aproximado, não é o limite oficial do TSE" de dentro do gate `!comparisonActive`, onde ela desaparece enquanto a malha aproximada continua pintada; **F4** deleta o que a entrega orfanou e o knip não vê (`getTerritoryFeature` perdeu o último consumidor de produção **neste commit**), colapsa a união de navegação de 3 estados num `string | undefined` e tira 435 strings mortas (`name`) do payload RSC do Início · ~0,75–1d eng · Impeccable A · sem migration (F1 reescreve o artefato commitado) · cortável · [plano](plans/escala-dry-pos-b8f2.md) - **E12+** escala/DRY pós-E12 — duas fases condicionais do `/simplify` da camada TI: **F1** extrair peers/sub-linhas do Metropolitano para `lib/` quando surgir 4º call site ou regra mais rica (hoje constantes + benchmark intra-TI compartilham `territoryOverview.ts`); **F2** memo de peer-group no benchmark T4 só com evidência de duplicação no detalhe · ~0,25–0,5d eng · Impeccable A · sem migration · cortável · [plano](plans/escala-dry-pos-e12.md) - **O0+** escala/DRY pós-Onda 0 · [plano](plans/escala-dry-pos-onda0.md) - **RS+** auth read leve + shells de senha · [plano](plans/escala-dry-pos-reset-senha-perfil.md)