diff --git a/docs/plans/escala-dry-pos-b32.md b/docs/plans/escala-dry-pos-b32.md index c1aa6dbd..dafce269 100644 --- a/docs/plans/escala-dry-pos-b32.md +++ b/docs/plans/escala-dry-pos-b32.md @@ -3,8 +3,8 @@ Status: rascunho Atualizado em: 2026-07-27 Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins abertos, **B32+**, fill-in de engenharia) -Impeccable: A — N/A (sem superfície UI nova; extrai comportamento já entregue sem mudar contrato de rede nem markup visível) -Appetite: ~1–1,25 dia eng; F1 é uma auditoria com decisão binária (extrair ou registrar rejeição medida, mesmo formato do B33+); F2/F3 são baratas e só valem a pena se F1 concluir "extrair" +Impeccable: A para F1/F2/F3 (sem superfície UI nova; extrai comportamento já entregue sem mudar contrato de rede nem markup visível); **B para F4/F5**, que mudam o que o usuário ouve e o que ele consegue tocar na casca compartilhada +Appetite: ~1,25–1,5 dia eng; F1 é uma auditoria com decisão binária (extrair ou registrar rejeição medida, mesmo formato do B33+); F2/F3 deixaram de ser condicionais quando o 5º call site chegou (E14 ✓); F4/F5 são fases da casca, independentes do resultado de F1 Responsável: — ## Dados → decisão → apresentação @@ -33,16 +33,19 @@ A comparação linha a linha dos três (feita nesta triage, `capture-review-debt **Atualização (2026-07-27, pós-B42 ✓):** o **container** dos três já saiu — `shared/CampaignCellEditOverlay.tsx` (Popover `md+` / Drawer mobile) absorveu Popover/Drawer/trigger/tooltip e `LeadershipListSupportStatusControl` foi migrado como 4º call site. Isso **não** consome o F1 (a casca não toca estado, refs, debounce nem `fetch` — foi o corte deliberado do B42, "só o container"), mas muda duas coisas para a auditoria: os três controles ficaram ~25–40 linhas menores, então a máquina de auto-save é agora a **maior** coisa duplicada neles; e o `/simplify` do B42 acrescentou à lista de peças idênticas um **13º item**, `reportFailure` — `setErrorMessage` + `toast.error` **só quando o overlay já fechou**, porque fechar desmonta o `Alert`/`aria-live` que carregariam o erro. Ele nasceu copiado nos quatro controles (os três do debounce + assessores) no mesmo passe, o que o torna a peça mais recente e mais óbvia do hook, se F1 concluir "extrair". +**Atualização (2026-07-27, pós-E14 ✓ — a triage do fechamento do E14):** o **gatilho de 5º call site** que a "Questão em aberto" abaixo guardava para F2/F3 **disparou**. `MunicipalityListLevelControl` (movimento de nível de envolvimento) e `POST /campanha/municipios/engagement-level` são o quinto controle e a quinta rota da família, e chegaram sem tocar em F1: o controle é de **submit explícito**, não de auto-save por debounce, então ele não é um 4º corpo da máquina do F1 — mas repete o preâmbulo dos dois lados como qualquer outro. F2 também mudou de natureza nessa contagem: `isSameOriginRequest` documenta-se como dona da política de CSRF, e a política está copiada em cinco `route.ts`, então **a rota que esquecer a linha falha aberta** — é uma verificação de origem que depende de disciplina de cópia, não do tipo. Isso põe F2 no piso de expensive_lock e a tira da condicionalidade a F1. Do lado do cliente, F3 fica igualmente com cinco call sites, e continua valendo só para o transporte (o controle do E14 não tem estado de auto-save para compartilhar). + As diferenças reais, que é o que torna a extração não trivial (achado do `/simplify` reuse review do B32, 2026-07-26): o **shape do valor** diverge (`VoteEstimateScenarioViewModel` objeto com 3 cenários × `MunicipalityListSavedPoliticalTrend` `{status, note}` × `SupportStatus` escalar), **display vs. draft** existe em dois dos três (o B32 colapsou os dois porque só tem um campo — ver "Decisões travadas" do próprio plano B32) e o **B24** tem um guard extra (`inFlightTrendRef`) para não abortar+reenviar o mesmo payload quando blur e fechamento do popover disparam o mesmo flush. -Um quarto controle, `MunicipalityListAdvisorsControl.tsx` (B27, chips + `Command`), **não** compartilha essa máquina — é delta multi-seleção com `requestSeqRef`/`latestConfirmedRef` (fora de escopo, mecânica genuinamente diferente) — mas compartilha com os quatro (incluindo ele) o **preâmbulo do `fetch`** (`credentials: 'same-origin'`, `Content-Type`/`Accept` JSON, `cast` da resposta) e, do lado do servidor, os quatro `route.ts` (`liderancas/support-status`, `municipios/political-trend`, `municipios/expected-votes`, `municipios/advisors`) repetem o mesmo preâmbulo de rota: `isSameOriginRequest(request)` → 403 → `parseCampaignJsonRequestBody` → `bodySchema.parse` → `try/catch` com `campaignJsonMutationErrorResponse`. +Um quarto controle, `MunicipalityListAdvisorsControl.tsx` (B27, chips + `Command`), **não** compartilha essa máquina — é delta multi-seleção com `requestSeqRef`/`latestConfirmedRef` (fora de escopo, mecânica genuinamente diferente) — mas compartilha com os cinco (incluindo ele e o do E14) o **preâmbulo do `fetch`** (`credentials: 'same-origin'`, `Content-Type`/`Accept` JSON, `cast` da resposta) e, do lado do servidor, os cinco `route.ts` (`liderancas/support-status`, `municipios/political-trend`, `municipios/expected-votes`, `municipios/advisors`, `municipios/engagement-level`) repetem o mesmo preâmbulo de rota: `isSameOriginRequest(request)` → 403 → `parseCampaignJsonRequestBody` → `bodySchema.parse` → `try/catch` com `campaignJsonMutationErrorResponse`. ## Objetivos - **F1 (dominante):** decidir, com auditoria de código (não de intenção), se um hook `useCampaignListAutosave` genérico compensa nos três call sites do debounce — e, se sim, extraí-lo; se não, registrar a rejeição medida (precedente B33+: a auditoria pode derrubar a premissa do plano). -- **F2 (condicional a F1 ter concluído "vale a pena mexer aqui"):** avaliar um helper de preâmbulo de rota (`guardCampaignJsonMutationRequest` ou nome equivalente) que combine `isSameOriginRequest` + `parseCampaignJsonRequestBody`, reduzindo o boilerplate dos 4 `route.ts`. -- **F3 (condicional, menor prioridade que F2):** avaliar um wrapper fino de `fetch` cliente (`postCampaignJson`) para os 4 controles, cobrindo só o preâmbulo comum (`credentials`/headers/`cast`), não a máquina de estado. +- **F2 (não é mais condicional; gatilho de 5º call site disparado pelo E14 ✓):** extrair o helper de preâmbulo de rota (`guardCampaignJsonMutationRequest` ou nome equivalente) que combina `isSameOriginRequest` + `parseCampaignJsonRequestBody` nos 5 `route.ts`. A motivação subiu de boilerplate para **política**: a verificação de origem só vale se toda rota da família a escrever, e hoje escrevê-la é opcional por construção — a que esquecer aceita POST cross-origin em silêncio. Fase mais importante do lote junto com F5, e a primeira a rodar. +- **F3 (não é mais condicional; mesmo gatilho, menor prioridade que F2):** wrapper fino de `fetch` cliente (`postCampaignJson`) para os 5 controles, cobrindo só o preâmbulo comum (`credentials`/headers/`cast`), nunca a máquina de estado. - **F4 (a11y, entrou pela triage do B42 ✓ em 2026-07-27; independente de F1 "extrair ou não"):** a região viva de cada controle é um `

` **dentro** do overlay, e fechar é justamente o que faz o flush da gravação — então "Salvando…" é anunciado numa região que acabou de desmontar, e o leitor de tela não recebe nada. É o mesmo furo que o `toast.error` do B42 tapou para o **erro**, mas o sucesso e o "salvando" continuam mudos. A casca (`CampaignCellEditOverlay`) é o lugar natural de uma região que sobrevive à superfície — o que muda o scaffold, e é por isso que é fase deste plano e não do B42. **Guardrail:** uma região por controle, não uma global por página; `escala-dry-pos-b33.md` já corrigiu uma página com **três** regiões vivas competindo, e a cura não pode reintroduzir isso. +- **F5 (slot `footer` + `formId` na casca; gatilho registrado pelo B42 ✓ e disparado pela triage do E14 ✓ em 2026-07-27; independente de F1):** o B42 adiou o slot com o gatilho "um 2º controle precisar de submit explícito num Drawer". `MunicipalityListLevelControl` é esse controle, e a falta do slot é pior do que o adiado previa — não é só o Sinal (B26) que fica sem migrar. Na folha, `CampaignCellEditOverlay` fixa um `DrawerFooter` com **"Fechar"**, enquanto "Registrar movimento" fica no fim de um corpo rolável com select, duas textareas e até dois checkboxes: **a ação que descarta está pinada e a que constrói está abaixo da dobra**, e "Fechar" joga fora uma justificativa digitada sem perguntar. O precedente de forma é o `MunicipalityListSignalControl`, que mantém container próprio justamente para pôr o submit no `DrawerFooter` com "Cancelar" ao lado. Escopo: slot opcional `footer` (mais `formId`, para o submit viver fora do `

` por HTML padrão), o controle do E14 e o Sinal como call sites, e um caso em `campaignCellEditOverlay.unit.spec.ts` pinando que a folha com `footer` **não** renderiza o "Fechar" default. Só o `variant="sheet"` muda: no Popover o submit já é visível sem rolagem. - Guardrail comum às três fases: **zero mudança de contrato de URL/rede e zero mudança de comportamento visível** — é refactor puro, testável pelos specs/e2e já existentes (`campaignLeaderships.e2e.spec.ts`, `campaignMunicipalities.e2e.spec.ts`). ## Decisões travadas @@ -53,7 +56,7 @@ Um quarto controle, `MunicipalityListAdvisorsControl.tsx` (B27, chips + `Command ## Questões em aberto - **O hook deve cobrir também o `inFlightTrendRef` do B24 (dedup de blur+close) ou deixar esse guard como responsabilidade do chamador?** **Opções:** (a) o hook expõe um `flush()` idempotente que já resolve isso internamente (um único caminho de save, chamado por blur ou por close); (b) o hook fica "burro" sobre isso e cada chamador decide se precisa do guard. **Recomendação:** (a) — o guard existe porque hoje há _dois_ caminhos de código para chegar ao mesmo save (o `onBlur` do textarea e o `handleOpenChange`); um hook com `flush()` único elimina a duplicidade na raiz em vez de generalizar o sintoma. -- **Vale a pena F2/F3 se F1 concluir "não compensa"?** **Opções:** (a) fazer F2/F3 de qualquer forma, são baratas e independentes de F1; (b) não fazer nenhuma se F1 rejeitar, porque a motivação de fundo (essa família de controles é jovem e ainda pode crescer/divergir) provavelmente também derruba F2/F3. **Recomendação:** (b) com uma ressalva — reavaliar F2/F3 isoladamente **só** se um 5º `route.ts`/controle chegar antes de alguém revisitar este plano (mesma lógica de "3º call site" aplicada à sub-dívida). +- ~~**Vale a pena F2/F3 se F1 concluir "não compensa"?**~~ **Resolvida em 2026-07-27 pela ressalva da própria pergunta.** A recomendação era (b), não fazer nada se F1 rejeitasse, _"com uma ressalva — reavaliar F2/F3 isoladamente só se um 5º `route.ts`/controle chegar antes de alguém revisitar este plano"_. Chegou: o E14 ✓ entregou o quinto de cada um antes de qualquer auditoria de F1. F2/F3 passam a valer por conta própria, e F2 ganhou um motivo que não existia quando a pergunta foi escrita (falha aberta por omissão, ver Contexto). ## Abordagem proposta @@ -66,6 +69,9 @@ flowchart LR Guard --> R2["municipios/political-trend/route.ts"] Guard --> R3["municipios/expected-votes/route.ts"] Guard --> R4["municipios/advisors/route.ts"] + Guard --> R5["municipios/engagement-level/route.ts"] + Shell["CampaignCellEditOverlay
(F4 região viva + F5 slot footer)"] --> LC["MunicipalityListLevelControl"] + Shell --> SC["MunicipalityListSignalControl"] ``` Componentes (condicionais à decisão de cada fase): @@ -92,7 +98,7 @@ Componentes (condicionais à decisão de cada fase): ## Adiado com gatilho -- **F2/F3 se F1 rejeitar a extração do hook principal.** Ver "Questões em aberto" — reavaliar isoladamente só com um 5º call site (rota ou controle). +- ~~**F2/F3 se F1 rejeitar a extração do hook principal.**~~ **Gatilho disparado em 2026-07-27** pelo E14 ✓ (5ª rota e 5º controle). F2/F3 são fases incondicionais deste plano. - **`MunicipalityListAdvisorsControl` adotar o preâmbulo de fetch do F3** (se F3 for feito) — hoje ele tem uma política de erro diferente (`pendingCountRef`/sequência, sem revert único), então adotar herdaria só o `fetch`+`cast`, não a lógica de estado; barato, mas não é o call site que motivou o item. Gatilho: F3 concluído e aprovado. - **Convergir o _corpo_ de multi-seleção (chips + `Command`), não só o container.** Contagem as-built, medida na triage do B42 ✓ (2026-07-27), para o próximo leitor não re-derivar: `shared/LeadershipStateDeputyRelationCell.tsx` (B36 ✓, já em `shared/` e já parametrizada por um record de `COPY`, servindo as duas direções da relação) e `MunicipalityListAdvisorsControl.tsx` (B27 ✓) repetem o mesmo corpo **string por string** — `w-80 p-0`, header `relative flex flex-col gap-2 p-3 pb-0`, Spinner, linha de chips `flex flex-wrap gap-1.5 px-3 pt-2`, o `Badge` de remover (`max-w-full cursor-pointer gap-1 pr-1 font-normal hover:bg-destructive/15`), `Command shouldFilter={false}` e a linha vazia `px-3 py-6 text-center`. `AdvisorMunicipalityCell` é uma terceira grafia, mas **inline** (sem overlay), então não conta como 3º call site do mesmo corpo. **Gatilho:** um 3º corpo em overlay (B34/B37 são os candidatos nomeados) — e aí a peça de destino é a que já mora em `shared/`, não uma quarta. Os planos B31/B34 registram este mesmo adiado do lado do widget; esta é a contagem do lado do markup. @@ -104,6 +110,8 @@ Componentes (condicionais à decisão de cada fase): - [polimento-mobile-lista-municipios.md](polimento-mobile-lista-municipios.md) (B42 ✓) — extraiu o **container** dos mesmos três controles (`CampaignCellEditOverlay`) e introduziu `reportFailure` nos quatro; leia o "As-built" antes de auditar o F1 - [escala-dry-pos-b33.md](escala-dry-pos-b33.md) — precedente direto de forma: hook via callbacks (não módulos de domínio), auditoria derrubando a premissa do plano antes de escrever código - `src/components/campaign/municipality/MunicipalityListExpectedVotesControl.tsx`, `MunicipalityListTrendControl.tsx`, `src/components/campaign/leadership/LeadershipListSupportStatusControl.tsx` — os três call sites de F1 -- `src/app/(campaign)/campanha/(app)/liderancas/support-status/route.ts`, `municipios/{political-trend,expected-votes,advisors}/route.ts` — os quatro call sites de F2 +- `src/app/(campaign)/campanha/(app)/liderancas/support-status/route.ts`, `municipios/{political-trend,expected-votes,advisors,engagement-level}/route.ts` — os cinco call sites de F2 - `src/utilities/campaignJsonMutationRoute.ts`, `src/utilities/sameOriginRequest.ts` — onde F2 pousaria -- AGENTS.md — abstrações precisam de 3+ call sites (aqui há exatamente 3 para F1, 4 para F2/F3) +- [niveis-de-envolvimento.md](niveis-de-envolvimento.md) (E14 ✓) — o 5º controle/rota, que disparou F2/F3 e F5; `src/components/campaign/municipality/MunicipalityListLevelControl.tsx` é o call site de submit explícito na folha +- `src/components/campaign/shared/CampaignCellEditOverlay.tsx` e `src/components/campaign/municipality/MunicipalityListSignalControl.tsx` — a casca de F4/F5 e o precedente de footer que ela ainda não expressa +- AGENTS.md — abstrações precisam de 3+ call sites (aqui há exatamente 3 para F1, 5 para F2/F3) diff --git a/docs/plans/polimento-mobile-lista-municipios.md b/docs/plans/polimento-mobile-lista-municipios.md index 86f8c112..e1485393 100644 --- a/docs/plans/polimento-mobile-lista-municipios.md +++ b/docs/plans/polimento-mobile-lista-municipios.md @@ -112,7 +112,7 @@ Depth check: reusa `Drawer` shadcn + padrão B26; não cria design system de ove - ~~**Tooltip/tap da Classe no card mobile**~~ — **fechado no critique da própria sessão** (ver As-built): o `sr-only` sozinho deixava o veredito inalcançável no toque, o que contraria `DESIGN.md`. - ~~**Drawer para votos estimados** no card~~ — **entrou nesta entrega** por decisão do usuário; foi o 3º call site que justificou extrair a casca. -- **Slot `footer` + `formId` na casca, para o Sinal (B26) migrar.** Gatilho: um 2º controle precisar de submit explícito num Drawer, ou o Sinal divergir visualmente dos outros três. +- ~~**Slot `footer` + `formId` na casca, para o Sinal (B26) migrar.**~~ **Gatilho disparado em 2026-07-27**, na triage de fechamento do **E14 ✓**: `MunicipalityListLevelControl` é o 2º controle de submit explícito e migrou para a casca sem o slot, o que na folha pina "Fechar" e empurra "Registrar movimento" para baixo da dobra — a ação que descarta uma justificativa digitada ficou mais alcançável que a que a registra. Virou **F5** do **B32+** ([escala-dry-pos-b32.md](escala-dry-pos-b32.md)), junto do F4 desta mesma casca, e não um ID novo. - **Montar o Drawer só no primeiro `open`** (`everOpened`, não `open`). Gatilho: a lista mobile aparecer num profile de render, ou o caminho `pagination: false` (435 linhas × 4 overlays) passar a ser rota quente. Números medidos no As-built. - **`prefers-reduced-motion` no `Drawer`.** O `Dialog`/`Sheet` do kit também não respeita; a animação de entrada é do base-ui e vem do kit, então a correção é do componente `ui/`, não deste item. Gatilho: qualquer trabalho de acessibilidade de movimento no kit. - **`DrawerVirtualKeyboardProvider`.** Sem ele, o teclado do Android pode cobrir os inputs de votos dentro da folha; hoje mitigado por não focar campo na abertura. Gatilho: relato de campo em Android. diff --git a/docs/roadmap.md b/docs/roadmap.md index 74db92b0..cccb82ac 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 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); **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); **B41 ✓** scroll horizontal + sticky Município · [plano](plans/scroll-horizontal-lista-municipios.md); **B32+** novo fill-in de engenharia registrado no fechamento do B32 — `MunicipalityListExpectedVotesControl`/`MunicipalityListTrendControl`/`LeadershipListSupportStatusControl` são o 3º call site da mesma máquina de auto-save por debounce (estado/refs/save/schedule/flush quase idênticos), gatilho que os três planos B24/B27/B32 já haviam registrado; auditoria primeiro, extração condicional (precedente B33+) — [plano](plans/escala-dry-pos-b32.md); **B32 ✓** editar Status de apoio na célula da lista de lideranças — `LeadershipListSupportStatusControl` (Popover + `NativeSelect`, sempre montado, sem toggle "Editar" de tabela inteira) grava por `POST /campanha/liderancas/support-status` (auto-save ~150 ms, sem `revalidatePath`), reusando `isSameOriginRequest`/`campaignJsonMutationRoute.ts` já extraídos pelo B24 ✓/B27 ✓; fecha o "Adiado com gatilho" do B29 — [plano](plans/autosave-status-lista-liderancas.md); **B33+ ✓** `useCampaignListFilterNavigation` — o scaffold de navegação/debounce sai dos três filtros de lista para um hook em `shared/` (281 linhas removidas contra 75 em `src`); a auditoria derrubou a premissa do plano de que os três domínios teriam assinaturas de canonicalização diferentes — `buildStateDeputyListHref`/`buildTerritoryListHref`/`buildMunicipalityListHref` **já canonicalizam internamente**, então o `parse(...)` explícito de dobradinhas/territórios era trabalho redundante e um único callback `toHref` cobre os três; a entrega achou **dois** bugs de comportamento, não de estrutura: tocar um filtro no meio da digitação **descartava** a busca pendente em dobradinhas e territórios (unificado na semântica de munícipios por `navigateWithSearch`), e o `setTimeout` do debounce capturava o `state` do render que o agendou, então um sort clicado dentro da janela de 1s era **revertido** um segundo depois — munícipios escapava por acidente, pelo `key=` da página que remonta e mata o timer (corrigido com `stateRef`, com caso de spec verificado vermelho nas três listas sem o fix); o `/simplify` ainda achou o fallback `useCampaignListPending() ?? useTransition()` copiado em 5 lugares, com o hook novo como 6º → `useCampaignListTransition`, e um `aria-live` que fazia o resumo de filtros de munícipios ser a **terceira** região viva da mesma página; `shouldUpdateMunicipalitySearchUrl` orfanou e foi deletado; `tests/unit/campaignListFilterNavigation.unit.spec.ts` (jsdom + router mockado + fake timers, 8 casos × 3 listas) é a primeira cobertura desses componentes — [plano](plans/escala-dry-pos-b33.md); **C8 F4b ✓** guard de `formActions` ampliado para irmãos `*FormActions.ts` + `taskActions.ts`; 6 ladders migradas; `taskActions.ts` allowlisted · [plano](plans/escala-dry-pos-c6.md); **Build resiliente ao global `metadata` ausente ✓** — `resolveSiteMetadata` em `src/utilities/seo.ts`; fallback `NEXT_PUBLIC_SITE_URL` e omit canonical/OG quando não há URL; 4 rotas + layout; [plano](plans/build-resiliente-metadata-ausente.md); **B8+ ✓** escala e DRY pós-B8 F2 — a política de escala de chão das malhas commitadas virou código nomeado (`scripts/lib/topology.mjs`, tolerância de Visvalingam em m² e dígitos de quantização derivados do bbox de cada topologia), e com ela a malha das ZE caiu de 480 para **30,2 pontos/feature** (72.310 → 7.687 bytes) sem que `bahia-municipalities` mudasse um byte; a auditoria corrigiu a alavanca do plano (quantização sozinha quase não corta — o peso absoluto manda) e **rejeitou** um item do F4 por medição (`city`/`zoneNumber` de `MunicipalityZoneNeighborhoodEntry` são evidência de proveniência hasheada, não código morto); `MAX_POINTS_PER_FEATURE = 60` é o novo tripwire, `featureMapKey` passou a ser exercido sobre features reais das três malhas (417 `codarea`, 27 `code`, 19 `slug`), o mapa **degrada** em vez de morrer quando o chunk das zonas falha, e a ressalva de aproximação saiu do gate de comparação · [plano](plans/escala-dry-pos-b8f2.md); **`users.roles` ✓** — `admin`\|`editor` em `users`, migration `20260727_032523_add_users_roles`, editor publica `post`/`tag`/`media`; unificação com `campaignUser` rejeitada · [plano](plans/roles-admin-payload.md); 2026-07-26 (**fill-in** remover lista "Municípios por zona eleitoral" do Início — pós-B8 F2 ✓ os polígonos tornam a lista textual redundante; dropar UI + `zoneBreakdown` do bundle; ressalva de aproximação fica na malha · [plano](plans/remover-lista-zonas-mapa-inicio.md); **B8 F2 ✓** polígonos das 19 ZE de Salvador — artefato `bahia-municipality-zones.topo.json` (72 KB) por dissolve bairro→zona do `BA_bairros_CD2022` do IBGE contra a RA 02/2017 do TRE-BA, com curadoria explícita e um passe de maior fronteira compartilhada validado; o coroplético passou a ser keyed por **map key** (`slug` na zona, `ibgeCode` no resto) e o polígono municipal de Salvador ficou como base sem interação por baixo, porque duas malhas simplificadas independentemente não fecham na mesma costa; `competitiveRank` segue por cidade (o artefato TSE só tem `federalRankByIbgeCode`) com int pinando o fato; desambiguar a ZE no card "Onde estou" do B14 ✓ virou débito com gatilho, não dependência — [plano](plans/poligonos-pracas-zona.md); **B8+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B8 F2, cujos relatórios chegaram depois do merge — a malha de 19 zonas ficou com a mesma contagem de vértices da malha dos 417 municípios (as duas constantes de simplificação são relativas ao bbox e foram copiadas verbatim, o que vale ~3,9 m de grade em Salvador contra ~103 m no estado), a regra da map key está em três formas sem nenhum teste ligando artefato → `featureMapKey` → catálogo, uma falha de chunk nas zonas passou a derrubar o mapa inteiro, a ressalva de aproximação desaparece no modo comparação, e `getTerritoryFeature` perdeu o último consumidor de produção neste commit · [plano](plans/escala-dry-pos-b8f2.md); **B39–B42** registrados: sessão "Lembrar de mim" + login biométrico WebAuthn + scroll horizontal/sticky na lista de municípios + polimento mobile da mesma lista — [B39](plans/lembrar-de-mim-sessao-campanha.md), [B40](plans/login-biometrico-campanha.md), [B41](plans/scroll-horizontal-lista-municipios.md), [B42](plans/polimento-mobile-lista-municipios.md); **E12 ✓** camada TI — rollups MAUP, colunas Cobertura/Captura/Classe em `/campanha/territorios`, benchmark intra-TI no `MunicipalityGoalAccountCard`; [plano](plans/camada-territorios-identidade.md); **B36 ✓** coluna "Lideranças" em `/campanha/dobradinhas` — chips + Popover+Command + delta reuse da mutação B31; `LeadershipStateDeputyRelationCell` compartilhada (`direction`/`measureOverflow`); columns factory (não Context); casca `dobradinhas/formActions.ts`; `loadLeadershipOptions` + `leaderships[]` no row; [plano](plans/chips-liderancas-lista-dobradinhas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` (`UserPlusIcon`) na coluna `actions` ao lado do contato do B28 ✓; Popover two-step reusa `createCampaignInvite`/`autopreenchimento`; disable por `normalizeBrazilianPhone`; [plano](plans/convite-whatsapp-lista-liderancas.md); **B31 ✓** coluna de dobradinhas na lista de lideranças entregue — chips clicáveis → `/campanha/dobradinhas/` em `/campanha/liderancas`, edição por Popover+`Command` (mecânica do **B27 ✓**) com escrita por **server action direta do cliente** (`useTransition`, padrão do B19 ✓) em vez do endpoint JSON do B27, porque aqui há derivados a revalidar (`leadershipCount`, a ficha do deputado); `setLeadershipStateDeputyMembership(Record)` em transação + lock `leadership-state-deputies:{id}` + `revalidatePath`; helper puro `nextStateDeputyIdsAfterMembership` espelha `nextAdvisorIdsAfterMembership`; catálogo entregue uma vez por tabela via `leadershipColumns(stateDeputyOptions)` (columns factory, mesmo precedente de `municipalityListColumns`) em vez de Context; é o **2º call site** do padrão Popover+Command+chips — extração para `shared/` continua esperando o 3º (B34/B36/B37, que agora consomem a mutação/lock em vez de correr para desenhá-los); [plano](plans/dobradinhas-lista-liderancas.md); **fill-in ✓** limitar pan/zoom do mapa à Bahia — `BahiaMap` com `maxBounds`/`maxBoundsViscosity: 1` sobre `BAHIA_BOUNDS.pad(0.1)` e `minZoom` via `getBoundsZoom` com o mesmo padding 16px do `fitBounds`, recalculado no `ResizeObserver` existente; único arquivo; [plano](plans/limitar-mapa-a-bahia.md); (**B14 ✓** município mais próximo entregue — card cliente "Onde estou" no Início staff resolvendo **point-in-polygon** sobre o chunk de geometria que o mapa do dashboard já carrega (a premissa "polígonos explodem o bundle" do plano de 24/07 caiu na medição: 41 KB gzip já estão no browser, então o artefato de centroides e seu script saíram de escopo), com fallback nomeando onde o assessor está e o município mais próximo **da carteira** dentro de 150 km; a posição nunca sai do aparelho (sem action, sem `Consent`); First Load JS de `/campanha` 265 → 268 kB depois de mover o href das zonas de Salvador para o servidor (`zoneCityHrefs`) — importar `buildMunicipalityListHref` no cliente custava **21 kB** por um link, porque arrasta `bahiaTerritories` + `municipalityCatalog`; [plano](plans/municipio-mais-proximo.md); ~~**B38**~~ ✓ sidebar recolhido em tablet entregue — offcanvas + trigger md+ + cookie/matchMedia; [plano](plans/sidebar-recolhido-tablet.md); **B33+** novo fill-in de engenharia registrado no fechamento do B33 — `StateDeputyFilters` é o 3º call site do scaffold de navegação/debounce que `TerritoryFilters`/`MunicipalityFilters` já tinham, achado do `/simplify` rodada 2, [plano](plans/escala-dry-pos-b33.md); **B28 ✓** e-mail + celular na lista de lideranças entregue — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp, no padrão de `/campanha/assessores` (B19 ✓); `shared/CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`, `loadLeadershipDetail` deduplicado; [plano](plans/email-celular-lista-liderancas.md); **B24 ✓** auto-save + justificativa no popover de Tendência de `/campanha/municipios` — sem botão "Salvar", `POST /campanha/municipios/political-trend`, [plano](plans/autosave-tendencia-lista-municipios.md); **B13 ✓** escala relativa no mapa do Início entregue — quantis como novo default, padrão próprio/LQ nas âncoras do E10 e posição no município a partir do **artefato TSE v3** (`federalRankByIbgeCode`), mais bolhas opcionais por válidos projetados coloridas pela classe; `computeAggregateTerritorialClass` nasce aqui e o **E12** deve herdá-lo em vez de escrever um rollup próprio; `TERRITORIAL_CLASS_ANCHORS` mudou de casa para `src/lib/territorialClassAnchors.ts`; [plano](plans/escala-relativa-mapa.md); **B13+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B13 — teste de concordância entre as duas escritas do LQ (mapa cliente × classificador server-only, a rede de segurança do **E15**), escopo geográfico declarado no readout (posição é da cidade inteira, classe é do escopo do usuário), uma varredura da fatia TSE por ano no script de agregados em vez de três, e a tupla no `competitiveRankByYear` que corta ~metade dos ~63,6 KB somados ao payload RSC · [plano](plans/escala-dry-pos-b13.md); **débito de teste** afinado no ledger ([TECH-DEBT.md](TECH-DEBT.md), P3 → P2): `campaignMunicipalities`/`campaign-pwa` são flaky com 2 workers **em `main`** também — clique no checkbox de consentimento antes da hidratação e regex de fixture em que "Conde" casa com "Condeúba"; **B27 ✓** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` entregue — a checkbox list + botão "Salvar assessores" virou chips removíveis + `Command` com gravação por delta, `nextAdvisorIdsAfterMembership` movido para `src/lib/`, endpoint `POST /campanha/municipios/advisors` espelho do `expected-votes/`, [plano](plans/combobox-assessores-lista-municipios.md); **B23 ✓** tooltip de conteúdo nas células das listas entregue — a mecânica genérica (`cellTooltip`, `CampaignCellTooltip`, `CampaignHoverTooltip` em `shared/`) já tinha saído de graça no E10 ✓; este item fechou `openOnTouch`/`disabled` no primitivo (mais um terceiro comportamento do `/simplify`: `content` falsy renderiza o trigger sem wrapper) e os dois consumidores de `/campanha/municipios` (nomes de assessores + justificativa da tendência), [plano](plans/tooltip-celulas-listas.md); **B22 ✓** explicação por coluna no header das listas entregue — `description?: ReactNode` em `CampaignTableHead`/`MunicipalitySortableHead` (rename de `tooltip`, sublinhado pontilhado), `campaignConceptOneLiner` como fonte única para colunas cobertas pelo E18 ✓, `municipalityColumnDescriptions` cobrindo as 11 colunas de `/campanha/municipios`; sem migration/action, [plano](plans/explicacao-colunas-header-listas.md); 2026-07-25: **E10 ✓** classificação territorial relativa entregue — Reduto/Expansão/Manutenção/Marginal/Sem base sobre o artefato TSE, coluna+sort+filtro na lista, badge no card do E8 e na capa do dossiê, e o seam genérico `cellTooltip` da `CampaignTable` (núcleo do B23) com `CampaignHoverTooltip` promovido para `shared/`; **B13** liberado; **E10+** novo fill-in de engenharia com os quatro débitos do `/simplify` do E10 — a lista lendo `municipality` duas vezes por request (o que **dispara o "Adiado com gatilho" do A11**: `classe` é o 4º path derivado no loader), memo de processo dos potenciais por município, `TooltipProvider` único no layout e os multi-filtros do mobile saindo das definitions · [plano](plans/escala-dry-pos-e10.md); ~~**C8 F4b**~~ ✓ (entregue 2026-07-27); **B26 ✓** registrar sinal na célula "Último sinal" — [plano](plans/registrar-sinal-lista-municipios.md); **C13 ✓** "Plano de Ação" → **"Atividade"** renomeado em toda a vertical — collection `activity`, rota `/campanha/atividades`, sidebar "Atividades", migration data-preserving escrita à mão (4 tabelas, 3 enums, 2 colunas, 39 índices, 14 FKs, 2 sequences + os 8 índices fósseis `%plaza%` da remodelagem) e guard de vocabulário table-driven varrendo `src`+`tests`+`scripts`; [plano](plans/renomear-plano-acao-para-atividade.md); **B37** chips editáveis de municípios na lista de dobradinhas — coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`municipalityCount`) por chips com link (município) / território (`MapPinIcon`) e o mesmo Popover+combobox do **B34** (busca por município/TI/ZE, lote de território/zona), mas escrevendo do lado invertido (`municipality.stateDeputies`, sem piso mínimo, lock por município em vez de por dobradinha, sem `assertMunicipalitiesWithinScope` porque `canUpdateMunicipality` já escopa o assessor); reaproveita/promove a mesma lib do B19/B34 (quem chegar primeiro faz a renomeação); mesmo conflito registrado com o pedido literal (toggle "Editar" de tabela inteira) que B28/B31/B32/B34/B36 já resolveram na direção do Popover por célula — decisão marcada como _proposta, a validar com produto_; sem migration, [plano](plans/chips-municipios-lista-dobradinhas.md); **B36** coluna "Lideranças" em `/campanha/dobradinhas` com a mesma UI/mecânica da coluna "Municípios" de `/campanha/assessores` (B19 ✓) — chips + Popover + `Command` + auto-save por delta + "Ver mais…" (clamp de 3 linhas por `ResizeObserver`, necessário aqui porque um deputado popular pode acumular muito mais lideranças do que uma liderança acumula dobradinhas); pedido explícito de não duplicar lógica — reusa **sem alteração** a mutação/schema/lock que o **B31** desenha para o lado inverso da mesma relação `leadership.stateDeputies`, promovendo a célula desse item para um componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`) em vez de um segundo componente-irmão; corrida de extração com o B31 (quem entrar primeiro paga a peça compartilhada); sem migration, [plano](plans/chips-liderancas-lista-dobradinhas.md); **B34** chips editáveis de municípios na lista de lideranças — coluna "Municípios" de `/campanha/liderancas` ganha chips com link (município) / território (`MapPinIcon`), edição por **Popover disparado por célula** com chips removíveis + combobox `Command` (busca por município/TI/ZE, lote de território/zona), gravação **por delta** direto em `leadership.municipalities` com lock `leadership-municipalities:{id}` (piso de 1 e teto de 30 respeitados) e `revalidatePath` (`liderancas`, `liderancas/[id]`, `municipios/[slug]` dos tocados); promove a lógica pura de `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros, sem portar a UI com `ResizeObserver`; **conflito registrado com o pedido literal** (pede um toggle "Editar" de tabela inteira, que B28/B31/B32 já travaram como anti-goal nesta mesma tabela — este plano segue o precedente e marca a decisão como _proposta, a validar com produto_); sem migration, [plano](plans/chips-municipios-lista-liderancas.md); ~~**B33**~~ ✓ ordenação e filtro no header da lista de dobradinhas (entregue 2026-07-26) — `?sort=`/`?dir=` por Nome/Partido + Popover multi-seleção de Partido com "Sem partido", consumindo o chrome compartilhado do **B21 ✓** (a corrida de ordem de chegada com B29 foi encerrada); paridade mobile (`StateDeputyFilters`) entrou por decisão desta sessão; ordem dos nulos de `party` pinada por int test; contagens derivadas (`municipalityCount`/`leadershipCount`) seguem sem sort/filtro, como travado, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` na coluna `actions` (entregue 2026-07-26), [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); ~~**B28**~~ ✓ e-mail + celular na lista de `/campanha/liderancas` (entregue 2026-07-26) — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp como em `/campanha/assessores` (B19 ✓); `CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`; edição in-list adiada; sem migration, [plano](plans/email-celular-lista-liderancas.md); ~~**B27**~~ ✓ combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/combobox-assessores-lista-municipios.md); ~~**B26**~~ ✓ registrar sinal na célula "Último sinal" (entregue 2026-07-25), [plano](plans/registrar-sinal-lista-municipios.md); **fill-in** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); glossário/KPI strips intactos; B16 ✓ liberou o nome ao remover "Assessoria", [plano](plans/renomear-coluna-cobertura-meta.md); ~~**B25**~~ ✓ link do território na lista de municípios (entregue 2026-07-26) — coluna "Território" + card mobile → `/campanha/territorios#ti-`; `territoryAnchor.ts` + `TerritoryLink`; seam `rowId` em `CampaignTable`; [plano](plans/link-territorio-lista-municipios.md); ~~**B24**~~ ✓ auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/autosave-tendencia-lista-municipios.md); **fill-in** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtro por Salvador cobre as zonas; drop também do filtro/sort/`?kind=` na URL da lista, schema `kind` intacto, [plano](plans/remover-coluna-tipo-municipios.md); ~~**B23**~~ ✓ tooltip de conteúdo nas células das listas (entregue 2026-07-26), [plano](plans/tooltip-celulas-listas.md); **fill-in** opções selecionadas no topo nos Popovers de filtro do header — helper puro reutilizável pelas demais listas, [plano](plans/selecionados-no-topo-filtros-header.md); ~~**B22**~~ ✓ explicação por coluna no header das listas (entregue 2026-07-26), [plano](plans/explicacao-colunas-header-listas.md); **B21** página própria dos Territórios de Identidade — rota `/campanha/territorios` com overview + tabela no sistema de listas (sort/filtro no header), sem migration, [plano](plans/pagina-territorios-identidade.md); **B20 ✓** Municípios prioritários no Início entregue 2026-07-25 — atalho ranqueado por déficit + markup sem bullets, [plano](plans/municipios-prioritarios-inicio.md); **Pass 2 ✓** consolidação de engenharia — listas genéricas/B16+ absorvido/B17-B18 triviais/E10 limpo/C8 F4 ✓/D3 consent WhatsApp; **B16 ✓**; **E16 ✓**; **E9 ✓**; **E18 ✓**; **B19 ✓**; **E8 ✓**; C12 ✓; B18; B17; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14 ✓; janela 1: smoke, R6)Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc). +Atualizado em: 2026-07-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); **B41 ✓** scroll horizontal + sticky Município · [plano](plans/scroll-horizontal-lista-municipios.md); **B32+** novo fill-in de engenharia registrado no fechamento do B32 — `MunicipalityListExpectedVotesControl`/`MunicipalityListTrendControl`/`LeadershipListSupportStatusControl` são o 3º call site da mesma máquina de auto-save por debounce (estado/refs/save/schedule/flush quase idênticos), gatilho que os três planos B24/B27/B32 já haviam registrado; auditoria primeiro, extração condicional (precedente B33+) — [plano](plans/escala-dry-pos-b32.md); **B32 ✓** editar Status de apoio na célula da lista de lideranças — `LeadershipListSupportStatusControl` (Popover + `NativeSelect`, sempre montado, sem toggle "Editar" de tabela inteira) grava por `POST /campanha/liderancas/support-status` (auto-save ~150 ms, sem `revalidatePath`), reusando `isSameOriginRequest`/`campaignJsonMutationRoute.ts` já extraídos pelo B24 ✓/B27 ✓; fecha o "Adiado com gatilho" do B29 — [plano](plans/autosave-status-lista-liderancas.md); **B33+ ✓** `useCampaignListFilterNavigation` — o scaffold de navegação/debounce sai dos três filtros de lista para um hook em `shared/` (281 linhas removidas contra 75 em `src`); a auditoria derrubou a premissa do plano de que os três domínios teriam assinaturas de canonicalização diferentes — `buildStateDeputyListHref`/`buildTerritoryListHref`/`buildMunicipalityListHref` **já canonicalizam internamente**, então o `parse(...)` explícito de dobradinhas/territórios era trabalho redundante e um único callback `toHref` cobre os três; a entrega achou **dois** bugs de comportamento, não de estrutura: tocar um filtro no meio da digitação **descartava** a busca pendente em dobradinhas e territórios (unificado na semântica de munícipios por `navigateWithSearch`), e o `setTimeout` do debounce capturava o `state` do render que o agendou, então um sort clicado dentro da janela de 1s era **revertido** um segundo depois — munícipios escapava por acidente, pelo `key=` da página que remonta e mata o timer (corrigido com `stateRef`, com caso de spec verificado vermelho nas três listas sem o fix); o `/simplify` ainda achou o fallback `useCampaignListPending() ?? useTransition()` copiado em 5 lugares, com o hook novo como 6º → `useCampaignListTransition`, e um `aria-live` que fazia o resumo de filtros de munícipios ser a **terceira** região viva da mesma página; `shouldUpdateMunicipalitySearchUrl` orfanou e foi deletado; `tests/unit/campaignListFilterNavigation.unit.spec.ts` (jsdom + router mockado + fake timers, 8 casos × 3 listas) é a primeira cobertura desses componentes — [plano](plans/escala-dry-pos-b33.md); **C8 F4b ✓** guard de `formActions` ampliado para irmãos `*FormActions.ts` + `taskActions.ts`; 6 ladders migradas; `taskActions.ts` allowlisted · [plano](plans/escala-dry-pos-c6.md); **Build resiliente ao global `metadata` ausente ✓** — `resolveSiteMetadata` em `src/utilities/seo.ts`; fallback `NEXT_PUBLIC_SITE_URL` e omit canonical/OG quando não há URL; 4 rotas + layout; [plano](plans/build-resiliente-metadata-ausente.md); **B8+ ✓** escala e DRY pós-B8 F2 — a política de escala de chão das malhas commitadas virou código nomeado (`scripts/lib/topology.mjs`, tolerância de Visvalingam em m² e dígitos de quantização derivados do bbox de cada topologia), e com ela a malha das ZE caiu de 480 para **30,2 pontos/feature** (72.310 → 7.687 bytes) sem que `bahia-municipalities` mudasse um byte; a auditoria corrigiu a alavanca do plano (quantização sozinha quase não corta — o peso absoluto manda) e **rejeitou** um item do F4 por medição (`city`/`zoneNumber` de `MunicipalityZoneNeighborhoodEntry` são evidência de proveniência hasheada, não código morto); `MAX_POINTS_PER_FEATURE = 60` é o novo tripwire, `featureMapKey` passou a ser exercido sobre features reais das três malhas (417 `codarea`, 27 `code`, 19 `slug`), o mapa **degrada** em vez de morrer quando o chunk das zonas falha, e a ressalva de aproximação saiu do gate de comparação · [plano](plans/escala-dry-pos-b8f2.md); **`users.roles` ✓** — `admin`\|`editor` em `users`, migration `20260727_032523_add_users_roles`, editor publica `post`/`tag`/`media`; unificação com `campaignUser` rejeitada · [plano](plans/roles-admin-payload.md); 2026-07-26 (**fill-in** remover lista "Municípios por zona eleitoral" do Início — pós-B8 F2 ✓ os polígonos tornam a lista textual redundante; dropar UI + `zoneBreakdown` do bundle; ressalva de aproximação fica na malha · [plano](plans/remover-lista-zonas-mapa-inicio.md); **B8 F2 ✓** polígonos das 19 ZE de Salvador — artefato `bahia-municipality-zones.topo.json` (72 KB) por dissolve bairro→zona do `BA_bairros_CD2022` do IBGE contra a RA 02/2017 do TRE-BA, com curadoria explícita e um passe de maior fronteira compartilhada validado; o coroplético passou a ser keyed por **map key** (`slug` na zona, `ibgeCode` no resto) e o polígono municipal de Salvador ficou como base sem interação por baixo, porque duas malhas simplificadas independentemente não fecham na mesma costa; `competitiveRank` segue por cidade (o artefato TSE só tem `federalRankByIbgeCode`) com int pinando o fato; desambiguar a ZE no card "Onde estou" do B14 ✓ virou débito com gatilho, não dependência — [plano](plans/poligonos-pracas-zona.md); **B8+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B8 F2, cujos relatórios chegaram depois do merge — a malha de 19 zonas ficou com a mesma contagem de vértices da malha dos 417 municípios (as duas constantes de simplificação são relativas ao bbox e foram copiadas verbatim, o que vale ~3,9 m de grade em Salvador contra ~103 m no estado), a regra da map key está em três formas sem nenhum teste ligando artefato → `featureMapKey` → catálogo, uma falha de chunk nas zonas passou a derrubar o mapa inteiro, a ressalva de aproximação desaparece no modo comparação, e `getTerritoryFeature` perdeu o último consumidor de produção neste commit · [plano](plans/escala-dry-pos-b8f2.md); **B39–B42** registrados: sessão "Lembrar de mim" + login biométrico WebAuthn + scroll horizontal/sticky na lista de municípios + polimento mobile da mesma lista — [B39](plans/lembrar-de-mim-sessao-campanha.md), [B40](plans/login-biometrico-campanha.md), [B41](plans/scroll-horizontal-lista-municipios.md), [B42](plans/polimento-mobile-lista-municipios.md); **E12 ✓** camada TI — rollups MAUP, colunas Cobertura/Captura/Classe em `/campanha/territorios`, benchmark intra-TI no `MunicipalityGoalAccountCard`; [plano](plans/camada-territorios-identidade.md); **B36 ✓** coluna "Lideranças" em `/campanha/dobradinhas` — chips + Popover+Command + delta reuse da mutação B31; `LeadershipStateDeputyRelationCell` compartilhada (`direction`/`measureOverflow`); columns factory (não Context); casca `dobradinhas/formActions.ts`; `loadLeadershipOptions` + `leaderships[]` no row; [plano](plans/chips-liderancas-lista-dobradinhas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` (`UserPlusIcon`) na coluna `actions` ao lado do contato do B28 ✓; Popover two-step reusa `createCampaignInvite`/`autopreenchimento`; disable por `normalizeBrazilianPhone`; [plano](plans/convite-whatsapp-lista-liderancas.md); **B31 ✓** coluna de dobradinhas na lista de lideranças entregue — chips clicáveis → `/campanha/dobradinhas/` em `/campanha/liderancas`, edição por Popover+`Command` (mecânica do **B27 ✓**) com escrita por **server action direta do cliente** (`useTransition`, padrão do B19 ✓) em vez do endpoint JSON do B27, porque aqui há derivados a revalidar (`leadershipCount`, a ficha do deputado); `setLeadershipStateDeputyMembership(Record)` em transação + lock `leadership-state-deputies:{id}` + `revalidatePath`; helper puro `nextStateDeputyIdsAfterMembership` espelha `nextAdvisorIdsAfterMembership`; catálogo entregue uma vez por tabela via `leadershipColumns(stateDeputyOptions)` (columns factory, mesmo precedente de `municipalityListColumns`) em vez de Context; é o **2º call site** do padrão Popover+Command+chips — extração para `shared/` continua esperando o 3º (B34/B36/B37, que agora consomem a mutação/lock em vez de correr para desenhá-los); [plano](plans/dobradinhas-lista-liderancas.md); **fill-in ✓** limitar pan/zoom do mapa à Bahia — `BahiaMap` com `maxBounds`/`maxBoundsViscosity: 1` sobre `BAHIA_BOUNDS.pad(0.1)` e `minZoom` via `getBoundsZoom` com o mesmo padding 16px do `fitBounds`, recalculado no `ResizeObserver` existente; único arquivo; [plano](plans/limitar-mapa-a-bahia.md); (**B14 ✓** município mais próximo entregue — card cliente "Onde estou" no Início staff resolvendo **point-in-polygon** sobre o chunk de geometria que o mapa do dashboard já carrega (a premissa "polígonos explodem o bundle" do plano de 24/07 caiu na medição: 41 KB gzip já estão no browser, então o artefato de centroides e seu script saíram de escopo), com fallback nomeando onde o assessor está e o município mais próximo **da carteira** dentro de 150 km; a posição nunca sai do aparelho (sem action, sem `Consent`); First Load JS de `/campanha` 265 → 268 kB depois de mover o href das zonas de Salvador para o servidor (`zoneCityHrefs`) — importar `buildMunicipalityListHref` no cliente custava **21 kB** por um link, porque arrasta `bahiaTerritories` + `municipalityCatalog`; [plano](plans/municipio-mais-proximo.md); ~~**B38**~~ ✓ sidebar recolhido em tablet entregue — offcanvas + trigger md+ + cookie/matchMedia; [plano](plans/sidebar-recolhido-tablet.md); **B33+** novo fill-in de engenharia registrado no fechamento do B33 — `StateDeputyFilters` é o 3º call site do scaffold de navegação/debounce que `TerritoryFilters`/`MunicipalityFilters` já tinham, achado do `/simplify` rodada 2, [plano](plans/escala-dry-pos-b33.md); **B28 ✓** e-mail + celular na lista de lideranças entregue — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp, no padrão de `/campanha/assessores` (B19 ✓); `shared/CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`, `loadLeadershipDetail` deduplicado; [plano](plans/email-celular-lista-liderancas.md); **B24 ✓** auto-save + justificativa no popover de Tendência de `/campanha/municipios` — sem botão "Salvar", `POST /campanha/municipios/political-trend`, [plano](plans/autosave-tendencia-lista-municipios.md); **B13 ✓** escala relativa no mapa do Início entregue — quantis como novo default, padrão próprio/LQ nas âncoras do E10 e posição no município a partir do **artefato TSE v3** (`federalRankByIbgeCode`), mais bolhas opcionais por válidos projetados coloridas pela classe; `computeAggregateTerritorialClass` nasce aqui e o **E12** deve herdá-lo em vez de escrever um rollup próprio; `TERRITORIAL_CLASS_ANCHORS` mudou de casa para `src/lib/territorialClassAnchors.ts`; [plano](plans/escala-relativa-mapa.md); **B13+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B13 — teste de concordância entre as duas escritas do LQ (mapa cliente × classificador server-only, a rede de segurança do **E15**), escopo geográfico declarado no readout (posição é da cidade inteira, classe é do escopo do usuário), uma varredura da fatia TSE por ano no script de agregados em vez de três, e a tupla no `competitiveRankByYear` que corta ~metade dos ~63,6 KB somados ao payload RSC · [plano](plans/escala-dry-pos-b13.md); **débito de teste** afinado no ledger ([TECH-DEBT.md](TECH-DEBT.md), P3 → P2): `campaignMunicipalities`/`campaign-pwa` são flaky com 2 workers **em `main`** também — clique no checkbox de consentimento antes da hidratação e regex de fixture em que "Conde" casa com "Condeúba"; **B27 ✓** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` entregue — a checkbox list + botão "Salvar assessores" virou chips removíveis + `Command` com gravação por delta, `nextAdvisorIdsAfterMembership` movido para `src/lib/`, endpoint `POST /campanha/municipios/advisors` espelho do `expected-votes/`, [plano](plans/combobox-assessores-lista-municipios.md); **B23 ✓** tooltip de conteúdo nas células das listas entregue — a mecânica genérica (`cellTooltip`, `CampaignCellTooltip`, `CampaignHoverTooltip` em `shared/`) já tinha saído de graça no E10 ✓; este item fechou `openOnTouch`/`disabled` no primitivo (mais um terceiro comportamento do `/simplify`: `content` falsy renderiza o trigger sem wrapper) e os dois consumidores de `/campanha/municipios` (nomes de assessores + justificativa da tendência), [plano](plans/tooltip-celulas-listas.md); **B22 ✓** explicação por coluna no header das listas entregue — `description?: ReactNode` em `CampaignTableHead`/`MunicipalitySortableHead` (rename de `tooltip`, sublinhado pontilhado), `campaignConceptOneLiner` como fonte única para colunas cobertas pelo E18 ✓, `municipalityColumnDescriptions` cobrindo as 11 colunas de `/campanha/municipios`; sem migration/action, [plano](plans/explicacao-colunas-header-listas.md); 2026-07-25: **E10 ✓** classificação territorial relativa entregue — Reduto/Expansão/Manutenção/Marginal/Sem base sobre o artefato TSE, coluna+sort+filtro na lista, badge no card do E8 e na capa do dossiê, e o seam genérico `cellTooltip` da `CampaignTable` (núcleo do B23) com `CampaignHoverTooltip` promovido para `shared/`; **B13** liberado; **E10+** novo fill-in de engenharia com os quatro débitos do `/simplify` do E10 — a lista lendo `municipality` duas vezes por request (o que **dispara o "Adiado com gatilho" do A11**: `classe` é o 4º path derivado no loader), memo de processo dos potenciais por município, `TooltipProvider` único no layout e os multi-filtros do mobile saindo das definitions · [plano](plans/escala-dry-pos-e10.md); ~~**C8 F4b**~~ ✓ (entregue 2026-07-27); **B26 ✓** registrar sinal na célula "Último sinal" — [plano](plans/registrar-sinal-lista-municipios.md); **C13 ✓** "Plano de Ação" → **"Atividade"** renomeado em toda a vertical — collection `activity`, rota `/campanha/atividades`, sidebar "Atividades", migration data-preserving escrita à mão (4 tabelas, 3 enums, 2 colunas, 39 índices, 14 FKs, 2 sequences + os 8 índices fósseis `%plaza%` da remodelagem) e guard de vocabulário table-driven varrendo `src`+`tests`+`scripts`; [plano](plans/renomear-plano-acao-para-atividade.md); **B37** chips editáveis de municípios na lista de dobradinhas — coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`municipalityCount`) por chips com link (município) / território (`MapPinIcon`) e o mesmo Popover+combobox do **B34** (busca por município/TI/ZE, lote de território/zona), mas escrevendo do lado invertido (`municipality.stateDeputies`, sem piso mínimo, lock por município em vez de por dobradinha, sem `assertMunicipalitiesWithinScope` porque `canUpdateMunicipality` já escopa o assessor); reaproveita/promove a mesma lib do B19/B34 (quem chegar primeiro faz a renomeação); mesmo conflito registrado com o pedido literal (toggle "Editar" de tabela inteira) que B28/B31/B32/B34/B36 já resolveram na direção do Popover por célula — decisão marcada como _proposta, a validar com produto_; sem migration, [plano](plans/chips-municipios-lista-dobradinhas.md); **B36** coluna "Lideranças" em `/campanha/dobradinhas` com a mesma UI/mecânica da coluna "Municípios" de `/campanha/assessores` (B19 ✓) — chips + Popover + `Command` + auto-save por delta + "Ver mais…" (clamp de 3 linhas por `ResizeObserver`, necessário aqui porque um deputado popular pode acumular muito mais lideranças do que uma liderança acumula dobradinhas); pedido explícito de não duplicar lógica — reusa **sem alteração** a mutação/schema/lock que o **B31** desenha para o lado inverso da mesma relação `leadership.stateDeputies`, promovendo a célula desse item para um componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`) em vez de um segundo componente-irmão; corrida de extração com o B31 (quem entrar primeiro paga a peça compartilhada); sem migration, [plano](plans/chips-liderancas-lista-dobradinhas.md); **B34** chips editáveis de municípios na lista de lideranças — coluna "Municípios" de `/campanha/liderancas` ganha chips com link (município) / território (`MapPinIcon`), edição por **Popover disparado por célula** com chips removíveis + combobox `Command` (busca por município/TI/ZE, lote de território/zona), gravação **por delta** direto em `leadership.municipalities` com lock `leadership-municipalities:{id}` (piso de 1 e teto de 30 respeitados) e `revalidatePath` (`liderancas`, `liderancas/[id]`, `municipios/[slug]` dos tocados); promove a lógica pura de `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros, sem portar a UI com `ResizeObserver`; **conflito registrado com o pedido literal** (pede um toggle "Editar" de tabela inteira, que B28/B31/B32 já travaram como anti-goal nesta mesma tabela — este plano segue o precedente e marca a decisão como _proposta, a validar com produto_); sem migration, [plano](plans/chips-municipios-lista-liderancas.md); ~~**B33**~~ ✓ ordenação e filtro no header da lista de dobradinhas (entregue 2026-07-26) — `?sort=`/`?dir=` por Nome/Partido + Popover multi-seleção de Partido com "Sem partido", consumindo o chrome compartilhado do **B21 ✓** (a corrida de ordem de chegada com B29 foi encerrada); paridade mobile (`StateDeputyFilters`) entrou por decisão desta sessão; ordem dos nulos de `party` pinada por int test; contagens derivadas (`municipalityCount`/`leadershipCount`) seguem sem sort/filtro, como travado, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` na coluna `actions` (entregue 2026-07-26), [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); ~~**B28**~~ ✓ e-mail + celular na lista de `/campanha/liderancas` (entregue 2026-07-26) — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp como em `/campanha/assessores` (B19 ✓); `CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`; edição in-list adiada; sem migration, [plano](plans/email-celular-lista-liderancas.md); ~~**B27**~~ ✓ combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/combobox-assessores-lista-municipios.md); ~~**B26**~~ ✓ registrar sinal na célula "Último sinal" (entregue 2026-07-25), [plano](plans/registrar-sinal-lista-municipios.md); **fill-in** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); glossário/KPI strips intactos; B16 ✓ liberou o nome ao remover "Assessoria", [plano](plans/renomear-coluna-cobertura-meta.md); ~~**B25**~~ ✓ link do território na lista de municípios (entregue 2026-07-26) — coluna "Território" + card mobile → `/campanha/territorios#ti-`; `territoryAnchor.ts` + `TerritoryLink`; seam `rowId` em `CampaignTable`; [plano](plans/link-territorio-lista-municipios.md); ~~**B24**~~ ✓ auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/autosave-tendencia-lista-municipios.md); **fill-in** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtro por Salvador cobre as zonas; drop também do filtro/sort/`?kind=` na URL da lista, schema `kind` intacto, [plano](plans/remover-coluna-tipo-municipios.md); ~~**B23**~~ ✓ tooltip de conteúdo nas células das listas (entregue 2026-07-26), [plano](plans/tooltip-celulas-listas.md); **fill-in** opções selecionadas no topo nos Popovers de filtro do header — helper puro reutilizável pelas demais listas, [plano](plans/selecionados-no-topo-filtros-header.md); ~~**B22**~~ ✓ explicação por coluna no header das listas (entregue 2026-07-26), [plano](plans/explicacao-colunas-header-listas.md); **B21** página própria dos Territórios de Identidade — rota `/campanha/territorios` com overview + tabela no sistema de listas (sort/filtro no header), sem migration, [plano](plans/pagina-territorios-identidade.md); **B20 ✓** Municípios prioritários no Início entregue 2026-07-25 — atalho ranqueado por déficit + markup sem bullets, [plano](plans/municipios-prioritarios-inicio.md); **Pass 2 ✓** consolidação de engenharia — listas genéricas/B16+ absorvido/B17-B18 triviais/E10 limpo/C8 F4 ✓/D3 consent WhatsApp; **B16 ✓**; **E16 ✓**; **E9 ✓**; **E18 ✓**; **B19 ✓**; **E8 ✓**; C12 ✓; B18; B17; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14 ✓; janela 1: smoke, R6)Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc). ## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026) @@ -148,7 +148,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re - **C8 F1–F2 restantes** perf de import/lista (F3 parsers fechou no Pass 2 W1-D2) · gatilho: import em volume real pós-Onda 0 · [plano](plans/escala-dry-pos-c6.md) - ~~**C8 F4b**~~ ✓ (entregue 2026-07-27) o guard de `formActions` só inspecionava arquivos chamados exatamente `formActions.ts` — ampliado para `*FormActions.ts` + `taskActions.ts`; 6 irmãos migrados para `runCampaignFormAction`/`runCampaignRedirectFormAction`; `taskActions.ts` allowlisted (contrato posicional) · achado do `/simplify` do C13 · [plano](plans/escala-dry-pos-c6.md) - ~~**B33+**~~ ✓ escala/DRY pós-B33 (entregue 2026-07-27) — `useCampaignListFilterNavigation` em `shared/` absorve o scaffold de navegação/debounce dos três filtros (**325 linhas removidas contra 228 em `src`**, das quais 138 são o hook novo; JSX só se moveu onde era bug); a auditoria derrubou o racional das "assinaturas diferentes" (os três `buildXListHref` já canonicalizam, então um só callback `toHref` basta) e achou **dois** bugs de comportamento: dobradinhas e territórios descartavam a busca digitada ao tocar um filtro (unificado na semântica de municípios via `navigateWithSearch`) e o timer do debounce capturava o `state` do render que o agendou, revertendo um sort clicado dentro da janela de 1s — municípios escapava 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` somou o fallback de transition copiado em 5 lugares (o hook era o 6º) virando `useCampaignListTransition`, e um `aria-live` a menos; a 2ª rodada fechou a ressincronização da caixa de busca com o `q` commitado (o que permitiu **remover** o `key=` de municípios e unificar a política de montagem das três listas — `buildMunicipalityFiltersKey` orfanou e morreu, junto com `shouldUpdateMunicipalitySearchUrl`), colapsou `setSearch`+`scheduleSearch` num `onSearchChange` (superfície de 8 → 6 nomes), passou a reusar o `buildMunicipalityFilterHref` que já existia + criou o irmão `buildStateDeputyFilterHref` (4 sítios pinavam página 1 à mão), e deu a municípios o resumo de filtros ativos que faltava no bloco mobile; `campaignListFilterNavigation.unit.spec.ts` (10 casos × 3 listas) é a primeira cobertura desses três componentes. **Dívidas deferidas com gatilho** (no plano, § "Adiado com gatilho"): shell de `` compartilhado (gatilho B29), `CampaignListSortSelect` (bloqueado numa decisão de altura de select, já divergida entre as três), `serialize`/`parseXSortValue` triplicado → `campaignListUrl`, `SupporterFilters`/`ActivityFilters` reconstruírem href à mão tendo builders pinados, ~10 kB gzip do catálogo no bundle cliente de `/campanha/municipios` (pré-existente, achado da review de performance — **gatilho já disparado duas vezes**: B41 ✓ e B42 ✓ entregaram na rota sem pagar, então deixou de ser condicional e é a dívida de bundle corrente da rota) e o 3º idioma de mock de router nos specs · [plano](plans/escala-dry-pos-b33.md) -- **B32+** escala/DRY pós-B32 — o fechamento do B32 disparou o "3º call site" que B24/B27/B32 já haviam registrado como _Adiado com gatilho_: `MunicipalityListExpectedVotesControl`/`MunicipalityListTrendControl`/`LeadershipListSupportStatusControl` repetem quase byte a byte o mesmo scaffold de auto-save por debounce (`open`/`isDirty`/`isPending`/`errorMessage`, `saveTimeoutRef`/`abortRef`/`committedRef`/`lastPropsRef`/`saveGenerationRef`, adoção de props externas, cleanup, save com abort+geração, schedule, flush-on-close, `statusMessage`); os quatro `route.ts` de auto-save (+ `municipios/advisors`) também repetem o preâmbulo `isSameOriginRequest`+`parseCampaignJsonRequestBody`. **F1** audita antes de extrair (precedente B33+: a auditoria pode derrubar a premissa), **F2**/**F3** (preâmbulo de rota/fetch) só valem a pena condicionados ao resultado de F1. **F4** entrou pela triage do **B42 ✓** (2026-07-27) e é a única fase **independente** do resultado de F1: a região `aria-live` de cada controle vive **dentro** do overlay e fechar é o que dispara o flush, então "Salvando…" é anunciado numa região que acabou de desmontar — o `toast.error` do B42 tapou o furo do erro, o sucesso continua mudo. A mesma triage mediu o **container** como já resolvido (o B42 extraiu `CampaignCellEditOverlay` e migrou o 4º call site) e registrou no plano a contagem as-built do **corpo** de multi-seleção (chips + `Command` byte-a-byte entre `LeadershipStateDeputyRelationCell` e `MunicipalityListAdvisorsControl`; gatilho = 3º corpo em overlay, B34/B37) · ~1–1,5d eng · Impeccable A · sem migration · cortável · [plano](plans/escala-dry-pos-b32.md) +- **B32+** escala/DRY pós-B32 — o fechamento do B32 disparou o "3º call site" que B24/B27/B32 já haviam registrado como _Adiado com gatilho_: `MunicipalityListExpectedVotesControl`/`MunicipalityListTrendControl`/`LeadershipListSupportStatusControl` repetem quase byte a byte o mesmo scaffold de auto-save por debounce (`open`/`isDirty`/`isPending`/`errorMessage`, `saveTimeoutRef`/`abortRef`/`committedRef`/`lastPropsRef`/`saveGenerationRef`, adoção de props externas, cleanup, save com abort+geração, schedule, flush-on-close, `statusMessage`); os quatro `route.ts` de auto-save (+ `municipios/advisors`) também repetem o preâmbulo `isSameOriginRequest`+`parseCampaignJsonRequestBody`. **F1** audita antes de extrair (precedente B33+: a auditoria pode derrubar a premissa), **F2**/**F3** (preâmbulo de rota/fetch) só valem a pena condicionados ao resultado de F1. A triage do fechamento do **E14 ✓** (2026-07-27) **disparou o gatilho de 5º call site** que o próprio plano guardava: `MunicipalityListLevelControl` + `POST /campanha/municipios/engagement-level` são o quinto controle e a quinta rota, e chegaram **sem** tocar em F1 (submit explícito, não auto-save) — então F2/F3 deixam de ser condicionais, e F2 sobe de boilerplate a **expensive_lock**, porque a política de origem que `isSameOriginRequest` diz possuir está copiada em cinco rotas e a que esquecer a linha falha **aberta**. A mesma triage abriu **F5**: o slot `footer`/`formId` que o **B42 ✓** havia adiado com o gatilho "um 2º controle precisar de submit explícito num Drawer" — hoje a folha pina "Fechar" e deixa "Registrar movimento" abaixo da dobra, ou seja, descartar uma justificativa digitada é mais alcançável que registrá-la. **F4** entrou pela triage do **B42 ✓** (2026-07-27) e é a outra fase **independente** do resultado de F1: a região `aria-live` de cada controle vive **dentro** do overlay e fechar é o que dispara o flush, então "Salvando…" é anunciado numa região que acabou de desmontar — o `toast.error` do B42 tapou o furo do erro, o sucesso continua mudo. A mesma triage mediu o **container** como já resolvido (o B42 extraiu `CampaignCellEditOverlay` e migrou o 4º call site) e registrou no plano a contagem as-built do **corpo** de multi-seleção (chips + `Command` byte-a-byte entre `LeadershipStateDeputyRelationCell` e `MunicipalityListAdvisorsControl`; gatilho = 3º corpo em overlay, B34/B37) · ~1,25–1,5d eng · Impeccable A (F1–F3) / B (F4–F5, mudam o que se ouve e o que se alcança na casca) · sem migration · cortável **exceto F2** (verificação de origem não é cortável por appetite) · [plano](plans/escala-dry-pos-b32.md) - Higiene PascalCase de componentes legados ### A validar (assumptions)