Point-in-polygon on the zone mesh opens the accessible ZE directly, with nearest-zone fallback within 30 km before the filtered list.
21 KiB
B14 — Município mais próximo (acesso rápido por geolocalização)
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 (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
Responsável: —
Como ficou (as-built, 2026-07-26)
O casamento é point-in-polygon, não centroide: a premissa do plano original (polígonos explodem o bundle) caiu ao medir o repositório de hoje — MunicipalityMapPanel monta sempre em mode="municipality" e BahiaMap chama loadMunicipalityGeometryModule() no mount, então os 417 polígonos (132 KB crus, 41 KB gzip, chunk próprio memoizado) já estão no browser de quem abre /campanha. Com isso o card responde "o município onde estou" em vez de "o mais próximo", e caíram do escopo pnpm build:municipality-centroids, src/lib/municipalityCentroids/ e o snapshot test do artefato. Centroide sobrevive apenas como critério de ordenação do fallback (featureCentroid + Haversine), onde errar por quilômetros não muda a decisão.
Entregue:
src/lib/municipalityProximity.ts— matemática pura sobre as features que o chamador já tem:haversineKm, ray casting com buracos (featureContainsPoint),featureCentroid(média ponderada por área) eresolveNearbyMunicipalitydevolvendo união discriminadainScope | zoneCity | outOfScope | outsideBahia. Carteira vazia não é um caso desta função: o card não renderiza nem pede posição nessa situação, e duplicar a política aqui custava ramos inalcançáveis (removido no/simplify).src/utilities/campaignGeolocation.ts— irmão derecentVisits.ts: flagteqo:campaign:geo-prompted-session, leitura de permissão guardada (Safari sem o descriptor →unknown, que é resposta normal) erequestCurrentPositioncom falha tipada.COARSE_ACCURACY_M = 10 kmsepara fix de aparelho de chute de rede.NearestMunicipalityCard, ao lado deRecentlyVisitedCardnuma linha de duas colunas escrita direto emCampaignDashboarde escondida em CSS (hidden … has-[>*]:grid) quando os dois cards renderizamnull. Houve um wrapperDashboardShortcutsno meio do caminho; sem estado nem lógica ele era só umdiv, e o/simplifyo inlinou.StaffDashboardViewpublicaaccessibleMunicipalities(slug/name/ibgeCode); o href da lista de zonas é serializado porCampaignDashboard(o RSC que já monta hrefs de lista) e desce como propzoneCityHrefs— o loader Payload não guarda URL.
Decisão de produto nova (usuário, 2026-07-26): assessor fisicamente fora da carteira é informado de onde está e recebe o município mais próximo que pode abrir, com a distância; acima de 150 km (NEAREST_IN_SCOPE_MAX_KM) a sugestão vira ruído e o card diz que não há nada por perto.
Medições (as-built). Payload RSC de /campanha com 435 entradas: ~6,6 KB gzip — abaixo do teto de ~10 KB do plano, então o campo ficou como objetos legíveis em vez de tuplas. First Load JS de /campanha: 265 kB → 268 kB. A primeira versão chegou a 286 kB porque o card chamava buildMunicipalityListHref para o link das zonas de Salvador, e o serializador canônico arrasta bahiaTerritories + municipalityCatalog (que puxa códigos IBGE e zonas TSE) para o bundle do cliente: 21 kB por um único link. Corrigido serializando o href no servidor (zoneCityHrefs, chaveado por IBGE, uma entrada só quando o ator enxerga mais de uma zona da cidade) — a regra "client components não importam módulos de dado do servidor para obter valores" também vale para serializadores de URL. No /simplify esse cálculo saiu do loader server-only para CampaignDashboard, reusando buildMunicipalitiesByIbgeCode (o índice por código IBGE que o painel do mapa já usa) em vez de contar linhas à mão.
Medições de CPU (feitas no /simplify, sobre o artefato real — não estimadas). A malha decodifica em 417 features, todas Polygon simples: 417 anéis, zero buracos, zero MultiPolygon, 15.111 pares de coordenadas, anel mais longo com 147 pontos. Varredura point-in-polygon completa (o caminho "fora da Bahia", sem acerto): 0,106 ms; até acertar Salvador: 0,143 ms; resolveNearbyMunicipality inteiro com carteira de 435: 0,32 ms, dos quais 0,26 ms são os 417 centroides. Com penalidade de 6× para celular médio, ~2,6 ms — um oitavo de um frame, então não há caminho quente aqui e o artefato de centroides continuaria sem se pagar. Construir um Map<codarea, feature> novo por chamada mediu 0,337 ms, mais lento que o find; o índice getMunicipalityFeature é usado porque o módulo já o construiu para o mapa, não como otimização. Chunk de geometria no build: 134.488 B crus / 42.158 B gzip, referenciado por um único arquivo (o chunk da página), confirmando um download e um decode (1,69 ms) para os dois consumidores. O que muda com o card é o agendamento: a geometria passa a ser pedida na hidratação da página, competindo com Leaflet (42.675 B gzip), em vez de só depois do chunk do painel. Aceito — mas não porque o Promise.all esconderia o decode atrás do fix de GPS: o decode roda no topo do módulo, ou seja, quando o chunk baixa e executa, independente do fix. Ele é aceito porque é barato (JSON.parse 1,62 ms + topojson.feature() 0,41 ms ≈ 2 ms no desktop, ~10 ms num celular 5× mais lento — abaixo da barra de 50 ms de long task) e porque a alternativa sequencial (fix primeiro, geometria depois) é pior no caso comum: com maximumAge de 5 min um fix em cache volta em milissegundos e aí os 42 KB cairiam inteiros no caminho crítico do card. O download também nunca é especulativo — MunicipalityMapPanel monta sempre em mode="municipality", então todo staff em /campanha precisa desse chunk de qualquer forma.
Débito registrado (não corrigido aqui). As 435 linhas de accessibleMunicipalities (~6,6 KB gzip) duplicam o municipalitiesByIbgeCode que MunicipalityMapBundle já serializa na mesma página, a partir do mesmo loadMunicipalityScope. Unificar exige mexer na fronteira de Suspense do mapa (o painel só monta atrás dela, e pode ser null), o que é reestruturação, não limpeza. Tuplas em vez de objetos economizariam apenas 398 B gzip — medido, e não vale o tipo auto-descritivo. A 3ª rodada de /simplify afiou o caminho da correção: publicar MunicipalitiesByIbgeCode no lugar do array plano faria resolveNearbyMunicipality trocar o filter sobre 435 linhas por uma busca por chave, e a página pararia de montar o mesmo agrupamento duas vezes (buildZoneCityHrefs reconstrói o índice a partir do array). Só que exige mover municipalityMapNavigation.ts para src/lib/ — hoje em utilities/ sem nenhum import, portanto já puro e mal-alojado — e tocar os importadores do domínio do mapa. Continua reestruturação, não limpeza.
Design (Impeccable)
Âncoras: PRODUCT.md (princípios 2 — clareza sob pressão — e 8 — Feel the action) / DESIGN.md (register product; Field Desk) · tema data-theme='campaign' · precedente de ilha client-side RecentlyVisitedCard.
Na implementação (implement-roadmap-item): craft compacto → critique → polish.
Brief compacto:
- Persona / contexto: assessor ou CG em campo (telefone, deslocamento entre municípios), abrindo o Início sob pressão de tempo — precisa chegar na ficha do município onde está sem digitar na lista.
- Job principal: com um toque, abrir o município operacional mais próximo da localização atual.
- Estratégia de cor: Restrained — card/atalho sóbrio ao lado de "Visitados recentemente", sem mapa embutido nem badge de GPS gamificado.
- Edit where you see: não — só leitura/navegação (atalho); sem mutação de dados.
- Anti-goals: segundo mapa; lista "próximos N" tipo Places; sync de localização no servidor; prompt de permissão em loop; superfície para
leader(lockdown).
Dados → decisão → apresentação
- Vou apresentar dados? Sim, superfície neste item — um resultado geo (município mais próximo ± distância opcional), não série/KPI eleitoral.
- Decisões desbloqueadas:
- Staff (assessor/CG/candidato): "abrir agora a ficha do município em que estou (ou o mais próximo no meu escopo)?"
- Forma escolhida: número + contexto mínimo (nome do município + CTA "Abrir" + distância relativa opcional, ex. "~8 km") — por quê: uma decisão, um destino; degrau mais pobre que resolve. Rejeitado: mapa de proximidade (já há mapa no Início com outro job); ranking dos 5 mais próximos (ruído); chart/gauge.
- Profile: geo pontual → 1 entidade do catálogo (ou lista filtrada Salvador); tamanho típico = 1 sugestão; absoluto (km) só como contexto de confiança, não como KPI.
- Anti-goals de dado: sem telemetria de posição; sem choropleth de "densidade de visitas"; sem % estadual.
Self-check dados: 5/5.
Contexto
O Início staff já oferece mapa, KPIs, filas curtas e Visitados recentemente (histórico local). Em deslocamento, o atalho que falta é "onde estou → ficha do município". O catálogo tem 435 unidades (municipalityCatalog.ts); Salvador é 19 Municípios-zona com o mesmo IBGE — a proximidade por centroide de município IBGE não desambigua ZE. Geolocalização é API do browser (navigator.geolocation); a posição não precisa ir ao servidor se o matching for client-side sobre um artefato de centroides.
Decisão de produto (2026-07-24, pedido explícito): sugerir acesso rápido ao município mais próximo e, uma vez por sessão, pedir automaticamente a permissão de localização se ainda não houver.
Objetivos
- No dashboard staff (
/campanha), card/atalho "Município mais próximo" (ou copy equivalente) com link para/campanha/municipios/[slug](ou lista filtrada quando a cidade for multi-zona). - Uma vez por sessão de aba: se a permissão de geolocalização ainda não estiver concedida/negada de forma estável, disparar automaticamente o pedido do browser; marcar em
sessionStorageque o prompt automático já ocorreu (não repetir na mesma sessão). - Se a permissão já estiver
granted, resolver a posição sem prompt e mostrar a sugestão. - Se
denied/ indisponível / fora da Bahia / sem match no escopo: card ausente ou estado curto com CTA manual "Usar minha localização" (sem loop de prompt). - Matching só no cliente; coordenadas nunca enviadas a server actions/API Payload.
- Filtrar candidatos ao escopo de acesso do ator (advisor: só municípios administrados; coordinator/candidate: catálogo completo do que a UI já enxerga).
- Sem migration, sem collection, sem server action de escrita, sem nova chave
Consent. - Staff only;
leaderpermanece noLeaderContactsPanel(sem mudança).
Decisões travadas
- 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: chaveConsentsó para UX de GPS (multiplica lead time jurídico sem dado no servidor). - Malha de matching = município IBGE (417), depois ZE em Salvador (B8 F2). Containment na malha municipal; cidades multi-zona usam
bahia-municipality-zonesquando 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 <município>", "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).
Questões em aberto
Todas resolvidas na entrega:
- Posição no Início: B — o card fica ao lado de
RecentlyVisitedCard(grid gap-6 lg:grid-cols-2, primeiro no mobile), e a linha inteira desaparece quando os dois cards não têm o que dizer. - Mostrar distância em km? Só no fallback ("Mais próximo na sua carteira: X, a 38 km"). Quando o ponto cai dentro de um município não há distância a mostrar — containment não é aproximação.
- Limiar "fora do teatro"? 150 km do centroide para a sugestão de carteira (
NEAREST_IN_SCOPE_MAX_KM). Fora da malha da Bahia o card diz que está fora, e só oferece atalho se houver algo dentro desse raio.
Abordagem (como implementada)
flowchart LR
Scope["loadMunicipalityScope<br/>(slug, name, ibgeCode)"] --> View["StaffDashboardView<br/>accessibleMunicipalities"]
View --> Hrefs["CampaignDashboard (RSC)<br/>zoneCityHrefs"]
Hrefs --> Card
View --> Card["NearestMunicipalityCard<br/>(client island)"]
Card --> Perm["permissions + sessionStorage<br/>prompt 1x por sessao"]
Perm --> Fix["getCurrentPosition"]
Fix --> Geo["loadMunicipalityGeometryModule<br/>(chunk compartilhado com o mapa)"]
Geo --> Resolve["resolveNearbyMunicipality<br/>(point-in-polygon + fallback)"]
Resolve --> Link["Link /campanha/municipios/[slug]<br/>ou lista filtrada (Salvador)"]
Componentes:
src/lib/municipalityProximity.ts(puro, client-safe, recebe a malha já carregada —features+ o índice memoizadogetMunicipalityFeature— em vez de importar geometria ou re-varrer as 417 features por município da carteira):haversineKm,featureContainsPoint,featureCentroid,findContainingMunicipality,resolveNearbyMunicipality.src/utilities/campaignGeolocation.ts: flag de sessão, permissão guardada,requestCurrentPositioncom resultado tipado (denied | unavailable | timeout | unsupported).NearestMunicipalityCard(src/components/campaign/dashboard/,'use client'):- Props:
{ accessible: { slug, name, ibgeCode }[]; zoneCityHrefs }— nada de doc Payload inteiro, nada de serializador de URL no cliente. - No mount: estado
starting(explicação sem CTA, para não convidar um toque que dispararia um segundo pedido de posição), lê permissão;grantedresolve silencioso;prompt/unknownainda não perguntado na sessão → uma chamada e marca a flag;denied→ estado acionável com "Tentar de novo", sem re-prompt. Guarda deuseRefpara o Strict Mode não pedir posição duas vezes. - Pending honesto (
aria-busy+ spinner) e umaria-live="polite"sr-onlymontado desde o início, para o desfecho ser anunciado.
- Props:
- Encaixe em
CampaignDashboard.tsx;page.tsxjá separa staff de leader. - Sem migration, sem collection, sem server action.
Dependências
- Nenhuma dura de outro item aberto. Reusa B2 (TopoJSON), catálogo
municipalityCatalog.ts, shells do dashboard, padrão client-storage de visitados. - Suave: B8 F2 (polígonos das ZE de Salvador) — quando existir, permite point-in-polygon para desambiguar ZE; até lá, lista filtrada Salvador.
Não escopo
- Geolocalização para
leader/ ferramenta de contatos. - Enviar ou armazenar posição no servidor / analytics.
- Navegação turn-by-turn, ranking dos N mais próximos, mapa de "perto de mim".
- Matching por seção eleitoral ou bairro (fora de escopo AGENTS; B8 F2 é zona Salvador apenas).
- Pedir permissão em outras rotas além do Início staff (v1).
Rabbit holes
Point-in-polygon no client com TopoJSON completo. Explode bundle e conflita com lazy do mapa (B5).Não era rabbit hole: o dashboard já monta o mapa, que carrega o mesmo chunk memoizado no mount. O card reusaloadMunicipalityGeometryModule()e não adiciona um byte de geometria. O rabbit hole real, encontrado ao medir, foi importar o serializador da lista no cliente (+21 KB) — resolvido serializando o href no servidor.- "Completar" Salvador por ZE sem malha. Chute de ZE erra operação de campo. Mitigação: lista filtrada até B8 F2.
- Abstração genérica de "location services". Um card, um helper. Mitigação: sem provider/context até 3º call site.
- Consent/RIPD por precaução. Sem dado no servidor não há lote jurídico. Mitigação: boundary explícito no plano; se no futuro houver sync, aí sim Consent + defer.
Adiado com gatilho
Desambiguação Salvador ZE por ponto-em-polígono.Resolvido 2026-07-29 (débito B8 F2-D1):NearestMunicipalityCardcarregaloadMunicipalityZoneGeometryModule()de forma opcional; falha do chunk degrada parazoneCity.- 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
sessionStoragepara 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 doLeaderContactsPanel. - Reusar a navegação do mapa no branch
inScope|zoneCity|outOfScope. Revisitar quando: um 3º chamador precisar dozoneCount/da entrada casada. Encerrado como reuso possível pelo B8+ F4:resolveMunicipalityMapNavigatione sua união de estados morreram — o mapa lêmunicipalitiesByMapKey[key]direto, umRecord<string, string>que não carrega nada do que este card precisa. - Helper compartilhado para flags de sessão (
sessionStorage). Revisitar quando: nascer a 3ª flag. Hoje são duas (campaignGeolocationeInstallPwaToast) e elas discordam de propósito no fallback de leitura — em modo privado o geo suprime o prompt automático e o toast continua aparecendo —, o que é a maior parte do que o helper faria.
Referências
docs/roadmap.md(B14; Demais itens abertos; grafo; cortes)- visitados-recentemente.md — precedente client-storage + card no dashboard
- poligonos-pracas-zona.md — B8 F2 (gatilho Salvador ZE)
- mapa-bahia-geometrias.md — TopoJSON fonte dos centroides
src/components/campaign/dashboard/CampaignDashboard.tsx,RecentlyVisitedCard.tsx,src/utilities/recentVisits.tssrc/lib/municipalityCatalog.ts,src/lib/bahiaMunicipalityCodes.ts,src/lib/bahiaGeometries.ts,src/lib/geometries/bahia-municipalities.topo.json- Testes:
tests/unit/municipalityProximity.unit.spec.ts,tests/unit/campaignGeolocation.unit.spec.ts,tests/int/bahiaGeometries.int.spec.ts,tests/e2e/campaignNearestMunicipality.e2e.spec.ts PRODUCT.md/DESIGN.md— Field Desk, clareza sob pressão, Feel the action- AGENTS.md — leader lockdown, naming, sem PostGIS sem query espacial, artefatos commitados via script (não no
pnpm build)