From c68e1d2394f88409c8ce27cf79e003a73112436c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Tue, 28 Jul 2026 10:38:56 -0300 Subject: [PATCH] Record the B37 closing triage on B34+ instead of a new ID. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /simplify triage produced two live debts, and both are about the same pair of functions the B34+ plan already names in F3, so they landed as F10 of that plan rather than as an escala-dry-pos-b37: the município-side batch actions write one município at a time (findByID + update each — ~870 round trips at the 435 ceiling B37 itself raised, but ~52 at the largest real TI, and no test above a batch of 2), and the two are twins at 2 call sites, one short of the bar. The common trigger is a "select all"/multi-TI affordance or a third batch on that side, and F10 records why the bulk-SQL rewrite is not obviously the answer: it would hand back the scope check that overrideAccess: false gives for free. This delivery also closed B34+ phases outright, which is worth reading in the plan rather than inferring from a diff. F2 is done and its prediction was wrong in a way worth keeping: it stated "o B37 não retira este débito", and B37 read the same evidence the other way — the ~165 measured lines were not deduplicated into useChipRowOverflow, they were deleted when both cells became wrappers of one engine. F9 is done via scopedPortfolioIndex. Half of F3 is done (the three single-id deltas), and the half that stayed out — the set-with-floor form — stayed out because seeing the collapsed code confirmed the plan's own inverted recommendation. F5 and two F6 bullets shrank for the same reason: "written twice across the two surfaces" stopped having two surfaces. Also corrects a comment I got wrong in the previous commit: the batch's sort does not carry the deadlock-avoidance guarantee, since acquireTextAdvisoryLocks sorts its own keys (C8 batched them into one round trip); it only makes the apply order stable. --- .cursor/rules/projects/nucleos-eleitorais.mdc | 2 +- AGENTS.md | 2 +- .../chips-municipios-lista-dobradinhas.md | 12 +++++ docs/plans/escala-dry-pos-b34.md | 45 ++++++++++++++----- docs/roadmap.md | 4 +- .../campanha/actions/stateDeputy.ts | 5 ++- 6 files changed, 53 insertions(+), 17 deletions(-) diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index b01165a5..114b1429 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -234,4 +234,4 @@ alwaysApply: false - **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) 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). +- **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). **O `/simplify` do fechamento achou um bug latente, e ele é a razão de uma convenção daqui ser carga estrutural: `mapCampaignFormActionError` casa a mensagem lançada contra `safeMessages` por string exata**, então uma mensagem de teto escrita como literal cru no `throw` **e** em dois allowlists independentes são três chances de reescrever uma e colapsar em silêncio uma recusa real em "tente novamente". O que as expôs: o helper de delta deste item era a **terceira** cópia byte-a-byte do mesmo cálculo de um id, diferindo só no cap que lê — virou `nextIdsAfterMembership` (cap como dado, três wrappers de uma linha) e os três specs clonados viraram um `describe.each` **com mais cobertura que antes**; as mensagens viraram constantes ao lado dos seus caps (`LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE`, `MUNICIPALITY_ADVISORS_CAP_MESSAGE`, `STATE_DEPUTY_STAFF_MESSAGE`). A forma "conjunto com piso e teto" ficou fora de propósito: o B34+ já havia invertido essa recomendação numa segunda leitura, e o código colapsado confirmou (genérico das duas formas pede quatro callbacks para poupar ~20 linhas; o helper novo tem 12 sem parâmetro opcional). Duas lacunas de memoização fechadas, ambas restaurando invariante que a base já tinha: `scopedPortfolioIndex` (o `.filter()` do escopo do assessor devolvia array novo por linha, derrotando o WeakMap que existe para compartilhar derivações entre linhas — fecha o F9 do B34+) e a busca da célula de dobradinhas, que renormalizava o catálogo a cada tecla. `maxItems` "por paridade" foi **rejeitado** (dobradinha não tem teto de produto; um "30 generoso" recusaria dobradinha estadual legítima). Dois débitos **absorvidos no B34+ como F10**, sem abrir ID: o laço município-a-município das duas actions de lote e a gemelaridade delas (2 call sites) — gatilho: "selecionar tudo"/multi-TI ou um terceiro lote desse lado. 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 a3248626..fe67aa17 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,7 +88,7 @@ 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-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 715/715, 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. **The closing `/simplify` found a latent bug rather than a deduction, and it is the reason one convention here is now load-bearing: `mapCampaignFormActionError` matches a thrown message against `safeMessages` by exact string, so a cap message spelled as a bare literal in the throw _and_ in two independent allowlists is three chances to reword one of them and silently collapse a real refusal into "tente novamente".** Those messages are constants next to their caps now (`LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE`, `MUNICIPALITY_ADVISORS_CAP_MESSAGE`, plus `STATE_DEPUTY_STAFF_MESSAGE` — renamed, since it guards create/update too, and `actions/stateDeputy.ts` was importing the constant while restating it as a literal one line below). What surfaced them: B37's delta helper was the **third** byte-for-byte copy of the same single-id calculation, differing only in which cap it reads, so the algorithm lives once in `src/lib/relationMembershipDelta.ts` (`nextIdsAfterMembership`, cap as data) with the three domain names as one-line wrappers, and the three cloned specs became one `describe.each` **with more coverage than before** (every relation now gets the "can go to zero" and no-mutation cases only B37's clone had). The set-with-floor form (`nextMunicipalityIdsAfterLeadershipMembership`) stayed out on purpose: B34+ had already inverted that recommendation on a second reading, and seeing the collapsed code confirmed it — a generic covering both forms needs four callbacks to save ~20 lines, while the new helper is 12 lines with no optional parameter. Two memoization gaps closed, both restoring invariants the codebase had elsewhere: the advisor's scoped `.filter()` returned a fresh array per row, defeating the very `WeakMap` that exists to share derivations across rows (now `scopedPortfolioIndex`, memoized on `(index, addableIds)` in the lib — this closes B34+ F9), and the leadership cell's search called `matchesAtWordStart` per candidate, paying an NFD pass plus three Unicode regexes on both sides per keystroke, the cost `wordStartFilter`'s own doc comment warns about. Also: the batch action stopped resolving the dobradinha slug before its loop (the slug exists only to target the revalidate, which the caller already skips on a no-op — the guard its leadership twin already had), its redundant second dedup is gone, and the bare `435` shared with the advisor batch is `MAX_MUNICIPALITIES_PER_BATCH`. **Rejected with reason:** `maxItems` on the dobradinhas cell "for parity" — a dobradinha has no product ceiling on municípios (which is why the bound is the catalog), so a "generous 30" would invent a rule that refuses a legitimate statewide dobradinha. **Two debts absorbed into B34+ as F10 rather than opening an ID:** both município-side batch actions write one município at a time (`findByID` + `update` each — ~870 round trips at the 435 ceiling this delivery raised, but ~52 at the largest real TI, and no test above a batch of 2), and they are **twins** at 2 call sites; the common trigger is a "select all"/multi-TI affordance or a third batch on that side. 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). diff --git a/docs/plans/chips-municipios-lista-dobradinhas.md b/docs/plans/chips-municipios-lista-dobradinhas.md index a9d3c1be..fba79b75 100644 --- a/docs/plans/chips-municipios-lista-dobradinhas.md +++ b/docs/plans/chips-municipios-lista-dobradinhas.md @@ -27,6 +27,18 @@ Responsável: — **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. +### `/simplify` do fechamento (2026-07-28) — o que mudou depois do as-built acima + +**O achado que vale guardar é um bug latente, não uma dedução:** `nextStateDeputyIdsAfterMunicipalityMembership` nasceu aqui como a **terceira** cópia byte-a-byte do mesmo delta de um id (as três diferiam só no teto que leem), então o algoritmo passou a viver uma vez em `src/lib/relationMembershipDelta.ts` e os três nomes de domínio são wrappers de uma linha. O efeito colateral é que duas das mensagens de teto eram **literais crus repetidos em três lugares de `src` cada** — a lançada mais dois allowlists independentes — e `mapCampaignFormActionError` casa a mensagem lançada contra `safeMessages` por **string exata**: reescrever uma delas colapsaria silenciosamente uma recusa real em "tente novamente". Viraram constantes ao lado dos seus caps, seguindo o `MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE` que esta entrega já havia ajustado nos mesmos arquivos. A mensagem de staff da dobradinha tinha a mesma exposição em quatro lugares (e `actions/stateDeputy.ts` já **importava** a constante enquanto reescrevia o literal uma linha abaixo), então é `STATE_DEPUTY_STAFF_MESSAGE` — renomeada, porque guarda create/update também, não só o lote. Os três specs clonados viraram um `describe.each`, e a cobertura subiu: cada relação recebeu os casos "pode ir a zero" e "não muta a entrada" que só o clone deste item tinha. Unit 651 → **715**. + +Na action de lote: o slug da dobradinha deixou de ser resolvido antes do laço (ele existe só para mirar o `revalidate`, que o chamador já pula quando nada mudou — o guard que o gêmeo `setLeadershipMunicipalitiesMembershipRecord` já tinha), o segundo dedup redundante saiu (o `.transform` do schema já deduplica; o `sort` fica, e o comentário dele foi corrigido: quem garante ordem de lock é `acquireTextAdvisoryLocks`, que ordena as próprias chaves), e o `435` cru que este item passou a dividir com o lote de assessores é `MAX_MUNICIPALITIES_PER_BATCH`. + +Duas lacunas de memoização fechadas, ambas restaurando invariantes que o código já tinha em outro lugar: o `.filter()` do escopo do assessor devolvia um array novo **por linha**, justamente derrotando o WeakMap que existe para compartilhar derivações entre linhas (`scopedPortfolioIndex` memoiza por `(index, addableIds)`, fechando o **F9** do B34+), e a busca do `LeadershipStateDeputyRelationCell` chamava `matchesAtWordStart` por candidato, pagando um passe NFD e três regexes Unicode dos dois lados a cada tecla sobre o catálogo de lideranças — o custo que o próprio docstring de `wordStartFilter` avisa; agora normaliza a query uma vez contra rótulos cacheados, como `municipalityPortfolio` já fazia. + +**Rejeitado com motivo:** `maxItems` na célula de dobradinhas, sugerido "por paridade" com o B34. Uma dobradinha não tem teto de produto de municípios (é por isso que o bound é o catálogo), então um "30 generoso" inventaria uma regra que recusa uma dobradinha estadual legítima, e o 435 honesto é inalcançável quando os ids são únicos. + +**Adiado com gatilho, absorvido no B34+ como fase F10** (não abriu ID novo): o laço município-a-município das duas actions de lote do lado do município (`findByID` + `update` por município — ~870 round trips no teto de 435, mas ~52 no maior TI real, e nenhum teste acima de lote 2) **e** a gemelaridade das duas actions (2 call sites, abaixo do limiar de 3). Gatilho comum: uma afordância de "selecionar tudo"/multi-TI, ou um terceiro lote do lado do município. + ## ⚠️ 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/plans/escala-dry-pos-b34.md b/docs/plans/escala-dry-pos-b34.md index fb197544..c29d94e0 100644 --- a/docs/plans/escala-dry-pos-b34.md +++ b/docs/plans/escala-dry-pos-b34.md @@ -54,11 +54,21 @@ Contra a escada de cache do repo: o degrau 1 (`cache()`) rende **zero** — é u **Correção ao F1:** a opção (b) "payload mínimo" encolhe o payload RSC de **35.510 B / 7.286 B gzip** para **13.610 B / 3.534 B gzip**, mas **não poupa tempo de servidor nenhum** (10,7 ms de qualquer jeito). A decisão registrada é payload-contra-bundle, não um ganho de servidor. -## F9 — O ramo do assessor entrega uma chave nova ao WeakMap por linha +## F9 ✓ — O ramo do assessor entrega uma chave nova ao WeakMap por linha -`searchIndex` filtra `municipalityIndex` quando há `addableIds`, produzindo **um array novo por linha** — e é esse array que vira a chave do WeakMap de derivações. O assessor fica com 25 conjuntos de derivação (4 Maps cada) em vez de um, e paga o build O(435) de novo na primeira busca de cada linha: **1,89 ms** para as 25 contra 0,076 ms para uma. É exatamente o modo de falha que a memoização foi escrita para evitar, embora o número absoluto seja pequeno. +**Fechado no `/simplify` do B37 ✓ (2026-07-28), pela segunda saída — e ela não ficou dentro da célula.** `searchIndex` filtrava `municipalityIndex` quando há `addableIds`, produzindo **um array novo por linha**, e era esse array que virava a chave do WeakMap de derivações: 25 conjuntos (4 Maps cada) em vez de um, mais o build O(435) repetido na primeira busca de cada linha (**1,89 ms** para as 25 contra 0,076 ms para uma). -Duas saídas, nenhuma feita agora: passar `addableIds` como opção de `searchMunicipalityPortfolio` (preserva a contagem escopada de hits de TI/ZE e elimina as 25 alocações, mas mexe na assinatura de uma função compartilhada), ou memoizar o filtro num WeakMap aninhado dentro da célula (contido, mas é uma segunda camada de cache). +O filtro passou a ser `scopedPortfolioIndex(index, addableIds)` em `src/lib/municipalityPortfolio.ts`, memoizado num WeakMap aninhado por `(index, addableIds)` — as duas chaves são estáveis por request (o índice é montado uma vez pelo RSC; o `Set` de escopo também), então todas as linhas voltam a receber **o mesmo array** e a memoização de derivações volta a funcionar como escrita. Mora na lib, não na célula, porque a alternativa "WeakMap dentro do componente" seria uma segunda camada de cache ao lado da que já existe no mesmo módulo — e porque a primeira saída (`addableIds` como opção de `searchMunicipalityPortfolio`) muda a assinatura de uma função compartilhada para resolver um problema de identidade de array, que é o que um WeakMap já resolve. + +## F10 — O lote do lado do município escreve município por município (absorvido do B37 ✓) + +Entrou por triage do `/simplify` do **B37 ✓** (2026-07-28), como fase desta família e não como item novo, porque a função que a fase discute — `setAdvisorMunicipalitiesBatchRecord` — já é nomeada no F3 acima. + +`setStateDeputyMunicipalitiesBatchRecord` e `setAdvisorMunicipalitiesBatchRecord` percorrem a relação **invertida** (o array mora em `municipality.advisors`/`municipality.stateDeputies`), e cada município do lote custa **um `findByID` + um `update`** com `overrideAccess: false` — a leitura existe porque o acesso por linha e por campo do Payload é o que escopa o assessor, e o update precisa do array inteiro. Os locks **não** são o problema (o C8 ✓ já os bateu em 1 round trip via `unnest`); o laço é. No teto do schema (435, elevado pelo próprio B37 para caber um TI inteiro) são ~870 round trips numa transação, cada um pagando ~1–3 ms de `gru1`↔`sa-east-1` — e o int spec mais gordo do caminho usa lote de 2, então o comportamento acima de um punhado de municípios é **inferido, não medido**. + +Não foi corrigido no fechamento porque o teto é inalcançável pelo produto de hoje: o maior lote que a UI oferece é um chip de território, e o maior TI da Bahia tem **26** municípios (Litoral Sul — medido em `bahiaTerritories.ts`, ~52 round trips). **O gatilho é uma afordância de "selecionar tudo" / "aplicar a vários TIs"** — quem construir isso mede antes: ou reescreve o laço em SQL bulk sobre a transação do Payload (precedente `supporterImportBulk.ts`, que já faz `payload.db.drizzle` em chunks dentro da transação aberta), **ou** mantém o laço e trava o lote num limite honesto abaixo do universo. Reescrever em bulk custa a checagem de escopo que o `overrideAccess: false` dá de graça hoje — ela teria de ser reimplementada explicitamente contra `getAdvisorMunicipalityIds`, que é exatamente o tipo de reimplementação de access que o repo evita, então a medição vem antes da escolha. + +**Segunda metade, com o mesmo gatilho: as duas actions são gêmeas (2 call sites).** Fora o nome do campo, do lock e da mensagem, são a mesma sequência: parse, dedup+sort, transação, `reloadStaffActor`, locks em bloco, laço de leitura escopada + delta puro + update, coleta de slugs mudados, revalidate por delta. Está **abaixo** do limiar de 3 do `AGENTS.md`, e — diferente do F3, onde o algoritmo era idêntico — aqui o que se repete é uma sequência de orquestração cuja extração pediria parametrizar "de que lado da aresta eu escrevo", a fontanaria genérica que o F3 já rejeitou uma vez. Registrado para que um **terceiro** lote do lado do município não nasça como terceira cópia: quem o escrever extrai `withMunicipalitySideRelationBatch({ relation, lockPrefix, computeNext })` primeiro, e fecha esta metade junto com a de cima. ## F1 — Regressão de First Load JS nas duas listas (prioritário) @@ -84,9 +94,13 @@ O staff edita muito e navega pouco, o que favorece **(b)** — o bundle é pago Um terceiro lance, **ortogonal e cumulativo**: carregar o `Drawer` por `next/dynamic` atrás do primeiro toque. Ele só abre em `pointer-coarse`, e uma gaveta que já anima pode absorver o import. Recupera parte dos +42 kB que atingem as duas rotas. -## F2 — `useChipRowOverflow`: o gatilho disparou e a máquina foi copiada +## F2 ✓ — `useChipRowOverflow`: o gatilho disparou e a máquina foi copiada -`MunicipalityPortfolioCell` e `LeadershipStateDeputyRelationCell` (B31 ✓) carregam ~105 linhas idênticas de medição de overflow — as constantes, o guard de "não trate o primeiro callback do observer como resize", a invalidação por conteúdo, o filtro `lastVisibleTop + 1`, o laço de largura da cauda, o `expandToggle` com `tabIndex={measuring ? -1 : undefined}` — incluindo um comentário verbatim. As duas células são renderizadas **pela mesma tabela**. +**Fechado no B37 ✓ (2026-07-28), e a previsão desta fase estava errada — vale registrar por quê.** Estava escrito aqui que "**o B37 não retira este débito**: B37 pluga uma terceira relação na célula compartilhada sem máquina nova; quem fecha o F2 é quem deletar `LeadershipStateDeputyRelationCell`". O critique pós-implementação do B37 leu a mesma evidência ao contrário: se as duas células rodam na mesma tabela com a mesma máquina escrita duas vezes e contratos incompatíveis, então **o B37 já era o 3º call site** — e a resposta foi generalizar na hora em vez de registrar um quarto adiamento. `src/components/campaign/shared/RelationChipCell.tsx` nasceu como o motor único e as duas células viraram wrappers finos (`MunicipalityPortfolioCell` 816 → ~180 linhas; `LeadershipStateDeputyRelationCell` 450 → ~185), então as ~165 linhas que esta fase mediu **não foram deduplicadas num hook, foram deletadas** — não há mais duas cópias de medição, adoção por `sameIdSet`, `expandToggle` nem redutor otimista para extrair. O módulo do qual a cópia sobreviveu continua existindo, mas sem máquina dentro: o que resta nele é domínio (chips com sufixo de partido, `FormData`, copy). + +Não reabrir como "extrair `useChipRowOverflow`": a medição vive uma vez, dentro do motor, e um hook separado só faria sentido para um terceiro consumidor que **não** seja uma célula de chips. + +**Descrição original (histórico):** `MunicipalityPortfolioCell` e `LeadershipStateDeputyRelationCell` (B31 ✓) carregavam ~105 linhas idênticas de medição de overflow — as constantes, o guard de "não trate o primeiro callback do observer como resize", a invalidação por conteúdo, o filtro `lastVisibleTop + 1`, o laço de largura da cauda, o `expandToggle` com `tabIndex={measuring ? -1 : undefined}` — incluindo um comentário verbatim. As duas células são renderizadas **pela mesma tabela**. A duplicação é **anterior** ao B34 (nasceu entre `AdvisorMunicipalityCell` e a célula do B31; esta entrega moveu uma das cópias para `shared/`), mas o `AGENTS.md` do B34 escreveu que "**ele**, não o B37, é o gatilho de extração" — e o gatilho disparou. Extrair `useChipRowOverflow({ chipsKey, chipAttribute, toggleAttribute, trailingWidth, gapPx, enabled })` para `shared/`; a única diferença real entre os dois chamadores é o que ocupa o slot final (um botão de lápis com `ref` contra o `min-width` calculado do input), que vira o parâmetro. @@ -94,9 +108,17 @@ A duplicação é **anterior** ao B34 (nasceu entre `AdvisorMunicipalityCell` e **O `/simplify` do B32+ ✓ mexeu nesta célula e mudou duas coisas para esta fase (2026-07-28).** A região `aria-live` dela estava **dentro** do `PopoverContent` — o bug exato que o B32+ F4 corrigiu nos outros cinco controles, e que aqui deixava a falha e o "Salvando…" mudos justamente depois de fechar, que é quando a gravação acontece — e foi içada para fora do ``, com `role="status"`, já nesta forma: **a migração não pode reintroduzi-la dentro do overlay.** E o argumento de custo virou: `CampaignCellEditOverlay` já é usado por seis chamadores, então a parte "trigger + Popover" desta célula deixou de ser trabalho de extração e passou a ser adoção de casca pronta (inclusive o `initialFocus` e o `aria-busy` que ela não tem) — o que sobra de verdade nesta fase é a máquina de chips e o redutor otimista. Um detalhe da adoção, medido no B34: a folha do container é um `Drawer` sempre montado e reporta `onOpenChange(false)` quando **qualquer** dismiss varre a pilha de camadas, não só quando ela fecha. -## F3 — Um só delta de membership +## F3 — Um só delta de membership (metade fechada) -`nextMunicipalityIdsAfterLeadershipMembership` é o **terceiro** módulo da família, e a generalização foi **verificada exata**, não aproximada: chamá-lo com um array de um elemento reproduz a semântica dos dois irmãos (`nextStateDeputyIdsAfterMembership`, `nextAdvisorIdsAfterMembership`) — `assigned === alreadyAssigned → null` equivale a `added.length === 0 → null`, e o teto checado antes do append equivale ao checado depois para um id. A única capacidade que os irmãos não têm é o piso, que já é opcional na forma. Colapsar em `nextRelationMembershipIds(current, changed, assigned, { max, capMessage, min, floorMessage })`, com os três nomes como wrappers de uma linha se o vocabulário importar; os três specs unitários viram um. +**A metade dos três deltas de um id foi fechada no `/simplify` do B37 ✓ (2026-07-28), e a recomendação invertida da segunda leitura se confirmou.** `nextStateDeputyIdsAfterMunicipalityMembership` (nascido no B37) era a **terceira** cópia byte-a-byte do mesmo algoritmo de um id — os três diferiam só no teto que leem —, então ele vive uma vez em `src/lib/relationMembershipDelta.ts` (`nextIdsAfterMembership(current, id, assigned, { max, message })`) e os três nomes de domínio são wrappers de uma linha ligando o seu próprio cap. Os três specs clonados viraram um `describe.each`, e a cobertura **subiu**: cada relação agora recebe os casos "pode ir a zero" e "não muta a entrada" que só o clone do B37 tinha, mais a asserção explícita de que cada wrapper recusa no **seu** teto com a **sua** mensagem. + +**`nextMunicipalityIdsAfterLeadershipMembership` ficou de fora de propósito** — é a forma "um conjunto com piso e teto", e a segunda leitura registrada acima (um genérico cobrindo as duas formas precisaria de quatro callbacks para poupar ~20 linhas) continua valendo depois de ver o código colapsado: o helper novo tem 12 linhas e nenhum parâmetro opcional. + +**Efeito colateral que era um bug latente, e a razão de o pareamento importar:** duas das mensagens de teto eram literais crus repetidos em três lugares de `src` cada (lançada + dois allowlists independentes), e `mapCampaignFormActionError` casa mensagem lançada contra `safeMessages` por **string exata** — reescrever uma delas colapsaria silenciosamente uma recusa real em "tente novamente". Viraram constantes ao lado dos seus caps (`LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE`, `MUNICIPALITY_ADVISORS_CAP_MESSAGE`), seguindo o precedente `MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE` que já existia nos mesmos arquivos. A mensagem de staff da dobradinha tinha a mesma exposição em quatro lugares — e `actions/stateDeputy.ts` já **importava** a constante enquanto reescrevia o literal uma linha abaixo — então é `STATE_DEPUTY_STAFF_MESSAGE` (renomeada: guarda create/update também, não só o lote), e a de conflito que ela divide com a rota de criação também tem nome. + +**Continua aberto nesta fase:** o `withLeadershipRelationDelta` abaixo (política de escrita por delta sob lock consultivo numa linha de liderança). + +**Descrição original (histórico):** `nextMunicipalityIdsAfterLeadershipMembership` é o **terceiro** módulo da família, e a generalização foi **verificada exata**, não aproximada: chamá-lo com um array de um elemento reproduz a semântica dos dois irmãos (`nextStateDeputyIdsAfterMembership`, `nextAdvisorIdsAfterMembership`) — `assigned === alreadyAssigned → null` equivale a `added.length === 0 → null`, e o teto checado antes do append equivale ao checado depois para um id. A única capacidade que os irmãos não têm é o piso, que já é opcional na forma. Colapsar em `nextRelationMembershipIds(current, changed, assigned, { max, capMessage, min, floorMessage })`, com os três nomes como wrappers de uma linha se o vocabulário importar; os três specs unitários viram um. No mesmo arquivo, `setLeadershipMunicipalitiesMembershipRecord` e `setLeadershipStateDeputyMembershipRecord` são ~55 de ~90 linhas iguais e **adjacentes**, com três comentários copiados palavra por palavra. Um `withLeadershipRelationDelta({ leadershipId, relation, lockKey, computeNext })` — local ao arquivo, não genérico entre collections — dono da transação, do lock, da leitura escopada e do early return de no-op, deixando ao chamador a asserção de escopo e a busca de slug. Precedente declarado: `runStaffEntityMutation` (política no helper, mutação tipada no chamador). São 2 call sites, abaixo do limiar de 3 — mas "escrita por delta sob lock consultivo numa linha de liderança" é uma política que merece nome, que é a outra metade da mesma regra. @@ -106,14 +128,15 @@ No mesmo arquivo, `setLeadershipMunicipalitiesMembershipRecord` e `setLeadership `leadershipData.ts` estreitou certo (`municipalityIDs: number[]`), mas `AdvisorsTable` faz `row.municipalities.map((m) => m.id)` e joga `name`/`slug` fora — enquanto `municipalitiesByAdvisorIds` ainda roda um `payload.find` inteiro sobre `municipality` para produzi-los. A mesma decisão tomada de dois jeitos opostos na mesma entrega. Dar ao loader **de lista** um caminho só-ids (a forma `{id,name,slug}` continua para `assessores/[id]`, que de fato renderiza os nomes). Se o F1 (b) entrar, cai junto: o cliente resolve tudo pelo catálogo. -## F5 — Combobox à mão onde o kit já embrulha um +## F5 — Combobox à mão onde o kit já embrulha um (escopo reduzido pelo B37 ✓) -São ~90 linhas de teclado/ARIA mais ~20 de atributos duplicados entre as duas superfícies. **A restrição que descartaria a alternativa não se aplica:** o cmdk `Command` está corretamente fora (ele resolve itens consultando `[cmdk-item]` dentro da própria raiz, e o listbox aqui **precisa** ser portalizado porque o container de scroll da tabela recorta uma lista em fluxo) — mas `ui/combobox.tsx`, que embrulha o `Combobox` do base-ui, já aceita **`anchor`** e tem um hook `data-chips={!!anchor}`, ou seja, foi escrito para exatamente esta forma: input dentro de uma linha de chips, listbox portalizado ancorado na célula. Fit parcial (hits heterogêneos — município/TI/ZE — e a recusa por teto pedem render de item próprio de qualquer jeito), então remove a metade de teclado/ARIA, não o todo. +**A metade "duplicada" morreu com o `RelationChipCell` (B37 ✓, 2026-07-28):** os ~20 atributos repetidos entre as duas superfícies e as ~16 linhas de `searchInputProps` do F6 não existem mais — o input de busca e o listbox são escritos **uma vez**, no motor. O que resta são as ~90 linhas de teclado/ARIA num único lugar, o que **melhora** a economia da troca em vez de piorá-la: adotar `ui/combobox.tsx` passou a ser uma edição, não duas mantidas em sincronia. O argumento abaixo (fit parcial, `anchor` já previsto, cmdk corretamente fora) continua íntegro. + +**Descrição original (histórico):** eram ~90 linhas de teclado/ARIA mais ~20 de atributos duplicados entre as duas superfícies. **A restrição que descartaria a alternativa não se aplica:** o cmdk `Command` está corretamente fora (ele resolve itens consultando `[cmdk-item]` dentro da própria raiz, e o listbox aqui **precisa** ser portalizado porque o container de scroll da tabela recorta uma lista em fluxo) — mas `ui/combobox.tsx`, que embrulha o `Combobox` do base-ui, já aceita **`anchor`** e tem um hook `data-chips={!!anchor}`, ou seja, foi escrito para exatamente esta forma: input dentro de uma linha de chips, listbox portalizado ancorado na célula. Fit parcial (hits heterogêneos — município/TI/ZE — e a recusa por teto pedem render de item próprio de qualquer jeito), então remove a metade de teclado/ARIA, não o todo. ## F6 — Baixos -- **`PortfolioChipLabel`**: o ramo `chip.kind === 'municipality' ? : ` está escrito duas vezes (~14 linhas) nas duas superfícies. O resto da divergência entre elas é a tese da entrega (16 px revelado no hover contra 44 px sempre visível) e deve continuar divergindo. -- **`searchInputProps(surface)`**: o input de busca está escrito nas duas superfícies com **oito** props idênticas (`role`, `aria-expanded`, `aria-controls`, `aria-autocomplete`, `aria-activedescendant`, `onKeyDown`, `aria-label` e o corpo `setQuery` + `setFeedback(null)`), divergindo só no `setOpen(true)`/`onFocus`/`onBlur` do inline. A divergência é **correta**, e é justamente por isso que deveria ser explícita: uma factory espalhada nos dois, com os overrides do inline escritos depois do spread, poupa ~16 linhas e troca uma assimetria inferida por uma declarada. +- ~~**`PortfolioChipLabel`**~~ ✓ e ~~**`searchInputProps(surface)`**~~ ✓ — os dois eram "escrito duas vezes nas duas superfícies", e as duas superfícies passaram a ser **uma** com o `RelationChipCell` (B37 ✓): o rótulo do chip é um `renderChipLabel` do motor e o input de busca é montado uma vez. Nada a extrair. - **e2e**: `createStaffLeadership({ role, municipalities })` e `expectPostResponse(page, fragment)` em `campaignE2EFixtures.ts` — 3 call sites hoje. - **`relationIds()`** exportado de `tests/helpers/campaignFixtures.ts`: `typeof entry === 'number' ? entry : entry.id` está reescrito em 9 lugares nos int specs, com `src/utilities/relationship.ts` já exportando o equivalente de produção. Débito anterior; o spec novo é a 9ª instância. - **`loadMunicipalityPortfolioIndex` fora da escada de cache**: leitura do catálogo inteiro, de campos só-geografia, idêntica para todo ator e toda request, sem `cache()` nem `unstable_cache`. Uma chamada por request hoje, então o degrau 1 não compra nada — mas o degrau 2 é a resposta da escada. Decidir, não deixar por omissão. diff --git a/docs/roadmap.md b/docs/roadmap.md index 228e54fb..8353a8b7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -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). +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, int 452/452, Aikido 0 findings). **O `/simplify` do fechamento achou um bug latente, não uma dedução:** o helper de delta que este item criou era a **terceira** cópia byte-a-byte do mesmo cálculo de um id, e o pareamento importa porque duas das mensagens de teto eram literais crus repetidos em três lugares de `src` cada — `mapCampaignFormActionError` casa a mensagem lançada contra `safeMessages` por **string exata**, então reescrever uma delas colapsaria silenciosamente uma recusa real em "tente novamente"; o algoritmo virou `nextIdsAfterMembership` (os três nomes de domínio são wrappers de uma linha), as mensagens viraram constantes ao lado dos seus caps, e os três specs clonados viraram um `describe.each` com **mais** cobertura que antes (unit 651 → 715). Também: `scopedPortfolioIndex` fecha o **F9** do B34+ (o `.filter()` do escopo do assessor devolvia um array novo por linha, derrotando o WeakMap que existe para compartilhar derivações entre linhas) e a busca da célula de dobradinhas parou de renormalizar o catálogo a cada tecla; `maxItems` "por paridade" foi **rejeitado** (dobradinha não tem teto de produto, e um "30 generoso" recusaria uma dobradinha estadual legítima); os dois débitos que sobraram — o laço município-a-município das duas actions de lote e a gemelaridade delas (2 call sites) — foram **absorvidos no B34+ como F10**, sem abrir ID novo · [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) @@ -119,7 +119,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re - ~~**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) - ~~**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) +- **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ó delta de membership para os três módulos de um id~~ (**fechado no `/simplify` do B37 ✓**: `nextIdsAfterMembership` com o cap como dado, três wrappers de uma linha, três specs clonados → um `describe.each`; a forma "conjunto com piso e teto" ficou **de fora de propósito** — a recomendação invertida da segunda leitura se confirmou depois de ver o código colapsado, porque um genérico cobrindo as duas formas pediria quatro callbacks para poupar ~20 linhas, e o helper novo tem 12 sem parâmetro opcional) — resta o `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` derrotava em 25 linhas a memoização que a própria entrega criou — **fechado no `/simplify` do B37 ✓** por `scopedPortfolioIndex`, memoizado por `(index, addableIds)` na lib e não numa segunda camada de cache dentro da célula; F9). A triage desse fechamento **absorveu uma fase nova em vez de abrir ID** (**F10**): as duas actions de lote do lado do município escrevem município a município (`findByID` + `update` cada, ~870 round trips no teto de 435 que o próprio B37 elevou, mas ~52 no maior TI real — Litoral Sul, 26 — e nenhum teste acima de lote 2), e são **gêmeas** em 2 call sites; gatilho comum: uma afordância de "selecionar tudo"/multi-TI, ou um terceiro lote desse lado — quem chegar mede antes de escolher entre SQL bulk (que devolveria à mão a checagem de escopo que o `overrideAccess: false` dá de graça) e um teto honesto abaixo do universo. 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` + `