From bcb58c2162b818daf9c985f0ce9499396df2198c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Mon, 27 Jul 2026 09:39:15 -0300 Subject: [PATCH] B33+: close the /simplify rounds on the shared list filter hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follows the URL when the committed query changes with no debounce pending, so Back/Forward and the empty state's clear anchor update the search box without overwriting text the user is still typing. That removed the last reason for /campanha/municipios to remount its filters, so the key= (and its now orphaned buildMunicipalityFiltersKey) is gone and the three lists share one mounting policy — the dedup guard was dead code on municípios while it remounted. Reuse: setSearch + scheduleSearch were always called as a pair, so they collapse into onSearchChange (hook surface 8 -> 6 names); MunicipalityFilters reuses the buildMunicipalityFilterHref it was reimplementing inline; dobradinhas gains the sibling buildStateDeputyFilterHref for the four sites pinning page 1 by hand; municípios gains the active-filter summary its mobile block was missing. Also drops an export with no consumer that was failing knip in CI (seo.ts). --- .cursor/rules/projects/nucleos-eleitorais.mdc | 2 +- docs/plans/escala-dry-pos-b33.md | 18 +++-- docs/roadmap.md | 2 +- .../campanha/(app)/dobradinhas/page.tsx | 3 +- .../campanha/(app)/municipios/page.tsx | 2 - .../municipality/MunicipalityFilters.tsx | 27 ++----- .../municipality/TerritoryFilters.tsx | 16 +--- .../shared/useCampaignListFilterNavigation.ts | 81 ++++++++++++------- .../stateDeputy/StateDeputyFilters.tsx | 23 ++---- .../stateDeputy/StateDeputyHeaderFilter.tsx | 7 +- src/utilities/municipalityListUrl.ts | 3 - src/utilities/seo.ts | 2 +- src/utilities/stateDeputyListFilters.ts | 8 ++ .../campaignListFilterNavigation.unit.spec.ts | 74 +++++++++++++++-- 14 files changed, 166 insertions(+), 102 deletions(-) diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index c7d535e8..00b20922 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -40,7 +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-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. **2ª rodada de `/simplify` (após o rebase) fechou a questão que ficara parada** — os dois revisores tinham proposto direções opostas para ressincronizar o input (remontar as três páginas × ressincronizar no hook e remover o `key=`), e a saída foi o terceiro caminho: o hook segue o `q` commitado **só quando não há debounce pendente**, então Back/Forward e o "Limpar busca e filtros" do estado vazio atualizam a caixa sem nunca atropelar quem está a digitar. Com isso o `key={buildMunicipalityFiltersKey(state)}` de municípios saiu (era a única razão da divergência entre as três, e remontar a cada navegação tornava a guarda de dedup do `stateRef` código morto justamente ali) e `buildMunicipalityFiltersKey` foi deletado. Mais quatro correções de reuso: `setSearch`+`scheduleSearch` eram sempre chamados em par (em duas grafias) e viraram `onSearchChange`, o que baixou a superfície do hook de 8 para 6 nomes; `MunicipalityFilters` reimplementava inline o `buildMunicipalityFilterHref` que `municipalityListFilters.ts` já exporta com 4 consumidores; nasceu o irmão `buildStateDeputyFilterHref` (4 sítios pinavam `page: 1` à mão); e municípios ganhou o resumo de filtros ativos que faltava no bloco `md:hidden` — no telemóvel não havia indicação nenhuma de quais filtros estavam ligados. A normalização que a 1ª rodada tinha metido em `navigateWithSearch` foi **revertida** (era a segunda política que ela dizia estar a remover: `scheduleSearch` sempre entregou a tecla crua ao serializador). Números finais: **325 linhas removidas contra 228 em `src`** (138 delas o hook), 10 casos × 3 listas, 586 unit. A review de performance mediu a entrega como neutra e o seu único achado é pré-existente: `/campanha/municipios` embarca ~10 kB gzip do catálogo no cliente por um `isMunicipalitySlug` — no roadmap, junto com as outras deferidas (shell de `
`, `CampaignListSortSelect`, `serialize`/`parseXSortValue` triplicado). 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 90dae018..dc5f2a23 100644 --- a/docs/plans/escala-dry-pos-b33.md +++ b/docs/plans/escala-dry-pos-b33.md @@ -12,7 +12,7 @@ Responsável: — 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. +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 — dos quais só `navigateWithSearch` sobreviveu à segunda rodada de `/simplify` (ponto 10a). `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. @@ -23,7 +23,11 @@ Entregue como planejado no essencial — um hook em `shared/`, três call sites, 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. +10. **Segunda rodada de `/simplify` (reuse + performance), depois do rebase.** Cinco correções, todas de reuso ou de superfície: (a) `setSearch` + `scheduleSearch` eram sempre chamados em par nos três `onChange` (em duas grafias diferentes) — viraram um `onSearchChange(value)`, o que tirou **os dois** da superfície exportada; com `navigateTo` já privado atrás de `clearSearchAndNavigate`, o hook devolve 6 nomes em vez de 8, e não existe mais forma de ligar metade do par. (b) `MunicipalityFilters` reimplementava inline o `buildMunicipalityFilterHref` que `municipalityListFilters.ts` **já exporta** com quatro consumidores; passou a recebê-lo, e o pin de página 1 volta a morar num lugar por domínio. (c) O mesmo helper faltava em dobradinhas, onde `buildStateDeputyListHref(next, 1)` estava escrito à mão em quatro sítios — nasceu `buildStateDeputyFilterHref`, e a assimetria de aridade do prop `toHref` (dois unários e uma lambda) desapareceu. (d) Só `MunicipalityFilters` **não** mostrava o resumo de filtros ativos no bloco `md:hidden`: no telemóvel, onde esse bloco é o ponto todo, o utilizador de municípios não tinha indicação nenhuma de quais filtros estavam ligados. Adicionado o parágrafo que os outros dois já tinham. (e) A normalização que o ponto 9 tinha metido dentro de `navigateWithSearch` foi **revertida**: `scheduleSearch` sempre entregou a tecla crua ao `toHref`, então normalizar num caminho e não no outro era a segunda política que o ponto 9 dizia estar a remover — agora os dois entregam cru e o serializador decide, documentado no cabeçalho do hook. Fora do saldo: `stripTrailingSlash` (`utilities/seo.ts`) era um export sem consumidor **pré-existente** em `main` que fazia o knip do CI falhar; deixou de ser exportado. + +Duas revisões da mesma rodada foram deferidas com gatilho em vez de aplicadas, e estão registadas no roadmap: extrair o `` de "Ordenar" (idêntico nos três, já com deriva de altura do select) e o par `serialize`/`parseXSortValue` triplicado verbatim nos três módulos de URL. A revisão de performance mediu a entrega como neutra (mesmo número de `router.replace`, mesma cadência de RSC) e o seu único achado real é anterior a este item: `/campanha/municipios` embarca ~10 kB gzip do catálogo no cliente por causa de um `isMunicipalitySlug`, também no roadmap. + +Saldo medido no fim (incluindo as duas rodadas de `/simplify`): **278 linhas removidas contra 48 adicionadas nos três filtros**, 325 contra 228 em todo o `src` — e as 138 linhas do hook novo (metade comentário) estão dentro desse "adicionadas", ou seja ~97 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. @@ -91,11 +95,15 @@ Componentes: ## Adiado com gatilho -Três achados do `/simplify` (reuse review, 2026-07-27) que são maiores que limpeza: +Achados do `/simplify` (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. +- ~~**Ressincronizar a busca com `state.q`**~~ — **decidido e feito na 2ª rodada de `/simplify`**, pelo terceiro caminho: o hook segue a URL quando o `q` commitado muda **e não há debounce pendente** (`committedQ`), então Back/Forward e a âncora "Limpar busca e filtros" atualizam a caixa, mas o texto de quem está a digitar nunca é atropelado — que era o custo com que os dois revisores se cobravam mutuamente. Com isso o `key={buildMunicipalityFiltersKey(state)}` de `/campanha/municipios` **saiu** (era a única razão para as três páginas divergirem, e remontar a cada navegação committed matava o timer, tornando a guarda de dedup do ponto 8 código morto justamente num dos três call sites); `buildMunicipalityFiltersKey` orfanou e foi deletado. Sobra a política de montagem de `/campanha/apoiadores` e `/campanha/atividades`, que continuam com `key=` — não são consumidores do hook, então nada quebra; unificar quando adotarem a navegação compartilhada. +- **`` de "Ordenar" extraído (`CampaignListSortSelect`).** Os três blocos de 21 linhas diferem só nos identificadores de domínio — e **já divergiram**: `MunicipalityFilters` usa `className="min-h-11 w-full"` onde os outros dois usam `"w-full [&_select]:h-11"`, duas alturas para o mesmo controlo na mesma família de listas. O precedente de extração está na mesma pasta e tem os mesmos três call sites (`CampaignMobileMultiFilterField`). Não foi feito aqui por não haver decisão de qual altura é a correta — é escolha de design, não de código. **Gatilho:** a decisão de altura, ou o shell acima (que engole este bloco). +- **`serializeXSortValue` / `parseXSortValue` triplicados verbatim** (`municipalityListUrl.ts`, `territoryListUrl.ts`, `stateDeputyListUrl.ts`): `` `${key}|${dir}` `` e um `split('|')` com dois `Set.has`. `campaignListUrl.ts` é a casa desse tipo de parsing partilhado (já tem `normalizedText`, `allParamValues`, `parseExhaustiveEnumParam`) e não tem nenhum helper de sort. Genérico lá remove as três cópias sem tocar em contrato de URL. **Gatilho:** o 4º módulo de URL com sort (B29), ou o primeiro bug de deriva entre as cópias. +- **`SupporterFilters` / `ActivityFilters` adotarem o hook.** Ganharam `useCampaignListTransition`, mas não a navegação. **A razão registada aqui antes era factualmente falsa** e fica corrigida (reuse review, 2ª rodada): não é "montam o href sem passo de parse" — `buildSupporterListSearchParams` e `buildActivityListSearchParams` chamam `parseXListParams(...)` na primeira linha, exatamente a propriedade que justificou colapsar os outros três num só `toHref`. O impedimento real é o `valuesRef`/`useOptimistic` espelhando todos os filtros, para o qual o hook não tem equivalente; e migrar lhes **daria** dedup de no-op, mudança de comportamento em duas superfícies vivas. **Gatilho:** quando um deles precisar de dedup ou de busca com debounce. Independente disso, os dois reconstroem o href à mão a partir de `usePathname()` embora `buildSupporterListHref`/`buildActivityListHref` já existam e estejam pinados — dívida separada, no roadmap. +- **`/campanha/municipios` embarca ~10 kB gzip do catálogo dos 435 municípios no bundle do cliente** — achado da performance review, **anterior a este item** e a única coisa que ela encontrou: um `isMunicipalitySlug` num componente cliente arrasta `municipalityCatalog` inteiro, exatamente a violação de fronteira que o B14 pagou 21 kB para aprender ("client components não importam módulos de dados do servidor para valores"). Fora de escopo aqui porque a correção é no lado que consome o predicado, não no hook. **Gatilho:** próximo trabalho de bundle em `/campanha/municipios` (o B41/B42 tocam a mesma rota). +- **Terceiro idioma de mock de router nos testes.** `campaignListFilterNavigation.unit.spec.ts` usa `vi.hoisted` + `vi.mock('next/navigation')` com spread do módulo real; a casa já tem `stub` dentro de `AppRouterContext.Provider` (`campaignComponents.unit.spec.ts`), que dá as mesmas asserções sem mock de módulo. Não convertido por ser troca de harness num spec de 10 casos × 3 listas que está verde. **Gatilho:** o 4º spec que precise de router, ou o primeiro sintoma de deriva do spread. ## Referências diff --git a/docs/roadmap.md b/docs/roadmap.md index bb0948dc..cb71a0c2 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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 (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) +- ~~**B33+**~~ ✓ escala/DRY pós-B33 (entregue 2026-07-27) — `useCampaignListFilterNavigation` em `shared/` absorve o scaffold de navegação/debounce dos três filtros (**325 linhas removidas contra 228 em `src`**, das quais 138 são o hook novo; JSX só se moveu onde era bug); a auditoria derrubou o racional das "assinaturas diferentes" (os três `buildXListHref` já canonicalizam, então um só callback `toHref` basta) e achou **dois** bugs de comportamento: dobradinhas e territórios descartavam a busca digitada ao tocar um filtro (unificado na semântica de municípios via `navigateWithSearch`) e o timer do debounce capturava o `state` do render que o agendou, revertendo um sort clicado dentro da janela de 1s — municípios escapava pelo `key=` da página, que remonta e mata o timer (corrigido com `stateRef`, com caso de spec verificado vermelho nas três listas sem o fix). O `/simplify` somou o fallback de transition copiado em 5 lugares (o hook era o 6º) virando `useCampaignListTransition`, e um `aria-live` a menos; a 2ª rodada fechou a ressincronização da caixa de busca com o `q` commitado (o que permitiu **remover** o `key=` de municípios e unificar a política de montagem das três listas — `buildMunicipalityFiltersKey` orfanou e morreu, junto com `shouldUpdateMunicipalitySearchUrl`), colapsou `setSearch`+`scheduleSearch` num `onSearchChange` (superfície de 8 → 6 nomes), passou a reusar o `buildMunicipalityFilterHref` que já existia + criou o irmão `buildStateDeputyFilterHref` (4 sítios pinavam página 1 à mão), e deu a municípios o resumo de filtros ativos que faltava no bloco mobile; `campaignListFilterNavigation.unit.spec.ts` (10 casos × 3 listas) é a primeira cobertura desses três componentes. **Dívidas deferidas com gatilho** (no plano, § "Adiado com gatilho"): shell de `` compartilhado (gatilho B29), `CampaignListSortSelect` (bloqueado numa decisão de altura de select, já divergida entre as três), `serialize`/`parseXSortValue` triplicado → `campaignListUrl`, `SupporterFilters`/`ActivityFilters` reconstruírem href à mão tendo builders pinados, ~10 kB gzip do catálogo no bundle cliente de `/campanha/municipios` (pré-existente, achado da review de performance) e o 3º idioma de mock de router nos specs · [plano](plans/escala-dry-pos-b33.md) - Higiene PascalCase de componentes legados ### A validar (assumptions) diff --git a/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx b/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx index d5e8b5bc..7133dacc 100644 --- a/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx +++ b/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx @@ -40,6 +40,7 @@ import { type StateDeputyRowViewModel, } from '@/utilities/stateDeputyData' import { + buildStateDeputyFilterHref, clearStateDeputyListFilters, type StateDeputyFilterOption, } from '@/utilities/stateDeputyListFilters' @@ -71,7 +72,7 @@ const StateDeputyListEmptyState = ({ state }: { state: StateDeputyListState }) = {/* Same contract as the filter bar's Limpar: drop filters, keep the sort. */} { - const { search, setSearch, draftQ, isPending, navigateTo, navigateWithSearch, scheduleSearch } = - useCampaignListFilterNavigation({ - state, - toHref: (next) => buildMunicipalityListHref(next, 1), - }) + const { search, onSearchChange, draftQ, isPending, navigateWithSearch, clearSearchAndNavigate } = + useCampaignListFilterNavigation({ state, toHref: buildMunicipalityFilterHref }) const { sort: activeSort, dir: activeDir } = resolveMunicipalityListSort(state) const activeFiltersSummary = formatMunicipalityActiveFiltersSummary({ ...state, @@ -70,21 +67,13 @@ export const MunicipalityFilters = ({ navigateWithSearch(state) }} > -

- {isPending ? 'Atualizando resultados…' : ''} -

-
{ - const value = event.target.value - setSearch(value) - scheduleSearch(value) - }} + onChange={(event) => onSearchChange(event.target.value)} /> {activeFiltersSummary ? (

@@ -96,10 +85,7 @@ export const MunicipalityFilters = ({ type="button" variant="ghost" className="min-h-11 shrink-0 md:self-end" - onClick={() => { - setSearch('') - navigateTo(clearMunicipalityListFilters(state)) - }} + onClick={() => clearSearchAndNavigate(clearMunicipalityListFilters(state))} > Limpar @@ -107,6 +93,9 @@ export const MunicipalityFilters = ({

+ {activeFiltersSummary ? ( +

{activeFiltersSummary}

+ ) : null} {showStaffFilters ? ( Prioridade diff --git a/src/components/campaign/municipality/TerritoryFilters.tsx b/src/components/campaign/municipality/TerritoryFilters.tsx index 200b3b62..72c7d85f 100644 --- a/src/components/campaign/municipality/TerritoryFilters.tsx +++ b/src/components/campaign/municipality/TerritoryFilters.tsx @@ -29,7 +29,7 @@ export const TerritoryFilters = ({ state: TerritoryListState regionOptions: TerritoryFilterOption[] }) => { - const { search, setSearch, draftQ, isPending, navigateTo, navigateWithSearch, scheduleSearch } = + const { search, onSearchChange, draftQ, isPending, navigateWithSearch, clearSearchAndNavigate } = useCampaignListFilterNavigation({ state, toHref: buildTerritoryListHref }) const { sort, dir } = resolveTerritoryListSort(state) const activeSummary = formatTerritoryActiveFiltersSummary({ ...state, q: draftQ }) @@ -45,20 +45,13 @@ export const TerritoryFilters = ({ navigateWithSearch(state) }} > -

- {isPending ? 'Atualizando resultados…' : ''} -

-
{ - setSearch(event.target.value) - scheduleSearch(event.target.value) - }} + onChange={(event) => onSearchChange(event.target.value)} /> {activeSummary ? (

@@ -70,10 +63,7 @@ export const TerritoryFilters = ({ type="button" variant="ghost" className="min-h-11 md:self-end" - onClick={() => { - setSearch('') - navigateTo(clearTerritoryListFilters(state)) - }} + onClick={() => clearSearchAndNavigate(clearTerritoryListFilters(state))} > Limpar diff --git a/src/components/campaign/shared/useCampaignListFilterNavigation.ts b/src/components/campaign/shared/useCampaignListFilterNavigation.ts index 77165ff3..5eaa520a 100644 --- a/src/components/campaign/shared/useCampaignListFilterNavigation.ts +++ b/src/components/campaign/shared/useCampaignListFilterNavigation.ts @@ -19,7 +19,9 @@ export const SEARCH_DEBOUNCE_MS = 1000 * * 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. + * URL contracts stay independent instead of collapsing into one DSL. Raw + * keystrokes are handed to `toHref` untrimmed for the same reason: trimming is + * the serializer's job, and doing it here too would be a second policy. */ export const useCampaignListFilterNavigation = ({ state, @@ -32,16 +34,34 @@ export const useCampaignListFilterNavigation = ({ }) => { const router = useRouter() const { isPending, startTransition } = useCampaignListTransition() - const [search, setSearch] = useState(state.q ?? '') const debounceRef = useRef | null>(null) + const [search, setSearch] = useState(state.q ?? '') + + /** + * Follow the URL when the committed query changes under us — back/forward and + * the empty state's "Limpar busca e filtros" navigate from outside this shell. + * Without this the box would keep showing a query the URL no longer has, and + * the next filter touch would put it back (`navigateWithSearch` carries the + * box). A pending debounce wins: that text is the user's, not the URL's. + */ + const [committedQ, setCommittedQ] = useState(state.q) + if (state.q !== committedQ) { + setCommittedQ(state.q) + if (!debounceRef.current) setSearch(state.q ?? '') + } + /** * 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. + * pagination — 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. Written in an effect, not + * during render: an interrupted transition render is abandoned before commit, + * and the timer must not resolve against state that never landed. */ const stateRef = useRef(state) - stateRef.current = state + useEffect(() => { + stateRef.current = state + }) const clearDebounce = () => { if (!debounceRef.current) return @@ -56,9 +76,6 @@ export const useCampaignListFilterNavigation = ({ * 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() @@ -70,24 +87,34 @@ export const useCampaignListFilterNavigation = ({ } /** - * 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. + * Navigates 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. */ const navigateWithSearch = (next: State) => { - navigateTo({ ...next, q: normalizedText(search) }) + navigateTo({ ...next, q: 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. + * The "Limpar" buttons: empties the box and drops `q` in the same gesture. + * Exists so no caller has to know that `navigateWithSearch` would re-commit + * the search being cleared, because `setSearch('')` has not flushed yet. */ - const scheduleSearch = (value: string) => { + const clearSearchAndNavigate = (next: State) => { + setSearch('') + navigateTo(next) + } + + /** + * The search input's only handler: mirrors the keystroke and (re)arms the + * debounce in one call, so no caller can wire up half of it. + * + * Typing back to the committed value still arms a timer — it cancels the + * pending navigation rather than merely skipping a new one, because the guard + * lives in `navigateTo` and the timer is free to resolve to a no-op. + */ + const onSearchChange = (value: string) => { + setSearch(value) clearDebounce() debounceRef.current = setTimeout(() => { navigateTo({ ...stateRef.current, q: value }) @@ -96,16 +123,16 @@ export const useCampaignListFilterNavigation = ({ return { search, - setSearch, + onSearchChange, /** - * 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). + * What the active-filters summary should describe. The draft wins while the + * box is non-empty; an emptied box falls back to the committed `q` so the + * summary does not flicker off during the debounce, before the results it + * describes have actually changed. */ draftQ: normalizedText(search) || state.q, isPending, - navigateTo, navigateWithSearch, - scheduleSearch, + clearSearchAndNavigate, } } diff --git a/src/components/campaign/stateDeputy/StateDeputyFilters.tsx b/src/components/campaign/stateDeputy/StateDeputyFilters.tsx index 42df66c3..cfa6a404 100644 --- a/src/components/campaign/stateDeputy/StateDeputyFilters.tsx +++ b/src/components/campaign/stateDeputy/StateDeputyFilters.tsx @@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button' import { Field, FieldLabel } from '@/components/ui/field' import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select' import { + buildStateDeputyFilterHref, buildStateDeputyPartyOptions, clearStateDeputyListFilters, formatStateDeputyActiveFiltersSummary, @@ -14,7 +15,6 @@ import { type StateDeputyFilterOption, } from '@/utilities/stateDeputyListFilters' import { - buildStateDeputyListHref, parseStateDeputySortValue, resolveStateDeputyListSort, serializeStateDeputySortValue, @@ -33,11 +33,8 @@ export const StateDeputyFilters = ({ /** Whether at least one facet-matching row has no party — gates the "Sem partido" option. */ hasNoParty: boolean }) => { - const { search, setSearch, draftQ, isPending, navigateTo, navigateWithSearch, scheduleSearch } = - useCampaignListFilterNavigation({ - state, - toHref: (next) => buildStateDeputyListHref(next, 1), - }) + const { search, onSearchChange, draftQ, isPending, navigateWithSearch, clearSearchAndNavigate } = + useCampaignListFilterNavigation({ state, toHref: buildStateDeputyFilterHref }) const { sort, dir } = resolveStateDeputyListSort(state) const mobilePartyOptions = buildStateDeputyPartyOptions(partyOptions, hasNoParty) const activeSummary = formatStateDeputyActiveFiltersSummary({ ...state, q: draftQ }) @@ -53,20 +50,13 @@ export const StateDeputyFilters = ({ navigateWithSearch(state) }} > -

- {isPending ? 'Atualizando resultados…' : ''} -

-
{ - setSearch(event.target.value) - scheduleSearch(event.target.value) - }} + onChange={(event) => onSearchChange(event.target.value)} /> {activeSummary ? (

@@ -78,10 +68,7 @@ export const StateDeputyFilters = ({ type="button" variant="ghost" className="min-h-11 md:self-end" - onClick={() => { - setSearch('') - navigateTo(clearStateDeputyListFilters(state)) - }} + onClick={() => clearSearchAndNavigate(clearStateDeputyListFilters(state))} > Limpar diff --git a/src/components/campaign/stateDeputy/StateDeputyHeaderFilter.tsx b/src/components/campaign/stateDeputy/StateDeputyHeaderFilter.tsx index 1c8f057d..7d99ecd6 100644 --- a/src/components/campaign/stateDeputy/StateDeputyHeaderFilter.tsx +++ b/src/components/campaign/stateDeputy/StateDeputyHeaderFilter.tsx @@ -7,13 +7,14 @@ import { type CampaignHeaderFilterRow, } from '@/components/campaign/shared/CampaignHeaderFilterPopover' import { + buildStateDeputyFilterHref, buildStateDeputyPartyOptions, clearStateDeputyPartyFilter, isStateDeputyPartyFilterActive, toggleStateDeputyPartyFilter, type StateDeputyFilterOption, } from '@/utilities/stateDeputyListFilters' -import { buildStateDeputyListHref, type StateDeputyListState } from '@/utilities/stateDeputyListUrl' +import { type StateDeputyListState } from '@/utilities/stateDeputyListUrl' export const StateDeputyHeaderFilter = ({ state, @@ -36,7 +37,7 @@ export const StateDeputyHeaderFilter = ({ return { value: option.value, label: option.label, - href: buildStateDeputyListHref(next, 1), + href: buildStateDeputyFilterHref(next), selected: Boolean(viewState.parties?.includes(option.value)), checkbox: true, onChoose: () => setOptimisticState(next), @@ -54,7 +55,7 @@ export const StateDeputyHeaderFilter = ({ clear={ active ? { - href: buildStateDeputyListHref(clearedState, 1), + href: buildStateDeputyFilterHref(clearedState), onChoose: () => setOptimisticState(clearedState), } : undefined diff --git a/src/utilities/municipalityListUrl.ts b/src/utilities/municipalityListUrl.ts index 97d050b5..2100b33d 100644 --- a/src/utilities/municipalityListUrl.ts +++ b/src/utilities/municipalityListUrl.ts @@ -361,9 +361,6 @@ const buildMunicipalityListSearchParams = ( parseMunicipalityListParams(municipalityListStateToRawParams(state, page)), ) -export const buildMunicipalityFiltersKey = (state: MunicipalityListState): string => - buildMunicipalityListSearchParams(state).toString() - export const buildMunicipalityListHref = (state: MunicipalityListState, page: number): string => buildListHref(state, buildMunicipalityListSearchParams, '/campanha/municipios', page) diff --git a/src/utilities/seo.ts b/src/utilities/seo.ts index 2c559af1..e5949944 100644 --- a/src/utilities/seo.ts +++ b/src/utilities/seo.ts @@ -1,5 +1,5 @@ /** Remove any trailing slash(es) so URLs concatenate predictably. */ -export const stripTrailingSlash = (url: string): string => url.replace(/\/+$/, '') +const stripTrailingSlash = (url: string): string => url.replace(/\/+$/, '') /** Truncate `text` to `max` characters, appending an ellipsis when cut. */ export const truncate = (text: string, max: number): string => diff --git a/src/utilities/stateDeputyListFilters.ts b/src/utilities/stateDeputyListFilters.ts index e3f67274..38958099 100644 --- a/src/utilities/stateDeputyListFilters.ts +++ b/src/utilities/stateDeputyListFilters.ts @@ -6,6 +6,7 @@ */ import { truncatedNamesLabel } from '@/utilities/campaignListUrl' import { + buildStateDeputyListHref, NO_PARTY_FILTER_VALUE, parseStateDeputyListParams, stateDeputyListStateToRawParams, @@ -64,6 +65,13 @@ export const clearStateDeputyListFilters = (state: StateDeputyListState): StateD dir: state.dir, }) +/** + * Every filter change resets pagination, so the page-1 pin belongs here rather + * than at each call site — sibling of `buildMunicipalityFilterHref`. + */ +export const buildStateDeputyFilterHref = (next: StateDeputyListState): string => + buildStateDeputyListHref(next, 1) + export const isStateDeputyPartyFilterActive = (state: StateDeputyListState): boolean => Boolean(state.parties?.length) diff --git a/tests/unit/campaignListFilterNavigation.unit.spec.ts b/tests/unit/campaignListFilterNavigation.unit.spec.ts index 8b5fadd9..d7a5e41f 100644 --- a/tests/unit/campaignListFilterNavigation.unit.spec.ts +++ b/tests/unit/campaignListFilterNavigation.unit.spec.ts @@ -1,6 +1,6 @@ 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' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const routerState = vi.hoisted(() => ({ replace: vi.fn(), @@ -121,13 +121,37 @@ const typeSearch = (label: string, value: string) => { fireEvent.change(screen.getByLabelText(label), { target: { value } }) } +const searchInput = (label: string) => screen.getByLabelText(label) as HTMLInputElement + +/** + * `dir` never appears in these: every `sortValue` here is an `asc` key, which is + * each list's default direction, so the canonical serializer drops it. + */ +const sortKeyOf = ({ sortValue }: FilterCase) => sortValue.split('|')[0] +const hrefWithSort = (filterCase: FilterCase) => + `${filterCase.bareHref}?sort=${sortKeyOf(filterCase)}` +const hrefWithQueryAndSort = (filterCase: FilterCase) => + `${filterCase.bareHref}?q=silva&sort=${sortKeyOf(filterCase)}` + const advance = (ms: number) => { act(() => { vi.advanceTimersByTime(ms) }) } +const reactActEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean +} + describe('campaign list filter navigation', () => { + beforeAll(() => { + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterAll(() => { + delete reactActEnvironment.IS_REACT_ACT_ENVIRONMENT + }) + beforeEach(() => { // React's scheduler needs real microtasks; only the debounce clock is faked. vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) @@ -209,9 +233,9 @@ describe('campaign list filter navigation', () => { 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]}`) + expect(routerState.replace).toHaveBeenCalledWith(hrefWithQueryAndSort(filterCase), { + scroll: false, + }) // The sort consumed the pending search; the timer must not fire a second one. advance(SEARCH_DEBOUNCE_MS) @@ -236,7 +260,7 @@ describe('campaign list filter navigation', () => { it.each(filterCases)( '$name: a pending search does not revert a navigation made from outside the shell', (filterCase) => { - const { searchLabel, sortValue, elementWithSort } = filterCase + const { searchLabel, elementWithSort } = filterCase const { rerenderWith } = mountCase(filterCase) typeSearch(searchLabel, 'silva') @@ -245,9 +269,43 @@ describe('campaign list filter navigation', () => { 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]}`) + expect(routerState.replace).toHaveBeenCalledWith(hrefWithQueryAndSort(filterCase), { + scroll: false, + }) + }, + ) + + it.each(filterCases)( + '$name: follows the URL when the committed query is dropped from outside the shell', + (filterCase) => { + const { searchLabel, element } = filterCase + const { rerenderWith } = mountCase(filterCase, 'silva') + + // The empty state's "Limpar busca e filtros" is an anchor: it drops `q` + // from the URL without this shell's controls being touched. + rerenderWith(element()) + expect(searchInput(searchLabel).value).toBe('') + + // The box must not put the dropped query back on the next filter touch. + fireEvent.change(screen.getByLabelText('Ordenar'), { + target: { value: filterCase.sortValue }, + }) + expect(routerState.replace).toHaveBeenCalledWith(hrefWithSort(filterCase), { scroll: false }) + }, + ) + + it.each(filterCases)( + '$name: the active-filters summary describes the typed query before it commits', + (filterCase) => { + const { searchLabel } = filterCase + mountCase(filterCase) + + typeSearch(searchLabel, 'silva') + + // One copy per breakpoint: dobradinhas and territórios also render the + // summary in their mobile column, municípios only in the desktop row. + expect(screen.getAllByText(/Busca "silva"/).length).toBeGreaterThan(0) + expect(routerState.replace).not.toHaveBeenCalled() }, )