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 `` do submenu, `aria-current="page"` nos links ativos. Apagar é **desfazível** (toast com "Desfazer" restaurando nome e posição alfabética), que é por que nunca pede confirmação; o logout limpa o storage ao lado de `clearRecentVisits()`. Sem migration, collection, action ou `Consent`; nada de rota `/vistas/[id]`, sync multi-device ou saved views genéricas nas outras listas. **Cuidado operacional novo:** `pnpm build` limpa o `.next`, e com ele o `.next/e2e` que o dev server do Playwright usa — rodar e2e logo depois de um build estoura o timeout do `setup` enquanto a rota recompila (o e2e ganhou orçamento de 20 s nas navegações, mesmo motivo do `campaignColumnPicker`). Plano as-built: [`filtros-salvos-municipios.md`](../../../docs/plans/filtros-salvos-municipios.md).
+- **B14 débito B8 F2-D1 entregue em código (2026-07-29):** o card "Onde estou" resolve a ZE de Salvador por containment na malha `bahia-municipality-zones` (`loadMunicipalityZoneGeometryModule`, opcional — falha do chunk degrada para lista filtrada); fallback `nearestZone` (≤ 30 km) quando o ponto cai no município mas fora de todo polígono de zona. `resolveNearbyMunicipality` + `NearestMunicipalityCard`; int/e2e atualizados. Plano: [`municipio-mais-proximo.md`](../../../docs/plans/municipio-mais-proximo.md). 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 `` do submenu, `aria-current="page"` nos links ativos. Apagar é **desfazível** (toast com "Desfazer" restaurando nome e posição alfabética), que é por que nunca pede confirmação; o logout limpa o storage ao lado de `clearRecentVisits()`. Sem migration, collection, action ou `Consent`; nada de rota `/vistas/[id]`, sync multi-device ou saved views genéricas nas outras listas. **Cuidado operacional novo:** `pnpm build` limpa o `.next`, e com ele o `.next/e2e` que o dev server do Playwright usa — rodar e2e logo depois de um build estoura o timeout do `setup` enquanto a rota recompila (o e2e ganhou orçamento de 20 s nas navegações, mesmo motivo do `campaignColumnPicker`). Plano as-built: [`filtros-salvos-municipios.md`](../../../docs/plans/filtros-salvos-municipios.md).
- **Fill-in ✓ dimir resultado no compositor de giro (2026-07-28):** em `/campanha/atividades/giros`, `CampaignListPendingBoundary` + `CampaignListResults` dimam a proposta ao trocar TI; `TourRegionPicker` usa `useCampaignListTransition` (spinner do select permanece); `CalendarPhaseNote` dentro de Results; unit `tourRegionPending.unit.spec.ts`. Sem migration/action/`Consent`. Critique sem P0/P1. Plano: [`feedback-pendente-compositor-giro.md`](../../../docs/plans/feedback-pendente-compositor-giro.md).
- **Fill-in ícone de prioridade na lista entregue em código (2026-07-28):** em `/campanha/municipios`, o Badge destrutivo "Prioritária" na coluna do nome (tabela) e no cabeçalho do card mobile (`priority === 'alta'`, staff) virou `MunicipalityPriorityIndicator` — `FlagIcon` `text-primary`, `aria-label` e `CampaignHoverTooltip` (B23 ✓) com copy fixa **"Município prioritário"** (filtro/forms/StrategyCard/Dossier continuam no rótulo curto "Prioritária"). No card, `className="relative size-11"` no trigger para ficar acima do link esticado B42. Ilha client mínima; `MunicipalityList` segue RSC. Pin unit: staff vê `aria-label="Município prioritário"`; liderança não. Sem migration/action/Consent. Gate: tsc/lint/format/cycles; unit 770; knip P3 pré-existente; Aikido 0 findings no indicador. Impeccable B; harden/optimize out. Plano: [`icone-prioridade-lista-municipios.md`](../../../docs/plans/icone-prioridade-lista-municipios.md).
- **Fill-in remover lista de zonas do Início entregue em código (2026-07-28):** sumiu o bloco "Municípios por zona eleitoral" sob o mapa de `/campanha`; `zoneBreakdown` / `MunicipalityZoneBreakdownRow` / `zoneVotes*BySlug` saíram do contrato (`municipalityMapContract`) e do loader (`municipalityMapData`); a ressalva de aproximação do **B8+ F3 ✓** permanece gated por `hasZoneMunicipalities`. Int `municipalityMapData` e `TESTING.md` atualizados. Sem migration/action/Consent. Plano: [`remover-lista-zonas-mapa-inicio.md`](../../../docs/plans/remover-lista-zonas-mapa-inicio.md).
diff --git a/docs/plans/municipio-mais-proximo.md b/docs/plans/municipio-mais-proximo.md
index 7d3a5a10..a3e791ff 100644
--- a/docs/plans/municipio-mais-proximo.md
+++ b/docs/plans/municipio-mais-proximo.md
@@ -1,7 +1,7 @@
# B14 — Município mais próximo (acesso rápido por geolocalização)
-Status: entregue (2026-07-26)
-Atualizado em: 2026-07-26
+Status: entregue (2026-07-26); débito B8 F2-D1 (ZE exata em Salvador) fechado em 2026-07-29
+Atualizado em: 2026-07-29
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Demais itens abertos, B14; superfície de coordenação)
Impeccable: B — encaixe no dashboard staff (`/campanha`), sem rota nova
Appetite: ~1 dia eng (gasto ~0,75d: o artefato de centroides e seu script saíram do escopo); ilha cliente no Início; sem migration, sem collection, sem Consent
@@ -73,7 +73,7 @@ Decisão de produto (2026-07-24, pedido explícito): sugerir acesso rápido ao m
- **Client-only, casando contra a malha que o mapa já carrega (sem modelo no servidor).** Posição fica no browser; containment por point-in-polygon sobre o chunk memoizado de `bahiaMunicipalityGeometries`, e Haversine sobre centroides derivados na hora só para ordenar o fallback. Análogo a visitados recentes. **Rejeitado:** POST de lat/lng ao servidor (PII de localização + Consent + superfície de abuso); reverse-geocode comercial (custo/vendor); PostGIS (fora de escopo do AGENTS.md sem query espacial real); **artefato commitado de centroides** — era a decisão de 2026-07-24 e foi revertida em 2026-07-26 por ser menos precisa a custo de bundle zero (o chunk já está no browser).
- **Prompt automático no máximo 1× por sessão (`sessionStorage`).** Cumpre o pedido de produto sem martelar o usuário a cada navegação no Início. Chave estável: `teqo:campaign:geo-prompted-session`. **Rejeitado:** prompt a cada mount do card; `localStorage` "nunca mais perguntar" como único gate (impediria retry legítimo em sessão nova após o usuário mudar a permissão no SO).
- **Sem `Consent` / sem lote jurídico.** Tratamento local da própria localização do usuário autenticado, sem transmissão — mesmo racional de visitados. **Rejeitado:** chave `Consent` só para UX de GPS (multiplica lead time jurídico sem dado no servidor).
-- **Malha de matching = município IBGE (417), depois mapear ao catálogo operacional.** Polígonos por `ibgeCode`; entradas `kind: 'municipio'` 1:1; entradas `kind: 'zona'` (Salvador) **não** recebem ZE automática na v1 — quem está em Salvador vai para `/campanha/municipios?q=Salvador`, com o href serializado no servidor e a contagem de zonas limitada ao que o ator pode abrir (um assessor com três zonas não é informado de dezenove). **Rejeitado:** escolher ZE aleatória/primeira; inventar centroides de zona sem polígonos (B8 F2).
+- **Malha de matching = município IBGE (417), depois ZE em Salvador (B8 F2).** Containment na malha municipal; cidades multi-zona usam `bahia-municipality-zones` quando o card carrega o chunk — link direto à ZE acessível, ou ZE mais próxima (≤ `NEAREST_ZONE_MAX_KM` = 30 km), ou lista filtrada (`zoneCityHrefs`). **Rejeitado:** chute de ZE sem polígonos; lista sempre.
- **Staff only no Início.** Leader lockdown intacto. **Rejeitado:** expor municípios ao leader via atalho geo.
- **i18n e naming:** identificadores em inglês (`NearestMunicipalityCard`, `municipalityProximity`, `resolveNearbyMunicipality`, `GEO_PROMPT_SESSION_KEY`); strings visíveis em pt-BR (título "Onde estou", "Abrir ", "Usar minha localização", "Ver zonas de Salvador").
- **Precisão declarada, nunca escondida.** Fix com raio acima de 10 km (rede/IP, típico de desktop) mostra o resultado com a ressalva "Localização aproximada — confira o município antes de agir", entre o título e o CTA. **Rejeitado:** descartar o fix grosseiro (o município costuma estar certo) e confiar nele em silêncio (nomear o município errado com confiança total é o pior dos dois).
@@ -134,7 +134,7 @@ Componentes:
## Adiado com gatilho
-- **Desambiguação Salvador ZE por ponto-em-polígono.** Revisitar quando: **B8 F2** entregue (polígonos no mapa).
+- ~~**Desambiguação Salvador ZE por ponto-em-polígono.**~~ **Resolvido 2026-07-29** (débito B8 F2-D1): `NearestMunicipalityCard` carrega `loadMunicipalityZoneGeometryModule()` de forma opcional; falha do chunk degrada para `zoneCity`.
- **Atalho também na lista `/campanha/municipios`.** Revisitar quando: evidência de uso no Início (campo pede o mesmo atalho na lista).
- **Lembrar última sugestão em `sessionStorage` para paint imediato.** Revisitar quando: critique acusar flash/vazio perceptível no card. _(Não acusou: o chunk de geometria costuma estar resolvido antes do fix chegar.)_
- **Atalho geo também para `leader`.** Permanece fora: liderança não sai do `LeaderContactsPanel`.
diff --git a/docs/plans/poligonos-pracas-zona.md b/docs/plans/poligonos-pracas-zona.md
index 4ef6e0fa..d95c6fe2 100644
--- a/docs/plans/poligonos-pracas-zona.md
+++ b/docs/plans/poligonos-pracas-zona.md
@@ -177,7 +177,7 @@ Os relatórios dos três revisores chegaram **depois** do merge; o cleanup barat
## Adiado com gatilho
- **S6 — CSS compartilhado lista zona** (`MunicipalityMapPanel` zone breakdown ↔ `MunicipalityZoneNeighborhoodsCard`). **Obsoleto 2026-07-26:** o fill-in [remover-lista-zonas-mapa-inicio.md](remover-lista-zonas-mapa-inicio.md) remove a lista do mapa; sem segundo call site, não há o que extrair. O card de bairros no detalhe permanece.
-- **F2-D1 — card "Onde estou" (B14 ✓) resolver a ZE exata de Salvador.** Os polígonos passaram a existir nesta entrega, e `featureContainsPoint` já é genérico o suficiente para recebê-los; o card continua levando à lista filtrada por decisão. **Gatilho:** pedido de campo ("estou em Salvador e quero abrir minha zona"). Custo real é uma segunda malha no chunk do card, não matemática nova. Aresta já registrada no grafo do roadmap.
+- ~~**F2-D1 — card "Onde estou" (B14 ✓) resolver a ZE exata de Salvador.**~~ **Resolvido 2026-07-29** — `resolveNearbyMunicipality` + `NearestMunicipalityCard` usam a malha das 19 ZE; fallback: ZE acessível mais próxima (30 km) ou lista filtrada. Plano: [municipio-mais-proximo.md](municipio-mais-proximo.md).
- **F2-D2 — rank competitivo por ZE.** Exige dimensão nova no artefato TSE (hoje `federalRankByIbgeCode`), o que mexe no budget de 700 KB. **Gatilho:** E11 pedir o eixo de competição dentro de Salvador, ou o dossiê de um Município-zona exibir posição própria. Hoje as 19 zonas herdam a posição da cidade, com int pinando o fato.
- **S2 — fonte única TS ↔ fixture do catálogo de bairros** (gerar `municipalityZoneNeighborhoods.ts` a partir do JSON). **Cortado na F2 prep:** a curadoria que a F2 exigia foi para o **script** de geometrias, não para o catálogo, então o gerador não teria a quem servir; o `deepEqual` fixture↔código já pega drift. **Gatilho:** um terceiro consumidor precisar do catálogo em runtime não-TS.
- **Malha GeoSalvador em vez de IBGE.** Revisitar quando: spike F2 mostrar unmatched críticos em Salvador (>10% bairros TRE sem polígono).
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 54795c5d..eb60b5e2 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -91,7 +91,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
- ~~**C13** renomear "Plano de Ação" → "Atividade" (entidade, código, banco e rotas)~~ — **entregue 2026-07-25** (collection `activity`, 4 tabelas + 3 enums + 2 colunas + 39 índices + 14 FKs + 2 sequences renomeados por migration data-preserving escrita à mão `20260725_213000_rename_action_plan_to_activity`, que também alinhou os 8 índices fósseis `%plaza%` da remodelagem; rota `/campanha/atividades`, sidebar "Atividades", rótulos de campo "Tipo/Origem/Resultado da atividade"; guard de vocabulário table-driven em `codebaseConventions.unit.spec.ts` varrendo `src`+`tests`+`scripts`) · [plano](plans/renomear-plano-acao-para-atividade.md)
- **A6** dobradinha 2026 automática quando o TSE publicar candidaturas · gatilho externo: pós-15/08 · camada de insight sobre o registro operacional `stateDeputy` (M4) · alimenta **E13** · [plano](plans/insight-dobradinha-2026.md)
- **B5 F2–F3** cache CLI compartilhado + factory mun/TI (scripts continuam) · [plano](plans/escala-dry-pos-b2.md)
-- ~~**B8 F2** polígonos dos Municípios-zona de Salvador (ZE 1–19)~~ — **entregue 2026-07-26** (artefato `bahia-municipality-zones.topo.json` por dissolve IBGE↔TRE RA 02/2017; coroplético keyed por map key `slug`|`ibgeCode`; polígono municipal de Salvador mantido como base sem interação; rank competitivo segue por cidade com int pinando o fato) · débitos com gatilho no plano: ZE exata no card "Onde estou" (B14 ✓) e rank por ZE · [plano](plans/poligonos-pracas-zona.md)
+- ~~**B8 F2** polígonos dos Municípios-zona de Salvador (ZE 1–19)~~ — **entregue 2026-07-26** (artefato `bahia-municipality-zones.topo.json` por dissolve IBGE↔TRE RA 02/2017; coroplético keyed por map key `slug`|`ibgeCode`; polígono municipal de Salvador mantido como base sem interação; rank competitivo segue por cidade com int pinando o fato) · ~~débito F2-D1: ZE exata no card "Onde estou" (B14)~~ **fechado 2026-07-29** · débito aberto: rank por ZE · [plano](plans/poligonos-pracas-zona.md)
- **C5** operação dia D / GOTV _(validar com produto)_ · design [`Dia-D-GOTV`](design-refs/latest/Dia-D-GOTV.png) · depende de C2 dados reais
- **D2** push + sino in-app · soft: chave `campanha-notificacoes-push` (Onda 0) · [plano](plans/notifications.md)
- **Programa WhatsApp interno (D3–D5)** · baixa/média prioridade · 1:1 staff↔lideranças via bridge **não oficial** · **número pessoal** de cada assessor/CG (QR no app, parece chat pessoal) · não Business API / não massa / não chip institucional · fecha Little Hire · gatilho honesto (2026-07-24): "ZAP é o campo" é fato confirmado — D3 entra se os deltas do ZAP **não** estiverem sendo absorvidos pelo fluxo sede-digita (C12) após uso real · plano-mestre [whatsapp-interno-campanha.md](plans/whatsapp-interno-campanha.md)
@@ -100,7 +100,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
- **D5** inbox da própria sessão → rascunhos (`municipalityUpdate`/demanda) com humano no loop · depende de D3 · [plano](plans/whatsapp-sugestao-atualizacoes.md)
- **R6** critique/polish visual da vertical remodelada (ciclo /impeccable completo por superfície; smoke visual coordenador feito em 2026-07-21) · absorve os débitos de produto/UX remanescentes de FD2 ([field-desk-ux-pos-critique.md](plans/field-desk-ux-pos-critique.md)): glossário inline (O3 — hipótese ainda sem evidência), triagem em lote, empty states de coordenador · gatilho: antes de 16/08
- ~~**B38** sidebar recolhido em tablet~~ — **entregue 2026-07-26** (`collapsible="offcanvas"` no `CampaignSidebar`; `SidebarTrigger` no inset `md+`; `defaultOpen` lê cookie `sidebar_state`; `CampaignSidebarViewportDefault` colapsa uma vez em 768–1023 sem cookie; mobile Sheet inalterado; sem migration) · [plano](plans/sidebar-recolhido-tablet.md)
-- ~~**B14**~~ ✓ município mais próximo (geolocalização → card "Onde estou" no Início staff, entregue 2026-07-26) · pede permissão **1× por sessão** se ainda não concedida; matching client-side **por point-in-polygon** sobre o chunk de geometria do mapa (centroides só ordenam o fallback de 150 km na carteira); Salvador multi-zona → lista filtrada; **B8 F2 ✓ (2026-07-26)** desenhou os polígonos das ZE, então resolver a zona exata passou a ser possível, mas o card não mudou — segue como débito com gatilho "pedido de campo" no [plano do B8](plans/poligonos-pracas-zona.md) · [plano](plans/municipio-mais-proximo.md)
+- ~~**B14**~~ ✓ município mais próximo (geolocalização → card "Onde estou" no Início staff, entregue 2026-07-26; **débito B8 F2-D1** ZE exata em Salvador fechado 2026-07-29) · pede permissão **1× por sessão** se ainda não concedida; matching client-side **por point-in-polygon** (malha municipal + malha das 19 ZE em Salvador; centroides ordenam fallback de carteira e ZE mais próxima) · [plano](plans/municipio-mais-proximo.md)
- ~~**B20** Municípios prioritários no Início~~ — **entregue 2026-07-25** (`pickDashboardPriorityMunicipalities` + amostra 8 por déficit `central`; Badge com total; “Ver todas” → `?priority=alta`; `list-none` na strip) · [plano](plans/municipios-prioritarios-inicio.md)
- ~~**B21** página própria dos Territórios de Identidade~~ — **entregue 2026-07-25** (`/campanha/territorios` staff-only; 27 TIs + decomposição do Metropolitano; sort/filtros/busca canônicos na URL; drill para municípios; chrome extraído em `shared/CampaignSortableHead` + `CampaignHeaderFilterPopover`; painel de TI removido do Início por decisão da sessão) · sem migration/collection/action · [plano](plans/pagina-territorios-identidade.md)
- **Downstream atualizado:** B25 teve a dependência dura atendida; B29 e B33 não pagam mais a extração do chrome compartilhado e agora implementam apenas política/URL/filtros dos próprios domínios.
@@ -333,7 +333,7 @@ flowchart TD
C13n -.vocabulário e caminhos da vertical.-> E13i
C2prod -.dados reais.-> E11i
E11i -.decisões acumuladas.-> E15i
- B8F2 -.ZE Salvador = débito aberto.-> B14n
+ B8F2 -.rank por ZE = débito aberto.-> B14n
```
Paralelizáveis agora: **B13** (E10 ✓ entregou as classes; consome `computeMunicipalityTerritorialClass` como está), **E12** (E8 ✓), ~~**E11**~~ ✓ (motor de sugestões v1 entregue 2026-07-28), ~~**B14**~~ ✓ (atalho geo no Início entregue 2026-07-26; ~~B8 F2~~ ✓ entregou os polígonos das ZE em 2026-07-26, então desambiguar Salvador virou débito com gatilho "pedido de campo", não dependência), ~~**B38**~~ ✓ (sidebar recolhido em tablet entregue 2026-07-26), **B39** (lembrar de mim / sessão longa; sem dep dura — cookie/JWT já existem; soft B40 só pela ordem no mesmo `LoginForm`), ~~**B40**~~ ✓ (login biométrico WebAuthn — entregue 2026-07-28), ~~**B41**~~ ✓ (scroll horizontal + sticky — entregue 2026-07-27), ~~**B42**~~ ✓ (polimento mobile da mesma lista — entregue 2026-07-27), **B21** (página dos TIs; sem dep dura — E17 ✓/B15 ✓/B16 ✓ já entregues; E12 pousa nela depois; **B25** é o único item que espera B21 — o link do território na lista só existe depois do destino), ~~**B17**~~ ✓ (seletor de colunas; soft B15 ✓/B16 ✓ — pousa no header de coluna atual), ~~**B18**~~ ✓ (filtros salvos + submenu Municípios; soft B16 ✓/B17 — botão na barra), **B22** (explicação por coluna no header; sem dep dura — B15 ✓/B16 ✓/E18 ✓), **B23** (tooltip nas células; a mecânica genérica e a promoção do primitivo já saíram no E10 ✓ — restam os consumidores Assessores/Tendência e a convivência com o Popover), ~~**B24**~~ ✓ (auto-save + justificativa no popover de Tendência; entregue 2026-07-26), **B26** (registrar sinal na célula "Último sinal"; sem dep dura — E9 ✓/C12 ✓ já entregues; soft B24/B23 só pela coerência de contrato na mesma linha), **B27** (combobox + chips + auto-save no popover de Assessores; sem dep dura — B19 ✓ já deu o delta+lock e o padrão de chips; soft B24 pelo endpoint e B23 pelo gatilho), **B28** (e-mail + celular na lista de lideranças; sem dep dura — B19 ✓ é só o padrão soft de copy/WhatsApp; `phone` já está no view model), **B29** (sort + filtro no header da lista de lideranças; sem dep dura — B15 ✓/B16 ✓ deram o contrato e o popover; soft B21 e B28 só pela ordem de chegada: quem entrar depois herda o head compartilhado / encaixa as colunas do outro), fill-ins (renomear coluna Cobertura → quick win de densidade no header; remover coluna Tipo — quick win de densidade; remover lista de zonas do Início — pós-B8 F2 ✓; ícone de prioridade na lista; Cenário junto aos filtros — encaixa na barra slim de B16 ✓ —; selecionados no topo nos filtros do header — helper puro que B21 reusa —; O0+, RS+). **B30** (convite WhatsApp na lista de lideranças; sem dep dura — reusa integralmente a action/collection existentes; soft B28 só pelo ícone na mesma linha, qualquer um pode ir primeiro — **entregue 2026-07-26**), **B31** (coluna de dobradinhas na lista de lideranças; sem dep dura — o dado já está no view model e o catálogo já tem loader; soft B27 pela peça de célula, que só vira extração compartilhada no 3º call site, e B28/B29/B30 pela ordem de chegada na mesma tabela), ~~**B32**~~ ✓ (editar Status de apoio na célula da lista de lideranças; entregue 2026-07-26), **B33** (sort + filtro no header da lista de dobradinhas; sem dep dura — B15 ✓/B16 ✓ deram o contrato; soft B29/B21 só pela ordem de chegada na mesma corrida de extração do chrome compartilhado, quem entrar primeiro paga a peça e o outro só consome), **B34** (chips editáveis de municípios na lista de lideranças; sem dep dura — B19 ✓ já deu o delta+lock, o padrão de chips e o loader do catálogo a promover; soft B27/B31 pela mesma peça de célula editável — quem chegar depois herda o combobox/Popover em vez de reinventar —, e B28/B30/B32 só pela mesma tabela; _decisão a validar com produto_: Popover por célula em vez do toggle "Editar" pedido literalmente, mesmo precedente do B31/B32), **B37** (chips editáveis de municípios na lista de dobradinhas; sem dep dura — B19 ✓/B34 já dão a lib a promover e o desenho do Popover+combobox; soft B34 só pela ordem de chegada em quem faz a promoção da lib, B31 pela mesma família de pedido em outra tabela, B33 pela mesma tabela; _mesma decisão a validar com produto_ do B31/B32/B34: Popover por célula, não toggle). **B36** (coluna de lideranças na lista de dobradinhas; sem dep dura — reusa integralmente a mutação/schema/lock que o B31 desenha para a mesma relação, do lado oposto; soft B31 pela corrida de extração da célula/action compartilhadas, B19 ✓ pelo padrão de origem, B33 pela mesma tabela), **C13** (renomear Plano de Ação → Atividade; sem dep dura — não toca nenhuma das listas em curso (B26–B37 são municípios/lideranças/dobradinhas), então roda em paralelo a elas; a única ordem que importa é **antes de E13**, que constrói dentro da vertical renomeada, e antes do congelamento ~20/09 por carregar migration; PR mecânico e isolado, não misturar com feature). **D3** só após smoke + folga e só se o fluxo sede-digita (C12 ✓) não absorver os deltas do ZAP (não compete com E9).
diff --git a/src/components/campaign/dashboard/NearestMunicipalityCard.tsx b/src/components/campaign/dashboard/NearestMunicipalityCard.tsx
index 3a8b82ea..655ec591 100644
--- a/src/components/campaign/dashboard/NearestMunicipalityCard.tsx
+++ b/src/components/campaign/dashboard/NearestMunicipalityCard.tsx
@@ -7,7 +7,10 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Spinner } from '@/components/ui/Spinner'
-import { loadMunicipalityGeometryModule } from '@/lib/bahiaGeometries'
+import {
+ loadMunicipalityGeometryModule,
+ loadMunicipalityZoneGeometryModule,
+} from '@/lib/bahiaGeometries'
import {
formatDistanceKm,
resolveNearbyMunicipality,
@@ -64,9 +67,9 @@ const failureCopy: Record null)
+
setState({
status: 'resolved',
coarse: result.fix.accuracyM > COARSE_ACCURACY_M,
- resolution: resolveNearbyMunicipality({ point: result.fix, geometry, accessible }),
+ resolution: resolveNearbyMunicipality({
+ point: result.fix,
+ geometry,
+ zoneGeometry: zoneModule ?? undefined,
+ accessible,
+ }),
})
}, [accessible])
@@ -330,13 +340,25 @@ const resolutionDetail = (
) : null
switch (resolution.kind) {
- case 'inScope':
+ case 'inScope': {
+ const nearestZone =
+ resolution.match === 'nearestZone' && resolution.distanceKm !== undefined ? (
+
+ Zona eleitoral mais próxima na sua carteira, a {formatDistanceKm(resolution.distanceKm)}{' '}
+ — confira antes de agir.
+
+ ) : null
+
return (
-
+ <>
+ {nearestZone}
+
+ >
)
+ }
case 'zoneCity': {
// Server and client read the same `accessible` set, so the href is there —
diff --git a/src/lib/municipalityProximity.ts b/src/lib/municipalityProximity.ts
index f1f0faa9..6316df6a 100644
--- a/src/lib/municipalityProximity.ts
+++ b/src/lib/municipalityProximity.ts
@@ -3,6 +3,7 @@ import type { Position } from 'geojson'
import type {
BahiaMunicipalityFeature,
MunicipalityGeometryModule,
+ MunicipalityZoneFeature,
PolygonalFeature,
} from '@/lib/bahiaGeometriesTypes'
import { oneDecimalFormatter } from '@/lib/electionFormat'
@@ -14,9 +15,10 @@ import { oneDecimalFormatter } from '@/lib/electionFormat'
* loads `bahiaMunicipalityGeometries` on mount, so the same memoized chunk
* answers containment without a centroid artifact of its own.
*
- * Containment is the real answer ("o município onde estou"); the centroid
- * distance below only ranks the fallback suggestions, where being off by a few
- * kilometres changes nothing.
+ * Salvador multi-zona: when the caller also supplies the B8 F2 zone mesh,
+ * containment runs on the 19 ZE polygons first; centroid distance ranks only
+ * the "nearest accessible zone" fallback when the point is in the city polygon
+ * but outside every zone polygon (bay water, mesh gap, coarse fix).
*/
export type GeoPoint = {
@@ -34,6 +36,10 @@ type MunicipalityGeometryLookup = Pick<
'features' | 'getMunicipalityFeature'
>
+type ZoneGeometryLookup = {
+ features: readonly MunicipalityZoneFeature[]
+}
+
/** The minimum a município needs to be linkable — what the server ships to the island. */
export type AccessibleMunicipality = {
slug: string
@@ -47,18 +53,22 @@ type NearestInScope = {
distanceKm: number
}
+export type InScopeMunicipalityMatch = 'municipality' | 'zoneContainment' | 'nearestZone'
+
export type NearbyMunicipalityResolution =
/** The point falls inside a município the actor can open. */
- | { kind: 'inScope'; municipality: AccessibleMunicipality }
+ | {
+ kind: 'inScope'
+ municipality: AccessibleMunicipality
+ /** Omitted for a single whole-municipality unit; set for Salvador ZE paths. */
+ match?: InScopeMunicipalityMatch
+ /** Present when `match` is `nearestZone` — distance to the zone centroid. */
+ distanceKm?: number
+ }
/**
- * The point falls inside a city modeled as several zone municipalities
- * (Salvador). The answer is the filtered list, never a guessed ZE. B8 F2 has
- * since drawn the zone polygons, so resolving the exact ZE became possible —
- * this resolver deliberately still does not, because that is a separate
- * decision (an open debt) rather than a side effect of the map delivery.
- * `zoneCount` is what the actor can open, so an advisor with three zones is
- * not told there are nineteen. `ibgeCode` is how the caller finds the
- * filtered-list href the server serialized for it.
+ * The actor can open several zone municipalities in the same city but the
+ * resolver could not pick one (zone mesh missing, or no zone within
+ * `NEAREST_ZONE_MAX_KM`). The filtered list is the honest answer.
*/
| { kind: 'zoneCity'; city: string; ibgeCode: string; zoneCount: number }
/** Inside Bahia, but in a município outside the actor's portfolio. */
@@ -176,22 +186,31 @@ export const featureCentroid = (feature: PolygonalFeature): GeoPoint => {
/**
* First feature whose polygons contain the point. A point on a shared border
- * lands in exactly one município by the half-open convention in `isPointInRing`,
+ * lands in exactly one feature by the half-open convention in `isPointInRing`,
* not by this scan's order: the mesh is TopoJSON-derived, so neighbours share
- * numerically identical arcs and never overlap. Feature order only decides
- * pathological overlaps the committed mesh does not contain.
+ * numerically identical arcs and never overlap.
*/
+export const findContainingFeature = (
+ features: readonly F[],
+ point: GeoPoint,
+): F | undefined => features.find((feature) => featureContainsPoint(feature, point))
+
export const findContainingMunicipality = (
features: readonly BahiaMunicipalityFeature[],
point: GeoPoint,
-): BahiaMunicipalityFeature | undefined =>
- features.find((feature) => featureContainsPoint(feature, point))
+): BahiaMunicipalityFeature | undefined => findContainingFeature(features, point)
/**
* Past this, "o mais próximo na sua carteira" stops being a shortcut and starts
* being noise — an advisor in Salvador does not need a link to Barreiras.
*/
-const NEAREST_IN_SCOPE_MAX_KM = 150
+export const NEAREST_IN_SCOPE_MAX_KM = 150
+
+/**
+ * When the point is inside Salvador's municipal polygon but outside every ZE
+ * polygon, only zones within this radius are offered as a direct link.
+ */
+export const NEAREST_ZONE_MAX_KM = 30
const findNearestInScope = (
getMunicipalityFeature: MunicipalityGeometryLookup['getMunicipalityFeature'],
@@ -214,6 +233,77 @@ const findNearestInScope = (
return nearest
}
+const findNearestAccessibleZone = (
+ zoneFeatures: readonly MunicipalityZoneFeature[],
+ accessibleBySlug: ReadonlyMap,
+ point: GeoPoint,
+): NearestInScope | null => {
+ let nearest: NearestInScope | null = null
+
+ for (const zone of zoneFeatures) {
+ const municipality = accessibleBySlug.get(zone.properties.municipalitySlug)
+ if (!municipality) continue
+
+ const distanceKm = haversineKm(point, featureCentroid(zone))
+ if (distanceKm > NEAREST_ZONE_MAX_KM) continue
+ if (nearest && nearest.distanceKm <= distanceKm) continue
+
+ nearest = { municipality, distanceKm }
+ }
+
+ return nearest
+}
+
+const resolveMultiZoneCity = ({
+ city,
+ ibgeCode,
+ inCity,
+ point,
+ zoneGeometry,
+ geometry,
+ accessible,
+}: {
+ city: string
+ ibgeCode: string
+ inCity: readonly AccessibleMunicipality[]
+ point: GeoPoint
+ zoneGeometry: ZoneGeometryLookup | undefined
+ geometry: MunicipalityGeometryLookup
+ accessible: readonly AccessibleMunicipality[]
+}): NearbyMunicipalityResolution => {
+ if (!zoneGeometry) {
+ return { kind: 'zoneCity', city, ibgeCode, zoneCount: inCity.length }
+ }
+
+ const accessibleBySlug = new Map(inCity.map((entry) => [entry.slug, entry]))
+ const containingZone = findContainingFeature(zoneGeometry.features, point)
+
+ if (containingZone) {
+ const municipality = accessibleBySlug.get(containingZone.properties.municipalitySlug)
+ if (municipality) {
+ return { kind: 'inScope', municipality, match: 'zoneContainment' }
+ }
+
+ return {
+ kind: 'outOfScope',
+ city: containingZone.properties.name,
+ nearestInScope: findNearestInScope(geometry.getMunicipalityFeature, accessible, point),
+ }
+ }
+
+ const nearestZone = findNearestAccessibleZone(zoneGeometry.features, accessibleBySlug, point)
+ if (nearestZone) {
+ return {
+ kind: 'inScope',
+ municipality: nearestZone.municipality,
+ match: 'nearestZone',
+ distanceKm: nearestZone.distanceKm,
+ }
+ }
+
+ return { kind: 'zoneCity', city, ibgeCode, zoneCount: inCity.length }
+}
+
/**
* Resolves a browser position into the one município the card should offer.
*
@@ -225,10 +315,12 @@ const findNearestInScope = (
export const resolveNearbyMunicipality = ({
point,
geometry,
+ zoneGeometry,
accessible,
}: {
point: GeoPoint
geometry: MunicipalityGeometryLookup
+ zoneGeometry?: ZoneGeometryLookup
accessible: readonly AccessibleMunicipality[]
}): NearbyMunicipalityResolution => {
const containing = findContainingMunicipality(geometry.features, point)
@@ -246,12 +338,15 @@ export const resolveNearbyMunicipality = ({
if (inCity.length === 1) return { kind: 'inScope', municipality: inCity[0] }
if (inCity.length > 1) {
- return {
- kind: 'zoneCity',
+ return resolveMultiZoneCity({
city: containing.properties.name,
ibgeCode: containing.properties.codarea,
- zoneCount: inCity.length,
- }
+ inCity,
+ point,
+ zoneGeometry,
+ geometry,
+ accessible,
+ })
}
return {
diff --git a/tests/e2e/campaignNearestMunicipality.e2e.spec.ts b/tests/e2e/campaignNearestMunicipality.e2e.spec.ts
index 168ff0eb..b46b945e 100644
--- a/tests/e2e/campaignNearestMunicipality.e2e.spec.ts
+++ b/tests/e2e/campaignNearestMunicipality.e2e.spec.ts
@@ -1,11 +1,18 @@
import type { Locator, Page } from '@playwright/test'
-import { loadMunicipalityGeometryModule } from '../../src/lib/bahiaGeometries.js'
+import {
+ loadMunicipalityGeometryModule,
+ loadMunicipalityZoneGeometryModule,
+} from '../../src/lib/bahiaGeometries.js'
import type { BahiaMunicipalityFeature } from '../../src/lib/bahiaGeometriesTypes.js'
-import { getMunicipalityCatalogEntry } from '../../src/lib/municipalityCatalog.js'
+import {
+ getMunicipalityCatalogEntry,
+ municipalityCatalog,
+} from '../../src/lib/municipalityCatalog.js'
import {
featureCentroid,
featureContainsPoint,
haversineKm,
+ resolveNearbyMunicipality,
type GeoPoint,
} from '../../src/lib/municipalityProximity.js'
import { featureBounds } from '../helpers/featureBounds.js'
@@ -15,13 +22,34 @@ import { expect, test } from './fixtures/campaignE2EFixtures.js'
/**
* B14 — the geo shortcut on the staff dashboard. What these tests protect is the
* contract the field depends on: a granted permission resolves the município the
- * user is standing in, Salvador degrades to its zone list instead of guessing a
- * ZE, a position outside the portfolio says so, and a refused permission leaves
+ * user is standing in (including a Salvador ZE when the zone mesh matches),
+ * a position outside the portfolio says so, and a refused permission leaves
* a card the user can act on instead of a spinner.
*/
const SALVADOR_CENTRE: GeoPoint = { lat: -12.973, lng: -38.5121 }
+const salvadorZoneAtCentre = async () => {
+ const geometry = await loadMunicipalityGeometryModule()
+ const zoneGeometry = await loadMunicipalityZoneGeometryModule()
+ const accessible = municipalityCatalog
+ .filter((entry) => entry.city === 'Salvador')
+ .map((entry) => ({ slug: entry.slug, name: entry.name, ibgeCode: entry.ibgeCode }))
+
+ const resolution = resolveNearbyMunicipality({
+ point: SALVADOR_CENTRE,
+ geometry,
+ zoneGeometry,
+ accessible,
+ })
+
+ if (resolution.kind !== 'inScope' || resolution.match !== 'zoneContainment') {
+ throw new Error('Expected Salvador centre to resolve to an in-scope zone for the e2e contract.')
+ }
+
+ return resolution.municipality
+}
+
/**
* The card mirrors its headline in an sr-only live region (so the outcome is
* announced), which means every headline matches twice. `.last()` is the visible
@@ -146,10 +174,8 @@ test.describe('Município mais próximo', () => {
await expect(card.getByRole('link', { name: /^Abrir/ })).toHaveCount(0)
})
- test('coordinator in Salvador is sent to the zone list, never to a guessed ZE', async ({
- campaign,
- page,
- }) => {
+ test('coordinator in Salvador opens the matching zone directly', async ({ campaign, page }) => {
+ const zone = await salvadorZoneAtCentre()
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Geo'),
@@ -158,27 +184,20 @@ test.describe('Município mais próximo', () => {
const email = coordinator.email!
await page.context().grantPermissions(['geolocation'])
- // A desk fix comes from the network, not the GPS: wide enough to be wrong about
- // the município, so the card must hedge.
await page.context().setGeolocation({
latitude: SALVADOR_CENTRE.lat,
longitude: SALVADOR_CENTRE.lng,
- accuracy: 25_000,
})
await campaign.login(page, email, password)
const card = geoCard(page)
- await expect(visibleText(card, 'dividida por zona eleitoral')).toBeVisible()
- await expect(card.getByText('Localização aproximada')).toBeVisible()
- await card.getByRole('link', { name: 'Ver zonas de Salvador' }).click()
+ await expect(visibleText(card, zone.name)).toBeVisible()
+ await card.getByRole('link', { name: `Abrir ${zone.name}` }).click()
- await expect(page).toHaveURL(/\/campanha\/municipios\?.*q=Salvador/)
- // The deficit default sort can push a given zone off page 1 (P3-C root
- // cause of the deterministic red here): narrow the list to the zone first.
- // Exact: "Salvador — ZE 1" is otherwise a prefix of ZE 12 and ZE 17.
- await page.getByLabel('Buscar município').fill('Salvador — ZE 1')
- await expect(page.getByRole('link', { name: 'Salvador — ZE 1', exact: true })).toBeVisible()
+ await expect(page).toHaveURL(
+ new RegExp(`/campanha/municipios/${zone.slug.replace(/-/g, '\\-')}$`),
+ )
})
test('a refused permission leaves an actionable card instead of a spinner', async ({
diff --git a/tests/int/bahiaGeometries.int.spec.ts b/tests/int/bahiaGeometries.int.spec.ts
index 107e79fc..719362a3 100644
--- a/tests/int/bahiaGeometries.int.spec.ts
+++ b/tests/int/bahiaGeometries.int.spec.ts
@@ -280,9 +280,40 @@ describe('Bahia static geometries', () => {
}
})
- it('sends a Salvador position to the zone list and an out-of-portfolio one to the nearest', async () => {
+ it('resolves Salvador to an accessible zone when the zone mesh is loaded', async () => {
const geometry = await loadMunicipalityGeometryModule()
- const salvadorZones = municipalityCatalog.filter((entry) => entry.city === 'Salvador')
+ const zoneGeometry = await loadMunicipalityZoneGeometryModule()
+ const salvadorZones = municipalityCatalog
+ .filter((entry) => entry.city === 'Salvador')
+ .map((entry) => ({
+ slug: entry.slug,
+ name: entry.name,
+ ibgeCode: entry.ibgeCode,
+ }))
+ const salvadorPoint = { lat: -12.973, lng: -38.5121 }
+
+ const resolution = resolveNearbyMunicipality({
+ point: salvadorPoint,
+ geometry,
+ zoneGeometry,
+ accessible: salvadorZones,
+ })
+
+ expect(resolution.kind).toBe('inScope')
+ if (resolution.kind !== 'inScope') return
+ expect(resolution.match).toBe('zoneContainment')
+ expect(salvadorZones.some((zone) => zone.slug === resolution.municipality.slug)).toBe(true)
+ })
+
+ it('falls back to the zone list without zone mesh and reports out-of-portfolio in Salvador', async () => {
+ const geometry = await loadMunicipalityGeometryModule()
+ const salvadorZones = municipalityCatalog
+ .filter((entry) => entry.city === 'Salvador')
+ .map((entry) => ({
+ slug: entry.slug,
+ name: entry.name,
+ ibgeCode: entry.ibgeCode,
+ }))
const seabra = municipalityCatalog.find((entry) => entry.slug === 'seabra')!
const salvadorPoint = { lat: -12.973, lng: -38.5121 }
@@ -303,7 +334,13 @@ describe('Bahia static geometries', () => {
resolveNearbyMunicipality({
point: salvadorPoint,
geometry,
- accessible: [seabra],
+ accessible: [
+ {
+ slug: seabra.slug,
+ name: seabra.name,
+ ibgeCode: seabra.ibgeCode,
+ },
+ ],
}),
).toEqual({ kind: 'outOfScope', city: 'Salvador', nearestInScope: null })
})
diff --git a/tests/unit/municipalityProximity.unit.spec.ts b/tests/unit/municipalityProximity.unit.spec.ts
index 182c6220..63ee82b7 100644
--- a/tests/unit/municipalityProximity.unit.spec.ts
+++ b/tests/unit/municipalityProximity.unit.spec.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
-import type { BahiaMunicipalityFeature } from '@/lib/bahiaGeometriesTypes'
+import type { BahiaMunicipalityFeature, MunicipalityZoneFeature } from '@/lib/bahiaGeometriesTypes'
import {
featureCentroid,
featureContainsPoint,
@@ -33,6 +33,28 @@ const square = (
},
})
+const zoneSquare = (
+ municipalitySlug: string,
+ name: string,
+ ibgeCode: string,
+ { west, south, size }: { west: number; south: number; size: number },
+): MunicipalityZoneFeature => ({
+ type: 'Feature',
+ properties: { municipalitySlug, name, ibgeCode },
+ geometry: {
+ type: 'Polygon',
+ coordinates: [
+ [
+ [west, south],
+ [west + size, south],
+ [west + size, south + size],
+ [west, south + size],
+ [west, south],
+ ],
+ ],
+ },
+})
+
const accessible = (slug: string, name: string, ibgeCode: string): AccessibleMunicipality => ({
slug,
name,
@@ -215,7 +237,7 @@ describe('municipalityProximity', () => {
).toEqual({ kind: 'inScope', municipality: seabraEntry })
})
- it('falls back to the filtered list for a multi-zone city', () => {
+ it('falls back to the filtered list for a multi-zone city without zone mesh', () => {
expect(
resolveNearbyMunicipality({
point: { lat: -12.9, lng: -38.5 },
@@ -228,6 +250,67 @@ describe('municipalityProximity', () => {
).toEqual({ kind: 'zoneCity', city: 'Salvador', ibgeCode: '2927408', zoneCount: 2 })
})
+ it('resolves an accessible zone by containment when the zone mesh is present', () => {
+ const zoneOne = accessible('salvador-ze-1', 'Salvador — ZE 1', '2927408')
+ const zoneTwo = accessible('salvador-ze-2', 'Salvador — ZE 2', '2927408')
+ const zoneGeometry = {
+ features: [
+ zoneSquare('salvador-ze-1', 'Salvador — ZE 1', '2927408', {
+ west: -38.6,
+ south: -13,
+ size: 0.2,
+ }),
+ zoneSquare('salvador-ze-2', 'Salvador — ZE 2', '2927408', {
+ west: -38.4,
+ south: -13,
+ size: 0.2,
+ }),
+ ],
+ }
+
+ expect(
+ resolveNearbyMunicipality({
+ point: { lat: -12.9, lng: -38.55 },
+ geometry,
+ zoneGeometry,
+ accessible: [zoneOne, zoneTwo],
+ }),
+ ).toEqual({ kind: 'inScope', municipality: zoneOne, match: 'zoneContainment' })
+ })
+
+ it('offers the nearest accessible zone when the point is in the city but outside every zone polygon', () => {
+ const zoneOne = accessible('salvador-ze-1', 'Salvador — ZE 1', '2927408')
+ const zoneTwo = accessible('salvador-ze-2', 'Salvador — ZE 2', '2927408')
+ const zoneGeometry = {
+ features: [
+ zoneSquare('salvador-ze-1', 'Salvador — ZE 1', '2927408', {
+ west: -38.58,
+ south: -12.95,
+ size: 0.05,
+ }),
+ zoneSquare('salvador-ze-2', 'Salvador — ZE 2', '2927408', {
+ west: -38.45,
+ south: -12.95,
+ size: 0.05,
+ }),
+ ],
+ }
+
+ const resolution = resolveNearbyMunicipality({
+ // Inside the Salvador municipal square, outside both tiny zone squares.
+ point: { lat: -12.85, lng: -38.5 },
+ geometry,
+ zoneGeometry,
+ accessible: [zoneOne, zoneTwo],
+ })
+
+ expect(resolution.kind).toBe('inScope')
+ if (resolution.kind !== 'inScope') return
+ expect(resolution.match).toBe('nearestZone')
+ expect(['salvador-ze-1', 'salvador-ze-2']).toContain(resolution.municipality.slug)
+ expect(resolution.distanceKm).toBeGreaterThan(0)
+ })
+
it('names where the actor is and offers the closest município in scope', () => {
const jequieEntry = accessible('jequie', 'Jequié', '2918001')