From be0d587005d99f5956697554c2ced0fa3d532566 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Tue, 28 Jul 2026 18:40:22 -0300 Subject: [PATCH] Dim the tour composer proposal when changing identity territory. Wire CampaignListPendingBoundary/Results on /campanha/atividades/giros so TI navigation matches list pending UX; pin contract in tourRegionPending unit spec. --- .cursor/rules/projects/nucleos-eleitorais.mdc | 1 + ...-campaign-campanha-app-atividades-giros.md | 32 +++++ .../feedback-pendente-compositor-giro.md | 16 ++- docs/roadmap.md | 4 +- .../campanha/(app)/atividades/giros/page.tsx | 91 ++++++++------- .../campaign/tour/TourRegionPicker.tsx | 4 +- tests/unit/tourRegionPending.unit.spec.ts | 110 ++++++++++++++++++ 7 files changed, 207 insertions(+), 51 deletions(-) create mode 100644 .impeccable/critique/2026-07-28T18-26-19Z__src-app-campaign-campanha-app-atividades-giros.md create mode 100644 tests/unit/tourRegionPending.unit.spec.ts diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index db54d3d6..ce099b07 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -7,6 +7,7 @@ alwaysApply: false ## Status +- **Fill-in ✓ dimir resultado no compositor de giro (2026-07-28):** em `/campanha/atividades/giros`, `CampaignListPendingBoundary` + `CampaignListResults` dimam a proposta ao trocar TI; `TourRegionPicker` usa `useCampaignListTransition` (spinner do select permanece); `CalendarPhaseNote` dentro de Results; unit `tourRegionPending.unit.spec.ts`. Sem migration/action/`Consent`. Critique sem P0/P1. Plano: [`feedback-pendente-compositor-giro.md`](../../../docs/plans/feedback-pendente-compositor-giro.md). - **Fill-in ícone de prioridade na lista entregue em código (2026-07-28):** em `/campanha/municipios`, o Badge destrutivo "Prioritária" na coluna do nome (tabela) e no cabeçalho do card mobile (`priority === 'alta'`, staff) virou `MunicipalityPriorityIndicator` — `FlagIcon` `text-primary`, `aria-label` e `CampaignHoverTooltip` (B23 ✓) com copy fixa **"Município prioritário"** (filtro/forms/StrategyCard/Dossier continuam no rótulo curto "Prioritária"). No card, `className="relative size-11"` no trigger para ficar acima do link esticado B42. Ilha client mínima; `MunicipalityList` segue RSC. Pin unit: staff vê `aria-label="Município prioritário"`; liderança não. Sem migration/action/Consent. Gate: tsc/lint/format/cycles; unit 770; knip P3 pré-existente; Aikido 0 findings no indicador. Impeccable B; harden/optimize out. Plano: [`icone-prioridade-lista-municipios.md`](../../../docs/plans/icone-prioridade-lista-municipios.md). - **Fill-in remover lista de zonas do Início entregue em código (2026-07-28):** sumiu o bloco "Municípios por zona eleitoral" sob o mapa de `/campanha`; `zoneBreakdown` / `MunicipalityZoneBreakdownRow` / `zoneVotes*BySlug` saíram do contrato (`municipalityMapContract`) e do loader (`municipalityMapData`); a ressalva de aproximação do **B8+ F3 ✓** permanece gated por `hasZoneMunicipalities`. Int `municipalityMapData` e `TESTING.md` atualizados. Sem migration/action/Consent. Plano: [`remover-lista-zonas-mapa-inicio.md`](../../../docs/plans/remover-lista-zonas-mapa-inicio.md). - **Fill-in "Selecionados no topo nos filtros do header" entregue em código (2026-07-28):** opções já marcadas sobem ao topo nos Popovers multi-seleção do sistema de listas (municípios, lideranças, territórios, dobradinhas) e nos `CampaignMobileMultiFilterField`. Helper puro `orderFilterOptionsSelectedFirst` em `src/lib/listFilterOptions.ts`; snapshot da seleção na abertura do Popover (ordem congelada enquanto aberto; checkbox continua live); hairline entre grupos, oculto durante a busca (polish do critique). Gate multi = `optionRows.every(checkbox)` — single-select intocado. A premissa "não extrair head genérico" do plano caducou: B21/B29 já tinham pago `CampaignHeaderFilterPopover` compartilhado, então o item pousou em 2 arquivos e 4 listas. Specs unit do helper + do Popover (freeze). Sem migration/action/Consent. Plano: [`selecionados-no-topo-filtros-header.md`](../../../docs/plans/selecionados-no-topo-filtros-header.md). diff --git a/.impeccable/critique/2026-07-28T18-26-19Z__src-app-campaign-campanha-app-atividades-giros.md b/.impeccable/critique/2026-07-28T18-26-19Z__src-app-campaign-campanha-app-atividades-giros.md new file mode 100644 index 00000000..ae6b2005 --- /dev/null +++ b/.impeccable/critique/2026-07-28T18-26-19Z__src-app-campaign-campanha-app-atividades-giros.md @@ -0,0 +1,32 @@ +--- +timestamp: 2026-07-28T18-26-19Z +slug: src-app-campaign-campanha-app-atividades-giros +--- + +# Critique: /campanha/atividades/giros pending feedback + +Method: dual-agent attempted (A interrupted hung; B d63ec5de clean). Assessment A synthesized in parent from source. Detector: 0 findings. Browser skipped (no campaign server). + +## Scoped Design Health ~3.6/4 + +Visibility of system status 4 — spinner on control + dim/aria-busy/live on results. +Consistency 4 — same CampaignListPending\* as lists. +AI slop: pass. + +## Strengths + +1. Shared transition without a second pending language. +2. Control outside Results (Feel the action). +3. Unit pin on aria-busy/data-pending. + +## Priority issues + +None P0/P1. + +## Decisions confirmed + +Keep "Atualizando resultados…"; keep select spinner + dim (gesture vs scope). + +## Polish + +No code changes — aligned with municipios nesting. CalendarPhaseNote under picker inside Results. diff --git a/docs/plans/feedback-pendente-compositor-giro.md b/docs/plans/feedback-pendente-compositor-giro.md index 0b7fce2a..adca4d6e 100644 --- a/docs/plans/feedback-pendente-compositor-giro.md +++ b/docs/plans/feedback-pendente-compositor-giro.md @@ -1,12 +1,16 @@ # Dimir o resultado no compositor de giro (feedback pendente) -Status: rascunho -Atualizado em: 2026-07-27 -Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins abertos) +Status: entregue (2026-07-28) +Atualizado em: 2026-07-28 +Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins abertos — riscado) Impeccable: B — encaixe em `/campanha/atividades/giros` (rota entregue no **E13 ✓**); nenhuma superfície nova Appetite: ~0,25 dia eng; três pontos de edição, sem migration/action/`Consent` Responsável: — +Revisão 2026-07-28 (auditoria pré-implementação): questões em aberto fechadas (copy aria-live reutilizada; spinner do select permanece). `CalendarPhaseNote` hoje renderiza _acima_ do picker — a entrega move a nota para dentro de `CampaignListResults` (abaixo do seletor), como a abordagem já descrevia. + +**As-built 2026-07-28:** `CampaignListPendingBoundary` + `CampaignListResults` em `giros/page.tsx`; `TourRegionPicker` passou a `useCampaignListTransition`; `CalendarPhaseNote` dentro de Results; unit `tourRegionPending.unit.spec.ts` pina `aria-busy`/`data-pending` com `push` Promise pendente (React 19). Critique: sem P0/P1; polish sem mudança de código. Gate tsc/lint/format/cycles; knip P3 pré-existente (`payload.config.ts`); Aikido 0 findings nos first-party. + ## Design (Impeccable) Âncoras: `PRODUCT.md` (clareza sob pressão) / `DESIGN.md` (register `product`, Field Desk) · tema `data-theme='campaign'` · precedente `engineering-standards.mdc` § Loading feedback e as 10 listas de `/campanha` que já usam `CampaignListPendingBoundary` + `CampaignListResults`. @@ -53,10 +57,10 @@ Dois efeitos concretos, além da inconsistência com as dez listas vizinhas: - **Nada de `loading.tsx` na rota.** A navegação é `router.push` dentro de uma transição, então o segmento não remonta; um `loading.tsx` só apareceria na entrada direta, que já é rápida. **Rejeitado:** skeleton do compositor (a proposta anterior dimada é mais informativa que um esqueleto). - **i18n e naming** (AGENTS.md): identificadores em inglês; a copy visível reusa "Atualizando resultados…" já existente, sem nova string. -## Questões em aberto +## Questões resolvidas (2026-07-28) -- **A copy `aria-live` "Atualizando resultados…" serve para uma proposta de giro?** **Opções:** A) reusar como está | B) prop de mensagem no `CampaignListResults` para dizer "Recompondo o giro…". **Recomendação:** **A** — "resultados" descreve honestamente o que muda, e um prop novo abriria a porta para dez variações de copy nas listas; revisitar no critique se soar errado em voz alta. -- **O spinner atual do `FieldDescription` fica ou sai?** **Opções:** A) fica (controle + resultado sinalizam) | B) sai, deixando só o dim. **Recomendação:** **A** — é o feedback imediato no ponto do gesto; o dim é o que informa o escopo. Custa duas linhas manter. +- **Copy `aria-live`:** reusar "Atualizando resultados…" (sem prop de mensagem no `CampaignListResults`). Revisitar no critique se soar errado em voz alta. +- **Spinner do `FieldDescription`:** permanece — feedback imediato no gesto; o dim informa o escopo. ## Abordagem proposta diff --git a/docs/roadmap.md b/docs/roadmap.md index 17028448..24d17394 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-28 (**fill-in ✓** selecionados no topo nos filtros do header — `orderFilterOptionsSelectedFirst` + `CampaignHeaderFilterPopover`/`CampaignMobileMultiFilterField` (4 listas); snapshot congelado; hairline oculto na busca · [plano](plans/selecionados-no-topo-filtros-header.md); **fill-in ✓** ícone de prioridade na lista de municípios — Flag + tooltip "Município prioritário" na tabela e no card mobile, Impeccable B · [plano](plans/icone-prioridade-lista-municipios.md); ~~**fill-in** remover lista de zonas do Início~~ ✓ (entregue 2026-07-28 — UI + `zoneBreakdown` removidos; ressalva via `hasZoneMunicipalities`) · [plano](plans/remover-lista-zonas-mapa-inicio.md); **E11 ✓** motor de sugestões v1 — os 8 padrões de maior confiança do §6.1 (P1/P2/P3/P5/P6/P7/K-A/K-B) como catálogo curado em código (`suggestionCatalog.ts`, client-safe: predicados puros sobre números derivados + a copy exata dos cards, revisados no mesmo diff — contrato do E18), avaliador stateless em leitura (`municipalityTriggers.ts`, molde do planner E13: leitura própria + 6 queries em `Promise.all`; nada de cron nem tabela de sugestões) e decisão humana gravando `allocationDecision` com o snapshot **recomputado no servidor** no momento da decisão — padrão fora da fila recusa como stale, lock `municipality-suggestion:{id}:{patternId}` antes de reler a última decisão; **a auditoria matou o "sem migration" do plano:** o enum real era `aceita|descarta|movimento` (os valores `descartada|adiada` do plano nunca existiram), então "Adiar" custou a migration `20260728_041547` adicionando `adiada` (precedente do `movimento` do E14) com `suppressUntil` no snapshot em vez de coluna; supressão por janelas (aceita 14d / descarta 30d / adiada até o snooze) com a regra "nível 1 fura a fila" pura e unit-testada; diferencial estrutural codificado (P2 cede a P3/K-B, P7 cede a K-A); P3 dispara só pelo artefato porque `runningAgain2026` está 100% `desconhecido` sem read path — "concorre de novo?" virou o 1º item do menu; pauta do silêncio = alta/N2+ sem gatilho e sem sinal ≥30d (inclui `priority: alta` porque E14 nasceu sem backfill); UI: painel Suspense no Início (top-5 + silêncio) e card na visão geral do município, aceite com radios do menu (1º pré-selecionado — a checagem barata) + adiamento de um toque (7d/14d pelo nível) + descarte com quick-picks dos "descartes" do padrão e leitura alternativa obrigatória, submit explícito (exceção prevista, mesmo precedente do E14), foco gerenciado na troca trigger↔form, só o nível 1 usa vermelho; int tests alocam municípios pelo alocador até um qualificar (taxas medidas 109/435 quartil superior, 205/435 quietos — slug explícito colide com a sequence com wrap); First Load JS de `/campanha` 268 → 277 kB; adjacente registrado no plano: ligar o override do "não vá" do compositor de giros ao `resolveSuggestion` · [plano](plans/motor-de-sugestoes.md); **B40 ✓** login biométrico WebAuthn em `/campanha` — enrollment opt-in (toast no shell + seção no perfil), botão de digital/Face ID na tela de login emitindo o mesmo `campaign-token` de 14 dias, revogação por dispositivo, para **todos os papéis** e **sem `Consent`** (só a chave pública sai do aparelho, nunca a biometria). O Payload não tem login password-less: a sessão é forjada com `db.findOne` cru + `checkLoginPermission` + `addSessionToUser` + `jwtSign` sob lock e transação, porque carregar o usuário pelo caminho normal apagaria `sessions` e derrubaria as outras sessões — int spec pinando o fato. Duas armadilhas fora do plano: o RP resolvia com sucesso num preview da Vercel e o browser estourava `SecurityError` (agora o host do request tem de bater com o host derivado, senão o botão não aparece), e o challenge do cookie tem de ser o mesmo que o `@simplewebauthn/server` gerou (a primeira versão gerava o seu e todo `register` dava 400); `campaignUser` ganhou `beforeDelete` em cascata porque a FK do Payload é `ON DELETE SET NULL` sobre coluna `NOT NULL`. `@simplewebauthn/browser` entra por `import()` dinâmico dentro do handler (a metade sempre carregada — probe + tipo de erro — vive em `campaignWebAuthnSupport.ts`), então `/campanha/login` fica em **126 kB** e `/campanha`, onde o toast monta em toda navegação, segue nos 268 kB de antes do item · [plano](plans/login-biometrico-campanha.md); **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~~ ✓ entregue 2026-07-28 — pós-B8 F2 ✓ os polígonos tornaram a lista textual redundante; UI + `zoneBreakdown` removidos; ressalva via `hasZoneMunicipalities` · [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 (**fill-in ✓** dimir resultado no compositor de giro — `CampaignListPendingBoundary`/`CampaignListResults` em `/campanha/atividades/giros`, `TourRegionPicker` → `useCampaignListTransition`, unit pin · [plano](plans/feedback-pendente-compositor-giro.md); **fill-in ✓** selecionados no topo nos filtros do header — `orderFilterOptionsSelectedFirst` + `CampaignHeaderFilterPopover`/`CampaignMobileMultiFilterField` (4 listas); snapshot congelado; hairline oculto na busca · [plano](plans/selecionados-no-topo-filtros-header.md); **fill-in ✓** ícone de prioridade na lista de municípios — Flag + tooltip "Município prioritário" na tabela e no card mobile, Impeccable B · [plano](plans/icone-prioridade-lista-municipios.md); ~~**fill-in** remover lista de zonas do Início~~ ✓ (entregue 2026-07-28 — UI + `zoneBreakdown` removidos; ressalva via `hasZoneMunicipalities`) · [plano](plans/remover-lista-zonas-mapa-inicio.md); **E11 ✓** motor de sugestões v1 — os 8 padrões de maior confiança do §6.1 (P1/P2/P3/P5/P6/P7/K-A/K-B) como catálogo curado em código (`suggestionCatalog.ts`, client-safe: predicados puros sobre números derivados + a copy exata dos cards, revisados no mesmo diff — contrato do E18), avaliador stateless em leitura (`municipalityTriggers.ts`, molde do planner E13: leitura própria + 6 queries em `Promise.all`; nada de cron nem tabela de sugestões) e decisão humana gravando `allocationDecision` com o snapshot **recomputado no servidor** no momento da decisão — padrão fora da fila recusa como stale, lock `municipality-suggestion:{id}:{patternId}` antes de reler a última decisão; **a auditoria matou o "sem migration" do plano:** o enum real era `aceita|descarta|movimento` (os valores `descartada|adiada` do plano nunca existiram), então "Adiar" custou a migration `20260728_041547` adicionando `adiada` (precedente do `movimento` do E14) com `suppressUntil` no snapshot em vez de coluna; supressão por janelas (aceita 14d / descarta 30d / adiada até o snooze) com a regra "nível 1 fura a fila" pura e unit-testada; diferencial estrutural codificado (P2 cede a P3/K-B, P7 cede a K-A); P3 dispara só pelo artefato porque `runningAgain2026` está 100% `desconhecido` sem read path — "concorre de novo?" virou o 1º item do menu; pauta do silêncio = alta/N2+ sem gatilho e sem sinal ≥30d (inclui `priority: alta` porque E14 nasceu sem backfill); UI: painel Suspense no Início (top-5 + silêncio) e card na visão geral do município, aceite com radios do menu (1º pré-selecionado — a checagem barata) + adiamento de um toque (7d/14d pelo nível) + descarte com quick-picks dos "descartes" do padrão e leitura alternativa obrigatória, submit explícito (exceção prevista, mesmo precedente do E14), foco gerenciado na troca trigger↔form, só o nível 1 usa vermelho; int tests alocam municípios pelo alocador até um qualificar (taxas medidas 109/435 quartil superior, 205/435 quietos — slug explícito colide com a sequence com wrap); First Load JS de `/campanha` 268 → 277 kB; adjacente registrado no plano: ligar o override do "não vá" do compositor de giros ao `resolveSuggestion` · [plano](plans/motor-de-sugestoes.md); **B40 ✓** login biométrico WebAuthn em `/campanha` — enrollment opt-in (toast no shell + seção no perfil), botão de digital/Face ID na tela de login emitindo o mesmo `campaign-token` de 14 dias, revogação por dispositivo, para **todos os papéis** e **sem `Consent`** (só a chave pública sai do aparelho, nunca a biometria). O Payload não tem login password-less: a sessão é forjada com `db.findOne` cru + `checkLoginPermission` + `addSessionToUser` + `jwtSign` sob lock e transação, porque carregar o usuário pelo caminho normal apagaria `sessions` e derrubaria as outras sessões — int spec pinando o fato. Duas armadilhas fora do plano: o RP resolvia com sucesso num preview da Vercel e o browser estourava `SecurityError` (agora o host do request tem de bater com o host derivado, senão o botão não aparece), e o challenge do cookie tem de ser o mesmo que o `@simplewebauthn/server` gerou (a primeira versão gerava o seu e todo `register` dava 400); `campaignUser` ganhou `beforeDelete` em cascata porque a FK do Payload é `ON DELETE SET NULL` sobre coluna `NOT NULL`. `@simplewebauthn/browser` entra por `import()` dinâmico dentro do handler (a metade sempre carregada — probe + tipo de erro — vive em `campaignWebAuthnSupport.ts`), então `/campanha/login` fica em **126 kB** e `/campanha`, onde o toast monta em toda navegação, segue nos 268 kB de antes do item · [plano](plans/login-biometrico-campanha.md); **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~~ ✓ (entregue 2026-07-28) · [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~~ ✓ entregue 2026-07-28 — pós-B8 F2 ✓ os polígonos tornaram a lista textual redundante; UI + `zoneBreakdown` removidos; ressalva via `hasZoneMunicipalities` · [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) @@ -136,7 +136,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re - ~~**Renomear coluna Cobertura da meta → Cobertura**~~ — **✓ (entregue 2026-07-28)** em `/campanha/municipios`: `municipalityListSortLabels.deficit` → "Cobertura" (B17 ✓ propaga header, picker e sort); card mobile via `municipalityColumnLabels.goalCoverage`; `whereItAppears` do conceito E18 atualizado; strips/card/título do glossário e `/campanha/territorios` intactos · [plano](plans/renomear-coluna-cobertura-meta.md) - **Remover coluna Tipo** em `/campanha/municipios` — pedido de produto (2026-07-25): a coluna "Tipo" (Município × Zona eleitoral) não informa a coordenação (ambos tratados iguais); filtrar ZE = filtrar Salvador (19 slugs). Remove coluna + filtro/sort UI + param `?kind=` do contrato URL da lista; **schema/catálogo `kind` intacto** (mapa, B8, dossiê) · ~0,25–0,5d · Impeccable B · soft: B15 ✓/B16 ✓ (header); B17 ganha uma coluna a menos · cortável · [plano](plans/remover-coluna-tipo-municipios.md) - ~~**Remover lista "Municípios por zona eleitoral" do Início**~~ — **✓ (entregue 2026-07-28)** bloco do painel + `zoneBreakdown` do bundle removidos; ressalva de aproximação permanece via `hasZoneMunicipalities` (B8+ F3 ✓) · [plano](plans/remover-lista-zonas-mapa-inicio.md) -- **Dimir o resultado no compositor de giro** em `/campanha/atividades/giros` — trocar de Território de Identidade recompõe a proposta no servidor, mas hoje só o seletor sinaliza (spinner na própria descrição): a proposta do território anterior fica nítida e clicável, e o leitor de tela não recebe evento nenhum, contra o que o `engineering-standards.mdc` exige ("a região de RESULTADOS dima, não só o controle"). O `/simplify` do **E13 ✓** passou por isto como _"too involved — the composer isn't a list"_; a auditoria do fechamento mediu o contrário — `CampaignListPendingBoundary` é um Provider sobre `children`, `CampaignListResults` é uma `div` com `data-pending`/`aria-busy`/`aria-live` e `useCampaignListTransition` já cai num transition local, então são três fios a ligar, não uma abstração a portar · ~0,25d · Impeccable B · dura: E13 ✓ · cortável · [plano](plans/feedback-pendente-compositor-giro.md) +- ~~**Dimir o resultado no compositor de giro**~~ — **✓ (entregue 2026-07-28)** em `/campanha/atividades/giros`: `CampaignListPendingBoundary` + `CampaignListResults` dimam a proposta (`data-pending`/`aria-busy`/`aria-live`), `TourRegionPicker` usa `useCampaignListTransition` (spinner do select permanece); `CalendarPhaseNote` moveu para dentro de Results; unit `tourRegionPending.unit.spec.ts` · sem migration · [plano](plans/feedback-pendente-compositor-giro.md) - ~~**Ícone de prioridade na lista**~~ ✓ (entregue 2026-07-28) em `/campanha/municipios` — Badge “Prioritária” trocado por Flag + `CampaignHoverTooltip` “Município prioritário” na coluna do nome (tabela + card mobile) · ~0,25d · Impeccable B · [plano](plans/icone-prioridade-lista-municipios.md) - ~~**Selecionados no topo nos filtros do header**~~ — **✓ (entregue 2026-07-28)** opções já marcadas sobem ao topo nos Popovers multi-seleção do sistema de listas (municípios, lideranças, territórios, dobradinhas) e nos `CampaignMobileMultiFilterField`; helper puro `orderFilterOptionsSelectedFirst` (`src/lib/listFilterOptions.ts`); snapshot congelado enquanto o Popover está aberto; hairline entre grupos (oculto durante busca); single-select gateado por `row.checkbox` · Impeccable B · [plano](plans/selecionados-no-topo-filtros-header.md) - **Cenário junto aos filtros** em `/campanha/municipios` — mover `VoteEstimateScenarioField` para a fileira/barra slim de `MunicipalityFilters` (com **B16**, a barra deixa de carregar os selects de coluna); overview só consome o contexto · ~0,25–0,5d · Impeccable B · soft: B16 · [plano](plans/cenario-junto-filtros-municipios.md) diff --git a/src/app/(campaign)/campanha/(app)/atividades/giros/page.tsx b/src/app/(campaign)/campanha/(app)/atividades/giros/page.tsx index e49f5245..10e35233 100644 --- a/src/app/(campaign)/campanha/(app)/atividades/giros/page.tsx +++ b/src/app/(campaign)/campanha/(app)/atividades/giros/page.tsx @@ -6,6 +6,10 @@ import { redirect } from 'next/navigation' import { getPayload } from 'payload' import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState' +import { + CampaignListPendingBoundary, + CampaignListResults, +} from '@/components/campaign/shared/CampaignListPending' import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' import { CalendarPhaseNote } from '@/components/campaign/tour/CalendarPhaseNote' import { TourComposerForm, type TourStopOption } from '@/components/campaign/tour/TourComposerForm' @@ -124,49 +128,54 @@ export default async function TourComposerPage({ searchParams }: TourComposerPag - {bundle ? : null} + {/* Shared transition: the picker navigates, the proposal dims ("Feel the action"). */} + + {regions.length > 0 ? ( + ({ + ...option, + href: buildTourComposerHref({ region: option.region }), + }))} + selectedRegion={region} + clearHref={buildTourComposerHref({ region: null })} + /> + ) : null} - {regions.length > 0 ? ( - ({ - ...option, - href: buildTourComposerHref({ region: option.region }), - }))} - selectedRegion={region} - clearHref={buildTourComposerHref({ region: null })} - /> - ) : null} + + {bundle ? : null} - {regions.length === 0 ? ( - - ) : region === null ? ( - - ) : stops.length === 0 ? ( - - - - ) : ( - - )} + {regions.length === 0 ? ( + + ) : region === null ? ( + + ) : stops.length === 0 ? ( + + + + ) : ( + + )} + + ) } diff --git a/src/components/campaign/tour/TourRegionPicker.tsx b/src/components/campaign/tour/TourRegionPicker.tsx index d7e89620..b6930b47 100644 --- a/src/components/campaign/tour/TourRegionPicker.tsx +++ b/src/components/campaign/tour/TourRegionPicker.tsx @@ -1,8 +1,8 @@ 'use client' import { useRouter } from 'next/navigation' -import { useTransition } from 'react' +import { useCampaignListTransition } from '@/components/campaign/shared/CampaignListPending' import { Field, FieldDescription, FieldLabel } from '@/components/ui/field' import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select' import { Spinner } from '@/components/ui/Spinner' @@ -33,7 +33,7 @@ export const TourRegionPicker = ({ clearHref: string }) => { const router = useRouter() - const [isPending, startTransition] = useTransition() + const { isPending, startTransition } = useCampaignListTransition() return ( diff --git a/tests/unit/tourRegionPending.unit.spec.ts b/tests/unit/tourRegionPending.unit.spec.ts new file mode 100644 index 00000000..6799779b --- /dev/null +++ b/tests/unit/tourRegionPending.unit.spec.ts @@ -0,0 +1,110 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, createElement, type ReactElement } from 'react' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' + +const routerState = vi.hoisted(() => ({ + push: vi.fn(), + /** + * Resolves the in-flight `push` Promise so the React 19 transition can + * settle. The shared pending boundary stays busy until this runs. + */ + resolvePush: null as (() => void) | null, +})) + +vi.mock('next/navigation', async (importActual) => ({ + ...(await importActual()), + useRouter: () => ({ + push: (...args: unknown[]) => routerState.push(...args), + }), +})) + +import { + CampaignListPendingBoundary, + CampaignListResults, +} from '@/components/campaign/shared/CampaignListPending' +import { TourRegionPicker } from '@/components/campaign/tour/TourRegionPicker' + +/** + * The composer isn't a list, but it reuses the list pending pieces. This pin + * is the contract the E13 `/simplify` missed: changing Território drives the + * shared transition so `CampaignListResults` dims (`data-pending` / `aria-busy`) + * while the picker keeps its own spinner. + */ +const regions = [ + { region: 'Sisal', municipalityCount: 3, href: '/campanha/atividades/giros?region=Sisal' }, + { + region: 'Portal do Sertão', + municipalityCount: 5, + href: '/campanha/atividades/giros?region=Portal%20do%20Sert%C3%A3o', + }, +] + +const mountComposer = (): ReactElement => + createElement( + CampaignListPendingBoundary, + null, + createElement(TourRegionPicker, { + regions, + selectedRegion: 'Sisal', + clearHref: '/campanha/atividades/giros', + }), + createElement(CampaignListResults, null, createElement('p', null, 'Proposta do giro Sisal')), + ) + +const reactActEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean +} + +describe('tour region pending', () => { + beforeAll(() => { + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterAll(() => { + delete reactActEnvironment.IS_REACT_ACT_ENVIRONMENT + }) + + afterEach(() => { + cleanup() + routerState.push.mockReset() + routerState.resolvePush = null + }) + + it('dims the results region while the territory navigation is in flight', async () => { + routerState.push.mockImplementation( + () => + new Promise((resolve) => { + routerState.resolvePush = resolve + }), + ) + + render(mountComposer()) + + const results = screen.getByText('Proposta do giro Sisal').parentElement + expect(results).not.toBeNull() + expect(results!.getAttribute('aria-busy')).not.toBe('true') + expect(results!.getAttribute('data-pending')).toBeNull() + + await act(async () => { + fireEvent.change(screen.getByLabelText('Território de Identidade'), { + target: { value: 'Portal do Sertão' }, + }) + }) + + expect(routerState.push).toHaveBeenCalledWith(regions[1].href, { scroll: false }) + expect(results!.getAttribute('aria-busy')).toBe('true') + expect(results!.getAttribute('data-pending')).toBe('true') + expect(screen.getByText('Atualizando resultados…')).toBeTruthy() + expect(screen.getByText('Montando a proposta de giro…')).toBeTruthy() + expect((screen.getByLabelText('Território de Identidade') as HTMLSelectElement).disabled).toBe( + true, + ) + + await act(async () => { + routerState.resolvePush?.() + }) + + expect(results!.getAttribute('aria-busy')).not.toBe('true') + expect(results!.getAttribute('data-pending')).toBeNull() + }) +})