From da4ca2c7136d4a21562ba799c77096a8ce582e41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Tue, 28 Jul 2026 09:26:11 -0300 Subject: [PATCH] =?UTF-8?q?Deliver=20B37:=20editable=20munic=C3=ADpio=20ch?= =?UTF-8?q?ips=20on=20the=20dobradinhas=20list.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Municípios" column of /campanha/dobradinhas reuses MunicipalityPortfolioCell unchanged, writing the inverted side of the relation (municipality.stateDeputies) through a single transactional batch action (setStateDeputyMunicipalitiesBatch): per-município advisory lock, all-or-nothing on a partial-scope batch instead of a silent partial apply, no floor (a dobradinha can hold zero municípios). stateDeputiesArraySchema's cap moved 20 → 435 to fit a whole território batch from the município side. The post-implementation critique found two P1s, and both were fixed instead of deferred. First: MunicipalityPortfolioCell and LeadershipStateDeputyRelationCell had grown the same chip-editing machine twice — optimistic delta, undo, ARIA 1.2 combobox, floor/ceiling, pointer-fine/coarse split — with incompatible contracts. This was the 3rd call site B34+ was waiting for, so RelationChipCell is now the single engine and both cells shrank to thin domain wrappers (chip/ search building, FormData, copy — nothing interaction-specific left in either). LeadershipStateDeputyRelationCell also gained an ownerName prop and its first dedicated test suite, which it shipped without before this. Second: an advisor could see, and attempt to remove, a município chip outside the portfolio they administer, failing silently at the document level. MunicipalityPortfolioCell now filters the visible chips by addableIds instead of only gating what can be added, so an advisor sees and can only touch municípios they administer. No migration, collection, or Consent. Docs updated: plan as-built, roadmap, AGENTS.md, project notebook. --- .cursor/rules/projects/nucleos-eleitorais.mdc | 3 +- AGENTS.md | 2 + .../chips-municipios-lista-dobradinhas.md | 24 +- docs/roadmap.md | 6 +- .../campanha/(app)/dobradinhas/formActions.ts | 31 +- .../campanha/(app)/dobradinhas/page.tsx | 54 +- .../campanha/(app)/liderancas/page.tsx | 1 + .../campanha/actions/stateDeputy.ts | 133 ++- .../LeadershipStateDeputyRelationCell.tsx | 463 +++------- .../shared/MunicipalityPortfolioCell.tsx | 822 ++---------------- .../campaign/shared/RelationChipCell.tsx | 813 +++++++++++++++++ src/lib/municipalityStateDeputyMembership.ts | 30 + src/lib/schemas/municipality.ts | 14 +- src/lib/schemas/stateDeputy.ts | 30 + src/utilities/stateDeputyData.ts | 13 +- tests/int/campaignEntityListData.int.spec.ts | 2 +- ...teDeputyMunicipalityMembership.int.spec.ts | 185 ++++ ...ershipStateDeputyRelationCell.unit.spec.ts | 157 ++++ ...cipalityStateDeputyMembership.unit.spec.ts | 33 + 19 files changed, 1733 insertions(+), 1083 deletions(-) create mode 100644 src/components/campaign/shared/RelationChipCell.tsx create mode 100644 src/lib/municipalityStateDeputyMembership.ts create mode 100644 tests/int/campaignStateDeputyMunicipalityMembership.int.spec.ts create mode 100644 tests/unit/leadershipStateDeputyRelationCell.unit.spec.ts create mode 100644 tests/unit/municipalityStateDeputyMembership.unit.spec.ts diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index 9bdc17a1..b01165a5 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -233,4 +233,5 @@ alwaysApply: false - **B7 entregue (2026-07-21):** mapa em `/campanha/municipios` usa `buildMunicipalityListWhere` (lista/overview/mapa alinhados). Plano: [`docs/plans/mapa-pracas-filtrado.md`](../../../docs/plans/mapa-pracas-filtrado.md). Débito perf triplicado → **A9+**. - **A9 / B6 / B9 / B7 / B10 / B11 / B8 F1 / filtros-auto entregues (2026-07-21; em produção desde 2026-07-23):** ver bullets no topo do Status. Próximos abertos: ver [`docs/roadmap.md`](../../../docs/roadmap.md) (compile 2026-07-24) — Janela 1: smoke pós-deploy + **R6** (+ **E8** em paralelo); Janela 3: **B8 F2** (polígonos das zonas de Salvador). - **B8 registrado (2026-07-21); F1 entregue (2026-07-21); F2 entregue (2026-07-26):** polígonos das Praças-zona Salvador/Camaçari (Camaçari saiu do escopo na M1). F1: catálogo `municipalityZoneNeighborhoods` + card na Praça. F2: dissolve malha IBGE → TopoJSON no mapa. Plano: [`docs/plans/poligonos-pracas-zona.md`](../../../docs/plans/poligonos-pracas-zona.md). -- **B34 entregue (2026-07-27):** a coluna "Municípios" de `/campanha/liderancas` virou chips editáveis — link para o município, chip de território com contagem quando a liderança cobre o TI inteiro, "×" no canto do chip (hover/foco, sem mexer no tamanho) e busca no fim da linha. **Decisão de produto que vale para toda a vertical:** o que decide inline vs. Drawer é a mídia `pointer:`, não a largura do viewport — `pointer-fine` recebe o editor sempre montado, `pointer-coarse` abre um Drawer com alvos de 44 px, porque um polegar rolando a tabela não pode ser convidado a distinguir "abrir" de "apagar". O anti-goal "input sempre montado em toda linha" (`PRODUCT.md` princípio 3 + `campanha-edit-where-you-see.mdc`) foi **emendado** para relações por chips em ponteiro fino, e não reabre B28/B31/B32: nada aqui é modo de tabela (o toggle de `AdvisorsTable` virou "Editar nome e contato"). Engenharia: `AdvisorMunicipalityCell` foi promovido a `shared/MunicipalityPortfolioCell` (o `ResizeObserver`/"Ver mais…" que o plano chamava de peso morto virou requisito quando a célula passou a estar sempre visível) e Assessores migrou no mesmo PR; uma única action de lote sobrevive (a gêmea de assessor foi deletada, int tests migrados); `LeadershipRowViewModel.municipalityNames` → `municipalityIDs`. Corrigiu de passagem um bug latente do seam `description` de `CampaignTableHead` (**B22**): os helpers do tooltip moravam num módulo `'use client'`, então qualquer chamador RSC quebrava. Débitos: `LeadershipStateDeputyRelationCell` (B31) segue com Popover próprio e é ele — não o B37 — o gatilho de extração de um editor de chips agnóstico. Plano: [`docs/plans/chips-municipios-lista-liderancas.md`](../../../docs/plans/chips-municipios-lista-liderancas.md). +- **B34 entregue (2026-07-27):** a coluna "Municípios" de `/campanha/liderancas` virou chips editáveis — link para o município, chip de território com contagem quando a liderança cobre o TI inteiro, "×" no canto do chip (hover/foco, sem mexer no tamanho) e busca no fim da linha. **Decisão de produto que vale para toda a vertical:** o que decide inline vs. Drawer é a mídia `pointer:`, não a largura do viewport — `pointer-fine` recebe o editor sempre montado, `pointer-coarse` abre um Drawer com alvos de 44 px, porque um polegar rolando a tabela não pode ser convidado a distinguir "abrir" de "apagar". O anti-goal "input sempre montado em toda linha" (`PRODUCT.md` princípio 3 + `campanha-edit-where-you-see.mdc`) foi **emendado** para relações por chips em ponteiro fino, e não reabre B28/B31/B32: nada aqui é modo de tabela (o toggle de `AdvisorsTable` virou "Editar nome e contato"). Engenharia: `AdvisorMunicipalityCell` foi promovido a `shared/MunicipalityPortfolioCell` (o `ResizeObserver`/"Ver mais…" que o plano chamava de peso morto virou requisito quando a célula passou a estar sempre visível) e Assessores migrou no mesmo PR; uma única action de lote sobrevive (a gêmea de assessor foi deletada, int tests migrados); `LeadershipRowViewModel.municipalityNames` → `municipalityIDs`. Corrigiu de passagem um bug latente do seam `description` de `CampaignTableHead` (**B22**): os helpers do tooltip moravam num módulo `'use client'`, então qualquer chamador RSC quebrava. Débitos: `LeadershipStateDeputyRelationCell` (B31) seguiu com Popover próprio até o **B37 ✓** generalizar os dois num motor comum. Plano: [`docs/plans/chips-municipios-lista-liderancas.md`](../../../docs/plans/chips-municipios-lista-liderancas.md). +- **B37 entregue (2026-07-28):** a coluna "Municípios" de `/campanha/dobradinhas` ganhou a mesma peça do B34 (`shared/MunicipalityPortfolioCell`, sem cópia, sem provider novo), escrevendo do lado invertido (`municipality.stateDeputies`) por uma única action de lote transacional (`setStateDeputyMunicipalitiesBatch`, lock por município, **tudo-ou-nada** — um município fora do escopo do assessor derruba o lote inteiro em vez de aplicar parcialmente em silêncio), sem piso mínimo e sem `assertMunicipalitiesWithinScope` (`canUpdateMunicipality` já escopa). Cap de `stateDeputiesArraySchema` subiu 20 → 435 para caber um lote de território inteiro do lado do município. **O critique pós-implementação achou dois P1 e a resposta foi generalizar na hora, em vez de adiar um "3º call site" hipotético:** este item **era** o 3º call site — `MunicipalityPortfolioCell` e `LeadershipStateDeputyRelationCell` (B31/B36) duplicavam a mesma máquina de interação (delta otimista, undo, combobox ARIA, piso/teto, split pointer-fine/coarse) com contratos incompatíveis — então `src/components/campaign/shared/RelationChipCell.tsx` nasceu como motor único e as duas células viraram wrappers finos (chips/busca/`FormData`/copy por domínio). O segundo P1 — um assessor via e tentava remover chips de município fora do escopo, falhando em silêncio no documento — fechou **escondendo** o chip: `MunicipalityPortfolioCell` agora filtra por `addableIds` os municípios que renderiza, não só os que oferece adicionar. `LeadershipStateDeputyRelationCell` ganhou `ownerName` e sua primeira suíte de testes própria (zero cobertura antes). Sem migration/collection/Consent. Plano: [`docs/plans/chips-municipios-lista-dobradinhas.md`](../../../docs/plans/chips-municipios-lista-dobradinhas.md). diff --git a/AGENTS.md b/AGENTS.md index 82df3d62..a3248626 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,6 +88,8 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap. **Recently resolved (2026-07-27):** **B8+** Escala e DRY pós-B8 F2 — the four `/simplify` debts of the ZE polygons, and the headline finding changed owner. The plan called the target "ground resolution implemented via quantization digits"; measuring the committed artifact re-snapped to coarser grids showed quantization alone barely cuts (~25 m → 90% of the original vertices, and **even on the state mesh's ~103 m grid the zones stayed at 203 points/feature**, ~9× the sibling). The lever is the **absolute Visvalingam threshold**, so the policy was inverted: absolute weight in **m² of ground** is the policy, digits are derived per topology. See the ground-scale policy bullet under "Campaign map geometries" for the numbers that matter; the outcome is the zone mesh at **574 arc points / 30.2 per feature / 7,687 bytes** (from 9,120 / 480 / 72,310), `bahia-municipalities` **byte-identical**, and `bahia-identity-territories` off by one byte because its grid is now derived from its bbox (102.730 → 102.822 m). `zoneNumber` left the artifact and the type. The other three phases: `featureMapKey` is now exercised on real features of all three committed meshes, each inside the loop that already sweeps it whole (417 `codarea` + 27 `code` + 19 `slug`) — the `/simplify` caught the first version asserting the city inside the zone loop, which covered **one** municipal feature nineteen times because the 19 ZE share Salvador's code — and the synthetic precedence test died (the invariant is real — see the Map bullet); a zone-chunk failure degrades instead of killing the map (with the reason logged, since a ChunkLoadError and a corrupt artifact are not the same event), and the approximation caveat left the `!comparisonActive` gate for a paragraph conditioned on the new bundle field `hasZoneMunicipalities`, published by the loader from `kind === 'zona'` — the `/simplify` rejected deriving it on the client from the key's shape (a second encoding of `mapKeyForMunicipality`, in negative space) and rejected gating on `zoneBreakdown`, which the zone-list fill-in deletes; and the delivery's orphans are gone — two accessors deleted, `MunicipalityMapNavigation` collapsed to `string | undefined`, `MunicipalitiesByMapKey` to `Record` (435 dead strings out of every Início RSC payload), `polygonRingsOf` exported, geometry helpers retyped on `PolygonalFeature`. **One planned deletion was rejected by measurement:** `city`/`zoneNumber` on `MunicipalityZoneNeighborhoodEntry` **stay** — no runtime reads them, but both feed the rows hashed by `canonicalEvidenceRows` and are columns of the RA 02/2017 provenance fixture (an independent transcription), so deleting them would weaken the verification for nothing. No migration, collection, action or `Consent`; e2e still 436 paths / 435 interactive. Plano: [`docs/plans/escala-dry-pos-b8f2.md`](docs/plans/escala-dry-pos-b8f2.md). +**Recently resolved (2026-07-28):** **B37** Chips editáveis de municípios na lista de dobradinhas — the "Municípios" column of `/campanha/dobradinhas` was `row.municipalityCount`, a dead number; it now uses the same `MunicipalityPortfolioCell` (B34 ✓) unchanged, writing the inverted side of the relation (`municipality.stateDeputies`) through a single batch action (`setStateDeputyMunicipalitiesBatch`, transactional, per-município advisory lock, **all-or-nothing** on a partial-scope batch), with no floor (a dobradinha can hold zero municípios) and no `assertMunicipalitiesWithinScope` (`canUpdateMunicipality` already scopes the advisor). `stateDeputiesArraySchema`'s cap moved 20 → 435 to fit a whole território batch from the município side. **The post-implementation critique found two P1s, and the answer was to generalize immediately instead of registering a fourth "deferred with trigger":** `MunicipalityPortfolioCell` and `LeadershipStateDeputyRelationCell` (B31 ✓/B36 ✓) had grown the same interaction machine (optimistic delta, undo, ARIA 1.2 combobox, floor/ceiling, pointer-fine/coarse split) twice, with incompatible interfaces — this item **was** the 3rd call site B34+ was waiting for, so `src/components/campaign/shared/RelationChipCell.tsx` (813 lines) became the single engine and both cells shrank to thin, domain-specific wrappers (chip/search building, `FormData`, copy — nothing interaction-specific left in either). This closes B34+'s `useChipRowOverflow` extraction trigger ahead of schedule: the ~105 duplicated lines it named are gone, not just deduplicated into a helper. `LeadershipStateDeputyRelationCell` also gained an `ownerName` prop (for the Drawer description and `aria-label`s, matching the município cell) and its first dedicated test suite — it shipped with zero coverage of its own before this. **The second P1** — an advisor could see, and attempt to remove, a município chip outside the portfolio they administer, and the removal silently no-oped at the document level with no way for the cell to explain why — was closed by **hiding** the chip instead of only explaining the failure: `MunicipalityPortfolioCell` now filters `municipalityIds` by `addableIds` when present (`visibleIds`), so an advisor sees and can only touch municípios they administer; staff without `addableIds` still sees and can remove every link. Accepted trade-off: in a rare portfolio-reassignment race, the client-side floor could refuse a removal the server would actually accept — acceptable because the server remains the source of truth and the explicit ask was to hide, not merely explain. Gate: `tsc`/`lint`/`format:check`/`check:cycles` clean, unit 651/651, int 452/452 (full suites, not just touched files), Aikido 0 findings on edited first-party files; `knip` blocked by the pre-existing, already-ledgered `payload.config.ts` load failure (P3 in `TECH-DEBT.md`), not a regression of this delivery. No migration, collection, or `Consent`. Plano: [`docs/plans/chips-municipios-lista-dobradinhas.md`](docs/plans/chips-municipios-lista-dobradinhas.md). + **Recently resolved (2026-07-26):** **E12** Camada TI — extends the E17/B21 rollup (`territoryOverview.ts` + `loadTerritoryOverview.ts`) with E8 goal coverage (`central`), MAUP-safe regional capture (aggregate ratio Σ own votes ÷ Σ field ceiling — never the mean of municipal ratios; median, min–max, named critical municipality and beacon), and aggregated territorial class via `computeAggregateTerritorialClass` (same rule as B13 for TI groups). Staff surface: three new columns on `/campanha/territorios` (Cobertura, Captura, Classe) with B22 header descriptions and sorts `cobertura`/`captura`/`classe`; municipality detail gets intra-TI benchmark T4 on `MunicipalityGoalAccountCard` (`territoryIntraCaptureBenchmark.ts` — Salvador zone slugs compare among the 19 zones, other Metropolitano peers among RMS municipalities). Two E18 concepts (`captura-regional`, `benchmark-intra-ti`). v1 cuts kept: no `?group=territorio` on the municipality list, no TI map mode. No migration. Plano: [`docs/plans/camada-territorios-identidade.md`](docs/plans/camada-territorios-identidade.md). **Recently resolved (2026-07-26):** **B24** Auto-save + justificativa no popover de Tendência — `MunicipalityListTrendControl` grava sozinho via `POST /campanha/municipios/political-trend` (status ~150 ms, nota 600 ms, flush blur/fechamento), `Textarea` de justificativa no Popover, sem `revalidatePath` da lista; `/editar` mantém submit explícito. Plano: [`docs/plans/autosave-tendencia-lista-municipios.md`](docs/plans/autosave-tendencia-lista-municipios.md). diff --git a/docs/plans/chips-municipios-lista-dobradinhas.md b/docs/plans/chips-municipios-lista-dobradinhas.md index 7e6ed345..a9d3c1be 100644 --- a/docs/plans/chips-municipios-lista-dobradinhas.md +++ b/docs/plans/chips-municipios-lista-dobradinhas.md @@ -1,12 +1,32 @@ # Chips editáveis de municípios na lista de dobradinhas (paridade com Assessores) -Status: rascunho -Atualizado em: 2026-07-25 +Status: entregue (2026-07-28) +Atualizado em: 2026-07-28 Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item **B37**) Impeccable: B — encaixe na coluna "Municípios" de `/campanha/dobradinhas`; sem rota nova Appetite: ~0,75–1 dia eng — 1 componente de célula novo (Popover + Command), 2 server actions + schema + helper puro, ajuste no loader da lista; sem migration. (Menor que o B34 análogo: sem piso mínimo, sem `assertMunicipalitiesWithinScope`, lock por município em vez de por documento.) Responsável: — +## Entregue (2026-07-28) — as-built + +**A célula é a mesma peça do B34, sem cópia.** Este item não construiu `StateDeputyMunicipalitiesCell` nem um provider novo — plugou `shared/MunicipalityPortfolioCell` (já promovida pelo B34 ✓) direto na coluna "Municípios" de `/campanha/dobradinhas`, com as costuras que o B34 já desenhou para isso: sem `minItems` (uma dobradinha pode ficar sem município — o piso deste plano nunca existiu no schema, e o comentário permanece correto), sem `maxItems` do lado da dobradinha (o teto que existe é por município — `MAX_STATE_DEPUTIES_PER_MUNICIPALITY`, inalterado), `addableIds` só quando o ator é `advisor`. O container herda **integralmente** a decisão do B34: sempre editável em `pointer-fine` (chips + "×" no hover/foco + input de busca na própria linha), Drawer em `pointer-coarse` — nenhuma decisão de container nova foi tomada aqui, e o "conflito com o pedido literal" registrado abaixo já estava fechado antes deste item começar (ver B34 ✓ e a nota B34/B36/B37 em `codebase-map.mdc`). + +**Generalização feita nesta entrega, não adiada para um "3º call site" futuro.** Este plano registrava a extração de um `RelationshipChipEditor` agnóstico em "Adiado com gatilho", esperando B31+B34+este landarem primeiro. Na crítica pós-implementação (`/simplify` + `/impeccable`) ficou visível que **este item já era o 3º call site**: `MunicipalityPortfolioCell` (município) e `LeadershipStateDeputyRelationCell` (dobradinha↔liderança, B31 ✓/B36 ✓) tinham interfaces incompatíveis para o mesmo padrão (delta otimista, undo, combobox ARIA, piso/teto, split pointer-fine/coarse) — P1 do critique. A resposta foi generalizar imediatamente em vez de registrar um quarto "adiado com gatilho": `src/components/campaign/shared/RelationChipCell.tsx` (813 linhas) nasceu como a máquina de interação única, e `MunicipalityPortfolioCell` (816 → ~180 linhas) e `LeadershipStateDeputyRelationCell` (450 → ~185 linhas) foram reduzidos a wrappers finos — chips/busca/`FormData`/copy por domínio, nada de interação duplicada. `LeadershipStateDeputyRelationCell` ganhou `ownerName` (para o Drawer e os `aria-label`) e perdeu sua própria implementação de Popover+Command, sem mudar contrato para os dois call sites existentes (`liderancas/page.tsx`, `dobradinhas/page.tsx`) além de passar o novo prop. + +**Segundo P1 do critique, também resolvido nesta entrega:** um assessor via — e tentava remover — chips de municípios fora do seu escopo, e a remoção falhava em silêncio no nível do documento (`canUpdateMunicipality` recusa, mas a célula não sabia explicar por quê). `MunicipalityPortfolioCell` agora filtra `municipalityIds` por `addableIds` quando presente (`visibleIds`), então um assessor só vê — e só pode tentar remover — municípios que administra; staff sem `addableIds` continua vendo e removendo todos os vínculos. Trade-off aceito: em uma janela rara de reatribuição de carteira, um piso client-side poderia recusar uma remoção que o servidor aceitaria — aceitável porque o servidor segue sendo a fonte de verdade e o pedido explícito era esconder, não apenas explicar. + +**Escrita:** `setStateDeputyMunicipalitiesBatch` — uma única action de lote (não a dupla delta+lote do plano), transacional, com lock por município (`municipality-state-deputies:{id}`, um por município tocado, ordenado) e leitura+escrita de `municipality.stateDeputies` com `overrideAccess: false` (o acesso real é `canUpdateMunicipality`; sem `assertMunicipalitiesWithinScope`, como planejado). **Falha em lote é tudo-ou-nada**: se qualquer município do lote está fora do escopo do assessor ou não existe, a transação inteira é revertida com uma mensagem única — decisão explícita desta sessão (rejeita a alternativa "aplica o que pode, ignora o resto", que deixaria o assessor sem saber quais municípios silenciosamente falharam). `nextStateDeputyMunicipalityIdsAfterMembership` (em `src/lib/municipalityStateDeputyMembership.ts`, puro) espelha `nextAdvisorIdsAfterMembership`. + +**Cap de 20 → 435 em `stateDeputiesArraySchema`** (`src/lib/schemas/municipality.ts`): o teto original de 20 dobradinhas por município era um vestígio de outra era do dado; com a coluna agora permitindo lote de território/ZE (até 435 municípios de uma vez do lado do dobradinha), o teto do lado do município precisava cobrir o universo inteiro em vez de truncar silenciosamente um lote grande. `MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE` foi ajustado e reexportado como mensagem segura da nova action. + +**Loader:** `StateDeputyRowViewModel.municipalityCount` (número) → `municipalityIDs: number[]` — não o `{ id, name, slug }[]` do plano. Mesma razão do B34: a célula já recebe o catálogo completo (435 municípios) via `municipalityIndex`, então nome/slug/território resolvem no cliente e o loader não repete strings por linha. + +**Sem provider de contexto** (mesma decisão do B34): `municipalityIndex` e `addableIds` viajam como props da factory de colunas que o RSC monta uma vez por tabela. + +**Testes:** unit da lib pura (`municipalityStateDeputyMembership.unit.spec.ts`), int da action de lote (`campaignStateDeputyMunicipalityMembership.int.spec.ts` — escopo do assessor, teto, lock, tudo-ou-nada, no-op idempotente), e uma nova suite unit dedicada a `LeadershipStateDeputyRelationCell` (`leadershipStateDeputyRelationCell.unit.spec.ts`) que não existia antes desta entrega — o componente tinha zero cobertura própria. + +**Gate:** `tsc`/`lint`/`format:check`/`check:cycles` limpos; unit 651/651, int 452/452 (suítes completas, não só os arquivos tocados); Aikido 0 findings nos arquivos de primeira parte editados; `knip` bloqueado pela limitação pré-existente e já ledgerada de não conseguir carregar `payload.config.ts` (P3 em `TECH-DEBT.md`) — não uma regressão desta entrega. + ## ⚠️ Conflito com o pedido literal — leia antes de implementar O pedido de produto pede um **botão de "modo de edição"** para a coluna inteira, espelhando o toggle "Editar" de `/campanha/assessores` (`AdvisorsTable`). No mesmo dia (2026-07-25), essa **mesma classe de pedido** já foi resolvida quatro vezes na direção oposta, em três tabelas diferentes: **B28**/**B31**/**B32** (lista de lideranças) e **B34** (a mesma pergunta, para a coluna "Municípios" de lideranças). O argumento se repete porque a estrutura se repete: o toggle de `AdvisorsTable` existe porque **todas** as colunas daquela tela são editáveis (nome/e-mail/celular/carteira); em `/campanha/dobradinhas`, só "Municípios" seria editável — "Nome", "Partido" e "Lideranças" continuam leitura. Este plano **segue o precedente travado** (Popover disparado pela célula, não modo de tabela) em vez do texto literal do pedido. Todas as demais funcionalidades pedidas — chips, link para o município, adicionar por digitação, remover, lote de território/ZE — estão contempladas; só o **container** muda. _(proposto — validar com produto; se a mesa realmente quiser o toggle de tabela aqui, é a exceção que reabre B28/B31/B32/B34 também — mudar um muda todos, porque o argumento é o mesmo.)_ diff --git a/docs/roadmap.md b/docs/roadmap.md index a49b35fb..228e54fb 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-28 (**B17+** novo fill-in de engenharia registrado no fechamento do B17 — esconder uma coluna não desliga a query que existe só para ela, e o botão "Colunas" some com a tabela no estado vazio de três rotas; **B17 ✓** seletor de colunas — entregue como capacidade do `CampaignTable`, nas **sete** superfícies do sistema de listas em vez de só em municípios; persistência em **cookie** (`campaign_columns`) com filtragem no servidor, porque `CampaignTableColumn.cell` é função e o array de colunas não atravessa a fronteira server/client — a coluna oculta some também do payload RSC; `label: string` virou obrigatório e o seam `defaultVisible` **morreu** (era `true` em 10 colunas e `false` em nenhuma: guardar só o conjunto de ocultas é o que faz coluna nova nascer visível, contrato que E9 ✓/E14 ✓ assumiram); uma coluna `mandatory` por superfície, refiltrada no servidor; o bug real pego na revisão foi o payload de um toggle chegando dentro da janela de debounce do seguinte e commitando o conjunto sem ele; a **segunda rodada** de revisão mudou a cadência por medição (**fechar o menu é o commit**, timer de 3 s como rede que grava **só o cookie, sem refresh** — os 400 ms agrupavam rajada, não ritmo de leitura, e qualquer timer que repintasse recriaria o mesmo custo mais devagar, re-pagando os ~229 ms / 371 statements de `/campanha/liderancas`, P1 no `TECH-DEBT`; sessão que termina onde começou não repinta, e desmontar com o menu aberto grava pelo cleanup) e apagou duplicação com dono: `contentKey` virou o 4º chamador de `sameIdSet` (generalizado p/ `string | number`), e os `label` de municípios/dobradinhas passaram a citar os módulos de rótulo que o header já usa, o que fez o `id` das colunas de municípios ser **tipado** contra `MunicipalityListColumnId` (id, descrição do B22 ✓ e rótulo do picker viraram a mesma chave; membro `nivel` → `level`, alinhado ao id e ao `?level=`) · [plano](plans/seletor-colunas-lista-municipios.md)); **B32+ ✓** escala e DRY pós-B32 — a máquina de auto-save dos controles de célula virou `useCampaignCellAutosave` (um valor só: a auditoria mostrou que `display`/`draft` eram o mesmo valor com dois nomes) e as cinco rotas JSON de `/campanha` passaram a nascer de `campaignJsonMutationRoute`, com guard de convenção — a verificação de mesma origem deixou de depender de disciplina de cópia; a região `aria-live` saiu de dentro do overlay (fechar é o que dispara a gravação, então ela desmontava junto) e o `variant="sheet"` da casca ganhou slot `footer`, o que fez o controle de Sinal (B26 ✓) largar o Popover/Drawer próprios · [plano](plans/escala-dry-pos-b32.md); 2026-07-27 (**E14 ✓** níveis de envolvimento N0–N4 — `municipality.engagementLevel` staff-only (sem default: nulo é "sem nível", e filtrar por ele é a fila de triagem), motivo e sinais de reversão obrigatórios, e o **primeiro escritor de produção do `allocationDecision`** (C12 ✓), gravado na mesma transação sob lock porque o "nível anterior" do snapshot precisa ser verdade; enum de `outcome` estendido com `movimento`; histerese do §6.8 (salto duplo só com choque triangulado, 3 semanas de proteção, um movimento por mês) num módulo puro que roda no cliente **e** no servidor, com a rota respondendo `blocked` (409) com as violações tipadas e o override registrado com as regras que contrariou; submit explícito em vez de auto-save (exceção prevista pela regra, e o controle não vira o 4º call site do B32+); coluna + filtro nativo (`?level=`) e sort em memória na lista (o Postgres põe NULL primeiro no DESC, e sem backfill isso entregaria "N4 primeiro" cheio de "Sem nível") + verbete no E18 · [plano](plans/niveis-de-envolvimento.md); a triage do fechamento **não abriu ID novo** — os três débitos caíram no **B32+**: o 5º controle/rota JSON tirou **F2/F3** da condicionalidade (a política de mesma origem que `isSameOriginRequest` diz possuir está copiada em cinco rotas, e a que esquecer a linha falha **aberta**) e o slot `footer` que o **B42 ✓** havia adiado virou **F5**, porque na folha "Fechar" está pinado e "Registrar movimento" fica abaixo da dobra; **fill-in** dimir o resultado no compositor de giro — a troca de TI em `/campanha/atividades/giros` sinaliza só no seletor; a triage do fechamento do E13 mediu que as peças de pending não são acopladas a lista e o `/simplify` as havia dispensado por isso · [plano](plans/feedback-pendente-compositor-giro.md); **E13 ✓** planejador de presença/giros — elegibilidade de visita em **5 condições ✓/—** (volume, headroom, rede, janela, encaixe) no detalhe do município, fase do calendário (`construcao|consolidacao|ativacao`, âncoras ilustrativas em `src/lib/visitPlannerAnchors.ts`) e compositor em `/campanha/atividades/giros` gerando `activity` em rascunho com `deputyPresent` numa transação; **duas premissas do plano caíram na auditoria** — `kind: 'visita'` nunca entrou na migration do C12 (e não entra: `deputyPresent` já É o marcador de presença) e `allocationDecision` não tem write path em `src/` (o "não vá" é aviso textual; gravar o override vira gatilho do **E11**); "encaixe em giro" = **mesmo TI**, porque não existe grafo de contiguidade em runtime; o teto-do-campo-não-capturado e as medianas do catálogo saíram do classificador do E10 para `municipalityPotential.ts` para que card e coluna "Classe" não pudessem divergir; a action relê os ids das paradas com `overrideAccess: false` **dentro** da transação (o `create` de `activity` é booleano de staff — sem isso um assessor semeava o estado inteiro), buraco achado por int test; sem migration, sem collection, sem `Consent`, sem entidade `tour` · [plano](plans/planejador-de-giros.md); **B42 ✓** polimento mobile da lista de municípios — Drawer em Tendência/Assessores/Votos estimados pela casca `CampaignCellEditOverlay`, card compacto e clicável por link esticado; a triage do fechamento não abriu ID novo: **F4** entrou no **B32+** (região `aria-live` que desmonta com o overlay) e o débito de bundle do catálogo no **B33+** deixou de ser condicional (gatilho disparado por B41 ✓ e B42 ✓) · [plano](plans/polimento-mobile-lista-municipios.md); **B34+** fill-in de engenharia pós-B34 registrado — a entrega custou **+58 kB** de First Load JS em `/campanha/liderancas` e **+42 kB** em `/campanha/assessores` (medido contra `main` limpo; a maior parte é a célula, não o catálogo), o gatilho de extração do `useChipRowOverflow` **disparou** (~105 linhas idênticas entre as duas células de chips da mesma tabela) e os três módulos de delta de membership colapsam num só; o passe localizado que fechou o B34 já derrubou o custo de CPU medido (chips 4,2 → 0,043 ms quente; busca 4,59 → 0,077 ms por tecla), e a **segunda rodada** somou três fases que mudaram o maior lever — `revalidatePath` por toggle (374 statements / 229 ms por clique), índice de municípios sem cache no degrau 2 da escada, e o `filter` por linha que derrota o próprio WeakMap — mais o N+1 de access pré-existente promovido a **P1** no `TECH-DEBT` (367 dos 371 statements da rota), que é quem precisa cair primeiro · [plano](plans/escala-dry-pos-b34.md); **B34 ✓** chips editáveis de municípios na lista de lideranças — célula sempre editável em `pointer-fine` + Drawer em `pointer-coarse`, `AdvisorMunicipalityCell` promovido a `shared/MunicipalityPortfolioCell` com Assessores migrado no mesmo PR, action única de lote (a de um id só, inclusive a de assessor, foi deletada), anti-goal de input sempre montado **emendado** para relações por chips; corrigiu de passagem o seam `description` de `CampaignTableHead` (B22 ✓), que quebrava qualquer chamador RSC · [plano](plans/chips-municipios-lista-liderancas.md); **B41 ✓** scroll horizontal + sticky Município · [plano](plans/scroll-horizontal-lista-municipios.md); **B32+** novo fill-in de engenharia registrado no fechamento do B32 — `MunicipalityListExpectedVotesControl`/`MunicipalityListTrendControl`/`LeadershipListSupportStatusControl` são o 3º call site da mesma máquina de auto-save por debounce (estado/refs/save/schedule/flush quase idênticos), gatilho que os três planos B24/B27/B32 já haviam registrado; auditoria primeiro, extração condicional (precedente B33+) — [plano](plans/escala-dry-pos-b32.md); **B32 ✓** editar Status de apoio na célula da lista de lideranças — `LeadershipListSupportStatusControl` (Popover + `NativeSelect`, sempre montado, sem toggle "Editar" de tabela inteira) grava por `POST /campanha/liderancas/support-status` (auto-save ~150 ms, sem `revalidatePath`), reusando `isSameOriginRequest`/`campaignJsonMutationRoute.ts` já extraídos pelo B24 ✓/B27 ✓; fecha o "Adiado com gatilho" do B29 — [plano](plans/autosave-status-lista-liderancas.md); **B33+ ✓** `useCampaignListFilterNavigation` — o scaffold de navegação/debounce sai dos três filtros de lista para um hook em `shared/` (281 linhas removidas contra 75 em `src`); a auditoria derrubou a premissa do plano de que os três domínios teriam assinaturas de canonicalização diferentes — `buildStateDeputyListHref`/`buildTerritoryListHref`/`buildMunicipalityListHref` **já canonicalizam internamente**, então o `parse(...)` explícito de dobradinhas/territórios era trabalho redundante e um único callback `toHref` cobre os três; a entrega achou **dois** bugs de comportamento, não de estrutura: tocar um filtro no meio da digitação **descartava** a busca pendente em dobradinhas e territórios (unificado na semântica de munícipios por `navigateWithSearch`), e o `setTimeout` do debounce capturava o `state` do render que o agendou, então um sort clicado dentro da janela de 1s era **revertido** um segundo depois — munícipios escapava por acidente, pelo `key=` da página que remonta e mata o timer (corrigido com `stateRef`, com caso de spec verificado vermelho nas três listas sem o fix); o `/simplify` ainda achou o fallback `useCampaignListPending() ?? useTransition()` copiado em 5 lugares, com o hook novo como 6º → `useCampaignListTransition`, e um `aria-live` que fazia o resumo de filtros de munícipios ser a **terceira** região viva da mesma página; `shouldUpdateMunicipalitySearchUrl` orfanou e foi deletado; `tests/unit/campaignListFilterNavigation.unit.spec.ts` (jsdom + router mockado + fake timers, 8 casos × 3 listas) é a primeira cobertura desses componentes — [plano](plans/escala-dry-pos-b33.md); **C8 F4b ✓** guard de `formActions` ampliado para irmãos `*FormActions.ts` + `taskActions.ts`; 6 ladders migradas; `taskActions.ts` allowlisted · [plano](plans/escala-dry-pos-c6.md); **Build resiliente ao global `metadata` ausente ✓** — `resolveSiteMetadata` em `src/utilities/seo.ts`; fallback `NEXT_PUBLIC_SITE_URL` e omit canonical/OG quando não há URL; 4 rotas + layout; [plano](plans/build-resiliente-metadata-ausente.md); **B8+ ✓** escala e DRY pós-B8 F2 — a política de escala de chão das malhas commitadas virou código nomeado (`scripts/lib/topology.mjs`, tolerância de Visvalingam em m² e dígitos de quantização derivados do bbox de cada topologia), e com ela a malha das ZE caiu de 480 para **30,2 pontos/feature** (72.310 → 7.687 bytes) sem que `bahia-municipalities` mudasse um byte; a auditoria corrigiu a alavanca do plano (quantização sozinha quase não corta — o peso absoluto manda) e **rejeitou** um item do F4 por medição (`city`/`zoneNumber` de `MunicipalityZoneNeighborhoodEntry` são evidência de proveniência hasheada, não código morto); `MAX_POINTS_PER_FEATURE = 60` é o novo tripwire, `featureMapKey` passou a ser exercido sobre features reais das três malhas (417 `codarea`, 27 `code`, 19 `slug`), o mapa **degrada** em vez de morrer quando o chunk das zonas falha, e a ressalva de aproximação saiu do gate de comparação · [plano](plans/escala-dry-pos-b8f2.md); **`users.roles` ✓** — `admin`\|`editor` em `users`, migration `20260727_032523_add_users_roles`, editor publica `post`/`tag`/`media`; unificação com `campaignUser` rejeitada · [plano](plans/roles-admin-payload.md); 2026-07-26 (**fill-in** remover lista "Municípios por zona eleitoral" do Início — pós-B8 F2 ✓ os polígonos tornam a lista textual redundante; dropar UI + `zoneBreakdown` do bundle; ressalva de aproximação fica na malha · [plano](plans/remover-lista-zonas-mapa-inicio.md); **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**~~ ✓ (entregue 2026-07-27); **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-28 (**B37 ✓** chips editáveis de municípios na lista de dobradinhas — a coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`row.municipalityCount`) pela mesma peça do **B34 ✓** (`shared/MunicipalityPortfolioCell`, sem cópia, sem provider novo), escrevendo do lado invertido (`municipality.stateDeputies`) por uma única action de lote (`setStateDeputyMunicipalitiesBatch`, transacional, lock por município, **tudo-ou-nada** — sem "aplica o que pode"), sem piso mínimo e sem `assertMunicipalitiesWithinScope` (`canUpdateMunicipality` já escopa o assessor); o cap de `stateDeputiesArraySchema` subiu de 20 para 435 para caber um lote de território inteiro do lado do município. **O critique pós-implementação achou dois P1 e a resposta foi generalizar na hora, não adiar:** `MunicipalityPortfolioCell` e `LeadershipStateDeputyRelationCell` (B31 ✓/B36 ✓) tinham a mesma máquina de interação (delta otimista, undo, combobox ARIA, piso/teto, split pointer-fine/coarse) implementada duas vezes com contratos incompatíveis — em vez de esperar um "3º call site" hipotético, este item **era** o 3º call site, então `src/components/campaign/shared/RelationChipCell.tsx` nasceu como o motor único e as duas células viraram wrappers finos (chips/busca/`FormData`/copy por domínio); isso fecha adiantado o gatilho `useChipRowOverflow` que o **B34+** havia registrado. O segundo P1 — um assessor via e tentava remover chips de município fora do escopo, falhando em silêncio no nível do documento — fechou escondendo o chip em vez de só explicar: `MunicipalityPortfolioCell` agora filtra por `addableIds` os municípios que renderiza, não só os que oferece para adicionar. `LeadershipStateDeputyRelationCell` ganhou `ownerName` e sua primeira suíte de testes própria (zero cobertura antes); gate completo (tsc/lint/format/cycles limpos, unit 651/651, int 452/452, Aikido 0 findings) · [plano](plans/chips-municipios-lista-dobradinhas.md); **B17+** novo fill-in de engenharia registrado no fechamento do B17 — esconder uma coluna não desliga a query que existe só para ela, e o botão "Colunas" some com a tabela no estado vazio de três rotas; **B17 ✓** seletor de colunas — entregue como capacidade do `CampaignTable`, nas **sete** superfícies do sistema de listas em vez de só em municípios; persistência em **cookie** (`campaign_columns`) com filtragem no servidor, porque `CampaignTableColumn.cell` é função e o array de colunas não atravessa a fronteira server/client — a coluna oculta some também do payload RSC; `label: string` virou obrigatório e o seam `defaultVisible` **morreu** (era `true` em 10 colunas e `false` em nenhuma: guardar só o conjunto de ocultas é o que faz coluna nova nascer visível, contrato que E9 ✓/E14 ✓ assumiram); uma coluna `mandatory` por superfície, refiltrada no servidor; o bug real pego na revisão foi o payload de um toggle chegando dentro da janela de debounce do seguinte e commitando o conjunto sem ele; a **segunda rodada** de revisão mudou a cadência por medição (**fechar o menu é o commit**, timer de 3 s como rede que grava **só o cookie, sem refresh** — os 400 ms agrupavam rajada, não ritmo de leitura, e qualquer timer que repintasse recriaria o mesmo custo mais devagar, re-pagando os ~229 ms / 371 statements de `/campanha/liderancas`, P1 no `TECH-DEBT`; sessão que termina onde começou não repinta, e desmontar com o menu aberto grava pelo cleanup) e apagou duplicação com dono: `contentKey` virou o 4º chamador de `sameIdSet` (generalizado p/ `string | number`), e os `label` de municípios/dobradinhas passaram a citar os módulos de rótulo que o header já usa, o que fez o `id` das colunas de municípios ser **tipado** contra `MunicipalityListColumnId` (id, descrição do B22 ✓ e rótulo do picker viraram a mesma chave; membro `nivel` → `level`, alinhado ao id e ao `?level=`) · [plano](plans/seletor-colunas-lista-municipios.md)); **B32+ ✓** escala e DRY pós-B32 — a máquina de auto-save dos controles de célula virou `useCampaignCellAutosave` (um valor só: a auditoria mostrou que `display`/`draft` eram o mesmo valor com dois nomes) e as cinco rotas JSON de `/campanha` passaram a nascer de `campaignJsonMutationRoute`, com guard de convenção — a verificação de mesma origem deixou de depender de disciplina de cópia; a região `aria-live` saiu de dentro do overlay (fechar é o que dispara a gravação, então ela desmontava junto) e o `variant="sheet"` da casca ganhou slot `footer`, o que fez o controle de Sinal (B26 ✓) largar o Popover/Drawer próprios · [plano](plans/escala-dry-pos-b32.md); 2026-07-27 (**E14 ✓** níveis de envolvimento N0–N4 — `municipality.engagementLevel` staff-only (sem default: nulo é "sem nível", e filtrar por ele é a fila de triagem), motivo e sinais de reversão obrigatórios, e o **primeiro escritor de produção do `allocationDecision`** (C12 ✓), gravado na mesma transação sob lock porque o "nível anterior" do snapshot precisa ser verdade; enum de `outcome` estendido com `movimento`; histerese do §6.8 (salto duplo só com choque triangulado, 3 semanas de proteção, um movimento por mês) num módulo puro que roda no cliente **e** no servidor, com a rota respondendo `blocked` (409) com as violações tipadas e o override registrado com as regras que contrariou; submit explícito em vez de auto-save (exceção prevista pela regra, e o controle não vira o 4º call site do B32+); coluna + filtro nativo (`?level=`) e sort em memória na lista (o Postgres põe NULL primeiro no DESC, e sem backfill isso entregaria "N4 primeiro" cheio de "Sem nível") + verbete no E18 · [plano](plans/niveis-de-envolvimento.md); a triage do fechamento **não abriu ID novo** — os três débitos caíram no **B32+**: o 5º controle/rota JSON tirou **F2/F3** da condicionalidade (a política de mesma origem que `isSameOriginRequest` diz possuir está copiada em cinco rotas, e a que esquecer a linha falha **aberta**) e o slot `footer` que o **B42 ✓** havia adiado virou **F5**, porque na folha "Fechar" está pinado e "Registrar movimento" fica abaixo da dobra; **fill-in** dimir o resultado no compositor de giro — a troca de TI em `/campanha/atividades/giros` sinaliza só no seletor; a triage do fechamento do E13 mediu que as peças de pending não são acopladas a lista e o `/simplify` as havia dispensado por isso · [plano](plans/feedback-pendente-compositor-giro.md); **E13 ✓** planejador de presença/giros — elegibilidade de visita em **5 condições ✓/—** (volume, headroom, rede, janela, encaixe) no detalhe do município, fase do calendário (`construcao|consolidacao|ativacao`, âncoras ilustrativas em `src/lib/visitPlannerAnchors.ts`) e compositor em `/campanha/atividades/giros` gerando `activity` em rascunho com `deputyPresent` numa transação; **duas premissas do plano caíram na auditoria** — `kind: 'visita'` nunca entrou na migration do C12 (e não entra: `deputyPresent` já É o marcador de presença) e `allocationDecision` não tem write path em `src/` (o "não vá" é aviso textual; gravar o override vira gatilho do **E11**); "encaixe em giro" = **mesmo TI**, porque não existe grafo de contiguidade em runtime; o teto-do-campo-não-capturado e as medianas do catálogo saíram do classificador do E10 para `municipalityPotential.ts` para que card e coluna "Classe" não pudessem divergir; a action relê os ids das paradas com `overrideAccess: false` **dentro** da transação (o `create` de `activity` é booleano de staff — sem isso um assessor semeava o estado inteiro), buraco achado por int test; sem migration, sem collection, sem `Consent`, sem entidade `tour` · [plano](plans/planejador-de-giros.md); **B42 ✓** polimento mobile da lista de municípios — Drawer em Tendência/Assessores/Votos estimados pela casca `CampaignCellEditOverlay`, card compacto e clicável por link esticado; a triage do fechamento não abriu ID novo: **F4** entrou no **B32+** (região `aria-live` que desmonta com o overlay) e o débito de bundle do catálogo no **B33+** deixou de ser condicional (gatilho disparado por B41 ✓ e B42 ✓) · [plano](plans/polimento-mobile-lista-municipios.md); **B34+** fill-in de engenharia pós-B34 registrado — a entrega custou **+58 kB** de First Load JS em `/campanha/liderancas` e **+42 kB** em `/campanha/assessores` (medido contra `main` limpo; a maior parte é a célula, não o catálogo), o gatilho de extração do `useChipRowOverflow` **disparou** (~105 linhas idênticas entre as duas células de chips da mesma tabela) e os três módulos de delta de membership colapsam num só; o passe localizado que fechou o B34 já derrubou o custo de CPU medido (chips 4,2 → 0,043 ms quente; busca 4,59 → 0,077 ms por tecla), e a **segunda rodada** somou três fases que mudaram o maior lever — `revalidatePath` por toggle (374 statements / 229 ms por clique), índice de municípios sem cache no degrau 2 da escada, e o `filter` por linha que derrota o próprio WeakMap — mais o N+1 de access pré-existente promovido a **P1** no `TECH-DEBT` (367 dos 371 statements da rota), que é quem precisa cair primeiro · [plano](plans/escala-dry-pos-b34.md); **B34 ✓** chips editáveis de municípios na lista de lideranças — célula sempre editável em `pointer-fine` + Drawer em `pointer-coarse`, `AdvisorMunicipalityCell` promovido a `shared/MunicipalityPortfolioCell` com Assessores migrado no mesmo PR, action única de lote (a de um id só, inclusive a de assessor, foi deletada), anti-goal de input sempre montado **emendado** para relações por chips; corrigiu de passagem o seam `description` de `CampaignTableHead` (B22 ✓), que quebrava qualquer chamador RSC · [plano](plans/chips-municipios-lista-liderancas.md); **B41 ✓** scroll horizontal + sticky Município · [plano](plans/scroll-horizontal-lista-municipios.md); **B32+** novo fill-in de engenharia registrado no fechamento do B32 — `MunicipalityListExpectedVotesControl`/`MunicipalityListTrendControl`/`LeadershipListSupportStatusControl` são o 3º call site da mesma máquina de auto-save por debounce (estado/refs/save/schedule/flush quase idênticos), gatilho que os três planos B24/B27/B32 já haviam registrado; auditoria primeiro, extração condicional (precedente B33+) — [plano](plans/escala-dry-pos-b32.md); **B32 ✓** editar Status de apoio na célula da lista de lideranças — `LeadershipListSupportStatusControl` (Popover + `NativeSelect`, sempre montado, sem toggle "Editar" de tabela inteira) grava por `POST /campanha/liderancas/support-status` (auto-save ~150 ms, sem `revalidatePath`), reusando `isSameOriginRequest`/`campaignJsonMutationRoute.ts` já extraídos pelo B24 ✓/B27 ✓; fecha o "Adiado com gatilho" do B29 — [plano](plans/autosave-status-lista-liderancas.md); **B33+ ✓** `useCampaignListFilterNavigation` — o scaffold de navegação/debounce sai dos três filtros de lista para um hook em `shared/` (281 linhas removidas contra 75 em `src`); a auditoria derrubou a premissa do plano de que os três domínios teriam assinaturas de canonicalização diferentes — `buildStateDeputyListHref`/`buildTerritoryListHref`/`buildMunicipalityListHref` **já canonicalizam internamente**, então o `parse(...)` explícito de dobradinhas/territórios era trabalho redundante e um único callback `toHref` cobre os três; a entrega achou **dois** bugs de comportamento, não de estrutura: tocar um filtro no meio da digitação **descartava** a busca pendente em dobradinhas e territórios (unificado na semântica de munícipios por `navigateWithSearch`), e o `setTimeout` do debounce capturava o `state` do render que o agendou, então um sort clicado dentro da janela de 1s era **revertido** um segundo depois — munícipios escapava por acidente, pelo `key=` da página que remonta e mata o timer (corrigido com `stateRef`, com caso de spec verificado vermelho nas três listas sem o fix); o `/simplify` ainda achou o fallback `useCampaignListPending() ?? useTransition()` copiado em 5 lugares, com o hook novo como 6º → `useCampaignListTransition`, e um `aria-live` que fazia o resumo de filtros de munícipios ser a **terceira** região viva da mesma página; `shouldUpdateMunicipalitySearchUrl` orfanou e foi deletado; `tests/unit/campaignListFilterNavigation.unit.spec.ts` (jsdom + router mockado + fake timers, 8 casos × 3 listas) é a primeira cobertura desses componentes — [plano](plans/escala-dry-pos-b33.md); **C8 F4b ✓** guard de `formActions` ampliado para irmãos `*FormActions.ts` + `taskActions.ts`; 6 ladders migradas; `taskActions.ts` allowlisted · [plano](plans/escala-dry-pos-c6.md); **Build resiliente ao global `metadata` ausente ✓** — `resolveSiteMetadata` em `src/utilities/seo.ts`; fallback `NEXT_PUBLIC_SITE_URL` e omit canonical/OG quando não há URL; 4 rotas + layout; [plano](plans/build-resiliente-metadata-ausente.md); **B8+ ✓** escala e DRY pós-B8 F2 — a política de escala de chão das malhas commitadas virou código nomeado (`scripts/lib/topology.mjs`, tolerância de Visvalingam em m² e dígitos de quantização derivados do bbox de cada topologia), e com ela a malha das ZE caiu de 480 para **30,2 pontos/feature** (72.310 → 7.687 bytes) sem que `bahia-municipalities` mudasse um byte; a auditoria corrigiu a alavanca do plano (quantização sozinha quase não corta — o peso absoluto manda) e **rejeitou** um item do F4 por medição (`city`/`zoneNumber` de `MunicipalityZoneNeighborhoodEntry` são evidência de proveniência hasheada, não código morto); `MAX_POINTS_PER_FEATURE = 60` é o novo tripwire, `featureMapKey` passou a ser exercido sobre features reais das três malhas (417 `codarea`, 27 `code`, 19 `slug`), o mapa **degrada** em vez de morrer quando o chunk das zonas falha, e a ressalva de aproximação saiu do gate de comparação · [plano](plans/escala-dry-pos-b8f2.md); **`users.roles` ✓** — `admin`\|`editor` em `users`, migration `20260727_032523_add_users_roles`, editor publica `post`/`tag`/`media`; unificação com `campaignUser` rejeitada · [plano](plans/roles-admin-payload.md); 2026-07-26 (**fill-in** remover lista "Municípios por zona eleitoral" do Início — pós-B8 F2 ✓ os polígonos tornam a lista textual redundante; dropar UI + `zoneBreakdown` do bundle; ressalva de aproximação fica na malha · [plano](plans/remover-lista-zonas-mapa-inicio.md); **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**~~ ✓ (entregue 2026-07-27); **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); **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) @@ -118,8 +118,8 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re - ~~**B32** editar Status de apoio na célula da lista de lideranças~~ — **entregue 2026-07-26** (`LeadershipListSupportStatusControl`: Popover + `NativeSelect` **sempre montado** na coluna "Status" de `/campanha/liderancas`, sem toggle "Editar" de tabela inteira — mesmo anti-goal já travado no B19 ✓/B28/B31 ✓; auto-save por debounce ~150 ms via `POST /campanha/liderancas/support-status` (2º/3º endpoint JSON da campanha — `isSameOriginRequest` e o shell `campaignJsonMutationRoute.ts` já existiam por B24 ✓/B27 ✓, este item só importa), reusando `updateLeadershipInternal` sem `revalidatePath`; extraiu `leadershipStaffEditSafeMessages` (completado nos irmãos `liderancas/formActions.ts`/`nova/formActions.ts` pelo `/simplify`) e `leadershipLabels.ts` (agora fonte única também para `SupportStatusBadge`); fecha o "Adiado com gatilho" que o B29 registrou) · [plano](plans/autosave-status-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 ✓** sem extrair de novo; `stateDeputyListUrl.ts`/`stateDeputyListFilters.ts` espelham `municipalityListUrl.ts`/`territoryListUrl.ts`; paridade mobile — `StateDeputyFilters` — entrou por decisão desta sessão; ordem dos nulos de `party` pinada por int test, não assumida; `municipalityCount`/`leadershipCount` seguem sem sort/filtro, como travado) · [plano](plans/ordenacao-filtros-lista-dobradinhas.md) - ~~**B34** chips editáveis de municípios na lista de lideranças~~ — **entregue 2026-07-27** (a coluna "Municípios" de `/campanha/liderancas` virou chips editáveis: cada município navega para `/campanha/municipios/[slug]`, um chip de território (`MapPinIcon` + contagem) aparece quando a liderança cobre o TI inteiro, e a célula é **sempre editável em ponteiro fino** — "×" no canto do chip só no hover/foco, input de busca no fim da linha — enquanto em **ponteiro grosso** o toque em qualquer ponto da célula abre um **Drawer** com "×" de 44 px, porque um polegar rolando a tabela não pode ser convidado a distinguir "abrir o município" de "apagar o vínculo". **O pedido literal ganhou, com o anti-goal emendado, não reaberto:** `PRODUCT.md` princípio 3 e `.cursor/rules/campanha-edit-where-you-see.mdc` passaram a admitir input sempre montado **para relações por chips em `pointer-fine`** (o input É o readout; um Popover por linha seria um clique a mais para um alvo já na tela) — B28/B31 ✓/B32 ✓ seguem valendo: nada aqui é "modo de tabela", e o toggle de `AdvisorsTable` foi renomeado para **"Editar nome e contato"**, que é tudo o que ele ainda destrava. **Divergências do plano, todas medidas:** `AdvisorMunicipalityCell` **foi** promovido a `shared/MunicipalityPortfolioCell` (o plano rejeitava — mas com a célula sempre visível a máquina de `ResizeObserver`/"Ver mais…" da origem passou de peso morto a requisito, e `/campanha/assessores` migrou para a peça compartilhada no mesmo PR, então o 2º call site nasceu junto); **sem provider de contexto** (índice + `addableIds` viajam por props da factory de colunas, montada uma vez pelo RSC); **uma única action de lote** (`setLeadershipMunicipalitiesMembership`, `municipalityIds` repetidos + `assigned`) — a gêmea de um id só nunca nasceu e a de assessor (`setAdvisorMunicipalityMembershipRecord` + schema) foi **deletada**, com os int tests migrados para a de lote; `LeadershipRowViewModel.municipalityNames` → `municipalityIDs: number[]` (nome/slug resolvidos no cliente contra o catálogo); piso de 1 com `aria-disabled` + razão no toast em vez de `disabled`, porque `disabled` esconde do teclado a única cópia da explicação; teto de 30 recusado no cliente **antes** do apply otimista, para um lote de TI não voltar inteiro com uma mensagem que não diz qual limite estourou. **`/impeccable` fechou dois P0/P1 reais:** as sugestões eram só de mouse (agora contrato ARIA 1.2 completo — setas/Home/End/Enter, `aria-activedescendant`, listbox portalizado com nome próprio, e-mail e "nenhum resultado" fora do listbox) e o auto-save era mudo (`aria-busy` + dim + live region, rollback só do delta que falhou, e **undo** em remoção de lote); mais alvo de 24 px no "×" via `before:-inset-1` (SC 2.5.8), links dos chips restaurados dentro do Drawer e borda do "×" com contraste real. **Bug latente encontrado e corrigido de graça:** o seam `description` de `CampaignTableHead` (**B22 ✓**) quebrava **qualquer** chamador RSC — `campaignHoverTooltipAlign`/`campaignHoverExplanationClassName` moravam no módulo `'use client'`, então o servidor chamava uma referência de cliente; foram para `src/lib/campaignHoverTooltip.ts` e a lista de lideranças é o primeiro header com explicação em RSC. Sem migration/collection/Consent/mudança de URL) · [plano](plans/chips-municipios-lista-liderancas.md) -- **B34+** escala e DRY pós-B34 — fill-in de engenharia com os débitos que sobreviveram ao `/simplify` do **B34 ✓**, cujos relatórios de perf e reuso chegaram depois do fechamento. O achado que manda é **medido, não estimado**: a entrega custou **+58 kB** de First Load JS em `/campanha/liderancas` (246 → 304 kB) e **+42 kB** em `/campanha/assessores` (253 → 295 kB), e a decomposição importa — assessores **já** importava o catálogo estático da Bahia, então os +42 kB de lá são a **célula em si** (Drawer, links, contrato de combobox, máquina de medição) e só os ~16 kB restantes em lideranças são o catálogo chegando a uma rota que não o tinha; é o mesmo custo que o **B14 ✓** já havia nomeado. As duas saídas se **opõem** (tirar o catálogo do browser engorda o payload de todo toggle; encolher o payload para `{id,slug}` mantém o bundle), e o plano recomenda a segunda por ser staff que edita muito e navega pouco — com a ressalva registrada de que isso troca o `region` do banco pelo do catálogo, que é snapshot-testado. Também entram: `useChipRowOverflow` (o gatilho de extração que o próprio B34 nomeou **disparou** — ~105 linhas idênticas entre `MunicipalityPortfolioCell` e `LeadershipStateDeputyRelationCell`, na mesma tabela), um só `nextRelationMembershipIds` para os três módulos de delta (generalização **verificada exata**, não aproximada) mais um `withLeadershipRelationDelta` local para as duas actions gêmeas e adjacentes, caminho só-ids no loader de lista de assessores, e o `Combobox` do base-ui — que ao contrário do cmdk **não** está descartado, porque `ui/combobox.tsx` já aceita `anchor` e tem hook `data-chips`. O passe localizado que fechou o B34 já derrubou o custo de CPU medido (chips 4,2 → 0,78 ms frio e **0,043 ms** quente; busca 4,59 → **0,077 ms** por tecla) e não volta ao plano. A **segunda rodada de revisão** somou três fases e mudou qual é o maior lever: cada toggle de chip dispara o `revalidatePath` da própria lista e o Next re-executa a página dentro da resposta da action — **374 statements e 229 ms por clique**, mais ~44 kB de payload (F7); o índice de municípios é dado de referência **sem cache** lido em duas rotas e de novo a cada re-render, e ~10 dos seus 12 ms são o transform por documento do Payload, não a query (F8 — degrau 2 da escada, `unstable_cache` + tag); e o ramo do assessor entrega uma **chave nova ao WeakMap por linha** (`municipalityIndex.filter`), derrotando em 25 linhas a memoização que a própria entrega criou (F9). Duas correções de registro vêm junto: o payload-mínimo recomendado acima poupa ~22 kB crus / 3,75 kB gzip mas **nenhum tempo de servidor** — a escolha é payload-contra-bundle, não ganho de servidor; e o custo real da página não é nada disto, é o N+1 de access **pré-existente** que o `TECH-DEBT` agora carrega em **P1** (367 dos 371 statements de `/campanha/liderancas` são a mesma leitura de `campaign_user`), então atacar F7/F8 antes dele é esforço mal alocado · ~1,5d · Janela 1–2 · soft: **B37** (pluga a mesma célula numa 3ª relação — extrair antes evita copiar a máquina uma 3ª vez) · cortável · [plano](plans/escala-dry-pos-b34.md) -- **B37** chips editáveis de municípios na lista de dobradinhas — pedido de produto (2026-07-25): em `/campanha/dobradinhas`, a coluna "Municípios" (hoje `row.municipalityCount`, um número sem nome) ganha o **mesmo tratamento** que a coluna homônima de `/campanha/assessores` (**B19 ✓**) e que o **B34** acabou de desenhar para `/campanha/liderancas`: chips com link para o município, chip de território (`MapPinIcon`) quando a dobradinha cobre o TI inteiro, e a mesma célula editável do **B34 ✓** (chips removíveis + busca por município/TI/ZE, lote de território/zona) — gravação **na hora**, sem "Salvar". Diferença estrutural do B34: aqui o vínculo mora do **lado errado** para reaproveitar aquele desenho — `municipality.stateDeputies` (reverse relationship, não `stateDeputy.municipalities`), sem piso mínimo (uma dobradinha pode não cobrir nenhum município ainda) e sem `assertMunicipalitiesWithinScope` (o próprio `canUpdateMunicipality` do Payload já escopa o assessor); escrita por **delta** no município tocado com lock **por município** (`municipality-state-deputies:{id}`, não por dobradinha), mesmo padrão do B19, reaproveitando o teto de 20 já existente em `stateDeputiesArraySchema`. **Atualizado pelo B34 ✓ (2026-07-27):** a lib pura já foi promovida (`municipalityPortfolio.ts` + `utilities/municipalityPortfolioIndex.ts`) e a célula também — `shared/MunicipalityPortfolioCell` já serve lideranças **e** assessores, com `minItems`/`maxItems`/`addableIds`/`draft` como costuras. Então este item deixa de ser "desenhar de novo com Popover" e passa a ser **plugar a peça existente** numa relação que mora do lado do município: reusar a célula (sem piso, sem `assertMunicipalitiesWithinScope`) e escrever por delta no município tocado com lock por município. O container também herda a decisão do B34: sempre editável em `pointer-fine`, Drawer em `pointer-coarse`, sem toggle de tabela — o conflito com o pedido literal está **fechado**, não pendente. Com lideranças + assessores + dobradinhas a peça chega ao 3º call site sem nenhuma extração nova: o gatilho do `RelationshipChipEditor` agnóstico continua sendo o `LeadershipStateDeputyRelationCell` (B31 ✓), que segue com Popover e máquina de medição próprias. `revalidatePath` (dobradinhas, `[slug]`, município tocado) — sem migration/collection/Consent/mudança de URL · ~0,75–1d · Janela 1–2 · Impeccable B · soft: B34 (mesma promoção de lib + mesma decisão de container), B31 ✓ (mesma família de pedido, outra tabela), B19 ✓ (origem da lib e do cálculo de próximo array), B33 ✓ (mesma tabela — ordenação/filtro no header), E4R ✓ (semeou dobradinha só do lado do município — este item é o preenchimento em lote que faltava) · cortável · [plano](plans/chips-municipios-lista-dobradinhas.md) +- ~~**B37** chips editáveis de municípios na lista de dobradinhas~~ — **entregue 2026-07-28** (a coluna "Municípios" de `/campanha/dobradinhas` troca `row.municipalityCount` pela mesma peça do **B34 ✓** — `shared/MunicipalityPortfolioCell`, sem cópia, sem provider novo — escrevendo do lado invertido `municipality.stateDeputies` por uma action de lote única (`setStateDeputyMunicipalitiesBatch`, transacional, lock por município, **tudo-ou-nada**), sem piso mínimo e sem `assertMunicipalitiesWithinScope` (`canUpdateMunicipality` já escopa o assessor); cap de `stateDeputiesArraySchema` 20 → 435 para caber um lote de território do lado do município. **O critique achou dois P1 e a resposta foi generalizar na hora:** `MunicipalityPortfolioCell` e `LeadershipStateDeputyRelationCell` (B31 ✓/B36 ✓) duplicavam a mesma máquina (delta otimista, undo, combobox ARIA, piso/teto, split pointer-fine/coarse) com contratos incompatíveis — este item **era** o 3º call site que o B34+ esperava, então `shared/RelationChipCell.tsx` nasceu como motor único e as duas células viraram wrappers finos; fecha adiantado o `useChipRowOverflow` do **B34+**. O segundo P1 — chip de município fora do escopo do assessor, removível na UI mas recusado em silêncio pelo servidor — fechou **escondendo** o chip (`addableIds` agora filtra o que a célula renderiza, não só o que oferece adicionar), não só explicando a falha. `LeadershipStateDeputyRelationCell` ganhou `ownerName` e sua primeira suíte de testes própria; gate completo (unit 651/651, int 452/452, Aikido 0 findings). Sem migration/collection/Consent/mudança de URL) · [plano](plans/chips-municipios-lista-dobradinhas.md) +- **B34+** escala e DRY pós-B34 — fill-in de engenharia com os débitos que sobreviveram ao `/simplify` do **B34 ✓**, cujos relatórios de perf e reuso chegaram depois do fechamento. O achado que manda é **medido, não estimado**: a entrega custou **+58 kB** de First Load JS em `/campanha/liderancas` (246 → 304 kB) e **+42 kB** em `/campanha/assessores` (253 → 295 kB), e a decomposição importa — assessores **já** importava o catálogo estático da Bahia, então os +42 kB de lá são a **célula em si** (Drawer, links, contrato de combobox, máquina de medição) e só os ~16 kB restantes em lideranças são o catálogo chegando a uma rota que não o tinha; é o mesmo custo que o **B14 ✓** já havia nomeado. As duas saídas se **opõem** (tirar o catálogo do browser engorda o payload de todo toggle; encolher o payload para `{id,slug}` mantém o bundle), e o plano recomenda a segunda por ser staff que edita muito e navega pouco — com a ressalva registrada de que isso troca o `region` do banco pelo do catálogo, que é snapshot-testado. Também entram: ~~`useChipRowOverflow`~~ (o gatilho de extração que o próprio B34 nomeou **disparou e já foi fechado** — o **B37 ✓** não extraiu só a medição de overflow, generalizou a célula inteira em `shared/RelationChipCell.tsx`, então `MunicipalityPortfolioCell` e `LeadershipStateDeputyRelationCell` não têm mais 105 linhas duplicadas, têm zero), um só `nextRelationMembershipIds` para os três módulos de delta (generalização **verificada exata**, não aproximada — ainda aberto, o B37 não tocou os módulos de delta/action, só a célula) mais um `withLeadershipRelationDelta` local para as duas actions gêmeas e adjacentes, caminho só-ids no loader de lista de assessores, e o `Combobox` do base-ui — que ao contrário do cmdk **não** está descartado, porque `ui/combobox.tsx` já aceita `anchor` e tem hook `data-chips`. O passe localizado que fechou o B34 já derrubou o custo de CPU medido (chips 4,2 → 0,78 ms frio e **0,043 ms** quente; busca 4,59 → **0,077 ms** por tecla) e não volta ao plano. A **segunda rodada de revisão** somou três fases e mudou qual é o maior lever: cada toggle de chip dispara o `revalidatePath` da própria lista e o Next re-executa a página dentro da resposta da action — **374 statements e 229 ms por clique**, mais ~44 kB de payload (F7); o índice de municípios é dado de referência **sem cache** lido em duas rotas e de novo a cada re-render, e ~10 dos seus 12 ms são o transform por documento do Payload, não a query (F8 — degrau 2 da escada, `unstable_cache` + tag); e o ramo do assessor entrega uma **chave nova ao WeakMap por linha** (`municipalityIndex.filter`), derrotando em 25 linhas a memoização que a própria entrega criou (F9). Duas correções de registro vêm junto: o payload-mínimo recomendado acima poupa ~22 kB crus / 3,75 kB gzip mas **nenhum tempo de servidor** — a escolha é payload-contra-bundle, não ganho de servidor; e o custo real da página não é nada disto, é o N+1 de access **pré-existente** que o `TECH-DEBT` agora carrega em **P1** (367 dos 371 statements de `/campanha/liderancas` são a mesma leitura de `campaign_user`), então atacar F7/F8 antes dele é esforço mal alocado · ~1,5d · Janela 1–2 · soft: ~~**B37**~~ ✓ (pluga a mesma célula numa 3ª relação — entregue 2026-07-28 generalizando para `shared/RelationChipCell.tsx` em vez de só copiar a máquina; fecha o `useChipRowOverflow` acima) · cortável (exceto o P1 de N+1 de access, que já não é cortável) · [plano](plans/escala-dry-pos-b34.md) - ~~**B36** coluna de lideranças na lista de dobradinhas~~ — **entregue 2026-07-26** (coluna **Lideranças** em `/campanha/dobradinhas`: chips clicáveis → `/campanha/liderancas/`, edição por Popover+`Command` com escrita por server action + `useTransition`, reusando **sem alteração** `setLeadershipStateDeputyMembership`/`leadershipStateDeputyMembershipSchema`/lock `leadership-state-deputies:{id}`/`nextStateDeputyIdsAfterMembership` do **B31 ✓**; `LeadershipStateDeputiesCell` promovido a `shared/LeadershipStateDeputyRelationCell` bidirecional (`direction` + `measureOverflow` — clamp 3 linhas + "Ver mais…" no lado do deputado; lado liderança passa `false`); columns factory `stateDeputyColumns(..., leadershipOptions)` — não Context; casca própria `dobradinhas/formActions.ts`; `loadLeadershipOptions`; `StateDeputyRowViewModel.leadershipCount` → `leaderships: {id,name}[]` na mesma query batch. Sem migration) · [plano](plans/chips-liderancas-lista-dobradinhas.md) - ~~**B17** seletor de colunas (mostrar/ocultar)~~ — **entregue 2026-07-28**, e **nas sete superfícies do sistema de listas**, não só em municípios: o picker nasceu como capacidade do `CampaignTable` (uma prop `columnVisibility` liga e filtra), então municípios, lideranças, dobradinhas, organizações, demandas, apoiadores e territórios ganharam a mesma barra "Colunas" no mesmo lugar, de graça. **Duas premissas do plano morreram na implementação.** A primeira é o `localStorage`: `CampaignTableColumn.cell` é uma **função**, o array de colunas não atravessa a fronteira server/client, e esconder no cliente exigiria `data-column` + `