From 158ef22c2ce5796c502978a937b08075a87d6881 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Wed, 29 Jul 2026 07:56:13 -0300 Subject: [PATCH] Resolve Salvador ZE in the staff "Onde estou" card (B14 / B8 F2-D1). Point-in-polygon on the zone mesh opens the accessible ZE directly, with nearest-zone fallback within 30 km before the filtered list. --- .cursor/rules/projects/nucleos-eleitorais.mdc | 1 + docs/plans/municipio-mais-proximo.md | 8 +- docs/plans/poligonos-pracas-zona.md | 2 +- docs/roadmap.md | 6 +- .../dashboard/NearestMunicipalityCard.tsx | 42 ++++-- src/lib/municipalityProximity.ts | 139 +++++++++++++++--- .../campaignNearestMunicipality.e2e.spec.ts | 59 +++++--- tests/int/bahiaGeometries.int.spec.ts | 43 +++++- tests/unit/municipalityProximity.unit.spec.ts | 87 ++++++++++- 9 files changed, 322 insertions(+), 65 deletions(-) diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index e2dd02dc..88ccafbd 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -10,6 +10,7 @@ alwaysApply: false - **B17+ entregue em código (2026-07-29):** Escala/DRY pós-B17 — F1: `readCampaignColumnVisibility` antes do `Promise.all` e `loadLeadershipOptions` / `loadStateDeputyOptions` só quando a coluna de relação está visível (`resolveVisibleColumns`). F2: `CampaignListEmptyState` na prop `empty` de `CampaignTable` (e `SupporterList`) em demandas, organizações e apoiadores, para o botão "Colunas" sobreviver com zero linhas. Plano: [`escala-dry-pos-b17.md`](../../../docs/plans/escala-dry-pos-b17.md). - **Fill-in remover coluna Tipo entregue em código (2026-07-29):** em `/campanha/municipios`, sumiram a coluna "Tipo", o filtro single-select no header (B16), o ramo mobile em `MunicipalityFilters`, a sort key `kind` e o param `?kind=` em `municipalityListUrl` / `buildMunicipalityListWhere` / `payloadSortFieldByKey` — bookmarks antigos ignoram `kind` no parse. `municipalityKindLabels` permanece para detalhe, mapa e dossiê. `MunicipalityHeaderFilter` ficou só com filtros multi (`singleRows`/`applyMunicipalityKindFilter` deletados). B17: 11 colunas staff; e2e do picker usa Território; B18 usa `?priority=alta`. Sem migration/action/Consent. Plano: [`remover-coluna-tipo-municipios.md`](../../../docs/plans/remover-coluna-tipo-municipios.md). - **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 `