From ac2390587ed84392579a8d11700f8e66f0bdfe68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Tue, 28 Jul 2026 19:23:07 -0300 Subject: [PATCH] B18: saved municipality list filters in localStorage and sidebar submenu. Bookmarks the canonical list recorte beside Limpar, syncs via useSyncExternalStore, and matches active URLs with a pure href comparator so the app layout never imports the heavy URL serializer. --- .cursor/rules/codebase-map.mdc | 1 + .cursor/rules/projects/nucleos-eleitorais.mdc | 3 +- AGENTS.md | 2 + docs/plans/filtros-salvos-municipios.md | 168 +++++------------ docs/roadmap.md | 12 +- .../municipality/MunicipalityFilters.tsx | 2 + .../SaveMunicipalityFilterControl.tsx | 136 ++++++++++++++ .../shared/useMunicipalitySavedFilters.ts | 28 +++ .../campaign/shell/CampaignSidebar.tsx | 18 +- .../shell/MunicipalityNavSavedFilters.tsx | 151 +++++++++++++++ src/components/campaign/shell/nav.ts | 9 +- src/components/ui/Sidebar.tsx | 93 ++++++++- src/lib/listQueryMatch.ts | 46 +++++ src/utilities/municipalityListFilters.ts | 8 + src/utilities/municipalitySavedFilters.ts | 177 ++++++++++++++++++ tests/e2e/campaignSavedFilters.e2e.spec.ts | 142 ++++++++++++++ tests/unit/listQueryMatch.unit.spec.ts | 65 +++++++ .../municipalitySavedFilters.unit.spec.ts | 134 +++++++++++++ 18 files changed, 1066 insertions(+), 129 deletions(-) create mode 100644 src/components/campaign/municipality/SaveMunicipalityFilterControl.tsx create mode 100644 src/components/campaign/shared/useMunicipalitySavedFilters.ts create mode 100644 src/components/campaign/shell/MunicipalityNavSavedFilters.tsx create mode 100644 src/lib/listQueryMatch.ts create mode 100644 src/utilities/municipalitySavedFilters.ts create mode 100644 tests/e2e/campaignSavedFilters.e2e.spec.ts create mode 100644 tests/unit/listQueryMatch.unit.spec.ts create mode 100644 tests/unit/municipalitySavedFilters.unit.spec.ts diff --git a/.cursor/rules/codebase-map.mdc b/.cursor/rules/codebase-map.mdc index f67c88d8..324910ce 100644 --- a/.cursor/rules/codebase-map.mdc +++ b/.cursor/rules/codebase-map.mdc @@ -29,6 +29,7 @@ Conceitos e decisões: `docs/ARCHITECTURE.md`. Operação (setup/seeds/deploy/se - URL: `utilities/campaignListUrl.ts` (parse/canonicalize/`resolveListUrl`) + módulo por domínio (`municipalityListUrl.ts`, `supporterUi.ts`, `activityUi.ts`, parsers nos `*Data.ts`). Contrato de URL congelado (B18). - Tabela: `shared/CampaignTable.tsx` — colunas como dado (`id`, `label` **obrigatório**, `head` opcional (default = `{label}`; declare só para alinhamento, `description` do B22 ou ilha de head), `cell`, `mandatory`); heads ricos = ilhas de domínio sobre `shared/CampaignSortableHead` + `shared/CampaignHeaderFilterPopover` (multi-seleção: opções marcadas sobem ao topo via `lib/listFilterOptions.ts` — snapshot na abertura; gate = `row.checkbox`; single-select intocado). Seletor de colunas (B17): passe `columnVisibility` — **um** valor, devolvido por `readCampaignColumnVisibility(listId)` (`utilities/campaignColumnVisibilityCookie.ts`, `server-only`) em quem renderiza a tabela — e ela monta o `CampaignColumnPicker` e filtra por `resolveVisibleColumns`; regra pura em `lib/campaignColumnVisibility.ts`. Só o conjunto de **ocultas** é guardado (`defaultVisible` não existe): coluna nova nasce visível. - Shells: `CampaignSearchForm`, `CampaignFilterChips`, `CampaignListFooter`, `CampaignListEmptyState`, `CampaignListPagination`, pending compartilhado em `CampaignListPending.tsx` (dima o resultado, não o controle). +- Filtros salvos (B18) — só municípios: `utilities/municipalitySavedFilters.ts` (`localStorage`, identidade = href canônico, ordem alfabética) + hook `shared/useMunicipalitySavedFilters` + `municipality/SaveMunicipalityFilterControl` (barra) + `shell/MunicipalityNavSavedFilters` (submenu). O casamento URL↔salvo é `lib/listQueryMatch.ts` e **tem de continuar puro**: a sidebar está no layout de `(app)`, então importar o serializador de URL do domínio ali custa ~21 kB em toda rota `/campanha`. - Relação por chips numa célula: `shared/MunicipalityPortfolioCell` (B34) — sempre montada em `pointer-fine`, Drawer em `pointer-coarse`; costuras `minItems`/`maxItems`/`addableIds`/`draft`/`commitAction`. Serve lideranças e assessores; não escrever uma terceira. Lógica pura em `lib/municipalityPortfolio.ts`, índice em `utilities/municipalityPortfolioIndex.ts`. - Exceções documentadas: atividades = cards; `TerritoryOverviewTable` = sort no cliente; comparação de candidatos, LeaderContacts e preview de import ficam fora. - Detalhe por tabs: `utilities/detailTabUi.ts` (factory) + sections RSC colocadas na rota (`municipios/[slug]/MunicipalityDetailTabs.tsx`, `atividades/[slug]/ActivityOverviewTab.tsx`). diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index ce099b07..74c5c77d 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -7,11 +7,11 @@ alwaysApply: false ## Status +- **B18 entregue em código (2026-07-28):** filtros salvos na lista de Municípios — o recorte ativo vira um bookmark nomeado no `localStorage` (`teqo:campaign:municipality-saved-filters`, `MAX_ENTRIES = 12`) e reaparece como submenu recolhível sob "Municípios" no sidebar. **Duas premissas do plano de 24/07 morreram na auditoria.** (1) `SidebarMenuSub`/`SidebarMenuSubItem`/`SidebarMenuSubButton`/`SidebarMenuAction` **não existiam** — o plano afirmava que estavam no kit sem uso, mas o Pass 2 W4b pôs knip `exports` em ERROR e o kit foi podado; os quatro primitivos voltaram do upstream, e **o re-add expôs um bug pré-existente no `SidebarMenuButton`**: `data-active={false}` renderiza como `data-active="false"` e o variante `data-active:` do Tailwind casa por **presença** do atributo, então todo item **não** ativo estava recebendo o realce do ativo (`isActive || undefined` nos dois botões). (2) `municipalityUi.ts` não existe mais (Pass 2 W1 o dividiu) e o estado da URL cresceu para 14 params — absorvido serializando o estado inteiro menos `page`/`compare` em `buildMunicipalitySavedFilterHref` (`compare` é a lente do mapa do Início, não recorte desta lista). Também corrigido no plano: **B17 persiste em cookie**, não `localStorage`. **A restrição que manda no desenho é o layout:** a sidebar está no layout de `(app)`, então o casamento "URL atual == filtro salvo" **não pode** usar o serializador do domínio (a lição dos 21 kB do B14 ✓) — virou `src/lib/listQueryMatch.ts`, comparador puro de `URLSearchParams` insensível à ordem e ignorando `page`, o que de passagem resolve estar na página 3 de um filtro salvo (caso que o plano não previa). Medido contra árvore limpa: `/campanha` **byte-idêntico**, `/campanha/municipios` +2 kB. **Identidade é o href canônico, não um id:** salvar o mesmo recorte duas vezes é upsert — o Popover pré-preenche o nome atual e o botão lê "Renomear"/"Atualizar nome". O nome padrão e o gate "tem filtro salvável?" saem os dois de `formatMunicipalityActiveFiltersSummary`, que o `Limpar` já usava, então o `municipalityListHasSavableFilters` que o plano pedia não precisou existir. Sincronização das duas ilhas (barra grava, sidebar lê) por `useSyncExternalStore` com cache de snapshot invalidado na escrita e no evento de outra aba — a armadilha conhecida do loop de render. **Três defeitos de comportamento achados na verificação, e os dois primeiros são a mesma família de bug que este repo já pagou várias vezes** (estado lido do render que agendou o trabalho / hidratação confundida com ação): (1) abrir o grupo quando `activeHref` **aparece** reabria o submenu em **todo reload**, porque o store responde vazio até o cliente ler o `localStorage` e o match surgia da hidratação — o gatilho passou a ser a **URL mudar**, guardada num `useRef`, e só então a preferência recolhida sobrevive a um F5 para quem mais usa a feature; (2) apagar o último filtro derrubava o foco para ``, porque o botão que o detinha desmontava — o sucessor é resolvido **antes** da remoção, e o fallback tem de ser o link de nav, já que esvaziar o grupo desmonta o próprio disclosure; (3) SC 2.5.3 — o `aria-label` "Salvar este filtro" **não continha** o rótulo visível "Salvar filtro", então "clique em Salvar filtro" por voz não casava nada; removido (o caso de renomear pode manter porque a palavra visível vem primeiro). Mais acessibilidade: `aria-describedby` ligando a recusa ao input, `aria-label` no `