From abe37cbc5c7cd91fa3bc0f578e2739c3f12d2684 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Mon, 27 Jul 2026 09:03:00 -0300 Subject: [PATCH] B33+: shared list filter navigation hook Extract the debounce/dedup/transition scaffold that StateDeputyFilters, TerritoryFilters and MunicipalityFilters each reimplemented into useCampaignListFilterNavigation, and unify the two behaviors that had silently diverged: touching a filter mid-typing used to discard the pending search on dobradinhas and territorios, and the debounce timer captured a stale state, so a sort clicked within its window was reverted a second later. Also folds the six copies of the pending-transition fallback into useCampaignListTransition and drops a redundant aria-live that made the municipality summary the third live region on its page. --- .cursor/rules/projects/nucleos-eleitorais.mdc | 1 + docs/plans/escala-dry-pos-b33.md | 30 +- docs/roadmap.md | 4 +- .../campaign/activity/ActivityFilters.tsx | 10 +- .../municipality/MunicipalityFilters.tsx | 158 ++--------- .../municipality/TerritoryFilters.tsx | 66 +---- .../campaign/shared/CampaignFilterChips.tsx | 9 +- .../campaign/shared/CampaignListPending.tsx | 21 +- .../campaign/shared/CampaignSearchForm.tsx | 9 +- .../shared/useCampaignListFilterNavigation.ts | 111 ++++++++ .../stateDeputy/StateDeputyFilters.tsx | 68 +---- .../campaign/supporter/SupporterFilters.tsx | 10 +- src/utilities/municipalityListUrl.ts | 5 - .../campaignListFilterNavigation.unit.spec.ts | 267 ++++++++++++++++++ tests/unit/municipalityList.unit.spec.ts | 14 - 15 files changed, 483 insertions(+), 300 deletions(-) create mode 100644 src/components/campaign/shared/useCampaignListFilterNavigation.ts create mode 100644 tests/unit/campaignListFilterNavigation.unit.spec.ts diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index 54d29e1f..c7d535e8 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -40,6 +40,7 @@ alwaysApply: false **UI.** Legenda discreta (`MapScaleLegend`) com as faixas reais, do mais fraco para o mais forte, e a nota ligada ao seletor por `aria-describedby`; o readout **nunca** mostra a classe sozinha (research §6.4), soletra o porquê em texto real ("4ª de 5 faixas", "1,8× o padrão estadual do candidato", "12º entre 663 candidatos votados aqui"). Bolhas opcionais por válidos projetados (área ∝ valor, `interactive: false` para não empilhar dois alvos de hover no mesmo município, desenhadas da menor para a maior); **medido antes de otimizar** — 417 marcadores não derrubaram o frame, então o corte para top-K não foi feito. Dois conceitos novos em `/campanha/conceitos` (E18) com "Saiba mais" inline na nota; LQ já estava coberto por `dominancia-relativa`. Sem migration, sem collection, sem server action; o **First Load JS** de `/campanha` ficou byte a byte igual (9,46 kB / 265 kB) porque o painel do mapa já carrega em chunk próprio (`ssr: false`) — o chunk em si cresceu, e o payload RSC subiu ~64 KB não comprimidos (~10 KB gzip), quase tudo `competitiveRankByYear`. Gate verde (tsc, lint, format, knip, check:cycles, 467 unit / 387 int, build); Aikido sem achado. **Débito de teste registrado:** `campaignMunicipalities.e2e.spec.ts` é flaky com 2 workers **em `main` também** — verificado por stash: com as mudanças passa 5/5 isolado e falha na suíte cheia; sem as mudanças passou 15/15 numa rodada e falhou na seguinte. Duas causas visíveis: o checkbox de consentimento do `LeaderContactsPanel` clicado antes da hidratação ("Clicking the checkbox did not change its state") e colisão de regex quando o fixture sorteia "Conde" e o `getByRole` casa também "Condeúba". +- **B33+ entregue em código (2026-07-27):** `useCampaignListFilterNavigation` (`src/components/campaign/shared/`) absorve o scaffold de navegação/debounce que `StateDeputyFilters`, `TerritoryFilters` e `MunicipalityFilters` reimplementavam — 216 linhas removidas contra 35 adicionadas, sem uma única linha de markup (`className`/`id`/`aria-*`/`htmlFor`) alterada nos três. **A auditoria pré-implementação derrubou a premissa central do plano:** ele rejeitava um callback único porque "os três domínios têm assinaturas de canonicalização diferentes", mas `buildStateDeputyListHref`/`buildTerritoryListHref`/`buildMunicipalityListHref` **já canonicalizam internamente** (`parseXListParams(xListStateToRawParams(...))` dentro do próprio builder) e `buildMunicipalityFiltersKey` é só `buildMunicipalityListSearchParams(state).toString()` — a mesma query string canônica. Logo o `parse(...)` explícito de dobradinhas/territórios era trabalho redundante, não diferença de domínio, e um só `toHref` cobre os três; o guardrail de fundo (o hook recebe callback e nunca importa os módulos de URL por domínio) ficou de pé. **Achado de comportamento, não de estrutura:** tocar um filtro/sort no meio da digitação **descartava** a busca pendente em dobradinhas e territórios (`navigateTo` chamava `clearDebounce()` primeiro) enquanto o input seguia exibindo o texto — só municípios carregava o rascunho. Unificado na semântica de municípios por `navigateWithSearch`, que é a **terceira** função exposta (a "questão em aberto" do plano fecha com três nomes, não dois); `clearDebounce` não é exposto porque `navigateTo` limpa antes de qualquer guarda, o que também matou o `router.replace` solto do "Limpar" de municípios. `shouldUpdateMunicipalitySearchUrl` orfanou (era `normalizedText(input) !== currentQ`, com o componente e o próprio spec como únicos consumidores) e foi deletado com seu `describe`. **Rede de segurança onde não havia nenhuma:** `tests/unit/campaignListFilterNavigation.unit.spec.ts` renderiza os três filtros em jsdom (`next/navigation` mockado, `vi.useFakeTimers({ toFake: ['setTimeout','clearTimeout'] })` — o scheduler do React precisa de microtasks reais), 5 casos × 3 listas; escrito antes da migração, o caso do rascunho nasceu **vermelho exatamente em dobradinhas e territórios**. `CampaignSearchForm` (lideranças/assessores/organizações) ficou fora por decisão explícita: 4º irmão, mas sem debounce, com submit explícito, e B29 provavelmente o substitui. Gate verde e bare (tsc, lint, format, knip, check:cycles, 555 unit + 413 int, build local); Aikido 0 achados. **e2e não serviu de veredito nesta sessão:** máquina com load average ~58 (dois outros worktrees com dev server), `campaignMunicipalities` falhando de forma instável na árvore nova **e** na árvore limpa em stash (3 → 6 → 4 falhas em rodadas idênticas, e verde isolado) — o flake pré-existente já ledgerizado, amplificado por timeout; `campaignTerritories` passou em todas as rodadas, e nenhum teste de municípios exercita o formulário de filtros (navegam direto por `?q=`), então o e2e nunca cobriu esta superfície. **Rodada `/simplify` (mesmo dia) mudou números e achou um bug:** o fallback `useCampaignListPending() ?? useTransition()` estava copiado em 5 lugares e o hook novo virou o 6º — virou `useCampaignListTransition()` em `CampaignListPending.tsx` (e `useCampaignListPending` deixou de ser exportado); `draftQ` saiu para o hook; `MunicipalityFilters` perdeu um `aria-live` na **terceira** região viva da mesma página (anunciava o resumo de filtros a cada tecla) e um `div` de um filho só. O bug: o `setTimeout` do debounce capturava o `state` do render que o agendou, então em territórios/dobradinhas digitar + clicar num sort dentro de 1s **revertia o sort** um segundo depois (municípios escapava pelo `key=` da página, que remonta e mata o timer) — corrigido com `stateRef`, com caso de spec verificado vermelho nas três listas sem o fix. Números finais: 281 linhas removidas contra 75 em todo o `src`, 8 casos × 3 listas, 562 unit + 413 int. Fica **parado com gatilho** (os dois revisores propuseram direções opostas): ressincronizar o input com `state.q` — remontar atropela quem digita rápido, não remontar deixa o input mentindo depois do Back. Plano: [`escala-dry-pos-b33.md`](../../../docs/plans/escala-dry-pos-b33.md). - **B33 entregue em código (2026-07-26):** ordenação e filtro no header de `/campanha/dobradinhas` — `stateDeputyListUrl.ts`/`stateDeputyListFilters.ts` (contrato de URL/filtro próprio, sentinela `NO_PARTY_FILTER_VALUE` para "Sem partido") espelhando `municipalityListUrl.ts` (paginação/`resolveListUrl`) e `territoryListUrl.ts` (chaves/labels de sort) — não `leadershipListUrl.ts`, que o plano mandava espelhar mas **não existe** (B29 não entregou). `StateDeputySortableHead`/`StateDeputyHeaderFilter` consomem o chrome já compartilhado pelo **B21 ✓** sem extrair de novo (a corrida de ordem de chegada com B29 está encerrada); `loadStateDeputyPartyFacet` segue o contrato de faceta de `loadMunicipalityListFilterFacets` (respeita a busca, ignora o próprio filtro do popover). Ordem dos nulos de `party` **pinada por int test** em vez de assumida — Postgres/Payload ordena nulls por último em `asc` e primeiro em `desc`; "Sem partido" continua sendo o caminho direto via `party: { exists: false }`. Paridade mobile (`StateDeputyFilters`, espelho de `TerritoryFilters`) entrou por decisão desta sessão, fora do appetite original. Redirect canônico em duas fases (`resolveStateDeputyListUrl` → `totalPages` → resolve de novo) no molde de `municipios/page.tsx`. Sort v1 = Nome (default) e Partido; filtro v1 = Partido (multi + "Sem partido"); `municipalityCount`/`leadershipCount` seguem sem sort/filtro, como travado no plano. Gate verde (tsc, lint, format, check:cycles, 396 testes, build); Aikido 0 achados. **Correção pós-entrega no mesmo dia (design review independente):** mobile não tinha a opção "Sem partido" que o popover desktop já injetava (`hasNoParty` não chegava a `StateDeputyFilters`) e a tabela não passava `caption` ao `CampaignTable`, ao contrário dos dois precedentes citados no plano — ambos corrigidos no mesmo molde; copy do empty state também virou plural. **`/simplify` no mesmo dia:** a correção acima tinha copiado a montagem da linha "Sem partido" em vez de compartilhá-la — extraído `buildStateDeputyPartyOptions` em `stateDeputyListFilters.ts`, consumido por `StateDeputyFilters` e `StateDeputyHeaderFilter`; `StateDeputySortableHead` perdeu o discriminante `filterParam: 'party'` (só há uma coluna filtrável, `filterOptions` já basta); `parsePartiesParam` parou de reimplementar o dedupe de `allParamValues`; um `if` sempre-verdadeiro saiu de `buildStateDeputyListWhere`; `loadStateDeputyListPageData` ganhou `select` na query principal. Gate reverificado verde. **`/simplify` pós-`/rebase-on-main` (2ª rodada, mesmo dia):** rodou de novo sobre o commit squashado — removeu tipo+cast morto `StateDeputyPartyRow` (`stateDeputyData.ts`, `select` só afeta runtime, não a checagem estática), `stateDeputyColumns` (`page.tsx`) passou a importar `StateDeputyFilterOption` em vez de redeclarar a forma, `parsePartiesParam` foi inlineado (helper de uma linha usado uma vez). Achado maior: `firstPartyNamesLabel` era a 3ª cópia byte-idêntica da mesma função de truncar nomes (`firstTerritoryNamesLabel` em `territoryListFilters.ts`, `firstNamesLabel` em `municipalityListFilters.ts`) — cruzou o limiar de "3+ call sites"; extraído `truncatedNamesLabel` em `campaignListUrl.ts`, os três domínios migrados. Recomendação de hook compartilhado para o scaffold de busca debounced (`StateDeputyFilters`/`TerritoryFilters`/`MunicipalityFilters` reimplementam o mesmo ~50 linhas) registrada mas não perseguida — tocaria os três domínios, fora do escopo deste item; capturada no fechamento como **B33+** (fill-in de engenharia, `useCampaignListFilterNavigation`, ~0,5d). Gate reverificado verde (tsc, lint, format, check:cycles, 396 testes, build); Aikido 0 achados. Agregado SQL para o facet de partido também adiado com gatilho (só compensa em escala). Plano: [`ordenacao-filtros-lista-dobradinhas.md`](../../../docs/plans/ordenacao-filtros-lista-dobradinhas.md) · fill-in: [`escala-dry-pos-b33.md`](../../../docs/plans/escala-dry-pos-b33.md). - **B27 entregue em código (2026-07-26):** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` — trocou a lista de checkboxes + botão "Salvar assessores" por chips removíveis (`Badge`+`XIcon`, `aria-label="Remover "`) acima de um `Command` (`shouldFilter={false}` + `matchesAtWordStart`, acento-insensível), com gravação **por delta** (um toggle por vez, não replace do array). Fase 1 — `nextAdvisorIdsAfterMembership` subiu de `actions/advisor.ts` para `src/lib/municipalityAdvisorMembership.ts` (compartilhado com a carteira do B19); `setMunicipalityAdvisorMembershipRecord`/`setMunicipalityAdvisorMembership` em `actions/municipality.ts` (mesma transação + lock `municipality-advisors:{id}` + `reloadUnrestrictedActor`, mas sem `assertTargetAdvisor` — o alvo pode ser coordenador/candidato, legal nesta tela). Fase 2 — endpoint `POST /campanha/municipios/advisors` espelhando `expected-votes/route.ts` (`isSameOriginRequest` local, zod, `mapCampaignFormActionError`). Fase 3 — `MunicipalityListAdvisorsControl.tsx` reescrito: estado otimista + contador de pendências (reconcilia só quando toda entrega em voo assentou, nunca aborta um delta anterior), `Alert` de erro, `aria-live` `sr-only`; polish do `/impeccable` alinhou o gatilho (`aria-expanded`/`aria-haspopup`/realce `bg-muted/60` ao abrir) ao padrão do `MunicipalityListExpectedVotesControl` irmão (B24). `assignMunicipalityAdvisorsRecord` (replace do array) sobrevive só para `/editar`. Achado de infraestrutura no caminho: o Playwright `webServer` (`pnpm dev`) recompila uma rota POST-only no primeiro hit e o Fast Refresh resultante aborta o fetch em voo do teste (`ERR_ABORTED`) — `tests/e2e/setup.e2e.spec.ts` ganhou um prewarm dummy de `/campanha/municipios/advisors` (e retroativamente de `expected-votes/`, mesmo padrão) antes dos specs reais. Gate verde: tsc/lint/format/`check:cycles` limpos, 393 unit+int (6 novos), e2e do fluxo (isolado e em suíte) verde — falhas observadas em rodadas full-suite eram flakiness de ambiente sob carga sustentada (testes não relacionados falhando de forma rotativa, sempre verdes isolados), não regressão do B27; build produção ok; Aikido 0 achados nos 9 arquivos novos/editados. Plano: [`combobox-assessores-lista-municipios.md`](../../../docs/plans/combobox-assessores-lista-municipios.md). - **B23 entregue em código (2026-07-26):** tooltip de conteúdo nas células das listas — a mecânica genérica (`cellTooltip` em `CampaignTableColumn`, `CampaignCellTooltip`, `CampaignHoverTooltip` promovido para `shared/`) já tinha saído de graça no **E10 ✓**; este item fechou o que faltava: `openOnTouch`/`disabled` em `CampaignHoverTooltip` (default `true`/`false`, sem mudança nos 4 call sites pré-B23) para conviver com um Popover no mesmo gatilho sem roubar o tap nem sobreviver atrás dele — e, no `/simplify`, ganhou um terceiro comportamento: `content` falsy renderiza o `children` sem wrapper, então os dois consumidores abaixo não precisam mais de um ternário `content ? … : children` cada; `formatAdvisorNamesTooltip` + `advisorEntriesFromIds` (dedupe do id→nome entre a lista e o controle) junto de `MunicipalityAdvisorAvatarStack.tsx`; e os dois consumidores de `/campanha/municipios` — célula "Assessores" (não-coordenador via `cellTooltip`; coordenador envolvendo o próprio `PopoverTrigger`, `openOnTouch={false}`/`disabled={open}`, nunca os dois ao mesmo tempo) e célula "Tendência" (tooltip só quando há `politicalTrendNote`; em toque a nota aparece como leitura dentro do próprio Popover, acima do select). Verificado manualmente em `/campanha/municipios` como coordenador e assessor: hover mostra nomes/justificativa, foco de teclado abre a mesma tooltip, clique/tap abre o Popover sem tooltip órfã atrás. Gate verde (tsc/lint/format/knip — P3 pré-existente/check:cycles/443 unit/387 int/build); Aikido 0 findings. Plano: [`tooltip-celulas-listas.md`](../../../docs/plans/tooltip-celulas-listas.md). diff --git a/docs/plans/escala-dry-pos-b33.md b/docs/plans/escala-dry-pos-b33.md index a719ace6..90dae018 100644 --- a/docs/plans/escala-dry-pos-b33.md +++ b/docs/plans/escala-dry-pos-b33.md @@ -1,12 +1,32 @@ # Escala e DRY pós-B33 (ordenação e filtros de dobradinhas) -Status: rascunho -Atualizado em: 2026-07-26 +Status: entregue (2026-07-27) +Atualizado em: 2026-07-27 Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins abertos, **B33+**, fill-in de engenharia) Impeccable: A — N/A (sem superfície UI nova; extrai comportamento já entregue sem mudar contrato de URL nem markup visível) Appetite: ~0,5 dia eng; fase única, sem migration, sem collection, sem `Consent` Responsável: — +## Como ficou (2026-07-27) + +Entregue como planejado no essencial — um hook em `shared/`, três call sites, JSX intocado — com quatro correções que a auditoria pré-implementação impôs sobre o texto abaixo. Ele fica preservado como registro do raciocínio; o que vale é esta seção. + +1. **O racional da "Decisão travada" caiu: um único `toHref` cobre os três.** O plano supunha que os domínios tinham assinaturas de canonicalização diferentes e que unificá-las custaria mudar `buildXListHref`/`parseXListParams`. Falso: `buildStateDeputyListHref`, `buildTerritoryListHref` e `buildMunicipalityListHref` **já canonicalizam internamente** (`parseXListParams(xListStateToRawParams(...))` dentro de `buildXListSearchParams` / `serializeTerritoryListSearchParams`), e `buildMunicipalityFiltersKey` é literalmente `buildMunicipalityListSearchParams(state).toString()` — a query string canônica, equivalente a comparar hrefs sob o mesmo basePath. Logo o `parse(...)` explícito em dobradinhas e territórios era **trabalho redundante**, não diferença de domínio, e o hook recebe um só callback `toHref`, não o par `toCanonical`/`toHref`. A decisão de fundo (o hook recebe callback e nunca importa os módulos de URL por domínio) permanece intacta. +2. **Achado novo — divergência de comportamento, corrigida.** Em `/campanha/municipios` cada filtro/sort carregava a busca digitada e não commitada; em dobradinhas e territórios `navigateTo` chamava `clearDebounce()` primeiro, então tocar um filtro no meio da digitação **descartava** a busca pendente enquanto o input seguia exibindo o texto. O hook unifica na semântica de municípios via `navigateWithSearch`, que é por isso a **terceira** função exposta — a "Questão em aberto" fecha com três nomes (`navigateTo`, `navigateWithSearch`, `scheduleSearch`), não dois. `clearDebounce` não é exposto: `navigateTo` limpa antes de qualquer guarda, então os três "Limpar" só precisam de `setSearch('') + navigateTo(clearXListFilters(state))` — o que também eliminou o `router.replace` solto de municípios. +3. **Export orfanado, deletado.** `shouldUpdateMunicipalitySearchUrl` (`municipalityListUrl.ts`) era `normalizedText(input) !== currentQ` com dois consumidores: `MunicipalityFilters` e o próprio spec. Com a guarda genérica dentro do hook, morreu junto com seu `describe` em `municipalityList.unit.spec.ts` — knip trata `exports` como ERROR no CI. +4. **Rede de segurança onde não havia nenhuma:** `tests/unit/campaignListFilterNavigation.unit.spec.ts` renderiza os três filtros em jsdom com `next/navigation` mockado e timers falsos (`toFake: ['setTimeout','clearTimeout']` — o scheduler do React precisa de microtasks reais). Cinco casos × três listas na primeira volta (oito depois do ponto 9): debounce de 1000ms num único `replace` canônico, reinício da janela ao continuar digitando, dedup de no-op, cancelamento no unmount e o carregamento do rascunho de busca. Escrito antes da migração, o quinto caso nasceu **vermelho em dobradinhas e territórios** e verde em municípios, que é exatamente o achado 2. + +5. **A extração tinha reimplantado o fallback de transition que já existia em quatro lugares (`/simplify`, reuse review).** O bloco `useCampaignListPending() ?? useTransition()` estava copiado em `CampaignSearchForm`, `CampaignFilterChips`, `SupporterFilters`, `ActivityFilters` e `CampaignTransitionAnchor` — o hook novo foi a sexta cópia, num item cuja tese é justamente extrair um padrão de 3 call sites. Virou `useCampaignListTransition()` em `CampaignListPending.tsx`, consumido pelos seis; `useCampaignListPending` deixou de ser exportado (o contexto agora se lê por um caminho só). +6. **`normalizedText(search) || state.q` também era a mesma expressão nos três** — o hook passou a devolvê-la como `draftQ`, o que tirou o último import de `campaignListUrl` dos três componentes e dá **um** lugar para resolver a assimetria que sobrou (o resumo cai no `q` commitado quando a caixa esvazia; `navigateWithSearch` limpa). Semântica preservada como estava. +7. **Uma correção de a11y que a comparação linha a linha revelou:** só `MunicipalityFilters` tinha `aria-live="polite"` no parágrafo **visível** de resumo, sobre um `md:block` — era a terceira região viva da mesma página (com o `sr-only` do próprio form e o de `CampaignListResults`), anunciando o resumo de filtros a cada tecla. Removida, junto com um `div` de um filho só herdado de quando a linha tinha dois botões. Isto **quebra** a promessa de "zero markup" do saldo abaixo, deliberadamente: era bug, não estilo. O `md:whitespace-normal` ficou (o resumo de municípios é o mais longo dos três). + +8. **Bug latente que a extração passou a possuir, corrigido (`/simplify`, code-quality review).** O `setTimeout` do debounce capturava o `state` do render que o agendou, e os controles que navegam **de fora** da casca (`CampaignSortableHead`, paginação do `CampaignListFooter`, a âncora "Limpar" do estado vazio) mudam `state` sem tocar no timer. Em territórios e dobradinhas isso significava: digite, clique num sort dentro de 1s, e um segundo depois a navegação pendente **revertia o sort**. Municípios escapava por acidente — o `key=` da página remonta o componente e o cleanup mata o timer. Agora `stateRef` dá ao timer e à guarda de dedup o estado commitado mais recente, então a navegação pendente **soma-se** ao sort em vez de atropelá-lo, nos três. O caso novo do spec (`não reverte uma navegação feita de fora da casca`) foi verificado vermelho nas três listas com o `stateRef` revertido. +9. **Outros achados da mesma revisão, todos baratos:** `navigateWithSearch` passava a busca crua e dependia de um invariante que só existia num JSDoc — agora normaliza no próprio hook (href idêntico, invariante local); `{ ...next, page: 1 }` em dois call sites era morto (`buildXListHref(state, page)` já ignora `state.page` quando recebe o argumento) e saiu junto com um terceiro em `MunicipalityFilters`; a guarda `normalizedText(value) === state.q` de `scheduleSearch` era uma **segunda** fonte de verdade para "isto muda a URL?" — exatamente o que o `shouldUpdateMunicipalitySearchUrl` deletado era — e foi removida, deixando a dedup só em `navigateTo`; e o spec passou a montar dentro de `CampaignListPendingBoundary`, porque as três páginas renderizam sob uma e o fallback local de `useTransition` é o ramo que **nunca** roda em produção (antes, todas as asserções corriam pelo ramo morto). Somaram-se casos para "Limpar", para esvaziar uma busca commitada e para provar que o timer morreu após o sort: 5 → 8 casos × 3 listas. + +Saldo medido no fim (incluindo a rodada de `/simplify`): **244 linhas removidas contra 48 adicionadas nos três filtros**, 281 contra 75 em todo o `src`, contra as 111 linhas do hook novo (metade comentário) — ~95 linhas líquidas a menos, sem nenhuma mudança de contrato de URL. O markup só se moveu no ponto 7. `CampaignSearchForm` (lideranças/assessores/organizações) segue fora de escopo, mas **pelo motivo durável, não pelo que este plano registrava antes**: não é "sem debounce" (adicionar um debounce reabriria a questão) — é que ele monta o href como `${basePath}?q=…`, **descartando todos os outros params**, porque esses três domínios não têm módulo de URL nem `parseXListParams`. Entregá-lo a um hook cujo contrato é "canonicalizar, comparar, deduplicar" tornaria o contrato falso naquele call site. B29 provavelmente o substitui por um `LeadershipFilters` real. + +**Verificação:** gate do AGENTS.md verde e bare (`tsc --noEmit`, `pnpm lint`, `format:check`, `knip`, `check:cycles`, 562 unit + 413 int, `pnpm build` local); Aikido sem findings. O e2e não pôde ser usado como veredito nesta sessão: a máquina estava com load average ~58 (dois outros worktrees com dev server ativo), e `campaignMunicipalities.e2e.spec.ts` falhou de forma instável tanto na árvore nova quanto na árvore limpa em stash — o flake pré-existente registrado no AGENTS.md, amplificado por timeout. `campaignTerritories.e2e.spec.ts` passou em todas as rodadas, e nenhum teste de municípios exercita o formulário de filtros (eles navegam direto por `?q=`), então o e2e não cobria esta superfície de qualquer forma. É o ponto fraco conhecido da entrega: a hidratação real segue sem pin. + ## Dados → decisão → apresentação - **Vou apresentar dados?** Não (N/A) — o lote é custo de manutenção de um hook de navegação já entregue três vezes; nenhuma métrica, série ou escala nova. @@ -71,7 +91,11 @@ Componentes: ## Adiado com gatilho -Nenhum neste item. +Três achados do `/simplify` (reuse review, 2026-07-27) que são maiores que limpeza: + +- **Shell de `
` compartilhado — a evidência registrada em "Decisões travadas" é mensuravelmente falsa e fica corrigida aqui.** Normalizando os substantivos de domínio, `TerritoryFilters` e `StateDeputyFilters` são **110 de 147 linhas byte-idênticas**, e o par `` + parágrafo `sr-only` (13 linhas) é byte-idêntico nos **três**. O plano tratou "o meio diverge" como prova de "a casca diverge", e são afirmações diferentes: a casca sai por `children` (o idioma que `CampaignListResults`/`LeaderContactsPanel`/`mapSlot` já usam), sem o render-prop que foi rejeitado. **Gatilho:** o 4º filtro de lista (o `LeadershipFilters` de B29). Quem reabrir não deve reusar o argumento antigo. +- **Ressincronizar a busca com `state.q` — e os dois revisores discordaram sobre a direção, o que é o próprio sinal de que não é limpeza.** Só `/campanha/municipios` remonta seus filtros (`key={buildMunicipalityFiltersKey(state)}`); nos outros dois, Back/Forward ou a âncora "Limpar busca e filtros" do estado vazio deixam o input com o rascunho antigo enquanto os resultados já mudaram. O reuse review propôs ressincronizar no hook (e então **remover** o `key` de municípios); o code-quality review propôs **adicionar** o `key` às outras duas páginas. São opostos, e cada um paga com o que o outro protege: ressincronizar/remontar atropela quem digita rápido (a navegação de "sal" aterrissa e sobrescreve o "sal x" já digitado — o que municípios faz hoje), e não fazer nada mantém o input mentindo depois do Back. O ponto 8 tirou daqui a metade que **era** bug puro (a reversão silenciosa do sort), sem escolher lado nessa; o que resta é decisão de produto, com um terceiro caminho (ressincronizar só quando a navegação não foi nossa) que custa máquina. **Gatilho:** relato de campo de input dessincronizado, ou a adoção do shell acima, que já toca os três. +- **`SupporterFilters` / `ActivityFilters` adotarem o hook.** Ganharam `useCampaignListTransition`, mas não a navegação: os dois montam o href de `usePathname()` + `buildXListSearchParams` sem passo de parse, e mantêm um `valuesRef` espelhando todos os filtros, que o hook não tem equivalente. Migrar lhes **daria** dedup de no-op — mudança de comportamento em duas superfícies vivas. **Gatilho:** quando um deles ganhar módulo de URL por domínio. ## Referências diff --git a/docs/roadmap.md b/docs/roadmap.md index 90fb920d..bb0948dc 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-27 (**C8 F4b ✓** guard de `formActions` ampliado para irmãos `*FormActions.ts` + `taskActions.ts`; 6 ladders migradas; `taskActions.ts` allowlisted · [plano](plans/escala-dry-pos-c6.md); **Build resiliente ao global `metadata` ausente ✓** — `resolveSiteMetadata` em `src/utilities/seo.ts`; fallback `NEXT_PUBLIC_SITE_URL` e omit canonical/OG quando não há URL; 4 rotas + layout; [plano](plans/build-resiliente-metadata-ausente.md); **B8+ ✓** escala e DRY pós-B8 F2 — a política de escala de chão das malhas commitadas virou código nomeado (`scripts/lib/topology.mjs`, tolerância de Visvalingam em m² e dígitos de quantização derivados do bbox de cada topologia), e com ela a malha das ZE caiu de 480 para **30,2 pontos/feature** (72.310 → 7.687 bytes) sem que `bahia-municipalities` mudasse um byte; a auditoria corrigiu a alavanca do plano (quantização sozinha quase não corta — o peso absoluto manda) e **rejeitou** um item do F4 por medição (`city`/`zoneNumber` de `MunicipalityZoneNeighborhoodEntry` são evidência de proveniência hasheada, não código morto); `MAX_POINTS_PER_FEATURE = 60` é o novo tripwire, `featureMapKey` passou a ser exercido sobre features reais das três malhas (417 `codarea`, 27 `code`, 19 `slug`), o mapa **degrada** em vez de morrer quando o chunk das zonas falha, e a ressalva de aproximação saiu do gate de comparação · [plano](plans/escala-dry-pos-b8f2.md); **`users.roles` ✓** — `admin`\|`editor` em `users`, migration `20260727_032523_add_users_roles`, editor publica `post`/`tag`/`media`; unificação com `campaignUser` rejeitada · [plano](plans/roles-admin-payload.md); 2026-07-26 (**fill-in** remover lista "Municípios por zona eleitoral" do Início — pós-B8 F2 ✓ os polígonos tornam a lista textual redundante; dropar UI + `zoneBreakdown` do bundle; ressalva de aproximação fica na malha · [plano](plans/remover-lista-zonas-mapa-inicio.md); **B8 F2 ✓** polígonos das 19 ZE de Salvador — artefato `bahia-municipality-zones.topo.json` (72 KB) por dissolve bairro→zona do `BA_bairros_CD2022` do IBGE contra a RA 02/2017 do TRE-BA, com curadoria explícita e um passe de maior fronteira compartilhada validado; o coroplético passou a ser keyed por **map key** (`slug` na zona, `ibgeCode` no resto) e o polígono municipal de Salvador ficou como base sem interação por baixo, porque duas malhas simplificadas independentemente não fecham na mesma costa; `competitiveRank` segue por cidade (o artefato TSE só tem `federalRankByIbgeCode`) com int pinando o fato; desambiguar a ZE no card "Onde estou" do B14 ✓ virou débito com gatilho, não dependência — [plano](plans/poligonos-pracas-zona.md); **B8+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B8 F2, cujos relatórios chegaram depois do merge — a malha de 19 zonas ficou com a mesma contagem de vértices da malha dos 417 municípios (as duas constantes de simplificação são relativas ao bbox e foram copiadas verbatim, o que vale ~3,9 m de grade em Salvador contra ~103 m no estado), a regra da map key está em três formas sem nenhum teste ligando artefato → `featureMapKey` → catálogo, uma falha de chunk nas zonas passou a derrubar o mapa inteiro, a ressalva de aproximação desaparece no modo comparação, e `getTerritoryFeature` perdeu o último consumidor de produção neste commit · [plano](plans/escala-dry-pos-b8f2.md); **B39–B42** registrados: sessão "Lembrar de mim" + login biométrico WebAuthn + scroll horizontal/sticky na lista de municípios + polimento mobile da mesma lista — [B39](plans/lembrar-de-mim-sessao-campanha.md), [B40](plans/login-biometrico-campanha.md), [B41](plans/scroll-horizontal-lista-municipios.md), [B42](plans/polimento-mobile-lista-municipios.md); **E12 ✓** camada TI — rollups MAUP, colunas Cobertura/Captura/Classe em `/campanha/territorios`, benchmark intra-TI no `MunicipalityGoalAccountCard`; [plano](plans/camada-territorios-identidade.md); **B36 ✓** coluna "Lideranças" em `/campanha/dobradinhas` — chips + Popover+Command + delta reuse da mutação B31; `LeadershipStateDeputyRelationCell` compartilhada (`direction`/`measureOverflow`); columns factory (não Context); casca `dobradinhas/formActions.ts`; `loadLeadershipOptions` + `leaderships[]` no row; [plano](plans/chips-liderancas-lista-dobradinhas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` (`UserPlusIcon`) na coluna `actions` ao lado do contato do B28 ✓; Popover two-step reusa `createCampaignInvite`/`autopreenchimento`; disable por `normalizeBrazilianPhone`; [plano](plans/convite-whatsapp-lista-liderancas.md); **B31 ✓** coluna de dobradinhas na lista de lideranças entregue — chips clicáveis → `/campanha/dobradinhas/` em `/campanha/liderancas`, edição por Popover+`Command` (mecânica do **B27 ✓**) com escrita por **server action direta do cliente** (`useTransition`, padrão do B19 ✓) em vez do endpoint JSON do B27, porque aqui há derivados a revalidar (`leadershipCount`, a ficha do deputado); `setLeadershipStateDeputyMembership(Record)` em transação + lock `leadership-state-deputies:{id}` + `revalidatePath`; helper puro `nextStateDeputyIdsAfterMembership` espelha `nextAdvisorIdsAfterMembership`; catálogo entregue uma vez por tabela via `leadershipColumns(stateDeputyOptions)` (columns factory, mesmo precedente de `municipalityListColumns`) em vez de Context; é o **2º call site** do padrão Popover+Command+chips — extração para `shared/` continua esperando o 3º (B34/B36/B37, que agora consomem a mutação/lock em vez de correr para desenhá-los); [plano](plans/dobradinhas-lista-liderancas.md); **fill-in ✓** limitar pan/zoom do mapa à Bahia — `BahiaMap` com `maxBounds`/`maxBoundsViscosity: 1` sobre `BAHIA_BOUNDS.pad(0.1)` e `minZoom` via `getBoundsZoom` com o mesmo padding 16px do `fitBounds`, recalculado no `ResizeObserver` existente; único arquivo; [plano](plans/limitar-mapa-a-bahia.md); (**B14 ✓** município mais próximo entregue — card cliente "Onde estou" no Início staff resolvendo **point-in-polygon** sobre o chunk de geometria que o mapa do dashboard já carrega (a premissa "polígonos explodem o bundle" do plano de 24/07 caiu na medição: 41 KB gzip já estão no browser, então o artefato de centroides e seu script saíram de escopo), com fallback nomeando onde o assessor está e o município mais próximo **da carteira** dentro de 150 km; a posição nunca sai do aparelho (sem action, sem `Consent`); First Load JS de `/campanha` 265 → 268 kB depois de mover o href das zonas de Salvador para o servidor (`zoneCityHrefs`) — importar `buildMunicipalityListHref` no cliente custava **21 kB** por um link, porque arrasta `bahiaTerritories` + `municipalityCatalog`; [plano](plans/municipio-mais-proximo.md); ~~**B38**~~ ✓ sidebar recolhido em tablet entregue — offcanvas + trigger md+ + cookie/matchMedia; [plano](plans/sidebar-recolhido-tablet.md); **B33+** novo fill-in de engenharia registrado no fechamento do B33 — `StateDeputyFilters` é o 3º call site do scaffold de navegação/debounce que `TerritoryFilters`/`MunicipalityFilters` já tinham, achado do `/simplify` rodada 2, [plano](plans/escala-dry-pos-b33.md); **B28 ✓** e-mail + celular na lista de lideranças entregue — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp, no padrão de `/campanha/assessores` (B19 ✓); `shared/CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`, `loadLeadershipDetail` deduplicado; [plano](plans/email-celular-lista-liderancas.md); **B24 ✓** auto-save + justificativa no popover de Tendência de `/campanha/municipios` — sem botão "Salvar", `POST /campanha/municipios/political-trend`, [plano](plans/autosave-tendencia-lista-municipios.md); **B13 ✓** escala relativa no mapa do Início entregue — quantis como novo default, padrão próprio/LQ nas âncoras do E10 e posição no município a partir do **artefato TSE v3** (`federalRankByIbgeCode`), mais bolhas opcionais por válidos projetados coloridas pela classe; `computeAggregateTerritorialClass` nasce aqui e o **E12** deve herdá-lo em vez de escrever um rollup próprio; `TERRITORIAL_CLASS_ANCHORS` mudou de casa para `src/lib/territorialClassAnchors.ts`; [plano](plans/escala-relativa-mapa.md); **B13+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B13 — teste de concordância entre as duas escritas do LQ (mapa cliente × classificador server-only, a rede de segurança do **E15**), escopo geográfico declarado no readout (posição é da cidade inteira, classe é do escopo do usuário), uma varredura da fatia TSE por ano no script de agregados em vez de três, e a tupla no `competitiveRankByYear` que corta ~metade dos ~63,6 KB somados ao payload RSC · [plano](plans/escala-dry-pos-b13.md); **débito de teste** afinado no ledger ([TECH-DEBT.md](TECH-DEBT.md), P3 → P2): `campaignMunicipalities`/`campaign-pwa` são flaky com 2 workers **em `main`** também — clique no checkbox de consentimento antes da hidratação e regex de fixture em que "Conde" casa com "Condeúba"; **B27 ✓** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` entregue — a checkbox list + botão "Salvar assessores" virou chips removíveis + `Command` com gravação por delta, `nextAdvisorIdsAfterMembership` movido para `src/lib/`, endpoint `POST /campanha/municipios/advisors` espelho do `expected-votes/`, [plano](plans/combobox-assessores-lista-municipios.md); **B23 ✓** tooltip de conteúdo nas células das listas entregue — a mecânica genérica (`cellTooltip`, `CampaignCellTooltip`, `CampaignHoverTooltip` em `shared/`) já tinha saído de graça no E10 ✓; este item fechou `openOnTouch`/`disabled` no primitivo (mais um terceiro comportamento do `/simplify`: `content` falsy renderiza o trigger sem wrapper) e os dois consumidores de `/campanha/municipios` (nomes de assessores + justificativa da tendência), [plano](plans/tooltip-celulas-listas.md); **B22 ✓** explicação por coluna no header das listas entregue — `description?: ReactNode` em `CampaignTableHead`/`MunicipalitySortableHead` (rename de `tooltip`, sublinhado pontilhado), `campaignConceptOneLiner` como fonte única para colunas cobertas pelo E18 ✓, `municipalityColumnDescriptions` cobrindo as 11 colunas de `/campanha/municipios`; sem migration/action, [plano](plans/explicacao-colunas-header-listas.md); 2026-07-25: **E10 ✓** classificação territorial relativa entregue — Reduto/Expansão/Manutenção/Marginal/Sem base sobre o artefato TSE, coluna+sort+filtro na lista, badge no card do E8 e na capa do dossiê, e o seam genérico `cellTooltip` da `CampaignTable` (núcleo do B23) com `CampaignHoverTooltip` promovido para `shared/`; **B13** liberado; **E10+** novo fill-in de engenharia com os quatro débitos do `/simplify` do E10 — a lista lendo `municipality` duas vezes por request (o que **dispara o "Adiado com gatilho" do A11**: `classe` é o 4º path derivado no loader), memo de processo dos potenciais por município, `TooltipProvider` único no layout e os multi-filtros do mobile saindo das definitions · [plano](plans/escala-dry-pos-e10.md); ~~**C8 F4b**~~ ✓ (entregue 2026-07-27); **B26 ✓** registrar sinal na célula "Último sinal" — [plano](plans/registrar-sinal-lista-municipios.md); **C13 ✓** "Plano de Ação" → **"Atividade"** renomeado em toda a vertical — collection `activity`, rota `/campanha/atividades`, sidebar "Atividades", migration data-preserving escrita à mão (4 tabelas, 3 enums, 2 colunas, 39 índices, 14 FKs, 2 sequences + os 8 índices fósseis `%plaza%` da remodelagem) e guard de vocabulário table-driven varrendo `src`+`tests`+`scripts`; [plano](plans/renomear-plano-acao-para-atividade.md); **B37** chips editáveis de municípios na lista de dobradinhas — coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`municipalityCount`) por chips com link (município) / território (`MapPinIcon`) e o mesmo Popover+combobox do **B34** (busca por município/TI/ZE, lote de território/zona), mas escrevendo do lado invertido (`municipality.stateDeputies`, sem piso mínimo, lock por município em vez de por dobradinha, sem `assertMunicipalitiesWithinScope` porque `canUpdateMunicipality` já escopa o assessor); reaproveita/promove a mesma lib do B19/B34 (quem chegar primeiro faz a renomeação); mesmo conflito registrado com o pedido literal (toggle "Editar" de tabela inteira) que B28/B31/B32/B34/B36 já resolveram na direção do Popover por célula — decisão marcada como _proposta, a validar com produto_; sem migration, [plano](plans/chips-municipios-lista-dobradinhas.md); **B36** coluna "Lideranças" em `/campanha/dobradinhas` com a mesma UI/mecânica da coluna "Municípios" de `/campanha/assessores` (B19 ✓) — chips + Popover + `Command` + auto-save por delta + "Ver mais…" (clamp de 3 linhas por `ResizeObserver`, necessário aqui porque um deputado popular pode acumular muito mais lideranças do que uma liderança acumula dobradinhas); pedido explícito de não duplicar lógica — reusa **sem alteração** a mutação/schema/lock que o **B31** desenha para o lado inverso da mesma relação `leadership.stateDeputies`, promovendo a célula desse item para um componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`) em vez de um segundo componente-irmão; corrida de extração com o B31 (quem entrar primeiro paga a peça compartilhada); sem migration, [plano](plans/chips-liderancas-lista-dobradinhas.md); **B34** chips editáveis de municípios na lista de lideranças — coluna "Municípios" de `/campanha/liderancas` ganha chips com link (município) / território (`MapPinIcon`), edição por **Popover disparado por célula** com chips removíveis + combobox `Command` (busca por município/TI/ZE, lote de território/zona), gravação **por delta** direto em `leadership.municipalities` com lock `leadership-municipalities:{id}` (piso de 1 e teto de 30 respeitados) e `revalidatePath` (`liderancas`, `liderancas/[id]`, `municipios/[slug]` dos tocados); promove a lógica pura de `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros, sem portar a UI com `ResizeObserver`; **conflito registrado com o pedido literal** (pede um toggle "Editar" de tabela inteira, que B28/B31/B32 já travaram como anti-goal nesta mesma tabela — este plano segue o precedente e marca a decisão como _proposta, a validar com produto_); sem migration, [plano](plans/chips-municipios-lista-liderancas.md); ~~**B33**~~ ✓ ordenação e filtro no header da lista de dobradinhas (entregue 2026-07-26) — `?sort=`/`?dir=` por Nome/Partido + Popover multi-seleção de Partido com "Sem partido", consumindo o chrome compartilhado do **B21 ✓** (a corrida de ordem de chegada com B29 foi encerrada); paridade mobile (`StateDeputyFilters`) entrou por decisão desta sessão; ordem dos nulos de `party` pinada por int test; contagens derivadas (`municipalityCount`/`leadershipCount`) seguem sem sort/filtro, como travado, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` na coluna `actions` (entregue 2026-07-26), [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); ~~**B28**~~ ✓ e-mail + celular na lista de `/campanha/liderancas` (entregue 2026-07-26) — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp como em `/campanha/assessores` (B19 ✓); `CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`; edição in-list adiada; sem migration, [plano](plans/email-celular-lista-liderancas.md); ~~**B27**~~ ✓ combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/combobox-assessores-lista-municipios.md); ~~**B26**~~ ✓ registrar sinal na célula "Último sinal" (entregue 2026-07-25), [plano](plans/registrar-sinal-lista-municipios.md); **fill-in** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); glossário/KPI strips intactos; B16 ✓ liberou o nome ao remover "Assessoria", [plano](plans/renomear-coluna-cobertura-meta.md); ~~**B25**~~ ✓ link do território na lista de municípios (entregue 2026-07-26) — coluna "Território" + card mobile → `/campanha/territorios#ti-`; `territoryAnchor.ts` + `TerritoryLink`; seam `rowId` em `CampaignTable`; [plano](plans/link-territorio-lista-municipios.md); ~~**B24**~~ ✓ auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/autosave-tendencia-lista-municipios.md); **fill-in** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtro por Salvador cobre as zonas; drop também do filtro/sort/`?kind=` na URL da lista, schema `kind` intacto, [plano](plans/remover-coluna-tipo-municipios.md); ~~**B23**~~ ✓ tooltip de conteúdo nas células das listas (entregue 2026-07-26), [plano](plans/tooltip-celulas-listas.md); **fill-in** opções selecionadas no topo nos Popovers de filtro do header — helper puro reutilizável pelas demais listas, [plano](plans/selecionados-no-topo-filtros-header.md); ~~**B22**~~ ✓ explicação por coluna no header das listas (entregue 2026-07-26), [plano](plans/explicacao-colunas-header-listas.md); **B21** página própria dos Territórios de Identidade — rota `/campanha/territorios` com overview + tabela no sistema de listas (sort/filtro no header), sem migration, [plano](plans/pagina-territorios-identidade.md); **B20 ✓** Municípios prioritários no Início entregue 2026-07-25 — atalho ranqueado por déficit + markup sem bullets, [plano](plans/municipios-prioritarios-inicio.md); **Pass 2 ✓** consolidação de engenharia — listas genéricas/B16+ absorvido/B17-B18 triviais/E10 limpo/C8 F4 ✓/D3 consent WhatsApp; **B16 ✓**; **E16 ✓**; **E9 ✓**; **E18 ✓**; **B19 ✓**; **E8 ✓**; C12 ✓; B18; B17; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14 ✓; janela 1: smoke, R6)Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc). +Atualizado em: 2026-07-27 (**B33+ ✓** `useCampaignListFilterNavigation` — o scaffold de navegação/debounce sai dos três filtros de lista para um hook em `shared/` (281 linhas removidas contra 75 em `src`); a auditoria derrubou a premissa do plano de que os três domínios teriam assinaturas de canonicalização diferentes — `buildStateDeputyListHref`/`buildTerritoryListHref`/`buildMunicipalityListHref` **já canonicalizam internamente**, então o `parse(...)` explícito de dobradinhas/territórios era trabalho redundante e um único callback `toHref` cobre os três; a entrega achou **dois** bugs de comportamento, não de estrutura: tocar um filtro no meio da digitação **descartava** a busca pendente em dobradinhas e territórios (unificado na semântica de munícipios por `navigateWithSearch`), e o `setTimeout` do debounce capturava o `state` do render que o agendou, então um sort clicado dentro da janela de 1s era **revertido** um segundo depois — munícipios escapava por acidente, pelo `key=` da página que remonta e mata o timer (corrigido com `stateRef`, com caso de spec verificado vermelho nas três listas sem o fix); o `/simplify` ainda achou o fallback `useCampaignListPending() ?? useTransition()` copiado em 5 lugares, com o hook novo como 6º → `useCampaignListTransition`, e um `aria-live` que fazia o resumo de filtros de munícipios ser a **terceira** região viva da mesma página; `shouldUpdateMunicipalitySearchUrl` orfanou e foi deletado; `tests/unit/campaignListFilterNavigation.unit.spec.ts` (jsdom + router mockado + fake timers, 8 casos × 3 listas) é a primeira cobertura desses componentes — [plano](plans/escala-dry-pos-b33.md); **C8 F4b ✓** guard de `formActions` ampliado para irmãos `*FormActions.ts` + `taskActions.ts`; 6 ladders migradas; `taskActions.ts` allowlisted · [plano](plans/escala-dry-pos-c6.md); **Build resiliente ao global `metadata` ausente ✓** — `resolveSiteMetadata` em `src/utilities/seo.ts`; fallback `NEXT_PUBLIC_SITE_URL` e omit canonical/OG quando não há URL; 4 rotas + layout; [plano](plans/build-resiliente-metadata-ausente.md); **B8+ ✓** escala e DRY pós-B8 F2 — a política de escala de chão das malhas commitadas virou código nomeado (`scripts/lib/topology.mjs`, tolerância de Visvalingam em m² e dígitos de quantização derivados do bbox de cada topologia), e com ela a malha das ZE caiu de 480 para **30,2 pontos/feature** (72.310 → 7.687 bytes) sem que `bahia-municipalities` mudasse um byte; a auditoria corrigiu a alavanca do plano (quantização sozinha quase não corta — o peso absoluto manda) e **rejeitou** um item do F4 por medição (`city`/`zoneNumber` de `MunicipalityZoneNeighborhoodEntry` são evidência de proveniência hasheada, não código morto); `MAX_POINTS_PER_FEATURE = 60` é o novo tripwire, `featureMapKey` passou a ser exercido sobre features reais das três malhas (417 `codarea`, 27 `code`, 19 `slug`), o mapa **degrada** em vez de morrer quando o chunk das zonas falha, e a ressalva de aproximação saiu do gate de comparação · [plano](plans/escala-dry-pos-b8f2.md); **`users.roles` ✓** — `admin`\|`editor` em `users`, migration `20260727_032523_add_users_roles`, editor publica `post`/`tag`/`media`; unificação com `campaignUser` rejeitada · [plano](plans/roles-admin-payload.md); 2026-07-26 (**fill-in** remover lista "Municípios por zona eleitoral" do Início — pós-B8 F2 ✓ os polígonos tornam a lista textual redundante; dropar UI + `zoneBreakdown` do bundle; ressalva de aproximação fica na malha · [plano](plans/remover-lista-zonas-mapa-inicio.md); **B8 F2 ✓** polígonos das 19 ZE de Salvador — artefato `bahia-municipality-zones.topo.json` (72 KB) por dissolve bairro→zona do `BA_bairros_CD2022` do IBGE contra a RA 02/2017 do TRE-BA, com curadoria explícita e um passe de maior fronteira compartilhada validado; o coroplético passou a ser keyed por **map key** (`slug` na zona, `ibgeCode` no resto) e o polígono municipal de Salvador ficou como base sem interação por baixo, porque duas malhas simplificadas independentemente não fecham na mesma costa; `competitiveRank` segue por cidade (o artefato TSE só tem `federalRankByIbgeCode`) com int pinando o fato; desambiguar a ZE no card "Onde estou" do B14 ✓ virou débito com gatilho, não dependência — [plano](plans/poligonos-pracas-zona.md); **B8+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B8 F2, cujos relatórios chegaram depois do merge — a malha de 19 zonas ficou com a mesma contagem de vértices da malha dos 417 municípios (as duas constantes de simplificação são relativas ao bbox e foram copiadas verbatim, o que vale ~3,9 m de grade em Salvador contra ~103 m no estado), a regra da map key está em três formas sem nenhum teste ligando artefato → `featureMapKey` → catálogo, uma falha de chunk nas zonas passou a derrubar o mapa inteiro, a ressalva de aproximação desaparece no modo comparação, e `getTerritoryFeature` perdeu o último consumidor de produção neste commit · [plano](plans/escala-dry-pos-b8f2.md); **B39–B42** registrados: sessão "Lembrar de mim" + login biométrico WebAuthn + scroll horizontal/sticky na lista de municípios + polimento mobile da mesma lista — [B39](plans/lembrar-de-mim-sessao-campanha.md), [B40](plans/login-biometrico-campanha.md), [B41](plans/scroll-horizontal-lista-municipios.md), [B42](plans/polimento-mobile-lista-municipios.md); **E12 ✓** camada TI — rollups MAUP, colunas Cobertura/Captura/Classe em `/campanha/territorios`, benchmark intra-TI no `MunicipalityGoalAccountCard`; [plano](plans/camada-territorios-identidade.md); **B36 ✓** coluna "Lideranças" em `/campanha/dobradinhas` — chips + Popover+Command + delta reuse da mutação B31; `LeadershipStateDeputyRelationCell` compartilhada (`direction`/`measureOverflow`); columns factory (não Context); casca `dobradinhas/formActions.ts`; `loadLeadershipOptions` + `leaderships[]` no row; [plano](plans/chips-liderancas-lista-dobradinhas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` (`UserPlusIcon`) na coluna `actions` ao lado do contato do B28 ✓; Popover two-step reusa `createCampaignInvite`/`autopreenchimento`; disable por `normalizeBrazilianPhone`; [plano](plans/convite-whatsapp-lista-liderancas.md); **B31 ✓** coluna de dobradinhas na lista de lideranças entregue — chips clicáveis → `/campanha/dobradinhas/` em `/campanha/liderancas`, edição por Popover+`Command` (mecânica do **B27 ✓**) com escrita por **server action direta do cliente** (`useTransition`, padrão do B19 ✓) em vez do endpoint JSON do B27, porque aqui há derivados a revalidar (`leadershipCount`, a ficha do deputado); `setLeadershipStateDeputyMembership(Record)` em transação + lock `leadership-state-deputies:{id}` + `revalidatePath`; helper puro `nextStateDeputyIdsAfterMembership` espelha `nextAdvisorIdsAfterMembership`; catálogo entregue uma vez por tabela via `leadershipColumns(stateDeputyOptions)` (columns factory, mesmo precedente de `municipalityListColumns`) em vez de Context; é o **2º call site** do padrão Popover+Command+chips — extração para `shared/` continua esperando o 3º (B34/B36/B37, que agora consomem a mutação/lock em vez de correr para desenhá-los); [plano](plans/dobradinhas-lista-liderancas.md); **fill-in ✓** limitar pan/zoom do mapa à Bahia — `BahiaMap` com `maxBounds`/`maxBoundsViscosity: 1` sobre `BAHIA_BOUNDS.pad(0.1)` e `minZoom` via `getBoundsZoom` com o mesmo padding 16px do `fitBounds`, recalculado no `ResizeObserver` existente; único arquivo; [plano](plans/limitar-mapa-a-bahia.md); (**B14 ✓** município mais próximo entregue — card cliente "Onde estou" no Início staff resolvendo **point-in-polygon** sobre o chunk de geometria que o mapa do dashboard já carrega (a premissa "polígonos explodem o bundle" do plano de 24/07 caiu na medição: 41 KB gzip já estão no browser, então o artefato de centroides e seu script saíram de escopo), com fallback nomeando onde o assessor está e o município mais próximo **da carteira** dentro de 150 km; a posição nunca sai do aparelho (sem action, sem `Consent`); First Load JS de `/campanha` 265 → 268 kB depois de mover o href das zonas de Salvador para o servidor (`zoneCityHrefs`) — importar `buildMunicipalityListHref` no cliente custava **21 kB** por um link, porque arrasta `bahiaTerritories` + `municipalityCatalog`; [plano](plans/municipio-mais-proximo.md); ~~**B38**~~ ✓ sidebar recolhido em tablet entregue — offcanvas + trigger md+ + cookie/matchMedia; [plano](plans/sidebar-recolhido-tablet.md); **B33+** novo fill-in de engenharia registrado no fechamento do B33 — `StateDeputyFilters` é o 3º call site do scaffold de navegação/debounce que `TerritoryFilters`/`MunicipalityFilters` já tinham, achado do `/simplify` rodada 2, [plano](plans/escala-dry-pos-b33.md); **B28 ✓** e-mail + celular na lista de lideranças entregue — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp, no padrão de `/campanha/assessores` (B19 ✓); `shared/CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`, `loadLeadershipDetail` deduplicado; [plano](plans/email-celular-lista-liderancas.md); **B24 ✓** auto-save + justificativa no popover de Tendência de `/campanha/municipios` — sem botão "Salvar", `POST /campanha/municipios/political-trend`, [plano](plans/autosave-tendencia-lista-municipios.md); **B13 ✓** escala relativa no mapa do Início entregue — quantis como novo default, padrão próprio/LQ nas âncoras do E10 e posição no município a partir do **artefato TSE v3** (`federalRankByIbgeCode`), mais bolhas opcionais por válidos projetados coloridas pela classe; `computeAggregateTerritorialClass` nasce aqui e o **E12** deve herdá-lo em vez de escrever um rollup próprio; `TERRITORIAL_CLASS_ANCHORS` mudou de casa para `src/lib/territorialClassAnchors.ts`; [plano](plans/escala-relativa-mapa.md); **B13+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B13 — teste de concordância entre as duas escritas do LQ (mapa cliente × classificador server-only, a rede de segurança do **E15**), escopo geográfico declarado no readout (posição é da cidade inteira, classe é do escopo do usuário), uma varredura da fatia TSE por ano no script de agregados em vez de três, e a tupla no `competitiveRankByYear` que corta ~metade dos ~63,6 KB somados ao payload RSC · [plano](plans/escala-dry-pos-b13.md); **débito de teste** afinado no ledger ([TECH-DEBT.md](TECH-DEBT.md), P3 → P2): `campaignMunicipalities`/`campaign-pwa` são flaky com 2 workers **em `main`** também — clique no checkbox de consentimento antes da hidratação e regex de fixture em que "Conde" casa com "Condeúba"; **B27 ✓** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` entregue — a checkbox list + botão "Salvar assessores" virou chips removíveis + `Command` com gravação por delta, `nextAdvisorIdsAfterMembership` movido para `src/lib/`, endpoint `POST /campanha/municipios/advisors` espelho do `expected-votes/`, [plano](plans/combobox-assessores-lista-municipios.md); **B23 ✓** tooltip de conteúdo nas células das listas entregue — a mecânica genérica (`cellTooltip`, `CampaignCellTooltip`, `CampaignHoverTooltip` em `shared/`) já tinha saído de graça no E10 ✓; este item fechou `openOnTouch`/`disabled` no primitivo (mais um terceiro comportamento do `/simplify`: `content` falsy renderiza o trigger sem wrapper) e os dois consumidores de `/campanha/municipios` (nomes de assessores + justificativa da tendência), [plano](plans/tooltip-celulas-listas.md); **B22 ✓** explicação por coluna no header das listas entregue — `description?: ReactNode` em `CampaignTableHead`/`MunicipalitySortableHead` (rename de `tooltip`, sublinhado pontilhado), `campaignConceptOneLiner` como fonte única para colunas cobertas pelo E18 ✓, `municipalityColumnDescriptions` cobrindo as 11 colunas de `/campanha/municipios`; sem migration/action, [plano](plans/explicacao-colunas-header-listas.md); 2026-07-25: **E10 ✓** classificação territorial relativa entregue — Reduto/Expansão/Manutenção/Marginal/Sem base sobre o artefato TSE, coluna+sort+filtro na lista, badge no card do E8 e na capa do dossiê, e o seam genérico `cellTooltip` da `CampaignTable` (núcleo do B23) com `CampaignHoverTooltip` promovido para `shared/`; **B13** liberado; **E10+** novo fill-in de engenharia com os quatro débitos do `/simplify` do E10 — a lista lendo `municipality` duas vezes por request (o que **dispara o "Adiado com gatilho" do A11**: `classe` é o 4º path derivado no loader), memo de processo dos potenciais por município, `TooltipProvider` único no layout e os multi-filtros do mobile saindo das definitions · [plano](plans/escala-dry-pos-e10.md); ~~**C8 F4b**~~ ✓ (entregue 2026-07-27); **B26 ✓** registrar sinal na célula "Último sinal" — [plano](plans/registrar-sinal-lista-municipios.md); **C13 ✓** "Plano de Ação" → **"Atividade"** renomeado em toda a vertical — collection `activity`, rota `/campanha/atividades`, sidebar "Atividades", migration data-preserving escrita à mão (4 tabelas, 3 enums, 2 colunas, 39 índices, 14 FKs, 2 sequences + os 8 índices fósseis `%plaza%` da remodelagem) e guard de vocabulário table-driven varrendo `src`+`tests`+`scripts`; [plano](plans/renomear-plano-acao-para-atividade.md); **B37** chips editáveis de municípios na lista de dobradinhas — coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`municipalityCount`) por chips com link (município) / território (`MapPinIcon`) e o mesmo Popover+combobox do **B34** (busca por município/TI/ZE, lote de território/zona), mas escrevendo do lado invertido (`municipality.stateDeputies`, sem piso mínimo, lock por município em vez de por dobradinha, sem `assertMunicipalitiesWithinScope` porque `canUpdateMunicipality` já escopa o assessor); reaproveita/promove a mesma lib do B19/B34 (quem chegar primeiro faz a renomeação); mesmo conflito registrado com o pedido literal (toggle "Editar" de tabela inteira) que B28/B31/B32/B34/B36 já resolveram na direção do Popover por célula — decisão marcada como _proposta, a validar com produto_; sem migration, [plano](plans/chips-municipios-lista-dobradinhas.md); **B36** coluna "Lideranças" em `/campanha/dobradinhas` com a mesma UI/mecânica da coluna "Municípios" de `/campanha/assessores` (B19 ✓) — chips + Popover + `Command` + auto-save por delta + "Ver mais…" (clamp de 3 linhas por `ResizeObserver`, necessário aqui porque um deputado popular pode acumular muito mais lideranças do que uma liderança acumula dobradinhas); pedido explícito de não duplicar lógica — reusa **sem alteração** a mutação/schema/lock que o **B31** desenha para o lado inverso da mesma relação `leadership.stateDeputies`, promovendo a célula desse item para um componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`) em vez de um segundo componente-irmão; corrida de extração com o B31 (quem entrar primeiro paga a peça compartilhada); sem migration, [plano](plans/chips-liderancas-lista-dobradinhas.md); **B34** chips editáveis de municípios na lista de lideranças — coluna "Municípios" de `/campanha/liderancas` ganha chips com link (município) / território (`MapPinIcon`), edição por **Popover disparado por célula** com chips removíveis + combobox `Command` (busca por município/TI/ZE, lote de território/zona), gravação **por delta** direto em `leadership.municipalities` com lock `leadership-municipalities:{id}` (piso de 1 e teto de 30 respeitados) e `revalidatePath` (`liderancas`, `liderancas/[id]`, `municipios/[slug]` dos tocados); promove a lógica pura de `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros, sem portar a UI com `ResizeObserver`; **conflito registrado com o pedido literal** (pede um toggle "Editar" de tabela inteira, que B28/B31/B32 já travaram como anti-goal nesta mesma tabela — este plano segue o precedente e marca a decisão como _proposta, a validar com produto_); sem migration, [plano](plans/chips-municipios-lista-liderancas.md); ~~**B33**~~ ✓ ordenação e filtro no header da lista de dobradinhas (entregue 2026-07-26) — `?sort=`/`?dir=` por Nome/Partido + Popover multi-seleção de Partido com "Sem partido", consumindo o chrome compartilhado do **B21 ✓** (a corrida de ordem de chegada com B29 foi encerrada); paridade mobile (`StateDeputyFilters`) entrou por decisão desta sessão; ordem dos nulos de `party` pinada por int test; contagens derivadas (`municipalityCount`/`leadershipCount`) seguem sem sort/filtro, como travado, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` na coluna `actions` (entregue 2026-07-26), [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); ~~**B28**~~ ✓ e-mail + celular na lista de `/campanha/liderancas` (entregue 2026-07-26) — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp como em `/campanha/assessores` (B19 ✓); `CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`; edição in-list adiada; sem migration, [plano](plans/email-celular-lista-liderancas.md); ~~**B27**~~ ✓ combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/combobox-assessores-lista-municipios.md); ~~**B26**~~ ✓ registrar sinal na célula "Último sinal" (entregue 2026-07-25), [plano](plans/registrar-sinal-lista-municipios.md); **fill-in** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); glossário/KPI strips intactos; B16 ✓ liberou o nome ao remover "Assessoria", [plano](plans/renomear-coluna-cobertura-meta.md); ~~**B25**~~ ✓ link do território na lista de municípios (entregue 2026-07-26) — coluna "Território" + card mobile → `/campanha/territorios#ti-`; `territoryAnchor.ts` + `TerritoryLink`; seam `rowId` em `CampaignTable`; [plano](plans/link-territorio-lista-municipios.md); ~~**B24**~~ ✓ auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/autosave-tendencia-lista-municipios.md); **fill-in** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtro por Salvador cobre as zonas; drop também do filtro/sort/`?kind=` na URL da lista, schema `kind` intacto, [plano](plans/remover-coluna-tipo-municipios.md); ~~**B23**~~ ✓ tooltip de conteúdo nas células das listas (entregue 2026-07-26), [plano](plans/tooltip-celulas-listas.md); **fill-in** opções selecionadas no topo nos Popovers de filtro do header — helper puro reutilizável pelas demais listas, [plano](plans/selecionados-no-topo-filtros-header.md); ~~**B22**~~ ✓ explicação por coluna no header das listas (entregue 2026-07-26), [plano](plans/explicacao-colunas-header-listas.md); **B21** página própria dos Territórios de Identidade — rota `/campanha/territorios` com overview + tabela no sistema de listas (sort/filtro no header), sem migration, [plano](plans/pagina-territorios-identidade.md); **B20 ✓** Municípios prioritários no Início entregue 2026-07-25 — atalho ranqueado por déficit + markup sem bullets, [plano](plans/municipios-prioritarios-inicio.md); **Pass 2 ✓** consolidação de engenharia — listas genéricas/B16+ absorvido/B17-B18 triviais/E10 limpo/C8 F4 ✓/D3 consent WhatsApp; **B16 ✓**; **E16 ✓**; **E9 ✓**; **E18 ✓**; **B19 ✓**; **E8 ✓**; C12 ✓; B18; B17; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14 ✓; janela 1: smoke, R6)Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc). ## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026) @@ -144,7 +144,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re - **C10 / C11** escala apoiadores/planos · C11 absorveu a extensão pós-C12 para picker de planos e paginação das demandas vinculadas (Fase 6 condicional; ≥100 planos ou ≥50 demandas/plano) · gatilhos: base nominal crescendo ([plano](plans/escala-dry-pos-c9.md)) / volume de planos medido ([plano](plans/escala-dry-pos-c7.md)) - **C8 F1–F2 restantes** perf de import/lista (F3 parsers fechou no Pass 2 W1-D2) · gatilho: import em volume real pós-Onda 0 · [plano](plans/escala-dry-pos-c6.md) - ~~**C8 F4b**~~ ✓ (entregue 2026-07-27) o guard de `formActions` só inspecionava arquivos chamados exatamente `formActions.ts` — ampliado para `*FormActions.ts` + `taskActions.ts`; 6 irmãos migrados para `runCampaignFormAction`/`runCampaignRedirectFormAction`; `taskActions.ts` allowlisted (contrato posicional) · achado do `/simplify` do C13 · [plano](plans/escala-dry-pos-c6.md) -- **B33+** escala/DRY pós-B33 — `StateDeputyFilters` é o **3º call site** do mesmo scaffold de navegação (`SEARCH_DEBOUNCE_MS`, debounce/cleanup, `navigateTo`/`scheduleSearch`) que `TerritoryFilters` (B21 ✓) e `MunicipalityFilters` (B16 ✓) já tinham, achado do `/simplify` do B33 — extrai `useCampaignListFilterNavigation` em `shared/`, sem tocar o JSX/markup de cada filtro nem o contrato de URL · ~0,5d eng · Impeccable A · sem migration · cortável · [plano](plans/escala-dry-pos-b33.md) +- ~~**B33+**~~ ✓ escala/DRY pós-B33 (entregue 2026-07-27) — `useCampaignListFilterNavigation` em `shared/` absorve o scaffold de navegação/debounce dos três filtros (281 linhas removidas contra 75 em `src`, JSX intocado fora de um `aria-live` que era bug); a auditoria derrubou o racional das "assinaturas diferentes" (os três `buildXListHref` já canonicalizam, então um só callback `toHref` basta) e achou **dois** bugs de comportamento: dobradinhas e territórios descartavam a busca digitada ao tocar um filtro (unificado na semântica de municípios via `navigateWithSearch`) e o timer do debounce capturava o `state` do render que o agendou, revertendo um sort clicado dentro da janela de 1s — municípios escapava pelo `key=` da página, que remonta e mata o timer (corrigido com `stateRef`, com caso de spec verificado vermelho nas três listas sem o fix); o `/simplify` somou o fallback de transition copiado em 5 lugares (o hook era o 6º) virando `useCampaignListTransition`, e um `aria-live` a menos; `shouldUpdateMunicipalitySearchUrl` orfanou e morreu; `campaignListFilterNavigation.unit.spec.ts` (8 casos × 3 listas) é a primeira cobertura desses três componentes · [plano](plans/escala-dry-pos-b33.md) - Higiene PascalCase de componentes legados ### A validar (assumptions) diff --git a/src/components/campaign/activity/ActivityFilters.tsx b/src/components/campaign/activity/ActivityFilters.tsx index acc43fbe..b23c7e3b 100644 --- a/src/components/campaign/activity/ActivityFilters.tsx +++ b/src/components/campaign/activity/ActivityFilters.tsx @@ -1,10 +1,10 @@ 'use client' -import { useCampaignListPending } from '@/components/campaign/shared/CampaignListPending' +import { useCampaignListTransition } from '@/components/campaign/shared/CampaignListPending' import { ChevronDownIcon, FilterIcon, XIcon } from 'lucide-react' import Link from 'next/link' import { usePathname, useRouter } from 'next/navigation' -import { useRef, useState, useTransition } from 'react' +import { useRef, useState } from 'react' import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect' import { Button } from '@/components/ui/button' @@ -82,11 +82,7 @@ export const ActivityFilters = ({ const valuesRef = useRef(initialValues) const [values, setValues] = useState(initialValues) const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false) - const sharedPending = useCampaignListPending() - const [isLocalPending, startLocalTransition] = useTransition() - // Prefer the page-level boundary so the results region dims together. - const isPending = sharedPending?.isPending ?? isLocalPending - const startTransition = sharedPending?.startTransition ?? startLocalTransition + const { isPending, startTransition } = useCampaignListTransition() const replaceValues = (nextValues: FilterValues) => { valuesRef.current = nextValues diff --git a/src/components/campaign/municipality/MunicipalityFilters.tsx b/src/components/campaign/municipality/MunicipalityFilters.tsx index 18fb24aa..cd20987f 100644 --- a/src/components/campaign/municipality/MunicipalityFilters.tsx +++ b/src/components/campaign/municipality/MunicipalityFilters.tsx @@ -1,15 +1,11 @@ 'use client' -import { useCampaignListPending } from '@/components/campaign/shared/CampaignListPending' -import { useRouter } from 'next/navigation' -import { useEffect, useRef, useState, useTransition } from 'react' - import { CampaignMobileMultiFilterField } from '@/components/campaign/shared/CampaignMobileMultiFilterField' import { CampaignSearchInput } from '@/components/campaign/shared/CampaignSearchInput' +import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation' import { Button } from '@/components/ui/button' import { Field, FieldLabel } from '@/components/ui/field' import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select' -import { normalizedText } from '@/utilities/campaignListUrl' import { applyMunicipalityKindFilter, clearMunicipalityListFilters, @@ -22,18 +18,14 @@ import { type MunicipalityFilterOption, } from '@/utilities/municipalityListFilters' import { - buildMunicipalityFiltersKey, buildMunicipalityListHref, municipalityListSortOptions, parseMunicipalitySortValue, resolveMunicipalityListSort, serializeMunicipalitySortValue, - shouldUpdateMunicipalitySearchUrl, type MunicipalityListState, } from '@/utilities/municipalityListUrl' -const SEARCH_DEBOUNCE_MS = 1000 - type MunicipalityFiltersProps = { state: MunicipalityListState showStaffFilters: boolean @@ -49,71 +41,18 @@ export const MunicipalityFilters = ({ regionFilterOptions, advisorFilterOptions, }: MunicipalityFiltersProps) => { - const router = useRouter() - const [search, setSearch] = useState(state.q ?? '') - const sharedPending = useCampaignListPending() - const [isLocalPending, startLocalTransition] = useTransition() - // Prefer the page-level boundary so the results region dims together. - const isPending = sharedPending?.isPending ?? isLocalPending - const startTransition = sharedPending?.startTransition ?? startLocalTransition - const debounceRef = useRef | null>(null) + const { search, setSearch, draftQ, isPending, navigateTo, navigateWithSearch, scheduleSearch } = + useCampaignListFilterNavigation({ + state, + toHref: (next) => buildMunicipalityListHref(next, 1), + }) const { sort: activeSort, dir: activeDir } = resolveMunicipalityListSort(state) - const draftQ = normalizedText(search) || state.q const activeFiltersSummary = formatMunicipalityActiveFiltersSummary({ ...state, q: draftQ, }) const hasActiveFilters = Boolean(activeFiltersSummary) - useEffect( - () => () => { - if (debounceRef.current) clearTimeout(debounceRef.current) - }, - [], - ) - - const clearDebounce = () => { - if (debounceRef.current) { - clearTimeout(debounceRef.current) - debounceRef.current = null - } - } - - const navigateTo = (next: MunicipalityListState) => { - clearDebounce() - const canonical: MunicipalityListState = { - ...next, - page: 1, - q: normalizedText(next.q), - } - if ( - buildMunicipalityFiltersKey(canonical) === buildMunicipalityFiltersKey({ ...state, page: 1 }) - ) - return - - startTransition(() => { - router.replace(buildMunicipalityListHref(canonical, 1), { scroll: false }) - }) - } - - const commitNavigation = (patch: Partial) => { - navigateTo({ - ...state, - ...patch, - q: normalizedText(patch.q !== undefined ? patch.q : search), - }) - } - - const scheduleSearchNavigation = (value: string) => { - clearDebounce() - if (!shouldUpdateMunicipalitySearchUrl(value, state.q)) return - - debounceRef.current = setTimeout(() => { - debounceRef.current = null - commitNavigation({ q: value }) - }, SEARCH_DEBOUNCE_MS) - } - const mobileFilterDefinitions = municipalityFilterDefinitions.filter((definition) => { if (definition.staffOnly && !showStaffFilters) return false // Header popovers own multi/name filters; mobile keeps exclusive selects. @@ -128,7 +67,7 @@ export const MunicipalityFilters = ({ aria-busy={isPending} onSubmit={(event) => { event.preventDefault() - commitNavigation({ q: search }) + navigateWithSearch(state) }} >

@@ -144,37 +83,26 @@ export const MunicipalityFilters = ({ onChange={(event) => { const value = event.target.value setSearch(value) - scheduleSearchNavigation(value) + scheduleSearch(value) }} /> {activeFiltersSummary ? ( -

+

{activeFiltersSummary}

) : null} {hasActiveFilters ? ( -
- -
+ ) : null} @@ -186,10 +114,9 @@ export const MunicipalityFilters = ({ id="municipality-filter-priority" value={state.priority ?? ''} onChange={(event) => { - navigateTo({ + navigateWithSearch({ ...state, priority: event.target.value === 'alta' ? 'alta' : undefined, - q: normalizedText(search), }) }} className="min-h-11 w-full" @@ -207,13 +134,7 @@ export const MunicipalityFilters = ({ options={regionFilterOptions} selected={state.regions ?? []} onToggle={(value) => - navigateTo( - toggleMunicipalityMultiFilterValue( - { ...state, q: normalizedText(search) }, - 'region', - value, - ), - ) + navigateWithSearch(toggleMunicipalityMultiFilterValue(state, 'region', value)) } /> ) : null} @@ -232,17 +153,16 @@ export const MunicipalityFilters = ({ value={value} onChange={(event) => { const selected = event.target.value - const withSearch = { ...state, q: normalizedText(search) } if (definition.selection === 'toggle') { - navigateTo( + navigateWithSearch( selected - ? toggleMunicipalityExclusiveFilterValue(withSearch, 'coverage', selected) - : { ...withSearch, coverage: undefined, page: 1 }, + ? toggleMunicipalityExclusiveFilterValue(state, 'coverage', selected) + : { ...state, coverage: undefined }, ) return } if (definition.param === 'kind') { - navigateTo(applyMunicipalityKindFilter(withSearch, selected)) + navigateWithSearch(applyMunicipalityKindFilter(state, selected)) } }} className="min-h-11 w-full" @@ -265,13 +185,7 @@ export const MunicipalityFilters = ({ options={getMunicipalityFilterDefinition('trend').options ?? []} selected={state.trends ?? []} onToggle={(value) => - navigateTo( - toggleMunicipalityMultiFilterValue( - { ...state, q: normalizedText(search) }, - 'trend', - value, - ), - ) + navigateWithSearch(toggleMunicipalityMultiFilterValue(state, 'trend', value)) } /> ) : null} @@ -283,13 +197,7 @@ export const MunicipalityFilters = ({ options={getMunicipalityFilterDefinition('class').options ?? []} selected={state.classes ?? []} onToggle={(value) => - navigateTo( - toggleMunicipalityMultiFilterValue( - { ...state, q: normalizedText(search) }, - 'class', - value, - ), - ) + navigateWithSearch(toggleMunicipalityMultiFilterValue(state, 'class', value)) } /> ) : null} @@ -301,13 +209,7 @@ export const MunicipalityFilters = ({ options={advisorFilterOptions} selected={(state.advisors ?? []).map(String)} onToggle={(value) => - navigateTo( - toggleMunicipalityMultiFilterValue( - { ...state, q: normalizedText(search) }, - 'advisor', - value, - ), - ) + navigateWithSearch(toggleMunicipalityMultiFilterValue(state, 'advisor', value)) } /> ) : null} @@ -318,7 +220,7 @@ export const MunicipalityFilters = ({ value={serializeMunicipalitySortValue(activeSort, activeDir)} onChange={(event) => { const parsed = parseMunicipalitySortValue(event.target.value) - if (parsed) commitNavigation({ sort: parsed.key, dir: parsed.dir }) + if (parsed) navigateWithSearch({ ...state, sort: parsed.key, dir: parsed.dir }) }} className="min-h-11 w-full" > diff --git a/src/components/campaign/municipality/TerritoryFilters.tsx b/src/components/campaign/municipality/TerritoryFilters.tsx index e4985095..200b3b62 100644 --- a/src/components/campaign/municipality/TerritoryFilters.tsx +++ b/src/components/campaign/municipality/TerritoryFilters.tsx @@ -1,15 +1,11 @@ 'use client' -import { useRouter } from 'next/navigation' -import { useEffect, useRef, useState, useTransition } from 'react' - -import { useCampaignListPending } from '@/components/campaign/shared/CampaignListPending' import { CampaignMobileMultiFilterField } from '@/components/campaign/shared/CampaignMobileMultiFilterField' import { CampaignSearchInput } from '@/components/campaign/shared/CampaignSearchInput' +import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation' import { Button } from '@/components/ui/button' import { Field, FieldLabel } from '@/components/ui/field' import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select' -import { normalizedText } from '@/utilities/campaignListUrl' import { clearTerritoryListFilters, formatTerritoryActiveFiltersSummary, @@ -19,17 +15,13 @@ import { } from '@/utilities/territoryListFilters' import { buildTerritoryListHref, - parseTerritoryListParams, parseTerritorySortValue, resolveTerritoryListSort, serializeTerritorySortValue, territoryListSortOptions, - territoryListStateToRawParams, type TerritoryListState, } from '@/utilities/territoryListUrl' -const SEARCH_DEBOUNCE_MS = 1000 - export const TerritoryFilters = ({ state, regionOptions, @@ -37,51 +29,10 @@ export const TerritoryFilters = ({ state: TerritoryListState regionOptions: TerritoryFilterOption[] }) => { - const router = useRouter() - const [search, setSearch] = useState(state.q ?? '') - const sharedPending = useCampaignListPending() - const [isLocalPending, startLocalTransition] = useTransition() - const isPending = sharedPending?.isPending ?? isLocalPending - const startTransition = sharedPending?.startTransition ?? startLocalTransition - const debounceRef = useRef | null>(null) + const { search, setSearch, draftQ, isPending, navigateTo, navigateWithSearch, scheduleSearch } = + useCampaignListFilterNavigation({ state, toHref: buildTerritoryListHref }) const { sort, dir } = resolveTerritoryListSort(state) - const activeSummary = formatTerritoryActiveFiltersSummary({ - ...state, - q: normalizedText(search) || state.q, - }) - - useEffect( - () => () => { - if (debounceRef.current) clearTimeout(debounceRef.current) - }, - [], - ) - - const clearDebounce = () => { - if (!debounceRef.current) return - clearTimeout(debounceRef.current) - debounceRef.current = null - } - - const navigateTo = (next: TerritoryListState) => { - clearDebounce() - const canonical = parseTerritoryListParams( - territoryListStateToRawParams({ ...next, q: normalizedText(next.q) }), - ) - if (buildTerritoryListHref(canonical) === buildTerritoryListHref(state)) return - startTransition(() => { - router.replace(buildTerritoryListHref(canonical), { scroll: false }) - }) - } - - const scheduleSearch = (value: string) => { - clearDebounce() - if (normalizedText(value) === state.q) return - debounceRef.current = setTimeout(() => { - debounceRef.current = null - navigateTo({ ...state, q: value }) - }, SEARCH_DEBOUNCE_MS) - } + const activeSummary = formatTerritoryActiveFiltersSummary({ ...state, q: draftQ }) return ( { event.preventDefault() - navigateTo({ ...state, q: search }) + navigateWithSearch(state) }} >

@@ -120,7 +71,6 @@ export const TerritoryFilters = ({ variant="ghost" className="min-h-11 md:self-end" onClick={() => { - clearDebounce() setSearch('') navigateTo(clearTerritoryListFilters(state)) }} @@ -138,7 +88,7 @@ export const TerritoryFilters = ({ emptyLabel="Todos" options={regionOptions} selected={state.regions ?? []} - onToggle={(value) => navigateTo(toggleTerritoryRegionFilter(state, value))} + onToggle={(value) => navigateWithSearch(toggleTerritoryRegionFilter(state, value))} /> @@ -147,7 +97,7 @@ export const TerritoryFilters = ({ id="territory-filter-coverage" value={state.coverage ?? ''} onChange={(event) => - navigateTo({ + navigateWithSearch({ ...state, coverage: event.target.value === 'com_assessor' || event.target.value === 'sem_assessor' @@ -173,7 +123,7 @@ export const TerritoryFilters = ({ value={serializeTerritorySortValue(sort, dir)} onChange={(event) => { const parsed = parseTerritorySortValue(event.target.value) - if (parsed) navigateTo({ ...state, sort: parsed.key, dir: parsed.dir }) + if (parsed) navigateWithSearch({ ...state, sort: parsed.key, dir: parsed.dir }) }} className="w-full [&_select]:h-11" > diff --git a/src/components/campaign/shared/CampaignFilterChips.tsx b/src/components/campaign/shared/CampaignFilterChips.tsx index 72987ba1..e25baa4d 100644 --- a/src/components/campaign/shared/CampaignFilterChips.tsx +++ b/src/components/campaign/shared/CampaignFilterChips.tsx @@ -2,9 +2,9 @@ import Link from 'next/link' import { useRouter } from 'next/navigation' -import { useState, useTransition, type MouseEvent } from 'react' +import { useState, type MouseEvent } from 'react' -import { useCampaignListPending } from '@/components/campaign/shared/CampaignListPending' +import { useCampaignListTransition } from '@/components/campaign/shared/CampaignListPending' import { Button } from '@/components/ui/button' export type CampaignFilterChip = { @@ -26,10 +26,7 @@ export const CampaignFilterChips = ({ chips: CampaignFilterChip[] }) => { const router = useRouter() - const shared = useCampaignListPending() - const [isLocalPending, startLocalTransition] = useTransition() - const isPending = shared?.isPending ?? isLocalPending - const startTransition = shared?.startTransition ?? startLocalTransition + const { isPending, startTransition } = useCampaignListTransition() const [optimisticHref, setOptimisticHref] = useState(null) const activeHref = diff --git a/src/components/campaign/shared/CampaignListPending.tsx b/src/components/campaign/shared/CampaignListPending.tsx index 516c1bcf..74c9d027 100644 --- a/src/components/campaign/shared/CampaignListPending.tsx +++ b/src/components/campaign/shared/CampaignListPending.tsx @@ -37,9 +37,20 @@ export const CampaignListPendingBoundary = ({ children }: { children: ReactNode ) } -/** Filter controls prefer the shared transition so the results dim with them. */ -export const useCampaignListPending = (): CampaignListPendingValue | null => - useContext(CampaignListPendingContext) +/** + * The transition a navigating control should drive: the page-level boundary + * when there is one — so the results region dims with the control — and a local + * one otherwise, for controls rendered outside a boundary. + */ +export const useCampaignListTransition = (): CampaignListPendingValue => { + const shared = useContext(CampaignListPendingContext) + const [isLocalPending, startLocalTransition] = useTransition() + + return { + isPending: shared?.isPending ?? isLocalPending, + startTransition: shared?.startTransition ?? startLocalTransition, + } +} /** Dims while a shared list navigation is pending; safe to render from RSC. */ export const CampaignTransitionAnchor = forwardRef< @@ -61,9 +72,7 @@ export const CampaignTransitionAnchor = forwardRef< ref, ) { const router = useRouter() - const shared = useContext(CampaignListPendingContext) - const [, startLocalTransition] = useTransition() - const startTransition = shared?.startTransition ?? startLocalTransition + const { startTransition } = useCampaignListTransition() return ( { const router = useRouter() - const shared = useCampaignListPending() - const [isLocalPending, startLocalTransition] = useTransition() - const isPending = shared?.isPending ?? isLocalPending - const startTransition = shared?.startTransition ?? startLocalTransition + const { isPending, startTransition } = useCampaignListTransition() const [query, setQuery] = useState(initialQuery) const hrefForQuery = (value: string) => diff --git a/src/components/campaign/shared/useCampaignListFilterNavigation.ts b/src/components/campaign/shared/useCampaignListFilterNavigation.ts new file mode 100644 index 00000000..77165ff3 --- /dev/null +++ b/src/components/campaign/shared/useCampaignListFilterNavigation.ts @@ -0,0 +1,111 @@ +'use client' + +import { useRouter } from 'next/navigation' +import { useEffect, useRef, useState } from 'react' + +import { useCampaignListTransition } from '@/components/campaign/shared/CampaignListPending' +import { normalizedText } from '@/utilities/campaignListUrl' + +/** + * Long for search-as-you-type on purpose: every commit is an RSC round trip + * over a mobile connection, and the field team types município names slowly. + */ +export const SEARCH_DEBOUNCE_MS = 1000 + +/** + * Navigation scaffold shared by the list filter shells (municípios, + * territórios, dobradinhas): debounced search, no-op deduplication and the + * pending transition the results region dims with. + * + * The hook never decides what is canonical — `toHref` comes from the domain's + * own URL module (`buildXListHref`, which already canonicalizes), so the three + * URL contracts stay independent instead of collapsing into one DSL. + */ +export const useCampaignListFilterNavigation = ({ + state, + toHref, +}: { + /** Canonical state parsed from the URL on the server. */ + state: State + /** Domain serializer; must canonicalize and pin the page reset it wants. */ + toHref: (next: State) => string +}) => { + const router = useRouter() + const { isPending, startTransition } = useCampaignListTransition() + const [search, setSearch] = useState(state.q ?? '') + const debounceRef = useRef | null>(null) + /** + * The controls that navigate from *outside* this shell — sortable heads, + * pagination, the empty state's "Limpar" — change `state` without touching + * the pending timer. Reading the committed state through a ref keeps a timer + * scheduled before such a navigation from reverting it a second later. + */ + const stateRef = useRef(state) + stateRef.current = state + + const clearDebounce = () => { + if (!debounceRef.current) return + clearTimeout(debounceRef.current) + debounceRef.current = null + } + + useEffect(() => clearDebounce, []) + + /** + * Cancels any pending search, then navigates — unless `next` serializes to + * what is already committed. Both sides pin the same page, so the guard asks + * "do the filters and sort change?", not "is this the current URL?": a + * re-submitted identical search on page 3 leaves the user on page 3. + * + * Cancelling before the guard is why `clearDebounce` is not exposed: the + * three "Limpar" buttons need nothing beyond `setSearch('') + navigateTo`. + */ + const navigateTo = (next: State) => { + clearDebounce() + const nextHref = toHref(next) + if (nextHref === toHref(stateRef.current)) return + startTransition(() => { + router.replace(nextHref, { scroll: false }) + }) + } + + /** + * Same as `navigateTo`, carrying the search the user has typed but not yet + * committed — otherwise touching a filter mid-typing would drop it while the + * input keeps showing the text. + * + * Not interchangeable with `navigateTo`: "Limpar" calls `setSearch('')` and + * that setter has not flushed yet, so going through here would re-commit the + * search the user just cleared. + */ + const navigateWithSearch = (next: State) => { + navigateTo({ ...next, q: normalizedText(search) }) + } + + /** + * Typing back to the committed value cancels a pending navigation rather + * than merely skipping a new one — the guard lives in `navigateTo`, so the + * timer is free to resolve to a no-op. + */ + const scheduleSearch = (value: string) => { + clearDebounce() + debounceRef.current = setTimeout(() => { + navigateTo({ ...stateRef.current, q: value }) + }, SEARCH_DEBOUNCE_MS) + } + + return { + search, + setSearch, + /** + * What the active-filters summary should describe: the text being typed, + * falling back to the committed `q` while the box is empty (the results on + * screen are still the committed query's). + */ + draftQ: normalizedText(search) || state.q, + isPending, + navigateTo, + navigateWithSearch, + scheduleSearch, + } +} diff --git a/src/components/campaign/stateDeputy/StateDeputyFilters.tsx b/src/components/campaign/stateDeputy/StateDeputyFilters.tsx index 618c9714..42df66c3 100644 --- a/src/components/campaign/stateDeputy/StateDeputyFilters.tsx +++ b/src/components/campaign/stateDeputy/StateDeputyFilters.tsx @@ -1,15 +1,11 @@ 'use client' -import { useRouter } from 'next/navigation' -import { useEffect, useRef, useState, useTransition } from 'react' - -import { useCampaignListPending } from '@/components/campaign/shared/CampaignListPending' import { CampaignMobileMultiFilterField } from '@/components/campaign/shared/CampaignMobileMultiFilterField' import { CampaignSearchInput } from '@/components/campaign/shared/CampaignSearchInput' +import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation' import { Button } from '@/components/ui/button' import { Field, FieldLabel } from '@/components/ui/field' import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select' -import { normalizedText } from '@/utilities/campaignListUrl' import { buildStateDeputyPartyOptions, clearStateDeputyListFilters, @@ -19,17 +15,13 @@ import { } from '@/utilities/stateDeputyListFilters' import { buildStateDeputyListHref, - parseStateDeputyListParams, parseStateDeputySortValue, resolveStateDeputyListSort, serializeStateDeputySortValue, stateDeputyListSortOptions, - stateDeputyListStateToRawParams, type StateDeputyListState, } from '@/utilities/stateDeputyListUrl' -const SEARCH_DEBOUNCE_MS = 1000 - export const StateDeputyFilters = ({ state, partyOptions, @@ -41,53 +33,14 @@ export const StateDeputyFilters = ({ /** Whether at least one facet-matching row has no party — gates the "Sem partido" option. */ hasNoParty: boolean }) => { - const router = useRouter() - const [search, setSearch] = useState(state.q ?? '') - const sharedPending = useCampaignListPending() - const [isLocalPending, startLocalTransition] = useTransition() - const isPending = sharedPending?.isPending ?? isLocalPending - const startTransition = sharedPending?.startTransition ?? startLocalTransition - const debounceRef = useRef | null>(null) + const { search, setSearch, draftQ, isPending, navigateTo, navigateWithSearch, scheduleSearch } = + useCampaignListFilterNavigation({ + state, + toHref: (next) => buildStateDeputyListHref(next, 1), + }) const { sort, dir } = resolveStateDeputyListSort(state) const mobilePartyOptions = buildStateDeputyPartyOptions(partyOptions, hasNoParty) - const activeSummary = formatStateDeputyActiveFiltersSummary({ - ...state, - q: normalizedText(search) || state.q, - }) - - useEffect( - () => () => { - if (debounceRef.current) clearTimeout(debounceRef.current) - }, - [], - ) - - const clearDebounce = () => { - if (!debounceRef.current) return - clearTimeout(debounceRef.current) - debounceRef.current = null - } - - const navigateTo = (next: StateDeputyListState) => { - clearDebounce() - const canonical = parseStateDeputyListParams( - stateDeputyListStateToRawParams({ ...next, q: normalizedText(next.q), page: 1 }, 1), - ) - const nextHref = buildStateDeputyListHref(canonical, 1) - if (nextHref === buildStateDeputyListHref(state, 1)) return - startTransition(() => { - router.replace(nextHref, { scroll: false }) - }) - } - - const scheduleSearch = (value: string) => { - clearDebounce() - if (normalizedText(value) === state.q) return - debounceRef.current = setTimeout(() => { - debounceRef.current = null - navigateTo({ ...state, q: value, page: 1 }) - }, SEARCH_DEBOUNCE_MS) - } + const activeSummary = formatStateDeputyActiveFiltersSummary({ ...state, q: draftQ }) return ( { event.preventDefault() - navigateTo({ ...state, q: search, page: 1 }) + navigateWithSearch(state) }} >

@@ -126,7 +79,6 @@ export const StateDeputyFilters = ({ variant="ghost" className="min-h-11 md:self-end" onClick={() => { - clearDebounce() setSearch('') navigateTo(clearStateDeputyListFilters(state)) }} @@ -144,7 +96,7 @@ export const StateDeputyFilters = ({ emptyLabel="Todos" options={mobilePartyOptions} selected={state.parties ?? []} - onToggle={(value) => navigateTo(toggleStateDeputyPartyFilter(state, value))} + onToggle={(value) => navigateWithSearch(toggleStateDeputyPartyFilter(state, value))} /> @@ -154,7 +106,7 @@ export const StateDeputyFilters = ({ value={serializeStateDeputySortValue(sort, dir)} onChange={(event) => { const parsed = parseStateDeputySortValue(event.target.value) - if (parsed) navigateTo({ ...state, sort: parsed.key, dir: parsed.dir, page: 1 }) + if (parsed) navigateWithSearch({ ...state, sort: parsed.key, dir: parsed.dir }) }} className="w-full [&_select]:h-11" > diff --git a/src/components/campaign/supporter/SupporterFilters.tsx b/src/components/campaign/supporter/SupporterFilters.tsx index 4cea8571..b9434ca6 100644 --- a/src/components/campaign/supporter/SupporterFilters.tsx +++ b/src/components/campaign/supporter/SupporterFilters.tsx @@ -1,9 +1,9 @@ 'use client' -import { useCampaignListPending } from '@/components/campaign/shared/CampaignListPending' +import { useCampaignListTransition } from '@/components/campaign/shared/CampaignListPending' import { ChevronDownIcon, FilterIcon, XIcon } from 'lucide-react' import { usePathname, useRouter } from 'next/navigation' -import { useRef, useState, useTransition, type FormEvent } from 'react' +import { useRef, useState, type FormEvent } from 'react' import { CampaignSearchInput } from '@/components/campaign/shared/CampaignSearchInput' import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect' @@ -112,11 +112,7 @@ export const SupporterFilters = ({ const valuesRef = useRef(initialValues) const [values, setValues] = useState(initialValues) const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false) - const sharedPending = useCampaignListPending() - const [isLocalPending, startLocalTransition] = useTransition() - // Prefer the page-level boundary so the results region dims together. - const isPending = sharedPending?.isPending ?? isLocalPending - const startTransition = sharedPending?.startTransition ?? startLocalTransition + const { isPending, startTransition } = useCampaignListTransition() const replaceValues = (nextValues: FilterValues) => { valuesRef.current = nextValues diff --git a/src/utilities/municipalityListUrl.ts b/src/utilities/municipalityListUrl.ts index ef8dc73f..97d050b5 100644 --- a/src/utilities/municipalityListUrl.ts +++ b/src/utilities/municipalityListUrl.ts @@ -435,11 +435,6 @@ export const parseMunicipalitySortValue = ( return { key: rawKey as MunicipalityListSortKey, dir: rawDir as MunicipalityListSortDirection } } -export const shouldUpdateMunicipalitySearchUrl = ( - input: string, - currentQ: string | undefined, -): boolean => normalizedText(input) !== currentQ - export const resolveMunicipalityListUrl = ( params: MunicipalityListSearchParams, totalPages?: number, diff --git a/tests/unit/campaignListFilterNavigation.unit.spec.ts b/tests/unit/campaignListFilterNavigation.unit.spec.ts new file mode 100644 index 00000000..8b5fadd9 --- /dev/null +++ b/tests/unit/campaignListFilterNavigation.unit.spec.ts @@ -0,0 +1,267 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, createElement, type ReactElement } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const routerState = vi.hoisted(() => ({ + replace: vi.fn(), +})) + +vi.mock('next/navigation', async (importActual) => ({ + ...(await importActual()), + useRouter: () => ({ replace: routerState.replace }), +})) + +import { MunicipalityFilters } from '@/components/campaign/municipality/MunicipalityFilters' +import { TerritoryFilters } from '@/components/campaign/municipality/TerritoryFilters' +import { CampaignListPendingBoundary } from '@/components/campaign/shared/CampaignListPending' +import { SEARCH_DEBOUNCE_MS } from '@/components/campaign/shared/useCampaignListFilterNavigation' +import { StateDeputyFilters } from '@/components/campaign/stateDeputy/StateDeputyFilters' + +/** + * The three list filter shells share one navigation scaffold + * (`useCampaignListFilterNavigation`). These cases are the contract that + * survives the extraction: debounce window, no-op dedup, unmount cleanup, and + * carrying the uncommitted search into a filter/sort navigation. + */ +type FilterCase = { + name: string + searchLabel: string + /** Canonical href for a committed `silva` search with no other state. */ + hrefWithQuery: string + /** Canonical href for no state at all — what "Limpar" must navigate to. */ + bareHref: string + /** + * A `key|dir` from the list's own sort options that is NOT its default — + * otherwise `sort=` drops out of the canonical href and the assertion fails + * for a reason unrelated to the hook. + */ + sortValue: string + element: (currentQuery?: string) => ReactElement + /** Same shell with `sortValue` already committed — an external navigation. */ + elementWithSort: () => ReactElement +} + +const filterCases: FilterCase[] = [ + { + name: 'dobradinhas', + searchLabel: 'Buscar dobradinha', + hrefWithQuery: '/campanha/dobradinhas?q=silva', + bareHref: '/campanha/dobradinhas', + sortValue: 'party|asc', + element: (currentQuery) => + createElement(StateDeputyFilters, { + state: { page: 1, ...(currentQuery ? { q: currentQuery } : {}) }, + partyOptions: [], + hasNoParty: false, + }), + elementWithSort: () => + createElement(StateDeputyFilters, { + state: { page: 1, sort: 'party', dir: 'asc' }, + partyOptions: [], + hasNoParty: false, + }), + }, + { + name: 'territórios', + searchLabel: 'Buscar território', + hrefWithQuery: '/campanha/territorios?q=silva', + bareHref: '/campanha/territorios', + sortValue: 'region|asc', + element: (currentQuery) => + createElement(TerritoryFilters, { + state: { ...(currentQuery ? { q: currentQuery } : {}) }, + regionOptions: [], + }), + elementWithSort: () => + createElement(TerritoryFilters, { + state: { sort: 'region', dir: 'asc' }, + regionOptions: [], + }), + }, + { + name: 'municípios', + searchLabel: 'Buscar município', + hrefWithQuery: '/campanha/municipios?q=silva', + bareHref: '/campanha/municipios', + sortValue: 'name|asc', + element: (currentQuery) => + createElement(MunicipalityFilters, { + state: { page: 1, ...(currentQuery ? { q: currentQuery } : {}) }, + showStaffFilters: true, + regionFilterOptions: [], + advisorFilterOptions: [], + }), + elementWithSort: () => + createElement(MunicipalityFilters, { + state: { page: 1, sort: 'name', dir: 'asc' }, + showStaffFilters: true, + regionFilterOptions: [], + advisorFilterOptions: [], + }), + }, +] + +/** + * Always inside the boundary: in production all three shells render under one, + * so the local `useTransition` fallback is the branch that never runs. + */ +const mountCase = ({ element }: FilterCase, currentQuery?: string) => { + const { rerender } = render( + createElement(CampaignListPendingBoundary, null, element(currentQuery)), + ) + + return { + /** Re-renders with new committed state, as a navigation from elsewhere would. */ + rerenderWith: (next: ReactElement) => + rerender(createElement(CampaignListPendingBoundary, null, next)), + } +} + +const typeSearch = (label: string, value: string) => { + fireEvent.change(screen.getByLabelText(label), { target: { value } }) +} + +const advance = (ms: number) => { + act(() => { + vi.advanceTimersByTime(ms) + }) +} + +describe('campaign list filter navigation', () => { + beforeEach(() => { + // React's scheduler needs real microtasks; only the debounce clock is faked. + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + }) + + afterEach(() => { + cleanup() + vi.useRealTimers() + routerState.replace.mockReset() + }) + + it.each(filterCases)( + '$name: debounces the search into a single canonical replace', + (filterCase) => { + const { searchLabel, hrefWithQuery } = filterCase + mountCase(filterCase) + + typeSearch(searchLabel, 'silva') + advance(SEARCH_DEBOUNCE_MS - 1) + expect(routerState.replace).not.toHaveBeenCalled() + + advance(1) + expect(routerState.replace).toHaveBeenCalledTimes(1) + expect(routerState.replace).toHaveBeenCalledWith(hrefWithQuery, { scroll: false }) + }, + ) + + it.each(filterCases)( + '$name: restarts the debounce window while the user keeps typing', + (filterCase) => { + const { searchLabel, hrefWithQuery } = filterCase + mountCase(filterCase) + + typeSearch(searchLabel, 'sil') + advance(SEARCH_DEBOUNCE_MS - 200) + typeSearch(searchLabel, 'silva') + advance(SEARCH_DEBOUNCE_MS - 200) + expect(routerState.replace).not.toHaveBeenCalled() + + advance(200) + expect(routerState.replace).toHaveBeenCalledTimes(1) + expect(routerState.replace).toHaveBeenCalledWith(hrefWithQuery, { scroll: false }) + }, + ) + + it.each(filterCases)( + '$name: skips navigation when the query would not change the URL', + (filterCase) => { + const { searchLabel } = filterCase + mountCase(filterCase, 'silva') + + typeSearch(searchLabel, 'silva ') + advance(SEARCH_DEBOUNCE_MS) + expect(routerState.replace).not.toHaveBeenCalled() + + fireEvent.submit(screen.getByRole('search')) + expect(routerState.replace).not.toHaveBeenCalled() + }, + ) + + it.each(filterCases)('$name: cancels the pending search navigation on unmount', (filterCase) => { + const { searchLabel } = filterCase + mountCase(filterCase) + + typeSearch(searchLabel, 'silva') + cleanup() + advance(SEARCH_DEBOUNCE_MS) + + expect(routerState.replace).not.toHaveBeenCalled() + }) + + it.each(filterCases)( + '$name: carries the uncommitted search when another control navigates', + (filterCase) => { + const { searchLabel, sortValue } = filterCase + mountCase(filterCase) + + typeSearch(searchLabel, 'silva') + fireEvent.change(screen.getByLabelText('Ordenar'), { target: { value: sortValue } }) + + expect(routerState.replace).toHaveBeenCalledTimes(1) + const [href] = routerState.replace.mock.calls[0] as [string, unknown] + expect(href).toContain('q=silva') + expect(href).toContain(`sort=${sortValue.split('|')[0]}`) + + // The sort consumed the pending search; the timer must not fire a second one. + advance(SEARCH_DEBOUNCE_MS) + expect(routerState.replace).toHaveBeenCalledTimes(1) + }, + ) + + it.each(filterCases)( + '$name: clears the box and the query in one deduped navigation', + (filterCase) => { + const { searchLabel, bareHref } = filterCase + mountCase(filterCase, 'silva') + + fireEvent.click(screen.getByRole('button', { name: 'Limpar' })) + + expect((screen.getByLabelText(searchLabel) as HTMLInputElement).value).toBe('') + expect(routerState.replace).toHaveBeenCalledTimes(1) + expect(routerState.replace).toHaveBeenCalledWith(bareHref, { scroll: false }) + }, + ) + + it.each(filterCases)( + '$name: a pending search does not revert a navigation made from outside the shell', + (filterCase) => { + const { searchLabel, sortValue, elementWithSort } = filterCase + const { rerenderWith } = mountCase(filterCase) + + typeSearch(searchLabel, 'silva') + // A sortable head / pagination link commits new state mid-debounce. + rerenderWith(elementWithSort()) + advance(SEARCH_DEBOUNCE_MS) + + expect(routerState.replace).toHaveBeenCalledTimes(1) + const [href] = routerState.replace.mock.calls[0] as [string, unknown] + expect(href).toContain('q=silva') + expect(href).toContain(`sort=${sortValue.split('|')[0]}`) + }, + ) + + it.each(filterCases)( + '$name: emptying a committed search navigates to the query-less URL', + (filterCase) => { + const { searchLabel, bareHref } = filterCase + mountCase(filterCase, 'silva') + + typeSearch(searchLabel, '') + advance(SEARCH_DEBOUNCE_MS) + + expect(routerState.replace).toHaveBeenCalledTimes(1) + expect(routerState.replace).toHaveBeenCalledWith(bareHref, { scroll: false }) + }, + ) +}) diff --git a/tests/unit/municipalityList.unit.spec.ts b/tests/unit/municipalityList.unit.spec.ts index a5227806..7c20127c 100644 --- a/tests/unit/municipalityList.unit.spec.ts +++ b/tests/unit/municipalityList.unit.spec.ts @@ -23,7 +23,6 @@ import { parseMunicipalitySortValue, resolveMunicipalityListSort, serializeMunicipalitySortValue, - shouldUpdateMunicipalitySearchUrl, } from '@/utilities/municipalityListUrl' import { formatMunicipalitySignalAgeLabel, @@ -33,19 +32,6 @@ import { resolveMunicipalityLastSignalAt, } from '@/utilities/municipalitySignal' -describe('shouldUpdateMunicipalitySearchUrl', () => { - it('returns false when canonical q matches the current URL q', () => { - expect(shouldUpdateMunicipalitySearchUrl('salvador', 'salvador')).toBe(false) - expect(shouldUpdateMunicipalitySearchUrl(' salvador ', 'salvador')).toBe(false) - }) - - it('returns true when q differs or is newly set', () => { - expect(shouldUpdateMunicipalitySearchUrl('salv', 'salvador')).toBe(true) - expect(shouldUpdateMunicipalitySearchUrl('salvador', undefined)).toBe(true) - expect(shouldUpdateMunicipalitySearchUrl('', 'salvador')).toBe(true) - }) -}) - describe('municipality list sort params (A11 + B15 + E9)', () => { it('omits the default deficit/desc from the URL (E9 allocation queue order)', () => { const state = parseMunicipalityListParams({ sort: 'deficit', dir: 'desc' })