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.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-28 19:23:07 -03:00
parent be0d587005
commit ac2390587e
18 changed files with 1066 additions and 129 deletions

View file

@ -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 = `<CampaignTableHead>{label}</CampaignTableHead>`; 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`).

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,142 +1,76 @@
# B18 — Filtros salvos na lista de Municípios (+ acesso rápido no sidebar)
Status: rascunho — **costuras prontas desde o Pass 2 W1 (2026-07-25):** o estado de URL é canônico e serializável (`municipalityListUrl.ts`, contrato congelado); salvar filtro = nomear a serialização atual
Atualizado em: 2026-07-24
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Demais itens abertos, B18; superfície de coordenação)
Impeccable: B — encaixe em `MunicipalityFilters` / barra slim + `CampaignSidebar` (2º nível sob Municípios); sem rota nova
Appetite: ~1–1,5 dia eng; persistência local + Salvar com nome + submenu hover/sticky no sidebar; sem migration, sem collection, sem Consent
Status: ✓ entregue (2026-07-28) — sem migration, sem collection, sem server action, sem `Consent`
Atualizado em: 2026-07-28
Item do roadmap: [docs/roadmap.md](../roadmap.md) (B18; superfície de coordenação)
Impeccable: B — craft + critique + polish em `MunicipalityFilters` e `CampaignSidebar`; sem rota nova
Appetite: ~1–1,5 dia eng (gasto: dentro)
Responsável: —
## Design (Impeccable)
## O que ficou de pé
Âncoras: `PRODUCT.md` (princípios 2 — clareza sob pressão — e 8 — Feel the action; exceção justificada ao “Auto-save, no Save button” porque nomear é confirmatório) / `DESIGN.md` (register `product`; Field Desk) · tema `data-theme='campaign'` · shells `CampaignSidebar`, `MunicipalityFilters`, primitivos `SidebarMenuSub*` já em `src/components/ui/Sidebar.tsx`.
Em `/campanha/municipios`, um botão **Salvar filtro** ao lado de **Limpar** nomeia o recorte atual da URL, e o atalho passa a viver como **submenu de 2º nível sob Municípios** no sidebar — com chevron, estado persistido, item ativo, apagar com desfazer.
Na implementação (`implement-roadmap-item`): craft compacto → critique → polish (só salvar + submenu; sem redesign da nav completa nem sync multi-device).
Três camadas:
Brief compacto:
- **`src/utilities/municipalitySavedFilters.ts`** — `localStorage` com cache de snapshot para `useSyncExternalStore`, `MAX_ENTRIES = 12`, `MAX_NAME_LENGTH = 60`, ordem alfabética, limpeza no logout.
- **`src/lib/listQueryMatch.ts`** — `isSameListHref(a, b, ignoredParams)`, puro e sem imports pesados.
- **`MunicipalityNavSavedFilters`** + **`SaveMunicipalityFilterControl`**, sincronizados pelo hook compartilhado `useMunicipalitySavedFilters`.
- **Persona / contexto:** Alex (CG / Assessor / Candidato) monta o mesmo recorte várias vezes na semana (ex. “Prioritárias sem assessor · TI X · sort votos”) e precisa voltar a ele em um toque a partir da nav — sem refazer selects.
- **Job principal:** nomear o estado atual da URL da lista e reabri-lo como atalho de 2º nível sob **Municípios** no sidebar.
- **Estratégia de cor:** Restrained — submenu indentado com `border-l` do `SidebarMenuSub`; active sóbrio (`data-active`); sem chips coloridos de “views”.
- **Edit where you see:** não — bookmark de navegação URL; mutações B9 nas células seguem iguais.
- **Anti-goals:** collection/`campaignUser` prefs; saved views genéricas em todas as listas; segundo nível sempre visível (polui nav); sticky em qualquer `/campanha/municipios` (só no filtro salvo ativo); auto-gravar sem nome (vira “Visitados”).
## Premissas do plano de 2026-07-24 que morreram na auditoria
## Dados → decisão → apresentação
Registradas porque cada uma custou trabalho que o appetite original não previa, e porque voltar a acreditar nelas custaria de novo:
- **Vou apresentar dados?** Não — **Dados: N/A**. Este item não inventa métrica; só persiste e reabre o **mesmo** recorte URL que a lista já resolve.
- **Decisões desbloqueadas:** Staff: “voltar ao recorte mental desta sessão (ex. prioritárias / TI / sem assessor) sem remontar filtros.”
- **Forma escolhida:** **lista/tabela existente** + links nomeados no sidebar — **por quê:** o dado já está na lista; o problema é atrito de remontagem. **Rejeitado:** dashboard de “minhas views”; chart de uso de filtros; segunda página de gestão de views.
- **Profile:** N/A.
- **Anti-goals de dado:** sem KPI de “quantos filtros salvos”; sem % estadual.
1. **`SidebarMenuSub` / `SidebarMenuSubItem` / `SidebarMenuSubButton` / `SidebarMenuAction` NÃO existiam.** O plano dizia "existem no shadcn local e não são usados" — o Pass 2 W4b pôs knip `exports` em ERROR e o kit foi podado. Foi preciso **re-adicioná-los** do upstream. Ao fazê-lo apareceu um bug latente do kit: React renderiza `data-active={false}` como `data-active="false"`, e o seletor Tailwind `data-active:` casa com `[data-active]` — ou seja, **todo item não-ativo estava recebendo o estilo de ativo**. Agora o atributo só é emitido quando verdadeiro (`data-active={isActive || undefined}`), em `SidebarMenuButton` e `SidebarMenuSubButton`.
2. **`src/utilities/municipalityUi.ts` não existe** — o Pass 2 W1 o dividiu em `municipalityListUrl.ts`, `municipalityListFilters.ts`, `municipalityLabels.ts` e `municipalitySignal.ts`. `buildMunicipalitySavedFilterHref` nasceu em `municipalityListFilters.ts`.
3. **O estado da URL cresceu** muito além do que o plano listava (`q`, `regions[]`, `slugs[]`, `advisors[]`, `kind`, `coverage`, `priority`, `trends[]`, `classes[]`, `levels[]`, `compare`, `sort`, `dir`, `page`). Serializar o estado inteiro menos `page`/`compare` absorveu isso sozinho — nenhuma lista de params foi escrita à mão.
4. **B17 persiste em cookie, não `localStorage`.** A linha de contexto do plano estava errada. Não muda a decisão (o bookmark não precisa chegar ao render do servidor).
5. **"Ao lado de Limpar / Colunas" como uma fileira só é impossível:** `Limpar` está em `MunicipalityFilters` e `Colunas` está dentro de `CampaignTable`, numa fileira compartilhada pelas sete listas. Decidido na sessão: **barra de filtros, ao lado de Limpar**.
6. **`municipalityListHasSavableFilters` não precisou existir.** `formatMunicipalityActiveFiltersSummary` já é o gate "tem filtro?" (é o que o `Limpar` usa) **e** já produz o rótulo legível de todos os filtros — então ele é ao mesmo tempo a condição de montagem do botão e o **nome padrão** sugerido no Popover.
7. **Disclosure por hover morreu com B38 ✓.** Com `collapsible="offcanvas"`, recolhido no desktop a sidebar sai inteira da tela e não sobra rail para hover. Virou **collapsible com chevron e estado persistido**, decidido com o usuário.
8. **Renomear entrou no v1**, ao contrário do "adiado" do plano: como a identidade é o href e re-salvar é upsert, o botão simplesmente lê **"Renomear"** quando o recorte atual já está salvo, e o Popover abre pré-preenchido. Custou uma string, não uma fase.
Self-check dados: N/A (sem superfície de métrica nova).
## Decisões de engenharia que valem guardar
## Contexto
- **O casamento "URL atual == filtro salvo" não pode usar o serializador de URL do domínio.** A sidebar está no layout de `(app)`, então tudo que ela importa entra no First Load JS de **toda** rota `/campanha`, e o AGENTS.md já registra que importar `buildMunicipalityListHref` num island custou **21 kB** (arrasta `bahiaTerritories` + `municipalityCatalog`). Daí `src/lib/listQueryMatch.ts`: comparação de `URLSearchParams` insensível à ordem, que de quebra resolve um caso que o plano não previu — **estar na página 3 de um filtro salvo ainda é estar nele** (`page` é posição dentro do recorte, não parte dele).
- **Medido contra árvore limpa (`git stash`), que é a única forma de afirmar isso:** `/campanha` ficou **inalterado em 277 kB**; `/campanha/municipios` foi de **328 → 330 kB**. A fase `optimize` não disparou.
- **A identidade é o href, não um UUID.** O plano previa `id` + `name` + `href` + `savedAt`; sobrou `{ href, name }`. Um UUID só existiria para permitir dois atalhos que navegam para o mesmo lugar, que é exatamente o que o upsert existe para impedir — e `savedAt` não é lido por nada, porque a ordem é **alfabética** de propósito: um atalho nomeado que se reordena sozinho sob o cursor cada vez que é re-salvo é um bug de navegação. Recência é o trabalho de Visitados.
- **O teto é checado contra as OUTRAS entradas** (`others.length >= MAX_ENTRIES`), não contra o tamanho armazenado — senão renomear no limite passaria a ser recusado.
- **O `useSyncExternalStore` tem o server snapshot como constante congelada.** Devolver `[]` fresco ali é o loop de render clássico; o snapshot do cliente é cacheado e invalidado por escrita e por `storage` de outra aba (o evento nativo não dispara na aba que escreveu — daí o evento custom que cobre a outra metade).
Em `/campanha/municipios`, o estado canônico de recorte/ordem vive na URL via `MunicipalityListState` (`src/utilities/municipalityUi.ts`: `q`, `region`, `kind`, `coverage`, `priority`, `trend`, `sort`/`dir`, `page`, `compare`). Helpers já canonicalizam href (`resolveMunicipalityListUrl` / `buildMunicipalityListHref` / `buildMunicipalityListVisitHref`). **B15** ✓ sort; **B16** relocaciona filtros ao header e deixa barra slim; **B17** preferência de colunas em `localStorage`.
## Dois bugs reais que só o e2e pegou
O sidebar (`CampaignSidebar.tsx` + `nav.ts`) é **flat** — um `SidebarMenuButton` por item; primitivos `SidebarMenuSub` / `SidebarMenuSubButton` existem no shadcn local e **não são usados**. “Visitados recentemente” (`recentVisits.ts`) já prova o padrão client-only + limpeza no logout, mas é histórico automático — não nomeado.
1. **O auto-open reabria o grupo em toda montagem, tornando a preferência persistida inobservável exatamente para quem mais usa filtros salvos:** o usuário recolhe o grupo, recarrega, e ele volta. A causa não é óbvia — o gatilho era `activeHref` aparecer, mas **o store responde vazio até o cliente ler o `localStorage`**, então um match surgindo da hidratação se parecia com uma navegação. O gatilho passou a ser a **URL mudar**; a preferência guardada vence na montagem e o "revelar onde você está" fica reservado à navegação que de fato muda o atalho corrente.
2. **Apagar uma linha derrubava o foco para `<body>`.** O sucessor é resolvido **antes** da remoção, porque esvaziar o grupo desmonta o componente inteiro — chevron incluído — e nesse caso o alvo tem de ser o link de nav.
Pedido de produto (2026-07-24): **salvar** o filtro atual com um **nome**, e expor os salvos como **acesso rápido de 2º nível** sob Municípios: aparecem no **hover** (desktop) ou **clique/expansão** (mobile no drawer do sidebar); **permanecem visíveis** quando a URL atual é a de um filtro salvo; **somem** fora de Municípios e sem hover/expansão.
## Achados de acessibilidade corrigidos no critique
FD2 ([field-desk-ux-pos-critique.md](field-desk-ux-pos-critique.md)) adiava “saved views genéricas”; este item é a fatia **concreta e única** (só lista de municípios), não um framework de views.
- **SC 2.5.3 (Label in Name):** o botão mostrava "Salvar filtro" e tinha `aria-label="Salvar este filtro"`. O nome acessível precisa **conter** o rótulo visível, ou "clique em Salvar filtro" por voz não casa com nada. O `aria-label` saiu do caso de salvar; o de renomear fica, porque mantém a palavra visível na frente.
- A mensagem de recusa ganhou `aria-describedby` do input além do `role="alert"` que o `Alert` já traz: o alerta anuncia uma vez, o `describedby` é o que conta a quem volta ao campo **por que** ele ainda está inválido.
- O `<ul>` do submenu ganhou nome (`aria-label="Filtros salvos de Municípios"`), e os links ativos ganharam `aria-current="page"` — inclusive os de 1º nível, que não tinham.
- Apagar é revelado no hover/foco no desktop com `opacity-0` (que continua focável e no a11y tree, ao contrário de `hidden`) e é **permanentemente visível no toque**.
## Objetivos
## Não escopo (mantido)
- Staff em `/campanha/municipios`: affordance **Salvar filtro** (nome obrigatório) que grava o estado canônico atual da URL (sem `page`, sem `compare`).
- Lista de filtros salvos no **sidebar**, indentada sob **Municípios**, cada um linkando ao href canônico; item ativo quando a URL atual casa com o href salvo.
- Visibilidade do submenu:
- **mostrar** no hover do item Municípios (desktop);
- **mostrar** ao expandir Municípios no drawer mobile (clique);
- **mostrar (sticky)** quando a página atual é um filtro salvo (match de href);
- **esconder** em rotas que não são de municípios **e** sem hover/expansão.
- CRUD mínimo: criar + abrir + apagar (renomear = questão em aberto).
- Guardrails: sem migration, sem collection, sem Consent, sem server action de escrita; `leader` sem a página; access/loader inalterados; Feel the action no Salvar (pending no controle) e na navegação do atalho (`CampaignListPending` já cobre a lista).
## Decisões travadas
- **Item de trilha B18 (não fill-in; não fase de B16/B17; não absorver em Visitados).** Job distinto: bookmark **nomeado e intencional** vs histórico automático (Visitados) vs viewport de colunas (B17) vs relocação de selects (B16). Appetite ~1–1,5d; cortável. (2026-07-24, roadmap-item.) **Rejeitado:** fill-in só (subestima o contrato de submenu sticky/hover); estender `recentVisits` com “pin” (mistura dwell automático com nomeação); saved views genéricas multi-lista (FD2 já vetou; explode escopo).
- **Persistência = `localStorage` (não servidor).** Preferência/atalho por dispositivo; mesmo racional de Visitados e B17. Key estável namespaced (ex. `teqo:campaign:municipality-saved-filters`). **Rejeitado:** collection `savedFilter` / campo em `campaignUser` (migration + access + sync sem evidência de multi-device); `sessionStorage` (perde no ritual diário); cookie server-side.
- **Estado salvo = subset da URL da lista**, canonicalizado via `municipalityUi` (reusar `parseMunicipalityListParams` + href page=1): `q`, `region`, `kind`, `coverage`, `priority`, `trend`, `sort`, `dir`. **Excluir** `page` (sempre reabre na 1) e `compare` (lente do mapa no Início, não da lista). **Excluir** cenário A10 (fora da URL; lente local — alinhado ao fill-in Cenário / decisão A10). **Rejeitado:** gravar `page`/`compare`/cenário; gravar snapshot de IDs de municípios (vira lista morta).
- **Identidade:** `id` client-side (UUID) + `name` (pt-BR, trim, length cap) + `href` canônico (+ opcional `savedAt`). Active = comparação de href canônico com a URL atual (pathname `/campanha/municipios` + search normalizado). **Rejeitado:** rota dedicada `/campanha/municipios/vistas/[id]` (URL nova cara; share pior); dedup só por nome (dois nomes iguais OK se href diferente).
- **UI de salvar = submit explícito com nome** (Dialog/Popover + input + confirmar). Exceção justa ao princípio “Auto-save, no Save button”: nomear é confirmatório. **Rejeitado:** auto-save silencioso a cada mudança de filtro (vira Visitados); prompt nativo `window.prompt`.
- **Sidebar = 2º nível sob Municípios** com `SidebarMenuSub*` existente; hover (desktop) / expand (mobile); sticky só no match de filtro salvo — **não** sticky em `/campanha/municipios` genérico nem em `/campanha/municipios/[slug]`. Bottom nav mobile permanece flat (atalhos vivem no drawer do sidebar). **Rejeitado:** sempre listar salvos sob Municípios (polui); grupo separado no rodapé do sidebar; chips no dashboard.
- **Limite + logout:** `MAX_ENTRIES` curto (recomendação 12); `clearMunicipalitySavedFilters()` no logout junto com `clearRecentVisits()` (dispositivo compartilhado). **Rejeitado:** ilimitado; sync cross-device.
- **i18n e naming:** `MunicipalitySavedFilter`, `municipalitySavedFilters.ts`, `SaveMunicipalityFilterControl`, `MunicipalityNavSavedFilters`; strings “Salvar filtro”, “Filtros salvos”, “Apagar”; identificadores em inglês.
## Questões em aberto
- **Permitir salvar a lista “nua” (sem filtros, só sort default)?** **Opções:** A) só com ao menos um param de recorte (`q`/`region`/…) | B) qualquer estado, inclusive nua | C) nua só se `sort`/`dir` ≠ default. **Recomendação:** **A** — alinhado a Visitados (`buildMunicipalityListVisitLabel` retorna null sem filtro); atalho “Municípios” já cobre a lista nua. _(assumido — validar com produto)_
- **Renomear no v1?** **Opções:** A) só criar/apagar | B) renomear no submenu (inline/Dialog). **Recomendação:** **A** — apagar+recriar basta no appetite; renomear = Adiado. _(assumido)_
- **Onde mora o botão Salvar?** **Opções:** A) barra slim de `MunicipalityFilters` (ao lado de Limpar / Colunas) | B) menu “…” da lista | C) só no overview. **Recomendação:** **A** — mesmo lugar mental dos filtros; soft com B16/B17. _(assumido)_
- **Dois salvos com o mesmo href: atualizar o existente ou criar duplicata?** **Opções:** A) upsert (pede confirmação se nome diferente) | B) sempre criar | C) bloquear com mensagem. **Recomendação:** **A** — um atalho por href; evita submenu duplicado. _(assumido)_
## Abordagem proposta
```mermaid
flowchart LR
List["/campanha/municipios?…\nMunicipalityListState"]
Save["SaveMunicipalityFilterControl\nnome + confirmar"]
Store["municipalitySavedFilters.ts\nlocalStorage"]
Nav["CampaignSidebar\nMunicípios + SidebarMenuSub"]
Match["href canônico == URL atual\n→ sticky open + active"]
List --> Save
Save --> Store
Store --> Nav
List --> Match
Match --> Nav
Nav -->|Link| List
```
Componentes:
- **`src/utilities/municipalitySavedFilters.ts`** (client-safe, guarda `typeof window`): `STORAGE_KEY`, `MAX_ENTRIES`, tipos, `list` / `upsert` / `remove` / `clear`; validação de shape; no-op fora do browser. Depth: espelhar `recentVisits.ts`, não inventar service genérico de “prefs”.
- **Helpers em `municipalityUi.ts`:** `buildMunicipalitySavedFilterHref(state)` (= visit href / page=1 sem compare) + `municipalityListHasSavableFilters(state)` (gate do botão). Reusar parse/canonical já existentes.
- **`SaveMunicipalityFilterControl`** (client, em `src/components/campaign/`): Popover/Dialog com nome; desabilitado se não há filtro salvável ou se já é o ativo sem mudanças; pending imediato no confirmar; toast/inline “Salvo” curto; montar na barra slim (`MunicipalityFilters` pós-B16, ou fileira atual se B16 ainda não aterrissou).
- **`MunicipalityNavSavedFilters`** (client island no `CampaignSidebar`): lê storage pós-mount (sem mismatch de hidratação); renderiza `SidebarMenuSub` sob o item Municípios; hover/focus-within (desktop) + estado expandido (mobile); `forceOpen` quando `usePathname`+`useSearchParams` casam com algum `href` salvo; cada linha = `SidebarMenuSubButton asChild` + `Link` + ação apagar (`SidebarMenuAction` ou botão sr-only no hover); fechar drawer mobile ao navegar (mesmo padrão atual).
- **`CampaignSidebar` / item Municípios:** especializar só o item `href === '/campanha/municipios'` (não mudar `nav.ts` para árvore genérica — YAGNI &lt;3 call sites). `isCampaignNavActive` do pai continua por pathname; sub-item ativo por match de search.
- **Logout:** chamar `clearMunicipalitySavedFilters()` ao lado de `clearRecentVisits()`.
- **Sem migration, sem collection, sem server action, sem Consent.**
## Dependências
- Soft: **B16** (barra slim = destino natural do Salvar) e **B17** (vizinho na mesma barra) — não bloqueiam; sem eles, Salvar pousa ao lado de Limpar na fileira atual.
- Soft: precedente Visitados ([visitados-recentemente.md](visitados-recentemente.md)) — padrão storage/logout.
- Soft: **B38** ([sidebar-recolhido-tablet.md](sidebar-recolhido-tablet.md)) — com `collapsible="offcanvas"`, o submenu hover/expand sob Municípios só é usável com o rail expandido (ou no Sheet mobile); se B38 landar antes, o craft do B18 trata collapsed como “abrir rail”.
- Nenhuma dependência dura de outro plano. Reusa `municipalityUi.ts`, `CampaignSidebar`, `SidebarMenuSub*`, `CampaignListPendingBoundary`.
## Não escopo
- Sync multi-device / preferências em `campaignUser` — exigiria migration + access.
- Saved views em Planos / Apoiadores / Demandas — FD2 “genéricas”; só Municípios aqui.
- Sticky do submenu em qualquer rota `/campanha/municipios*` — só match de filtro salvo.
- Incluir Cenário A10 ou `compare` do mapa no bookmark.
- Substituir Visitados recentemente / B14 município mais próximo.
- Reorder DnD de colunas — [fora de escopo](reordenar-colunas-lista-municipios.md).
## Rabbit holes
- **Framework de “nav tree” / collapsible genérico no `nav.ts`.** Se alguém “só preparar” para outros itens: explode o shell. **Mitigação:** especializar só Municípios neste item; 3º call site = gatilho de abstrair.
- **Rota `/vistas/[id]` + loader server.** Se alguém “só para deep-link nomeado”: migration mental + share frágil. **Mitigação:** href = query canônica da lista.
- **Optimistic list sem refresh ao abrir atalho.** Anti-goal Feel the action. **Mitigação:** `Link` + pending da região de resultados existente.
- **Spreadsheet / painel de gestão de views.** **Mitigação:** criar/apagar no fluxo; sem página admin de views.
Sync multi-device / preferências em `campaignUser`; saved views em outras listas (FD2 já vetou o genérico); rota `/vistas/[id]`; incluir Cenário A10 ou `compare` do mapa no bookmark; salvar junto o viewport de colunas do B17.
## Adiado com gatilho
- **Renomear filtro salvo.** Revisitar quando: ≥2 atores pedirem em R6/sessão **ou** apagar+recriar gerar atrito medido.
- **Sync servidor / share de view entre assessores.** Revisitar quando: evidência de multi-device **e** pedido explícito de “mandar este recorte” (aí sim collection + access; não neste appetite).
- **Salvar também o viewport B17 (colunas visíveis) junto do filtro.** Revisitar quando: B17 estável **e** usuários pedirem “a view completa” (hoje misturaria bookmark de decisão com preferência de densidade).
- **Árvore de navegação genérica em `nav.ts`.** Este item especializa **só** Municípios, via a constante exportada `MUNICIPALITY_NAV_HREF`. **Gatilho:** o 3º call site de submenu no sidebar.
- **Sync servidor / compartilhar recorte entre assessores.** **Gatilho:** evidência de multi-device **e** pedido explícito de "mandar este recorte" — aí sim collection + access, fora deste appetite.
## Testes
- `tests/unit/listQueryMatch.unit.spec.ts` — ordem trocada casa, `page` diferente casa, filtro a mais não casa, percent-encoding normaliza.
- `tests/unit/municipalitySavedFilters.unit.spec.ts` — upsert por href, cap, shape inválido, dedup, cache de snapshot.
- `tests/e2e/campaignSavedFilters.e2e.spec.ts` — filtrar → salvar → submenu → ativo na página 2 → restaurar → renomear → apagar → desfazer, e o disclosure persistido através de reload.
**Nota operacional:** `pnpm build` limpa `.next`, e o servidor de dev do e2e usa `NEXT_DIST_DIR=.next/e2e` — rodar o build entre duas rodadas de e2e derruba o cache de compilação e faz o projeto `setup` estourar os 90 s. Não é flake do teste.
## Referências
- `docs/roadmap.md` (Demais itens abertos · grafo · Janela 1–2 · cortes)
- `docs/plans/visitados-recentemente.md` — precedente localStorage + logout
- `docs/plans/filtros-no-header-lista-municipios.md` (B16) · `docs/plans/seletor-colunas-lista-municipios.md` (B17)
- `docs/plans/field-desk-ux-pos-critique.md` — “saved views genéricas” adiadas; este item é a fatia concreta
- `src/utilities/municipalityUi.ts` — estado URL / href canônico / visit label
- `docs/plans/visitados-recentemente.md` — precedente de localStorage + logout
- `docs/plans/seletor-colunas-lista-municipios.md` (B17) — vizinho que persiste em **cookie**, porque o servidor precisa ler
- `src/utilities/municipalityListUrl.ts` / `municipalityListFilters.ts` — contrato de URL congelado
- `src/utilities/recentVisits.ts` — espelho de storage
- `src/components/campaign/CampaignSidebar.tsx`, `nav.ts`, `MunicipalityFilters.tsx`
- `src/components/ui/Sidebar.tsx` — `SidebarMenuSub*`
- AGENTS.md — naming EN / copy pt-BR; campaign auth; sem Consent para preferência local do próprio usuário
- `PRODUCT.md` / `DESIGN.md` — Field Desk; Feel the action; Auto-save (exceção nomear)
- `src/components/ui/Sidebar.tsx` — `SidebarMenuSub*` / `SidebarMenuAction` re-adicionados

File diff suppressed because one or more lines are too long

View file

@ -1,5 +1,6 @@
'use client'
import { SaveMunicipalityFilterControl } from '@/components/campaign/municipality/SaveMunicipalityFilterControl'
import { CampaignMobileMultiFilterField } from '@/components/campaign/shared/CampaignMobileMultiFilterField'
import { CampaignSearchInput } from '@/components/campaign/shared/CampaignSearchInput'
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
@ -80,6 +81,7 @@ export const MunicipalityFilters = ({
{activeFiltersSummary}
</p>
) : null}
<SaveMunicipalityFilterControl state={state} />
{hasActiveFilters ? (
<Button
type="button"

View file

@ -0,0 +1,136 @@
'use client'
import { BookmarkPlusIcon } from 'lucide-react'
import { useId, useState, type FormEvent } from 'react'
import { toast } from 'sonner'
import { useMunicipalitySavedFilters } from '@/components/campaign/shared/useMunicipalitySavedFilters'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Field, FieldDescription, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import {
buildMunicipalitySavedFilterHref,
formatMunicipalityActiveFiltersSummary,
} from '@/utilities/municipalityListFilters'
import type { MunicipalityListState } from '@/utilities/municipalityListUrl'
import {
MAX_ENTRIES,
MAX_NAME_LENGTH,
saveMunicipalitySavedFilter,
} from '@/utilities/municipalitySavedFilters'
type SaveMunicipalityFilterControlProps = {
/** The APPLIED state — a search still inside the debounce window is not in the URL yet. */
state: MunicipalityListState
}
/**
* Names the current recorte and keeps it in the sidebar (B18).
*
* Naming is confirmatory, so this is the explicit-submit exception
* `.cursor/rules/campanha-edit-where-you-see.mdc` already carves out for flows
* that need a note — and it keeps this control out of the auto-save machine the
* quick-edit cells share.
*/
export const SaveMunicipalityFilterControl = ({ state }: SaveMunicipalityFilterControlProps) => {
const savedFilters = useMunicipalitySavedFilters()
const [open, setOpen] = useState(false)
const [name, setName] = useState('')
const [errorMessage, setErrorMessage] = useState('')
const nameId = useId()
const errorId = `${nameId}-error`
const summary = formatMunicipalityActiveFiltersSummary(state)
const href = buildMunicipalitySavedFilterHref(state)
const existing = savedFilters.find((entry) => entry.href === href)
// The bare list has no recorte to name. This is the same gate the `Limpar`
// button uses, minus the draft search: only what the URL already holds can be
// bookmarked, or the name would describe a filter the href does not carry.
if (!summary) return null
const handleOpenChange = (next: boolean) => {
setOpen(next)
if (!next) return
setName(existing?.name ?? summary.slice(0, MAX_NAME_LENGTH))
setErrorMessage('')
}
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!name.trim()) {
setErrorMessage('Dê um nome ao filtro.')
return
}
const result = saveMunicipalitySavedFilter({ href, name })
if (result === 'limit') {
setErrorMessage(
`Você já tem ${MAX_ENTRIES} filtros salvos. Apague um em Municípios, na navegação, para salvar outro.`,
)
return
}
if (result === 'failed') {
setErrorMessage('Não foi possível salvar neste dispositivo. Tente de novo.')
return
}
setOpen(false)
toast.success(existing ? 'Nome atualizado.' : 'Filtro salvo em Municípios, na navegação.')
}
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
className="min-h-11 shrink-0 md:self-end"
// SC 2.5.3: an accessible name has to CONTAIN the visible label, or
// "clique em Salvar filtro" matches nothing by voice. The rename case
// may add the target's name because it keeps the visible word first.
aria-label={existing ? `Renomear o filtro salvo ${existing.name}` : undefined}
>
<BookmarkPlusIcon aria-hidden="true" />
{existing ? 'Renomear' : 'Salvar filtro'}
</Button>
</PopoverTrigger>
{/* Radix gives the content `role="dialog"` and no name of its own. */}
<PopoverContent align="end" aria-label="Salvar filtro" className="w-80">
{/* Portaled out of the search form above, so this nests no form. */}
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
<Field>
<FieldLabel htmlFor={nameId}>Nome do filtro</FieldLabel>
<Input
id={nameId}
value={name}
onChange={(event) => {
setName(event.target.value)
setErrorMessage('')
}}
maxLength={MAX_NAME_LENGTH}
autoComplete="off"
autoFocus
className="min-h-11"
aria-invalid={errorMessage ? true : undefined}
// `role="alert"` announces the refusal once; this is what tells a
// reader returning to the field WHY it is still invalid.
aria-describedby={errorMessage ? errorId : undefined}
/>
<FieldDescription>{summary}</FieldDescription>
</Field>
{errorMessage ? (
<Alert variant="destructive" id={errorId}>
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
) : null}
<Button type="submit" className="min-h-11 w-full">
{existing ? 'Atualizar nome' : 'Salvar'}
</Button>
</form>
</PopoverContent>
</Popover>
)
}

View file

@ -0,0 +1,28 @@
'use client'
import { useSyncExternalStore } from 'react'
import {
listMunicipalitySavedFilters,
subscribeMunicipalitySavedFilters,
type MunicipalitySavedFilter,
} from '@/utilities/municipalitySavedFilters'
/**
* Stable reference for the render that has no `localStorage` to read. Returning
* a fresh `[]` here is the classic `useSyncExternalStore` render loop.
*/
const NO_SAVED_FILTERS: readonly MunicipalitySavedFilter[] = []
/**
* The filter bar writes this store and the sidebar reads it, from trees that
* never meet — so both subscribe rather than lift state, and one write repaints
* both. Shared because the snapshot/subscribe pairing is exactly the thing that
* must not be written twice.
*/
export const useMunicipalitySavedFilters = (): readonly MunicipalitySavedFilter[] =>
useSyncExternalStore(
subscribeMunicipalitySavedFilters,
listMunicipalitySavedFilters,
() => NO_SAVED_FILTERS,
)

View file

@ -2,16 +2,18 @@
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useState, type FormEvent } from 'react'
import { useState, type FormEvent, type ReactNode } from 'react'
import { logoutCampaign } from '@/app/(campaign)/campanha/actions/auth'
import { CampaignScopeBadge } from '@/components/campaign/shared/CampaignScopeBadge'
import { CampaignUserAvatar } from '@/components/campaign/shared/CampaignUserAvatar'
import { CampaignLogo } from '@/components/campaign/shell/campaign-logo'
import { MunicipalityNavSavedFilters } from '@/components/campaign/shell/MunicipalityNavSavedFilters'
import {
getCampaignNav,
getCampaignSecondaryNav,
isCampaignNavActive,
MUNICIPALITY_NAV_HREF,
type CampaignNavItem,
} from '@/components/campaign/shell/nav'
import { Button } from '@/components/ui/button'
@ -31,6 +33,7 @@ import { Spinner } from '@/components/ui/Spinner'
import { clearCampaignPwaCaches } from '@/utilities/campaignPwaClient'
import type { CampaignUserShellView } from '@/utilities/campaignUserProfile'
import { campaignRoleLabels } from '@/utilities/campaignUserProfile'
import { clearMunicipalitySavedFilters } from '@/utilities/municipalitySavedFilters'
import { clearRecentVisits } from '@/utilities/recentVisits'
export type CampaignSidebarUser = CampaignUserShellView
@ -39,18 +42,22 @@ const CampaignSidebarLink = ({
item,
isActive,
onNavigate,
children,
}: {
item: CampaignNavItem
isActive: boolean
onNavigate: () => void
/** Row affordances that sit beside the link — B18's disclosure and sub-list. */
children?: ReactNode
}) => (
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={isActive}>
<Link href={item.href} onClick={onNavigate}>
<Link href={item.href} onClick={onNavigate} aria-current={isActive ? 'page' : undefined}>
<item.icon />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
{children}
</SidebarMenuItem>
)
@ -69,6 +76,7 @@ export const CampaignSidebar = ({ user }: { user: CampaignSidebarUser }) => {
if (isLoggingOut) return
setIsLoggingOut(true)
clearRecentVisits()
clearMunicipalitySavedFilters()
await clearCampaignPwaCaches()
// logoutCampaign redirects — no need to reset the pending flag on success.
await logoutCampaign()
@ -98,7 +106,11 @@ export const CampaignSidebar = ({ user }: { user: CampaignSidebarUser }) => {
item={item}
isActive={isCampaignNavActive(pathname, item.href)}
onNavigate={closeMobileSidebar}
/>
>
{item.href === MUNICIPALITY_NAV_HREF ? (
<MunicipalityNavSavedFilters onNavigate={closeMobileSidebar} />
) : null}
</CampaignSidebarLink>
))}
</SidebarMenu>
</SidebarGroupContent>

View file

@ -0,0 +1,151 @@
'use client'
import { ChevronRightIcon, Trash2Icon } from 'lucide-react'
import Link from 'next/link'
import { usePathname, useSearchParams } from 'next/navigation'
import { useEffect, useId, useRef, useState } from 'react'
import { toast } from 'sonner'
import { useMunicipalitySavedFilters } from '@/components/campaign/shared/useMunicipalitySavedFilters'
import {
SidebarMenuAction,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from '@/components/ui/Sidebar'
import { isSameListHref } from '@/lib/listQueryMatch'
import {
readMunicipalitySavedFiltersOpen,
removeMunicipalitySavedFilter,
saveMunicipalitySavedFilter,
writeMunicipalitySavedFiltersOpen,
type MunicipalitySavedFilter,
} from '@/utilities/municipalitySavedFilters'
/** A page is a position inside a recorte, not part of it. */
const IGNORED_PARAMS = ['page']
/**
* The saved filters of `/campanha/municipios` (B18), hung under that nav item.
*
* Renders only the disclosure and the sub-list: the nav link above it stays the
* one `CampaignSidebarLink` every other item uses.
*/
export const MunicipalityNavSavedFilters = ({ onNavigate }: { onNavigate: () => void }) => {
const savedFilters = useMunicipalitySavedFilters()
const pathname = usePathname()
const searchParams = useSearchParams()
// Read once, not tracked: the store owns the list, this is only the
// disclosure, and the toggle already writes it back.
const [open, setOpen] = useState(readMunicipalitySavedFiltersOpen)
const listId = useId()
const disclosureRef = useRef<HTMLButtonElement>(null)
const query = searchParams.toString()
const currentHref = query ? `${pathname}?${query}` : pathname
const activeHref = savedFilters.find((entry) =>
isSameListHref(currentHref, entry.href, IGNORED_PARAMS),
)?.href
// Arriving at a saved filter reveals where you are, but only on a real
// navigation into one: opening on every mount would make the persisted
// preference unhonorable for whoever uses saved filters most, since they
// collapse the group, reload, and find it back. The trigger is the URL
// changing rather than `activeHref` appearing, because the store answers
// empty until the client has read `localStorage` — a match surfacing out of
// hydration would otherwise read as a navigation on every single load.
const previousHref = useRef(currentHref)
useEffect(() => {
const navigated = previousHref.current !== currentHref
previousHref.current = currentHref
if (navigated && activeHref) setOpen(true)
}, [currentHref, activeHref])
// No group until there is something in it — and nothing to hydrate either,
// since the store answers empty until the client reads `localStorage`.
if (!savedFilters.length) return null
const toggle = () => {
const next = !open
setOpen(next)
writeMunicipalitySavedFiltersOpen(next)
}
const handleRemove = (entry: MunicipalitySavedFilter) => {
// The button about to vanish is the one holding focus, which would drop to
// <body> and strand a keyboard user at the top of the page. Resolve the
// survivor BEFORE the removal: emptying the group unmounts this whole
// component, disclosure included, so the fallback has to be the nav link.
const disclosure = disclosureRef.current
const successor =
savedFilters.length > 1
? disclosure
: disclosure?.closest('li')?.querySelector<HTMLElement>('a')
removeMunicipalitySavedFilter(entry.href)
successor?.focus()
// Undo instead of a confirmation: the entry restores to the same name and
// the same alphabetical slot, so the cheap path is reversal, not a prompt.
toast.success(`Filtro “${entry.name}” apagado.`, {
action: {
label: 'Desfazer',
onClick: () => {
if (saveMunicipalitySavedFilter(entry) !== 'saved') {
toast.error('Não foi possível restaurar o filtro.')
}
},
},
})
}
return (
<>
<SidebarMenuAction
ref={disclosureRef}
type="button"
aria-expanded={open}
aria-controls={listId}
aria-label={`${open ? 'Ocultar' : 'Mostrar'} os filtros salvos de Municípios`}
onClick={toggle}
className={open ? 'rotate-90' : undefined}
>
<ChevronRightIcon aria-hidden="true" />
</SidebarMenuAction>
{/* Always mounted so `aria-controls` always resolves; `hidden` wins over
the list's own `flex` through tailwind-merge. */}
<SidebarMenuSub
id={listId}
aria-label="Filtros salvos de Municípios"
className={open ? undefined : 'hidden'}
>
{savedFilters.map((entry) => {
const isActive = entry.href === activeHref
return (
<SidebarMenuSubItem key={entry.href}>
<SidebarMenuSubButton asChild isActive={isActive}>
<Link
href={entry.href}
onClick={onNavigate}
aria-current={isActive ? 'page' : undefined}
title={entry.name}
>
<span>{entry.name}</span>
</Link>
</SidebarMenuSubButton>
<SidebarMenuAction
type="button"
onClick={() => handleRemove(entry)}
aria-label={`Apagar o filtro salvo ${entry.name}`}
className="top-1 group-focus-within/menu-sub-item:opacity-100 group-hover/menu-sub-item:opacity-100 md:opacity-0"
>
<Trash2Icon aria-hidden="true" />
</SidebarMenuAction>
</SidebarMenuSubItem>
)
})}
</SidebarMenuSub>
</>
)
}

View file

@ -22,9 +22,16 @@ export type CampaignNavItem = {
icon: LucideIcon
}
/**
* Named because the sidebar has to recognize this one item to hang B18's saved
* filters under it, and a third spelling that silently no-ops when it drifts is
* the failure mode this codebase keeps paying for.
*/
export const MUNICIPALITY_NAV_HREF = '/campanha/municipios'
const staffNav: CampaignNavItem[] = [
{ title: 'Início', href: '/campanha', icon: HomeIcon },
{ title: 'Municípios', href: '/campanha/municipios', icon: MapPinIcon },
{ title: 'Municípios', href: MUNICIPALITY_NAV_HREF, icon: MapPinIcon },
{ title: 'Territórios', href: '/campanha/territorios', icon: MapIcon },
{ title: 'Lideranças', href: '/campanha/liderancas', icon: HandshakeIcon },
{ title: 'Dobradinhas', href: '/campanha/dobradinhas', icon: Users2Icon },

View file

@ -391,7 +391,10 @@ const SidebarMenuButton = ({
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
// Absent, not `"false"`: Tailwind compiles `data-active:` to the bare
// `[data-active]`, and React renders `data-active={false}` as the string
// `"false"` — which matches it. Every row was wearing the active accent.
data-active={isActive || undefined}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
@ -420,6 +423,90 @@ const SidebarMenuButton = ({
)
}
/**
* Re-added from upstream shadcn for B18 (the kit had been pruned of all four
* submenu pieces). Two deliberate deviations, both load-bearing:
*
* - upstream's `showOnHover` prop is not here — the only caller reveals its
* action against the SUB-item group, and `group-hover/menu-item` would leak
* from the parent row, revealing every child's action at once;
* - the mobile hit-area expansion is not `md:`-gated. A 20 px visible target on
* a pointer device is still a 20 px target (SC 2.5.8 has no mouse exemption),
* which is the call B34 already made for the chip "×".
*/
const SidebarMenuAction = ({
className,
asChild = false,
...props
}: React.ComponentProps<'button'> & {
asChild?: boolean
}) => {
const Comp = asChild ? Slot.Root : 'button'
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
'absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform after:absolute after:-inset-2 peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
className,
)}
{...props}
/>
)
}
const SidebarMenuSub = ({ className, ...props }: React.ComponentProps<'ul'>) => (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
'mx-3.5 flex min-w-0 translate-x-px list-none flex-col gap-0 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
)
const SidebarMenuSubItem = ({ className, ...props }: React.ComponentProps<'li'>) => (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn('group/menu-sub-item relative', className)}
{...props}
/>
)
const SidebarMenuSubButton = ({
asChild = false,
size = 'md',
isActive = false,
className,
...props
}: React.ComponentProps<'a'> & {
asChild?: boolean
size?: 'sm' | 'md'
isActive?: boolean
}) => {
const Comp = asChild ? Slot.Root : 'a'
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive || undefined}
className={cn(
'flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-has-data-[sidebar=menu-action]/menu-sub-item:pr-8 group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
size === 'sm' && 'text-xs',
size === 'md' && 'text-sm',
className,
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
@ -429,8 +516,12 @@ export {
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarTrigger,
useSidebar,

46
src/lib/listQueryMatch.ts Normal file
View file

@ -0,0 +1,46 @@
/**
* Whether two list hrefs point at the same recorte.
*
* This exists as string comparison rather than a re-serialization through the
* domain's canonical builder because its caller is the campaign sidebar, which
* lives in the `(app)` layout and therefore ships on the First Load JS of every
* `/campanha` route: importing `buildMunicipalityListHref` there would drag
* `bahiaTerritories` + `municipalityCatalog` along with it (~21 kB, measured in
* B14).
*/
const HREF_BASE = 'https://list-query-match.invalid'
const parseHref = (href: string): { pathname: string; search: URLSearchParams } | null => {
try {
// A base makes relative hrefs parseable and normalizes percent-encoding on
// both sides, so `?q=ita%C3%BAna` and `?q=itaúna` are the same recorte.
const url = new URL(href, HREF_BASE)
return { pathname: url.pathname, search: url.searchParams }
} catch {
return null
}
}
const queryKey = (search: URLSearchParams, ignoredParams: readonly string[]): string => {
const pairs: string[] = []
for (const [name, value] of search) {
if (ignoredParams.includes(name)) continue
pairs.push(`${name}=${value}`)
}
// Sorted so param order never decides the answer, and repeated params
// (`?region=A&region=B`) compare as the set they encode.
return pairs.sort().join('&')
}
export const isSameListHref = (
left: string,
right: string,
ignoredParams: readonly string[] = [],
): boolean => {
const parsedLeft = parseHref(left)
const parsedRight = parseHref(right)
if (!parsedLeft || !parsedRight) return false
if (parsedLeft.pathname !== parsedRight.pathname) return false
return queryKey(parsedLeft.search, ignoredParams) === queryKey(parsedRight.search, ignoredParams)
}

View file

@ -407,3 +407,11 @@ export const buildMunicipalityListVisitLabel = (state: MunicipalityListState): s
export const buildMunicipalityListVisitHref = (state: MunicipalityListState): string =>
buildMunicipalityListHref(state, 1)
/**
* The href a saved filter (B18) bookmarks: the visit href minus `compare`, which
* is the Início map's comparison lens rather than a recorte of this list, and
* which the list's param set only carries so a hand-typed URL survives.
*/
export const buildMunicipalitySavedFilterHref = (state: MunicipalityListState): string =>
buildMunicipalityListHref({ ...state, compare: undefined }, 1)

View file

@ -0,0 +1,177 @@
/**
* Named bookmarks of the municipality list URL (B18).
*
* Device-local on purpose: a nav shortcut never has to reach the RSC render, so
* this is `localStorage` — the same call `recentVisits.ts` made, and unlike
* B17's column cookie, which the server reads to filter columns.
*
* The canonical href IS the identity. One shortcut per recorte, so re-saving a
* slice that is already stored renames it instead of growing a second submenu
* row that navigates to the same place.
*/
export const STORAGE_KEY = 'teqo:campaign:municipality-saved-filters'
/**
* A named bookmark is not a history entry: at the cap the save is refused with
* a reason instead of silently evicting something the user deliberately named
* (which is what `recentVisits`'s `slice` does, correctly, for dwell history).
*/
export const MAX_ENTRIES = 12
export const MAX_NAME_LENGTH = 60
/**
* Two islands share this store — the filter bar writes it, the sidebar reads it
* — and they never share a React tree, so a write has to announce itself. The
* native `storage` event covers other tabs and deliberately does not fire in the
* tab that wrote, which is exactly the half this event adds.
*/
const CHANGE_EVENT = 'teqo:campaign:municipality-saved-filters-change'
export type MunicipalitySavedFilter = {
/** Canonical list href (page 1, no `compare`) — also the entry's identity. */
href: string
name: string
}
export type SaveMunicipalitySavedFilterResult = 'saved' | 'limit' | 'failed'
const EMPTY: readonly MunicipalitySavedFilter[] = Object.freeze([])
/**
* `useSyncExternalStore` compares snapshots by reference, so parsing storage on
* every `getSnapshot` call would render forever. The cache is dropped by every
* write and by a `storage` event from another tab.
*/
let snapshot: readonly MunicipalitySavedFilter[] | null = null
const isMunicipalitySavedFilter = (value: unknown): value is MunicipalitySavedFilter => {
if (!value || typeof value !== 'object') return false
const entry = value as Record<string, unknown>
return (
typeof entry.href === 'string' &&
entry.href.startsWith('/campanha/municipios') &&
typeof entry.name === 'string' &&
entry.name.trim().length > 0
)
}
/**
* Alphabetical, not by recency: a named shortcut that reorders itself under the
* cursor every time it is re-saved is a nav bug. Recency is Visitados' job.
*/
const byName = (left: MunicipalitySavedFilter, right: MunicipalitySavedFilter): number =>
left.name.localeCompare(right.name, 'pt-BR')
const read = (): readonly MunicipalitySavedFilter[] => {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return EMPTY
const parsed: unknown = JSON.parse(raw)
if (!Array.isArray(parsed)) return EMPTY
const seen = new Set<string>()
const entries = parsed.filter(isMunicipalitySavedFilter).filter((entry) => {
if (seen.has(entry.href)) return false
seen.add(entry.href)
return true
})
return entries.length ? entries.slice(0, MAX_ENTRIES).sort(byName) : EMPTY
} catch {
return EMPTY
}
}
export const listMunicipalitySavedFilters = (): readonly MunicipalitySavedFilter[] => {
if (typeof window === 'undefined') return EMPTY
snapshot ??= read()
return snapshot
}
const write = (entries: readonly MunicipalitySavedFilter[]): boolean => {
let persisted = true
try {
if (entries.length) localStorage.setItem(STORAGE_KEY, JSON.stringify(entries))
else localStorage.removeItem(STORAGE_KEY)
} catch {
// Quota or private mode. Readers are told to re-read either way, so the
// submenu shows what actually persisted rather than a save that did not.
persisted = false
}
snapshot = null
window.dispatchEvent(new Event(CHANGE_EVENT))
return persisted
}
export const saveMunicipalitySavedFilter = (
entry: MunicipalitySavedFilter,
): SaveMunicipalitySavedFilterResult => {
if (typeof window === 'undefined') return 'failed'
const name = entry.name.trim().slice(0, MAX_NAME_LENGTH)
if (!name) return 'failed'
const others = listMunicipalitySavedFilters().filter((item) => item.href !== entry.href)
// Renaming at the cap has to keep working, which is why the ceiling is checked
// against the OTHER entries and not against the stored length.
if (others.length >= MAX_ENTRIES) return 'limit'
return write([...others, { href: entry.href, name }].sort(byName)) ? 'saved' : 'failed'
}
export const removeMunicipalitySavedFilter = (href: string): void => {
if (typeof window === 'undefined') return
const existing = listMunicipalitySavedFilters()
const next = existing.filter((item) => item.href !== href)
if (next.length === existing.length) return
write(next)
}
export const clearMunicipalitySavedFilters = (): void => {
if (typeof window === 'undefined') return
try {
localStorage.removeItem(OPEN_STORAGE_KEY)
} catch {
// Same reason as below: the preference is a nicety.
}
write([])
}
const OPEN_STORAGE_KEY = 'teqo:campaign:municipality-saved-filters-open'
/**
* The submenu's disclosure. Open by default — a shortcut nobody can see is a
* shortcut nobody uses, and the group only exists once something is in it.
*/
export const readMunicipalitySavedFiltersOpen = (): boolean => {
if (typeof window === 'undefined') return true
try {
return localStorage.getItem(OPEN_STORAGE_KEY) !== 'false'
} catch {
return true
}
}
export const writeMunicipalitySavedFiltersOpen = (open: boolean): void => {
if (typeof window === 'undefined') return
try {
localStorage.setItem(OPEN_STORAGE_KEY, open ? 'true' : 'false')
} catch {
// A remembered disclosure is a nicety; a full quota must not break the
// toggle itself, which works from React state either way.
}
}
export const subscribeMunicipalitySavedFilters = (onChange: () => void): (() => void) => {
if (typeof window === 'undefined') return () => {}
const handleChange = (event: Event) => {
// `storage` fires for every key in the origin (recent visits, biometrics
// flags); a null key means the whole store was cleared.
if (event instanceof StorageEvent && event.key !== null && event.key !== STORAGE_KEY) return
snapshot = null
onChange()
}
window.addEventListener(CHANGE_EVENT, handleChange)
window.addEventListener('storage', handleChange)
return () => {
window.removeEventListener(CHANGE_EVENT, handleChange)
window.removeEventListener('storage', handleChange)
}
}

View file

@ -0,0 +1,142 @@
import { expect, test } from './fixtures/campaignE2EFixtures.js'
/**
* B18 — a named recorte of `/campanha/municipios` becomes a sidebar shortcut.
*
* Own file for the same reason B17 took one: `campaignMunicipalities.e2e.spec.ts`
* carries deterministic failures at one worker, ledgered as P1, and a red run
* there must not be read as a regression here.
*/
/** 416 whole municipalities — deep enough that `?page=2` is a real page. */
const SAVED_RECORTE = '/campanha/municipios?kind=municipio'
const SAVED_NAME = 'Recorte do teste'
const RENAMED = 'Recorte renomeado'
/**
* A route transition on this list costs a full RSC round-trip, and in dev the
* suite's two workers share one server — which overruns the 5 s default of
* `expect` and produces a flake nobody reproduces at one worker. Same budget,
* for the same reason, as `campaignColumnPicker.e2e.spec.ts`.
*/
const NAVIGATION = { timeout: 20_000 }
test.describe('Filtros salvos de Municípios', () => {
test('saves the current recorte, restores it from the sidebar and deletes it', async ({
campaign,
page,
}) => {
test.slow()
const { fixtures } = campaign
const password = fixtures.value('senha')
const email = `${fixtures.value('filtros-coordinator')}@example.com`
await campaign.payload.create({
collection: 'campaignUser',
data: {
name: fixtures.value('Coordenadora Filtros'),
email,
password,
role: 'coordinator',
},
depth: 0,
})
await campaign.login(page, email, password)
// The bare list has no recorte to name.
await page.goto('/campanha/municipios')
await expect(page.getByRole('button', { name: 'Salvar filtro', exact: true })).toHaveCount(0)
await page.goto(SAVED_RECORTE)
await page.getByRole('button', { name: 'Salvar filtro', exact: true }).click()
const nameInput = page.getByLabel('Nome do filtro')
// The suggested name is the active-filters summary the bar already shows.
await expect(nameInput).not.toHaveValue('')
await nameInput.fill(SAVED_NAME)
await page.getByRole('button', { name: 'Salvar', exact: true }).click()
const shortcut = page.getByRole('link', { name: SAVED_NAME })
await expect(shortcut).toBeVisible()
await expect(shortcut).toHaveAttribute('aria-current', 'page')
// A page is a position inside the recorte, not part of it.
await page.goto(`${SAVED_RECORTE}&page=2`)
await expect(shortcut).toHaveAttribute('aria-current', 'page')
// Another recorte is not this one, and the shortcut brings it back.
await page.goto('/campanha/municipios')
await expect(shortcut).not.toHaveAttribute('aria-current', 'page')
await shortcut.click()
await expect(page).toHaveURL(SAVED_RECORTE, NAVIGATION)
await expect(shortcut).toHaveAttribute('aria-current', 'page', NAVIGATION)
// Saving a recorte already bookmarked renames it instead of adding a twin.
await page.getByRole('button', { name: `Renomear o filtro salvo ${SAVED_NAME}` }).click()
await expect(page.getByLabel('Nome do filtro')).toHaveValue(SAVED_NAME)
await page.getByLabel('Nome do filtro').fill(RENAMED)
await page.getByRole('button', { name: 'Atualizar nome' }).click()
await expect(page.getByRole('link', { name: RENAMED })).toBeVisible()
await expect(shortcut).toHaveCount(0)
const remove = page.getByRole('button', { name: `Apagar o filtro salvo ${RENAMED}` })
await remove.click()
await expect(page.getByRole('link', { name: RENAMED })).toHaveCount(0)
// Emptying the group unmounts the disclosure too, so focus lands on the
// nav link rather than falling to <body>.
await expect(page.getByRole('link', { name: 'Municípios', exact: true })).toBeFocused()
// Deleting is undoable, which is why it never asks for confirmation.
await page.getByRole('button', { name: 'Desfazer' }).click()
await expect(page.getByRole('link', { name: RENAMED })).toBeVisible()
})
test('survives a reload and hides the shortcuts behind the disclosure', async ({
campaign,
page,
}) => {
// Three loads of a heavy list route, against a dev server shared with the
// other worker — the login redirect alone can outrun the 30 s default.
test.slow()
const { fixtures } = campaign
const password = fixtures.value('senha')
const email = `${fixtures.value('filtros-disclosure')}@example.com`
await campaign.payload.create({
collection: 'campaignUser',
data: {
name: fixtures.value('Coordenadora Disclosure'),
email,
password,
role: 'coordinator',
},
depth: 0,
})
await campaign.login(page, email, password)
await page.goto(SAVED_RECORTE)
await page.getByRole('button', { name: 'Salvar filtro', exact: true }).click()
await page.getByLabel('Nome do filtro').fill(SAVED_NAME)
await page.getByRole('button', { name: 'Salvar', exact: true }).click()
const shortcut = page.getByRole('link', { name: SAVED_NAME })
await expect(shortcut).toBeVisible()
await page.reload()
await expect(shortcut).toBeVisible(NAVIGATION)
await page
.getByRole('button', { name: 'Ocultar os filtros salvos de Municípios' })
.click(NAVIGATION)
await expect(shortcut).toBeHidden()
// The collapsed disclosure is remembered across a reload.
await page.reload()
await expect(page.getByRole('link', { name: SAVED_NAME })).toBeHidden(NAVIGATION)
await page
.getByRole('button', { name: 'Mostrar os filtros salvos de Municípios' })
.click(NAVIGATION)
await expect(page.getByRole('link', { name: SAVED_NAME })).toBeVisible()
})
})

View file

@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import { isSameListHref } from '@/lib/listQueryMatch'
describe('isSameListHref', () => {
it('ignores param order', () => {
expect(
isSameListHref(
'/campanha/municipios?priority=alta&region=Sert%C3%A3o%20Produtivo',
'/campanha/municipios?region=Sert%C3%A3o%20Produtivo&priority=alta',
),
).toBe(true)
})
it('compares repeated params as the set they encode', () => {
expect(
isSameListHref(
'/campanha/municipios?level=n3&level=n4',
'/campanha/municipios?level=n4&level=n3',
),
).toBe(true)
expect(
isSameListHref('/campanha/municipios?level=n3&level=n4', '/campanha/municipios?level=n3'),
).toBe(false)
})
it('normalizes percent-encoding on both sides', () => {
expect(
isSameListHref('/campanha/municipios?q=ita%C3%BAna', '/campanha/municipios?q=itaúna'),
).toBe(true)
})
it('ignores the params it is told to ignore, so page 3 of a saved filter still matches', () => {
expect(
isSameListHref(
'/campanha/municipios?priority=alta&page=3',
'/campanha/municipios?priority=alta',
['page'],
),
).toBe(true)
expect(
isSameListHref(
'/campanha/municipios?priority=alta&page=3',
'/campanha/municipios?priority=alta',
),
).toBe(false)
})
it('does not match a different recorte or a different path', () => {
expect(
isSameListHref(
'/campanha/municipios?priority=alta',
'/campanha/municipios?priority=alta&kind=zona',
),
).toBe(false)
expect(isSameListHref('/campanha/municipios', '/campanha/territorios')).toBe(false)
})
it('matches the bare list against itself', () => {
expect(isSameListHref('/campanha/municipios', '/campanha/municipios')).toBe(true)
expect(isSameListHref('/campanha/municipios', '/campanha/municipios?page=2', ['page'])).toBe(
true,
)
})
})

View file

@ -0,0 +1,134 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
clearMunicipalitySavedFilters,
listMunicipalitySavedFilters,
MAX_ENTRIES,
MAX_NAME_LENGTH,
removeMunicipalitySavedFilter,
saveMunicipalitySavedFilter,
STORAGE_KEY,
} from '@/utilities/municipalitySavedFilters'
const hrefFor = (index: number) => `/campanha/municipios?q=recorte-${index}`
describe('municipalitySavedFilters storage', () => {
afterEach(() => {
clearMunicipalitySavedFilters()
localStorage.clear()
})
it('returns an empty list when storage is missing or invalid', () => {
expect(listMunicipalitySavedFilters()).toEqual([])
localStorage.setItem(STORAGE_KEY, 'not-json')
expect(listMunicipalitySavedFilters()).toEqual([])
localStorage.setItem(STORAGE_KEY, JSON.stringify({ href: '/campanha/municipios' }))
expect(listMunicipalitySavedFilters()).toEqual([])
})
it('drops persisted entries with a foreign href or a blank name', () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify([
{ href: '/campanha/municipios?priority=alta', name: 'Prioritárias' },
{ href: '/campanha/liderancas?q=x', name: 'Outra lista' },
{ href: '/campanha/municipios?kind=zona', name: ' ' },
]),
)
expect(listMunicipalitySavedFilters()).toEqual([
{ href: '/campanha/municipios?priority=alta', name: 'Prioritárias' },
])
})
it('sorts alphabetically so a re-save never reorders the submenu', () => {
saveMunicipalitySavedFilter({ href: hrefFor(1), name: 'Zona da Mata' })
saveMunicipalitySavedFilter({ href: hrefFor(2), name: 'Àgua Fria' })
saveMunicipalitySavedFilter({ href: hrefFor(3), name: 'Metropolitano' })
expect(listMunicipalitySavedFilters().map((entry) => entry.name)).toEqual([
'Àgua Fria',
'Metropolitano',
'Zona da Mata',
])
})
it('renames instead of duplicating when the same recorte is saved again', () => {
saveMunicipalitySavedFilter({ href: hrefFor(1), name: 'Primeiro nome' })
expect(saveMunicipalitySavedFilter({ href: hrefFor(1), name: 'Segundo nome' })).toBe('saved')
expect(listMunicipalitySavedFilters()).toEqual([{ href: hrefFor(1), name: 'Segundo nome' }])
})
it('trims and caps the name', () => {
saveMunicipalitySavedFilter({
href: hrefFor(1),
name: ` ${'a'.repeat(MAX_NAME_LENGTH + 10)} `,
})
expect(listMunicipalitySavedFilters()[0]?.name).toBe('a'.repeat(MAX_NAME_LENGTH))
})
it('refuses a blank name', () => {
expect(saveMunicipalitySavedFilter({ href: hrefFor(1), name: ' ' })).toBe('failed')
expect(listMunicipalitySavedFilters()).toEqual([])
})
it('refuses a new entry at the cap instead of evicting a named one', () => {
for (let index = 0; index < MAX_ENTRIES; index += 1) {
expect(saveMunicipalitySavedFilter({ href: hrefFor(index), name: `Recorte ${index}` })).toBe(
'saved',
)
}
expect(saveMunicipalitySavedFilter({ href: hrefFor(99), name: 'Um a mais' })).toBe('limit')
expect(listMunicipalitySavedFilters()).toHaveLength(MAX_ENTRIES)
expect(listMunicipalitySavedFilters().some((entry) => entry.name === 'Um a mais')).toBe(false)
})
it('still renames an existing entry at the cap', () => {
for (let index = 0; index < MAX_ENTRIES; index += 1) {
saveMunicipalitySavedFilter({ href: hrefFor(index), name: `Recorte ${index}` })
}
expect(saveMunicipalitySavedFilter({ href: hrefFor(0), name: 'Renomeado no teto' })).toBe(
'saved',
)
expect(listMunicipalitySavedFilters()).toHaveLength(MAX_ENTRIES)
expect(listMunicipalitySavedFilters().some((entry) => entry.name === 'Renomeado no teto')).toBe(
true,
)
})
it('reports a failed write when storage refuses it', () => {
const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('QuotaExceededError')
})
expect(saveMunicipalitySavedFilter({ href: hrefFor(1), name: 'Sem espaço' })).toBe('failed')
setItem.mockRestore()
expect(listMunicipalitySavedFilters()).toEqual([])
})
it('removes by href and clears everything', () => {
saveMunicipalitySavedFilter({ href: hrefFor(1), name: 'Um' })
saveMunicipalitySavedFilter({ href: hrefFor(2), name: 'Dois' })
removeMunicipalitySavedFilter(hrefFor(1))
expect(listMunicipalitySavedFilters()).toEqual([{ href: hrefFor(2), name: 'Dois' }])
clearMunicipalitySavedFilters()
expect(listMunicipalitySavedFilters()).toEqual([])
expect(localStorage.getItem(STORAGE_KEY)).toBeNull()
})
it('serves a stable snapshot reference until a write invalidates it', () => {
saveMunicipalitySavedFilter({ href: hrefFor(1), name: 'Um' })
const first = listMunicipalitySavedFilters()
expect(listMunicipalitySavedFilters()).toBe(first)
saveMunicipalitySavedFilter({ href: hrefFor(2), name: 'Dois' })
expect(listMunicipalitySavedFilters()).not.toBe(first)
})
})