B14: municipio mais proximo (atalho geo no Inicio staff)

Card "Onde estou" no dashboard staff: point-in-polygon contra a malha
municipal que o painel do mapa ja carrega, sem artefato de centroides,
sem migration, sem collection, sem server action e sem Consent -- a
posicao e lida por navigator.geolocation e casada no browser, nunca sai
do aparelho.

- src/lib/municipalityProximity.ts: haversine, ray casting, centroide
  ponderado por area e uniao discriminada
  (inScope | zoneCity | outOfScope | outsideBahia)
- src/utilities/campaignGeolocation.ts: flag de sessao, leitura guardada
  de permissao e requestCurrentPosition com falha tipada
- Salvador nao ganha ZE chutada: link para a lista filtrada, com href
  serializado no servidor (importar o serializador canonico no cliente
  custava 21 kB de First Load JS)
- fallback fora da carteira ordenado por centroide, teto de 150 km
- fix grosseiro (>10 km) mostra o resultado com ressalva de precisao

/campanha First Load JS: 265 -> 268 kB.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-26 17:30:09 -03:00
parent f63cd2cd50
commit e482005ce0
15 changed files with 1646 additions and 51 deletions

View file

@ -8,6 +8,15 @@ alwaysApply: false
## Status
- **B24 entregue em código (2026-07-26):** auto-save + justificativa editável no popover de Tendência de `/campanha/municipios`. Tirou o botão "Salvar" (anti-goal do princípio 4) e o `trendNote` hidden; `MunicipalityListTrendControl` agora espelha a máquina de `MunicipalityListExpectedVotesControl` — draft/display/committed + AbortController + geração, status ~150 ms / nota 600 ms (timer cancelável), flush no blur e no fechamento do Popover, Badge otimista, `Textarea` "Justificativa" (`maxLength` 2000), `Alert` + live region `sr-only`, tooltip B23 lendo a nota exibida. Endpoint `POST /campanha/municipios/political-trend` (mesmo shell CSRF/JSON do B27: `isSameOriginRequest` + `campaignJsonMutationRoute`) chama `setMunicipalityPoliticalTrend` **sem** `revalidatePath` — faceta/`?trend=` reconciliam na próxima navegação. Lista/page deixam de passar `trendFormAction`; `/editar` mantém o form action multi-campo. Sem migration. Gate verde (tsc/lint/format/knip/check:cycles/479 unit/393 int/build); e2e B24 + prewarm da rota; Aikido 0 findings. Plano: [`autosave-tendencia-lista-municipios.md`](../../../docs/plans/autosave-tendencia-lista-municipios.md).
- **B14 entregue em código (2026-07-26):** card "Onde estou" no Início staff. **A auditoria do plano derrubou sua decisão central antes de codar:** o plano de 24/07 tratava point-in-polygon como rabbit hole ("explode bundle") e escolhia um artefato commitado de centroides — mas `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`. Decidido com o usuário: usar polígonos. Saíram de escopo `pnpm build:municipality-centroids`, `src/lib/municipalityCentroids/` e o snapshot test — e o card passou a responder "o município **onde estou**" em vez de "o mais próximo". Centroide sobrevive só para ordenar o fallback.
**Decisão de produto do usuário nesta sessão:** 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 a sugestão vira ruído e o card diz que não há nada por perto. Salvador continua sem chute de ZE (gatilho segue **B8 F2**): leva à lista filtrada, com a contagem de zonas limitada ao que o ator enxerga — um assessor com três zonas não é informado de dezenove.
**A medição pegou um custo invisível.** A primeira versão levou o First Load JS de `/campanha` de **265 → 286 kB**: o card chamava `buildMunicipalityListHref` para o link das zonas, e o serializador canônico arrasta `bahiaTerritories` + `municipalityCatalog` (que puxa códigos IBGE e zonas TSE) — **21 kB por um único link**. Corrigido serializando o href no servidor (`zoneCityHrefs`, chaveado por IBGE, uma entrada só quando o ator vê mais de uma zona da cidade): ficou em **268 kB**. O `/simplify` levou esse cálculo do loader `server-only` para `CampaignDashboard` (o RSC que já monta hrefs de lista, então o bundle não muda) e trocou a contagem manual pelo índice `buildMunicipalitiesByIbgeCode` que o painel do mapa já usa; `StaffDashboardView` deixou de carregar URL. A regra "client component não importa módulo de dado do servidor para obter valores" vale também para **serializadores de URL**, e a medição de baseline exigiu `git stash` — o número do B13 (265 kB) não servia como referência por si só.
Peças: `src/lib/municipalityProximity.ts` (puro, recebe a malha já carregada — `features` **e** o índice memoizado `getMunicipalityFeature`: haversine, ray casting com buracos, centroide ponderado por área, união discriminada `inScope | zoneCity | outOfScope | outsideBahia`), `src/utilities/campaignGeolocation.ts` (irmão de `recentVisits`: flag `teqo:campaign:geo-prompted-session`, permissão guardada — Safari sem descriptor devolve `unknown`, que é resposta normal —, `requestCurrentPosition` com falha tipada), `NearestMunicipalityCard` ao lado de `RecentlyVisitedCard` numa linha escrita direto em `CampaignDashboard` (linha inteira escondida quando os dois estão vazios — **em CSS** (`has-[>*]:grid`), decisão do `/simplify`: a primeira versão pedia aos filhos que reportassem visibilidade para cima e pagava dois slots de estado, um callback, um effect e um _layout shift_ por load, porque o servidor renderizava a linha `hidden` e o effect só a revelava depois do primeiro paint; a regra compilada entra depois de `.hidden` na cascata, verificado no CSS do build. Sem esse estado o wrapper `DashboardShortcuts` era um `div` com nome, e foi inlinado). Estado `starting` no mount para não mostrar um botão que viraria spinner milissegundos depois (um toque ali dispararia um segundo pedido de posição); guarda de `useRef` contra o Strict Mode; fix com raio acima de 10 km mostra o resultado **com** a ressalva de precisão em vez de fingir certeza; `aria-live` `sr-only` montado desde o início. Sem migration, sem collection, sem server action, sem `Consent` — a posição nunca sai do aparelho. Payload RSC das 435 entradas: ~6,6 KB gzip (teto do plano era ~10 KB, então ficaram objetos legíveis em vez de tuplas). Gate verde (tsc, lint, format, knip com o P3 pré-existente, check:cycles, 510 unit / 397 int, 4 e2e novos, build); Aikido 0 achados.
**O `/simplify` mediu o que a revisão só suspeitava** (três revisores paralelos: reuso, qualidade, performance). Números reais, para ninguém re-derivar: a malha decodifica em **417 features, todas `Polygon` simples — 417 anéis, zero buracos, zero MultiPolygon, 15.111 pares de coordenadas**, então os ramos de buraco/MultiPolygon são seguro contra rebuild, não caminho quente; a varredura point-in-polygon completa custa **0,106 ms** e o fallback inteiro **0,32 ms** (81% disso são os 417 centroides), ~2,6 ms com penalidade de 6× de celular médio — ou seja **não havia problema de CPU**, e construir um `Map` novo por chamada mediu **mais lento** (0,337 ms). O índice `getMunicipalityFeature` entrou porque **já existe** (o módulo o constrói para o mapa), não porque a varredura doía. Um **bug real** apareceu aqui: `loadMunicipalityGeometryModule` memoiza a *rejeição* do `import()` dinâmico, então um ChunkLoadError deixava o card girando para sempre com um "Tentar de novo" incapaz de recuperar — agora há `catch` → estado acionável, com a dica de recarregar (só o reload recupera). Também morreu o `noScope`: a política "carteira vazia não pede posição" já vive no componente (que nem renderiza, nem chama `locate`), e repeti-la no módulo puro custava 7 ramos inalcançáveis em dois arquivos. **Débito registrado, não corrigido:** as 435 linhas de `accessibleMunicipalities` (~6,6 KB gzip) duplicam o `municipalitiesByIbgeCode` que o bundle do mapa já manda na mesma página — unificar mexe na fronteira de Suspense do mapa, não é limpeza. **Débito de teste confirmado, não introduzido:** `campaignMunicipalities.e2e.spec.ts` falha 3/6 na suíte cheia com 2 workers e passa 6/6 serial — o mesmo flake que o B13 já registrou no ledger. Plano: [`municipio-mais-proximo.md`](../../../docs/plans/municipio-mais-proximo.md).
- **B13 entregue em código (2026-07-26):** escala relativa no mapa do Início. O coropleto era quase monocromático porque 0–100% dos válidos comprime uma disputa cujo topo estadual é 4,71% — três escalas **relativas ao próprio candidato**, em **classes discretas** (nunca rampa contínua): **quantis** (novo default), **padrão próprio (LQ)** e **posição no município**, ao lado de "% dos válidos" (B11) e "Total (votos)". **Auditoria do plano antes de codar** achou três defasagens, corrigidas em [`escala-relativa-mapa.md`](../../../docs/plans/escala-relativa-mapa.md) § Revisão na entrega: caminhos pós-Pass 2 W2 (`components/campaign/map/`), o nome inexistente `computeDfTerritorialClass` (é `computeMunicipalityTerritorialClass`) e a premissa "o mapa acompanha a lista" (desde a remodelagem o mapa só existe no Início) — resolvida como **quantis sobre o escopo de acesso do usuário**, decisão do usuário nesta sessão. Resolvi também a **contradição interna do plano** sobre LQ: os Objetivos pediam diverging em torno de 1 e as Decisões travadas reservavam diverging ao compare — ficou com as Decisões travadas, LQ como 5 classes nomeadas nas âncoras do E10, porque vermelho × azul significa "Solla × adversário" e não pode passar a significar duas coisas.
**Artefato v3 (decisão cara do usuário: construir).** "Posição no município" não existia nem no artefato nem em query reaproveitável, e computá-la em request seria varrer ~100 mil linhas por ano para um dado imutável. Foi para o artefato commitado (regra 3 da escada de cache, como o E8 na v2): `federalRankByIbgeCode: Record<codarea, Record<year, { rank, candidates }>>` — colocação entre **todos** os candidatos com votos > 0 na geografia, 2014/2018/2022, alimentada por `loadFederalVotesByCityZoneAndCandidate` (irmão CLI-only de `loadCampoFederalVotesByCityZone`). Chaveado por `ibgeCode` e não por slug porque é o que o mapa desenha (as 19 zonas de Salvador somam a cidade inteira); rank por slug entra quando dossiê/E11 pedirem. 534 → 623 KB, dentro do orçamento de 700 KB. Rank fica indisponível em 2026 (dado TSE), mesmo precedente do compare; LQ em 2026 reusa o padrão estadual de 2022.

View file

@ -72,6 +72,7 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap.
**Recently resolved (2026-07-26):** **B24** Auto-save + justificativa no popover de Tendência — `MunicipalityListTrendControl` grava sozinho via `POST /campanha/municipios/political-trend` (status ~150 ms, nota 600 ms, flush blur/fechamento), `Textarea` de justificativa no Popover, sem `revalidatePath` da lista; `/editar` mantém submit explícito. Plano: [`docs/plans/autosave-tendencia-lista-municipios.md`](docs/plans/autosave-tendencia-lista-municipios.md).
**Recently resolved (2026-07-26):** **B14** Município mais próximo (atalho geo no Início staff) — a new client island `NearestMunicipalityCard` ("Onde estou") answers "open the município I am standing in", and the answer is **point-in-polygon**, not a nearest-centroid guess. The source plan had rejected polygons as a bundle rabbit hole; the premise was false by the time it shipped, because `MunicipalityMapPanel` always mounts in `mode="municipality"` and `BahiaMap` calls `loadMunicipalityGeometryModule()` on mount, so the 417 polygons (132 KB raw, **41 KB gzip**, own memoized chunk) are already in the browser of anyone on `/campanha` — the card reuses that promise and adds no geometry. So **no centroid artifact, no build script, no migration, no collection, no server action and no `Consent` key**: the position is read by `navigator.geolocation` and matched in the browser, and never leaves the device. Pure math in `src/lib/municipalityProximity.ts` (haversine, ray casting with holes, area-weighted centroid, discriminated union `inScope | zoneCity | outOfScope | outsideBahia`; **measured, so nobody re-derives it:** the mesh decodes to 417 plain `Polygon`s with zero holes and zero MultiPolygons, a full containment scan costs **0.106 ms** and the whole fallback **0.32 ms** — 81% of it centroids — so there is no hot path here and a centroid artifact would still not pay for itself; building a fresh `Map` per call measured _slower_, and the module's existing `getMunicipalityFeature` index is passed in only because the map already built it); browser plumbing in `src/utilities/campaignGeolocation.ts` (sibling of `recentVisits.ts`: session flag `teqo:campaign:geo-prompted-session` so the automatic prompt fires at most once per tab, guarded permission read where Safari's missing descriptor legitimately answers `unknown`, typed failures). Centroid distance survives only to rank the fallback: an advisor standing outside his portfolio is told where he is **and** offered the closest município he can open, up to `NEAREST_IN_SCOPE_MAX_KM` = 150 km. Salvador still gets no guessed ZE (trigger stays **B8 F2**) — it links to the filtered list, with the zone count limited to what the actor can read. `StaffDashboardView` gained `accessibleMunicipalities` (~6.6 KB gzip of RSC payload for all 435), and the zone-list href reaches the island as a `zoneCityHrefs` prop serialized by `CampaignDashboard` (the RSC that already builds list hrefs) rather than by the Payload loader: the first version imported `buildMunicipalityListHref` into the island and cost **21 kB** of First Load JS for a single link, because the canonical serializer drags `bahiaTerritories` + `municipalityCatalog` in — the "client components must not import server data modules for values" rule applies to **URL serializers** too, so the href is serialized on the server. `/campanha` First Load JS: 265 → **268 kB** (measured against a stashed baseline, not against the number in a previous note). A coarse fix (accuracy > 10 km, typical of a desk) renders the result **with** an explicit "localização aproximada" caveat rather than silently naming the wrong município. The shortcuts row hides itself in **CSS** (`hidden … has-[>*]:grid`, written inline in `CampaignDashboard`): having the children report visibility upward cost two state slots, a callback prop, an effect and a layout shift on every load, because the server rendered the row hidden and the effect only unhid it after first paint — and with that state gone, the `DashboardShortcuts` wrapper was a named `div`, so it is gone too. `loadMunicipalityGeometryModule` memoizes the dynamic import's **rejection**, so the card catches it and falls into an actionable failure state — otherwise a ChunkLoadError left a spinner with a retry button that could never recover. Plano: [`docs/plans/municipio-mais-proximo.md`](docs/plans/municipio-mais-proximo.md).
**Recently resolved (2026-07-26):** **B13** Escala relativa no mapa dos Municípios — the dashboard choropleth was nearly monochrome because 0–100% of valid votes compresses a race whose statewide ceiling is 4.71%. It now offers three scales **relative to the candidate himself**, all as **discrete classes**: **quantis** (the new default), **padrão próprio (LQ)** and **posição no município**, next to the existing "% dos válidos" (B11) and "Total (votos)". Pure math in `src/lib/mapScaleClasses.ts` (new file — `bahiaMapStyle.ts` already owned path styling); `resolvePathStyle` gained a `fillByKey` path that **replaces** the continuous ramp when present, where a missing key is "no data", which is not a value of zero. **LQ is 5 named classes cut on the E10 anchors, not diverging** — the source plan asked for both and contradicted itself; red×blue keeps meaning "Solla × adversário" and stays exclusive to the compare mode. `TERRITORIAL_CLASS_ANCHORS` / `AT_STANDARD_LQ` moved to `src/lib/territorialClassAnchors.ts` because the map panel is a client component and importing the classifier there would drag the ~600 KB TSE artifact into the browser bundle; numbers only, so recalibration (**E15**) stays a one-line diff in one file. Quantiles are computed over the **user's access scope** (435 for coordinator/candidate, the portfolio for an advisor) and **degrade to 3 classes below 10 municípios**, with the legend stating the effective count. "Posição no município" existed nowhere, so it went into the **committed artifact (v3)** rather than a request-time query — see "Election baseline data" above. New in E10: `computeAggregateTerritorialClass(slugs)` classes a GROUP as one territory by summing inputs and running the **same** `classifyMunicipalityTerritory`, never averaging per-slug classes (LQ is a ratio, and the mean of ratios is not the ratio of the sums) — **E12's TI rollup must inherit this helper instead of growing a second one**. The map loader fills `statewideShareByYear`, `territorialClassByCode`, `competitiveRankByYear` and `projectedValidVotesByCode`, all pure over the artifact: **no new query per request**. UI: discrete `MapScaleLegend` with real ranges tied to the selector via `aria-describedby`, a readout that always spells out the reason ("4ª de 5 faixas", "1,8× o padrão estadual do candidato", "12º entre 663 candidatos votados aqui"), and an opt-in proportional-symbol layer (area ∝ projected valid votes, E10 class colour, `interactive: false` so the bubble does not stack a second hover target on its own município) — 417 markers were **measured** before optimizing and did not drop the frame, so the top-K fallback was not built. Two new concepts in `/campanha/conceitos`. No migration, no collection, no server action; `/campanha`'s First Load JS is byte-identical (9.46 kB / 265 kB) because the map panel already ships in its own `ssr: false` chunk — that chunk itself grew, and the RSC payload grew ~64 KB uncompressed (~10 KB gzipped), dominated by `competitiveRankByYear`. **Known flake (pre-existing, reproduced on a clean tree):** `campaignMunicipalities.e2e.spec.ts` fails intermittently with 2 workers — the `LeaderContactsPanel` consent checkbox clicked before hydration, and a `getByRole` regex that matches both "Conde" and "Condeúba" when the fixture picks the former. Plano: [`docs/plans/escala-relativa-mapa.md`](docs/plans/escala-relativa-mapa.md).
**Recently resolved (2026-07-25):** **C13** "Plano de Ação" → "Atividade" — the vertical speaks one word now, the one the mesa already uses. Collection `actionPlan` → `activity` (`src/collections/Activity.ts`, labels "Atividade"/"Atividades"), `campaignDemand.actionPlan` → `activity`, route `/campanha/planos` → `/campanha/atividades` (create at `/campanha/atividades/nova`), sidebar entry "Atividades", and field labels realigned to the entity ("Tipo de atividade", "Origem da atividade", "Resultado da atividade") — enum **values** (`kind`/`status`/`origin`) are unchanged. Schema moved by a **hand-written data-preserving migration** (`20260725_213000_rename_action_plan_to_activity`): 4 tables, 3 enums, 2 columns, 39 indexes (4 pkeys included — renaming a pkey index renames its constraint), 14 FKs and 2 sequences, every statement guarded on the source object existing so environments converge either way, with a symmetric `down()`. Payload's generator would have emitted DROP+CREATE (data loss) and cannot see the hand-written partial index `action_plan_upcoming_start_at_idx`, so the object list came from `pg_indexes`/`pg_constraint`/`pg_type`/`pg_sequences` and the `.json` snapshot was written by transforming its predecessor — validated by `pnpm migrate:create __probe` reporting no schema changes. The same migration aligned the **8 fossil `%plaza%` index names** the Município remodel left behind (it renamed the `plaza_id` columns but not their indexes, so the database and the snapshot had been disagreeing since 2026-07-23). Guard: the `codebaseConventions.unit.spec.ts` "banned campaign terminology" describe is now table-driven and sweeps `src` + `tests` + `scripts` (migrations stay out — frozen history), banning the `actionPlan` compounds, the pt-BR copy and the old route, but **not** bare `plan`/`plano` (that would collide with the white-label subscription plans of Fase 2); allowlist covers the spec itself, `scripts/generate-remodel-municipalities-migration.mjs` and the historical comment in `campaignMigrationReconciliation.int.spec.ts`. Plano: [`docs/plans/renomear-plano-acao-para-atividade.md`](docs/plans/renomear-plano-acao-para-atividade.md).

View file

@ -1,12 +1,31 @@
# B14 — Município mais próximo (acesso rápido por geolocalização)
Status: rascunho
Atualizado em: 2026-07-24
Status: entregue (2026-07-26)
Atualizado em: 2026-07-26
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; artefato estático de centroides + ilha cliente no Início; sem migration, sem collection, sem Consent
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`](../../src/components/campaign/map/MunicipalityMapPanel.tsx) 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`](../../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) e `resolveNearbyMunicipality` devolvendo união discriminada `inScope | 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`](../../src/utilities/campaignGeolocation.ts) — irmão de `recentVisits.ts`: flag `teqo:campaign:geo-prompted-session`, leitura de permissão guardada (Safari sem o descriptor → `unknown`, que é resposta normal) e `requestCurrentPosition` com falha tipada. `COARSE_ACCURACY_M = 10 km` separa fix de aparelho de chute de rede.
- [`NearestMunicipalityCard`](../../src/components/campaign/dashboard/NearestMunicipalityCard.tsx), ao lado de `RecentlyVisitedCard` numa linha de duas colunas escrita **direto em `CampaignDashboard`** e escondida em CSS (`hidden … has-[>*]:grid`) quando os dois cards renderizam `null`. Houve um wrapper `DashboardShortcuts` no meio do caminho; sem estado nem lógica ele era só um `div`, e o `/simplify` o inlinou.
- `StaffDashboardView` publica `accessibleMunicipalities` (slug/name/ibgeCode); o href da lista de zonas é serializado por `CampaignDashboard` (o RSC que já monta hrefs de lista) e desce como prop `zoneCityHrefs` — 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, em vez de só depois do chunk do painel — aceito porque o `Promise.all` esconde isso atrás do fix de GPS (100 ms a 10 s).
**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.
## 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`](../plans/visitados-recentemente.md).
@ -51,49 +70,47 @@ Decisão de produto (2026-07-24, pedido explícito): sugerir acesso rápido ao m
## Decisões travadas
- **Client-only + artefato de centroides (sem modelo no servidor).** Posição fica no browser; nearest = Haversine (ou equivalente) sobre centroides IBGE commitados. 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).
- **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.** Centroides por `ibgeCode`; entradas `kind: 'municipio'` 1:1; entradas `kind: 'zona'` (Salvador) **não** recebem ZE automática na v1 — se o mais próximo for Salvador, CTA para `/campanha/municipios?q=Salvador` (ou filtro equivalente já existente). **Rejeitado:** escolher ZE aleatória/primeira; inventar centroides de zona sem polígonos (B8 F2).
- **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).
- **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`, `municipalityCentroids`, `findNearestMunicipality`, `sessionGeoPrompt`, `PROMPT_SESSION_KEY`); strings visíveis em pt-BR ("Município mais próximo", "Abrir", "Usar minha localização").
- **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
- **Posição no Início: acima dos KPIs, junto a Recentes, ou sob o mapa?** **Opções:** A) imediatamente sob o header (máxima descoberta em campo) | B) ao lado/abaixo de `RecentlyVisitedCard` (família de atalhos) | C) sob o mapa. **Recomendação:** **B** — agrupa atalhos de navegação sem competir com o hero espacial do mapa; em mobile, card curto acima de Recentes. _(assumido — validar no craft/critique)_
- **Mostrar distância em km?** **Opções:** sempre | só se &lt; limiar | nunca. **Recomendação:** mostrar quando a precisão/resolução for confiável (ex. &lt; 50 km do centroide); omitir se o usuário parecer fora da BA (e aí não sugerir).
- **Limiar "fora da Bahia"?** **Recomendação:** se o nearest &gt; ~80–100 km do ponto, tratar como fora do teatro e não sugerir (evita "Abrir Mucugê" com usuário em SP). Valor exato no craft com fixture.
Todas resolvidas na entrega:
## Abordagem proposta
- **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)
```mermaid
flowchart LR
Topo["bahia-municipalities.topo.json<br/>(B2, commitado)"]
Build["pnpm build:municipality-centroids<br/>(CLI, sem DB)"]
Artifact["src/lib/municipalityCentroids/<br/>bahia-municipality-centroids.json"]
Scope["Dashboard RSC<br/>slugs acessíveis do ator"]
Card["NearestMunicipalityCard<br/>(client island)"]
Geo["Geolocation + Permissions<br/>+ sessionStorage prompt flag"]
Match["findNearestMunicipality<br/>(Haversine puro)"]
Link["Link → /campanha/municipios/..."]
Topo --> Build --> Artifact
Scope --> Card
Artifact --> Card
Card --> Geo --> Match --> Link
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:
- **`pnpm build:municipality-centroids`** (`scripts/build-municipality-centroids.mjs`): lê o TopoJSON já commitado (ou IBGE Localidades se for mais estável — preferir TopoJSON para zero rede no rebuild), emite centroides `{ [ibgeCode]: { lat, lng } }` em `src/lib/municipalityCentroids/`; snapshot/int test de cobertura vs `bahiaMunicipalityCodes` / catálogo (padrão do artefato TSE). **Não** entra em `pnpm build` de deploy.
- **`src/lib/municipalityCentroids.ts`** (loader client-safe do JSON) + **`src/lib/municipalityProximity.ts`** (puro): `haversineKm`, `findNearestByIbgeCode(lat, lng, candidates)`, `resolveCatalogTargets(ibgeCode)` → slug único ou `{ kind: 'salvadorZones', listHref }`.
- **`NearestMunicipalityCard`** (`src/components/campaign/`, `'use client'`):
- Props: `{ accessible: { slug, name, ibgeCode, kind }[] }` (mínimo necessário; sem docs Payload inteiros).
- No mount: lê `sessionStorage` flag; consulta `navigator.permissions` quando disponível; se ainda não prompted na sessão e estado ≠ `granted`/`denied` estável, chama `getCurrentPosition` **uma vez** e seta a flag; se `granted`, resolve silencioso.
- Hidratação segura (estado inicial vazio/`null` até o efeito — sem mismatch).
- CTA principal = `Link` shadcn/`Button`; pending honesto enquanto `getCurrentPosition` resolve (Feel the action); erro/negado → CTA manual sem re-prompt automático.
- **Encaixe** em `CampaignDashboard.tsx` (junto a `RecentlyVisitedCard`); página `page.tsx` já restringe staff vs leader — passar só a lista acessível derivada de `getCampaignDashboardData` / scope já carregado (`loadMunicipalityScope` / view).
- **Sem migration, sem collection, sem server action de escrita.**
- **`src/lib/municipalityProximity.ts`** (puro, client-safe, recebe a malha já carregada — `features` + o índice memoizado `getMunicipalityFeature` — 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, `requestCurrentPosition` com 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; `granted` resolve silencioso; `prompt`/`unknown` ainda não perguntado na sessão → **uma** chamada e marca a flag; `denied` → estado acionável com "Tentar de novo", sem re-prompt. Guarda de `useRef` para o Strict Mode não pedir posição duas vezes.
- Pending honesto (`aria-busy` + spinner) e um `aria-live="polite"` `sr-only` montado desde o início, para o desfecho ser anunciado.
- **Encaixe** em `CampaignDashboard.tsx`; `page.tsx` já separa staff de leader.
- **Sem migration, sem collection, sem server action.**
## Dependências
@ -110,7 +127,7 @@ Componentes:
## Rabbit holes
- **Point-in-polygon no client com TopoJSON completo.** Explode bundle e conflita com lazy do mapa (B5). **Mitigação:** só centroides (~poucos KB); polígonos ficam no path do mapa.
- ~~**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 reusa `loadMunicipalityGeometryModule()` 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.
@ -119,7 +136,8 @@ Componentes:
- **Desambiguação Salvador ZE por ponto-em-polígono.** Revisitar quando: **B8 F2** entregue (polígonos no mapa).
- **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.
- **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`.
## Referências
@ -127,7 +145,8 @@ Componentes:
- [visitados-recentemente.md](visitados-recentemente.md) — precedente client-storage + card no dashboard
- [poligonos-pracas-zona.md](poligonos-pracas-zona.md) — B8 F2 (gatilho Salvador ZE)
- [mapa-bahia-geometrias.md](mapa-bahia-geometrias.md) — TopoJSON fonte dos centroides
- `src/components/campaign/CampaignDashboard.tsx`, `RecentlyVisitedCard.tsx`, `src/utilities/recentVisits.ts`
- `src/lib/municipalityCatalog.ts`, `src/lib/bahiaMunicipalityCodes.ts`, `src/lib/geometries/bahia-municipalities.topo.json`
- `src/components/campaign/dashboard/CampaignDashboard.tsx`, `RecentlyVisitedCard.tsx`, `src/utilities/recentVisits.ts`
- `src/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`)

File diff suppressed because one or more lines are too long

View file

@ -3,6 +3,7 @@ import Link from 'next/link'
import type { ReactNode } from 'react'
import { NearestMunicipalityCard } from '@/components/campaign/dashboard/NearestMunicipalityCard'
import { RecentlyVisitedCard } from '@/components/campaign/dashboard/RecentlyVisitedCard'
import { CampaignMetricStrip } from '@/components/campaign/shared/CampaignMetricStrip'
import {
@ -11,7 +12,9 @@ import {
} from '@/components/campaign/shell/CampaignPageShell'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { municipalityForCode } from '@/lib/bahiaMunicipalityCodes'
import { formatElectionNumber } from '@/lib/electionFormat'
import type { AccessibleMunicipality } from '@/lib/municipalityProximity'
import { formatVoteEstimateEndpointsLabel, voteEstimateScenarioLabels } from '@/lib/voteEstimate'
import type { StaffDashboardView } from '@/utilities/campaignDashboardData'
import {
@ -20,12 +23,37 @@ import {
goalCoverageProgressPercent,
} from '@/utilities/goalCoverage'
import { buildMunicipalityListHref } from '@/utilities/municipalityListUrl'
import { buildMunicipalitiesByIbgeCode } from '@/utilities/municipalityMapNavigation'
const dateTimeFormatter = new Intl.DateTimeFormat('pt-BR', {
dateStyle: 'short',
timeStyle: 'short',
})
/**
* B14 — one filtered-list href per city the catalog splits into zone municipalities
* (today only Salvador), for the cities where the actor can open more than one zone.
* Serialized here rather than in the island: the canonical list-URL builder validates
* against the catalog and the identity-territory table, and importing it into a client
* component added 21 KB to this route's First Load JS for a single link.
*
* The search term is the whole-city name, which matches every `Salvador — ZE n` row
* and is then scoped by the list to what the actor may read.
*/
const buildZoneCityHrefs = (
accessible: readonly AccessibleMunicipality[],
): Record<string, string> => {
const hrefs: Record<string, string> = {}
for (const [ibgeCode, entries] of Object.entries(buildMunicipalitiesByIbgeCode(accessible))) {
if (entries.length < 2) continue
const city = municipalityForCode(ibgeCode)
if (city) hrefs[ibgeCode] = buildMunicipalityListHref({ page: 1, q: city }, 1)
}
return hrefs
}
export const CampaignDashboard = ({
view,
userName,
@ -178,7 +206,21 @@ export const CampaignDashboard = ({
</section>
) : null}
<RecentlyVisitedCard now={new Date()} />
{/*
* Navigation shortcuts. Both cards decide for themselves whether they have
* anything to say — recent visits are empty on a fresh device, and the geo
* card hides when the browser cannot help — so the row disappears in CSS
* (`has-[>*]`) instead of asking the islands to report visibility upward,
* which cost two state slots and a layout shift on every load. Two columns
* even with a single card, keeping the width rhythm of the panels above.
*/}
<div className="hidden gap-6 has-[>*]:grid lg:grid-cols-2">
<NearestMunicipalityCard
accessible={view.accessibleMunicipalities}
zoneCityHrefs={buildZoneCityHrefs(view.accessibleMunicipalities)}
/>
<RecentlyVisitedCard now={new Date()} />
</div>
<div>
<Button asChild className="min-h-11">

View file

@ -0,0 +1,371 @@
'use client'
import { ArrowRightIcon, LocateFixedIcon } from 'lucide-react'
import Link from 'next/link'
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 {
formatDistanceKm,
resolveNearbyMunicipality,
type AccessibleMunicipality,
type NearbyMunicipalityResolution,
} from '@/lib/municipalityProximity'
import {
COARSE_ACCURACY_M,
hasPromptedThisSession,
markPromptedThisSession,
readGeolocationPermissionState,
requestCurrentPosition,
type GeolocationFailureReason,
} from '@/utilities/campaignGeolocation'
type CardState =
/**
* Mounted, permission not read yet. Renders the explanation without the CTA:
* showing a button that turns into a spinner milliseconds later invites a tap
* that fires a second position request.
*/
| { status: 'starting' }
/** Waiting for the user: either the session already prompted, or permission was refused before. */
| { status: 'ready' }
| { status: 'locating' }
| { status: 'resolved'; resolution: NearbyMunicipalityResolution; coarse: boolean }
| { status: 'failed'; reason: GeolocationFailureReason }
const failureCopy: Record<GeolocationFailureReason, { title: string; hint: string } | null> = {
denied: {
title: 'Localização bloqueada neste navegador',
hint: 'Libere o acesso à localização nas permissões do site e tente de novo.',
},
// Also the copy for a geometry chunk that failed to load, hence the reload nudge:
// the module promise memoizes its rejection, so only a reload can recover.
unavailable: {
title: 'Não foi possível obter sua localização',
hint: 'Tente de novo — em outro ponto, ou recarregando a página.',
},
timeout: {
title: 'A localização demorou para responder',
hint: 'Sinal fraco costuma ser a causa. Tente de novo.',
},
// Nothing the user can do here, so the card does not take space (see render).
unsupported: null,
}
/**
* B14 — "abrir a ficha do município onde estou" no Início staff.
*
* A posição fica no navegador: o casamento é feito contra a malha municipal que
* o mapa do dashboard já carrega (mesmo chunk memoizado), sem action, sem
* artefato e sem `Consent` — nada de coordenada sai do aparelho.
*
* `leader` nunca chega aqui: a página `/campanha` roteia liderança para o
* `LeaderContactsPanel` antes do dashboard.
*/
export const NearestMunicipalityCard = ({
accessible,
zoneCityHrefs,
}: {
accessible: readonly AccessibleMunicipality[]
/**
* Filtered-list href per multi-zone city, keyed by IBGE code. Ready-made on
* purpose: importing the canonical list-URL builder here costs 21 KB of the
* dashboard's First Load JS (see `buildZoneCityHrefs` in `CampaignDashboard`).
*/
zoneCityHrefs: Readonly<Record<string, string>>
}) => {
const [state, setState] = useState<CardState>({ status: 'starting' })
const autoStartedRef = useRef(false)
const locate = useCallback(async () => {
setState({ status: 'locating' })
/*
* The geometry chunk is shared with the dashboard map, so this is usually
* already resolved by the time the fix arrives. `requestCurrentPosition`
* cannot reject, but the dynamic import can (ChunkLoadError on a weak field
* connection) — and `loadMunicipalityGeometryModule` memoizes the rejection,
* so without this catch the card would spin forever with a retry button that
* can never succeed.
*/
const loaded = await Promise.all([
requestCurrentPosition(),
loadMunicipalityGeometryModule(),
]).catch(() => null)
if (!loaded) {
setState({ status: 'failed', reason: 'unavailable' })
return
}
const [result, geometry] = loaded
if (!result.ok) {
setState({ status: 'failed', reason: result.reason })
return
}
setState({
status: 'resolved',
coarse: result.fix.accuracyM > COARSE_ACCURACY_M,
resolution: resolveNearbyMunicipality({ point: result.fix, geometry, accessible }),
})
}, [accessible])
useEffect(() => {
/*
* One automatic attempt per mount: a re-render with a fresh `accessible`
* array must never fire a second position request. The guard is a ref
* rather than a cancellation flag on purpose — Strict Mode re-runs this
* effect on the same instance, and cancelling the first run while the ref
* blocks the second one would leave the card idle forever.
*/
if (autoStartedRef.current || accessible.length === 0) return
autoStartedRef.current = true
const start = async () => {
const permission = await readGeolocationPermissionState()
if (permission === 'granted') {
void locate()
return
}
// Product decision (2026-07-24): ask automatically, but at most once per
// tab session — never on every navigation back to the dashboard.
if (permission !== 'denied' && !hasPromptedThisSession()) {
markPromptedThisSession()
void locate()
return
}
setState(
permission === 'denied' ? { status: 'failed', reason: 'denied' } : { status: 'ready' },
)
}
void start()
}, [accessible.length, locate])
if (accessible.length === 0 || !isCardWorthShowing(state)) return null
return (
<Card className="flex h-full flex-col">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<LocateFixedIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
Onde estou
</CardTitle>
</CardHeader>
<CardContent
className="flex flex-1 flex-col gap-2 pt-0"
aria-busy={state.status === 'locating'}
>
{/* Mounted from the start so the state change is actually announced. */}
<p className="sr-only" aria-live="polite">
{statusAnnouncement(state)}
</p>
{renderBody(state, locate, zoneCityHrefs)}
</CardContent>
</Card>
)
}
/** A browser without geolocation takes no space: there is nothing to act on. */
const isCardWorthShowing = (state: CardState): boolean =>
state.status !== 'failed' || state.reason !== 'unsupported'
/**
* The multi-second wait and its outcome must reach a screen reader, and the
* visible copy is spread over paragraph and button — hence one sentence here.
*/
const statusAnnouncement = (state: CardState): string => {
if (state.status === 'locating') return 'Localizando…'
if (state.status === 'failed') return failureCopy[state.reason]?.title ?? ''
if (state.status !== 'resolved') return ''
return headlineSentence(locationHeadline(state.resolution))
}
/**
* Where the actor is, written once: the announcement above and the visible
* headline below both read it, so they cannot drift apart.
*/
type LocationHeadline = { place: string; suffix: string } | { place: null; sentence: string }
const locationHeadline = (resolution: NearbyMunicipalityResolution): LocationHeadline => {
switch (resolution.kind) {
case 'inScope':
return { place: resolution.municipality.name, suffix: '.' }
case 'zoneCity':
return { place: resolution.city, suffix: ', dividida por zona eleitoral.' }
case 'outOfScope':
return { place: resolution.city, suffix: ', fora da sua carteira.' }
case 'outsideBahia':
return {
place: null,
sentence: resolution.nearestInScope
? 'Você está fora da Bahia.'
: 'Nenhum município da campanha perto de você.',
}
}
}
const headlineSentence = (headline: LocationHeadline): string =>
headline.place === null ? headline.sentence : `Você está em ${headline.place}${headline.suffix}`
const Headline = ({ headline }: { headline: LocationHeadline }) => (
<p className="text-sm">
{headline.place === null ? (
headline.sentence
) : (
<>
Você está em <strong className="font-medium">{headline.place}</strong>
{headline.suffix}
</>
)}
</p>
)
const LocateButton = ({ label, onClick }: { label: string; onClick: () => void }) => (
<Button type="button" variant="outline" className="min-h-11 self-start" onClick={onClick}>
<LocateFixedIcon data-icon="inline-start" aria-hidden="true" />
{label}
</Button>
)
const OpenMunicipalityButton = ({ slug, name }: { slug: string; name: string }) => (
<Button asChild className="min-h-11 self-start">
<Link href={`/campanha/municipios/${slug}`}>
Abrir {name}
<ArrowRightIcon data-icon="inline-end" aria-hidden="true" />
</Link>
</Button>
)
const Explanation = ({ children }: { children: ReactNode }) => (
<p className="text-xs text-muted-foreground">{children}</p>
)
const CoarseFixCaveat = () => (
<Explanation>
Localização aproximada (o navegador não usou o GPS) — confira o município antes de agir.
</Explanation>
)
const renderBody = (
state: CardState,
locate: () => void,
zoneCityHrefs: Readonly<Record<string, string>>,
): ReactNode => {
if (state.status === 'starting' || state.status === 'ready') {
return (
<>
<Explanation>Abra a ficha do município em que você está.</Explanation>
{/* No CTA while permission is unread: it would become a spinner under the finger. */}
{state.status === 'ready' ? (
<LocateButton label="Usar minha localização" onClick={locate} />
) : null}
</>
)
}
if (state.status === 'locating') {
return (
<p className="flex items-center gap-2 text-sm text-muted-foreground">
{/* The live region above announces the wait; Spinner's own role="status" would repeat it. */}
<Spinner aria-hidden="true" />
Localizando…
</p>
)
}
if (state.status === 'failed') {
const copy = failureCopy[state.reason]
if (!copy) return null
return (
<>
<p className="text-sm font-medium">{copy.title}</p>
<Explanation>{copy.hint}</Explanation>
<LocateButton label="Tentar de novo" onClick={locate} />
</>
)
}
return (
<>
<Headline headline={locationHeadline(state.resolution)} />
{/* Between "where you are" and what to do about it — a hedge after the CTA is a footnote. */}
{state.coarse ? <CoarseFixCaveat /> : null}
{resolutionDetail(state.resolution, locate, zoneCityHrefs)}
</>
)
}
/** What to do about where you are — the headline itself comes from `locationHeadline`. */
const resolutionDetail = (
resolution: NearbyMunicipalityResolution,
locate: () => void,
zoneCityHrefs: Readonly<Record<string, string>>,
): ReactNode => {
const nearest = 'nearestInScope' in resolution ? resolution.nearestInScope : null
const nearestDetail = nearest ? (
<>
<Explanation>
Mais próximo na sua carteira: {nearest.municipality.name}, a{' '}
{formatDistanceKm(nearest.distanceKm)}.
</Explanation>
<OpenMunicipalityButton slug={nearest.municipality.slug} name={nearest.municipality.name} />
</>
) : null
switch (resolution.kind) {
case 'inScope':
return (
<OpenMunicipalityButton
slug={resolution.municipality.slug}
name={resolution.municipality.name}
/>
)
case 'zoneCity': {
// Server and client read the same `accessible` set, so the href is there —
// but a button labeled "Ver zonas de X" that opened the unfiltered list
// would be worse than no button.
const zonesHref = zoneCityHrefs[resolution.ibgeCode]
return (
<>
<Explanation>
Escolha entre as {resolution.zoneCount} zonas — a localização não distingue a zona.
</Explanation>
{zonesHref ? (
<Button asChild className="min-h-11 self-start">
<Link href={zonesHref}>
Ver zonas de {resolution.city}
<ArrowRightIcon data-icon="inline-end" aria-hidden="true" />
</Link>
</Button>
) : null}
</>
)
}
case 'outOfScope':
return nearestDetail ?? <Explanation>Nenhum município da sua carteira por perto.</Explanation>
case 'outsideBahia':
return (
nearestDetail ?? (
<>
<Explanation>Sua posição está longe do território da campanha.</Explanation>
<LocateButton label="Tentar de novo" onClick={locate} />
</>
)
)
}
}

View file

@ -19,7 +19,6 @@ type RecentlyVisitedCardProps = {
compact?: boolean
clearControl?: 'icon' | 'labeled'
className?: string
onVisitCountChange?: (count: number) => void
}
export const RecentlyVisitedCard = ({
@ -27,20 +26,16 @@ export const RecentlyVisitedCard = ({
compact = false,
clearControl = 'icon',
className,
onVisitCountChange,
}: RecentlyVisitedCardProps) => {
const [entries, setEntries] = useState<RecentVisitEntry[]>([])
useEffect(() => {
const visits = listRecentVisits()
setEntries(visits)
onVisitCountChange?.(visits.length)
}, [onVisitCountChange])
setEntries(listRecentVisits())
}, [])
const handleClear = () => {
clearRecentVisits()
setEntries([])
onVisitCountChange?.(0)
}
if (!entries.length) return null

View file

@ -0,0 +1,261 @@
import type { Position } from 'geojson'
import type {
BahiaMunicipalityFeature,
MunicipalityGeometryModule,
} from '@/lib/bahiaGeometriesTypes'
/**
* B14 — "onde estou" resolvido contra a malha municipal da Bahia.
*
* Pure math over GeoJSON features the caller already has: the dashboard map
* 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.
*/
export type GeoPoint = {
lat: number
lng: number
}
/**
* What the resolver needs from the loaded mesh: the features to scan for
* containment, and the module's own memoized `codarea` index so the fallback
* does not re-scan 417 features once per município in the portfolio.
*/
type MunicipalityGeometryLookup = Pick<
MunicipalityGeometryModule,
'features' | 'getMunicipalityFeature'
>
/** The minimum a município needs to be linkable — what the server ships to the island. */
export type AccessibleMunicipality = {
slug: string
name: string
ibgeCode: string
}
type NearestInScope = {
municipality: AccessibleMunicipality
/** Straight-line distance to the município's approximate centre. */
distanceKm: number
}
export type NearbyMunicipalityResolution =
/** The point falls inside a município the actor can open. */
| { kind: 'inScope'; municipality: AccessibleMunicipality }
/**
* The point falls inside a city modeled as several zone municipalities
* (Salvador). Without zone polygons (B8 F2) the honest answer is the filtered
* list, never a guessed ZE. `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.
*/
| { kind: 'zoneCity'; city: string; ibgeCode: string; zoneCount: number }
/** Inside Bahia, but in a município outside the actor's portfolio. */
| { kind: 'outOfScope'; city: string; nearestInScope: NearestInScope | null }
/** No Bahia município contains the point. */
| { kind: 'outsideBahia'; nearestInScope: NearestInScope | null }
const EARTH_RADIUS_KM = 6371
const toRadians = (degrees: number): number => (degrees * Math.PI) / 180
/** Great-circle distance — enough for "está a ~38 km", not for surveying. */
export const haversineKm = (from: GeoPoint, to: GeoPoint): number => {
const deltaLat = toRadians(to.lat - from.lat)
const deltaLng = toRadians(to.lng - from.lng)
const fromLat = toRadians(from.lat)
const toLat = toRadians(to.lat)
const chord =
Math.sin(deltaLat / 2) ** 2 + Math.cos(fromLat) * Math.cos(toLat) * Math.sin(deltaLng / 2) ** 2
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(chord)))
}
/**
* Even-odd ray casting. A point exactly on a ring is undefined by construction,
* which is why `findContainingMunicipality` documents a deterministic tie-break
* instead of pretending shared borders belong to nobody.
*/
const isPointInRing = (point: GeoPoint, ring: readonly Position[]): boolean => {
let inside = false
for (let current = 0, previous = ring.length - 1; current < ring.length; previous = current++) {
const [currentLng, currentLat] = ring[current]
const [previousLng, previousLat] = ring[previous]
const straddlesRay = currentLat > point.lat !== previousLat > point.lat
if (!straddlesRay) continue
const crossingLng =
currentLng +
((point.lat - currentLat) / (previousLat - currentLat)) * (previousLng - currentLng)
if (point.lng < crossingLng) inside = !inside
}
return inside
}
const polygonRingsOf = (feature: BahiaMunicipalityFeature): readonly Position[][][] =>
feature.geometry.type === 'Polygon'
? [feature.geometry.coordinates]
: feature.geometry.coordinates
/**
* Holes and MultiPolygons do not occur in the committed mesh (417 plain
* Polygons), but a rebuild at finer quality would introduce them.
*/
export const featureContainsPoint = (feature: BahiaMunicipalityFeature, point: GeoPoint): boolean =>
polygonRingsOf(feature).some(([outerRing, ...holes]) => {
if (!outerRing || !isPointInRing(point, outerRing)) return false
return !holes.some((hole) => isPointInRing(point, hole))
})
/**
* Area-weighted centroid of the outer rings (planar shoelace — the distortion
* at Bahia's latitudes is far below the precision this feeds). Holes are
* ignored: they move the centre by less than the rounding of the label.
*/
export const featureCentroid = (feature: BahiaMunicipalityFeature): GeoPoint => {
let weightedLng = 0
let weightedLat = 0
let totalArea = 0
for (const [outerRing] of polygonRingsOf(feature)) {
if (!outerRing || outerRing.length < 3) continue
let signedArea = 0
let ringLng = 0
let ringLat = 0
for (
let current = 0, previous = outerRing.length - 1;
current < outerRing.length;
previous = current++
) {
const [currentLng, currentLat] = outerRing[current]
const [previousLng, previousLat] = outerRing[previous]
const cross = previousLng * currentLat - currentLng * previousLat
signedArea += cross
ringLng += (previousLng + currentLng) * cross
ringLat += (previousLat + currentLat) * cross
}
const area = Math.abs(signedArea) / 2
if (area === 0) continue
totalArea += area
weightedLng += (ringLng / (3 * signedArea)) * area
weightedLat += (ringLat / (3 * signedArea)) * area
}
if (totalArea === 0) {
// Degenerate ring — no feature in the committed mesh is like this (pinned by
// the centroid-in-bbox int test). The first vertex keeps NaN out downstream.
const [lng = 0, lat = 0] = polygonRingsOf(feature)[0]?.[0]?.[0] ?? []
return { lat, lng }
}
return { lat: weightedLat / totalArea, lng: weightedLng / totalArea }
}
/**
* First feature whose polygons contain the point. Shared borders are resolved
* by feature order, so a point on a boundary belongs to exactly one município —
* never to both, never to none.
*/
export const findContainingMunicipality = (
features: readonly BahiaMunicipalityFeature[],
point: GeoPoint,
): BahiaMunicipalityFeature | undefined =>
features.find((feature) => featureContainsPoint(feature, 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
const findNearestInScope = (
getMunicipalityFeature: MunicipalityGeometryLookup['getMunicipalityFeature'],
accessible: readonly AccessibleMunicipality[],
point: GeoPoint,
): NearestInScope | null => {
let nearest: NearestInScope | null = null
for (const municipality of accessible) {
const feature = getMunicipalityFeature(municipality.ibgeCode)
if (!feature) continue
const distanceKm = haversineKm(point, featureCentroid(feature))
if (distanceKm > NEAREST_IN_SCOPE_MAX_KM) continue
if (nearest && nearest.distanceKm <= distanceKm) continue
nearest = { municipality, distanceKm }
}
return nearest
}
/**
* Resolves a browser position into the one município the card should offer.
*
* `accessible` is the actor's readable set, so an advisor standing outside his
* portfolio is told where he is AND offered the closest município he can open.
* An empty set is the caller's business: the card refuses to render or ask for a
* position at all in that case, so this function does not repeat the policy.
*/
export const resolveNearbyMunicipality = ({
point,
geometry,
accessible,
}: {
point: GeoPoint
geometry: MunicipalityGeometryLookup
accessible: readonly AccessibleMunicipality[]
}): NearbyMunicipalityResolution => {
const containing = findContainingMunicipality(geometry.features, point)
if (!containing) {
return {
kind: 'outsideBahia',
nearestInScope: findNearestInScope(geometry.getMunicipalityFeature, accessible, point),
}
}
const inCity = accessible.filter(
(municipality) => municipality.ibgeCode === containing.properties.codarea,
)
if (inCity.length === 1) return { kind: 'inScope', municipality: inCity[0] }
if (inCity.length > 1) {
return {
kind: 'zoneCity',
city: containing.properties.name,
ibgeCode: containing.properties.codarea,
zoneCount: inCity.length,
}
}
return {
kind: 'outOfScope',
city: containing.properties.name,
nearestInScope: findNearestInScope(geometry.getMunicipalityFeature, accessible, point),
}
}
const kmFormatter = new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 1 })
/** Distance as field copy: precise where it is felt, rounded where it is not. */
export const formatDistanceKm = (distanceKm: number): string => {
if (distanceKm < 1) return 'menos de 1 km'
return `${kmFormatter.format(distanceKm < 10 ? distanceKm : Math.round(distanceKm))} km`
}

View file

@ -2,6 +2,7 @@ import 'server-only'
import type { Payload } from 'payload'
import type { AccessibleMunicipality } from '@/lib/municipalityProximity'
import type { VoteEstimateScenario } from '@/lib/voteEstimate'
import type { CampaignUser, VotePledge } from '@/payload-types'
import { loadMunicipalityScope } from '@/utilities/campaignMunicipalityScope'
@ -34,6 +35,13 @@ export type StaffDashboardView = {
declaredVotes: number
}>
priorityMunicipalities: DashboardPriorityMunicipality[]
/**
* B14 — every município the actor can open, for the geo shortcut to match a
* browser position against without sending the position anywhere. Minimal by
* design: three short strings per row, so the whole catalog stays a few KB of
* RSC payload.
*/
accessibleMunicipalities: AccessibleMunicipality[]
recentUpdates: Array<{
id: number
municipalityName: string
@ -148,6 +156,10 @@ export const getCampaignDashboardData = async (
goalCoverageBundle.coverageByMunicipalityID,
)
const accessibleMunicipalities: AccessibleMunicipality[] = municipalities.docs.map(
({ slug, name, ibgeCode }) => ({ slug, name, ibgeCode }),
)
return {
kind: 'staff',
role:
@ -179,6 +191,7 @@ export const getCampaignDashboardData = async (
}),
highPriorityCount: dashboardPriority.highPriorityCount,
priorityMunicipalities: dashboardPriority.municipalities,
accessibleMunicipalities,
recentUpdates: recentUpdatesResult.docs.map((update) => {
const municipality = municipalityById.get(relationshipId(update.municipality) ?? -1)
const author = authorsById.get(relationshipId(update.author) ?? -1)

View file

@ -0,0 +1,121 @@
/**
* B14 — browser geolocation for the `/campanha` field desk.
*
* The position never leaves the device: it is read here and matched against the
* committed municipality mesh in the client, so there is no server action, no
* PII at rest and no `Consent` key (same boundary as `recentVisits`).
*
* Sibling of `recentVisits.ts` on purpose — both are browser-storage/browser-API
* clients, not Payload-coupled loaders.
*/
export const GEO_PROMPT_SESSION_KEY = 'teqo:campaign:geo-prompted-session'
/** Field networks are slow; past this the card offers the manual retry instead of spinning. */
const POSITION_TIMEOUT_MS = 10_000
/** A fix from the last few minutes still answers "which município am I in". */
const POSITION_MAX_AGE_MS = 5 * 60_000
/**
* Above this reported radius the fix is a network/IP guess, not a device fix: on a
* desk it can land tens of kilometres away, which is enough to name the wrong
* município with total confidence. Surfaced as a caveat, never silently trusted.
*/
export const COARSE_ACCURACY_M = 10_000
export type GeolocationPermissionState = 'granted' | 'denied' | 'prompt' | 'unknown'
type GeolocationFix = {
lat: number
lng: number
/** Reported accuracy radius in metres — surfaced only to withhold false precision. */
accuracyM: number
}
export type GeolocationFailureReason = 'unsupported' | 'denied' | 'unavailable' | 'timeout'
export type GeolocationResult =
| { ok: true; fix: GeolocationFix }
| { ok: false; reason: GeolocationFailureReason }
/**
* Whether this tab already fired the automatic prompt. `sessionStorage` (not
* `localStorage`) on purpose: a new session may legitimately ask again after the
* user changes the permission in the OS, but one navigation inside the dashboard
* must never re-prompt.
*/
export const hasPromptedThisSession = (): boolean => {
if (typeof window === 'undefined') return false
try {
return sessionStorage.getItem(GEO_PROMPT_SESSION_KEY) === '1'
} catch {
// Private mode: treat as already prompted so the automatic path stays silent.
return true
}
}
export const markPromptedThisSession = (): void => {
if (typeof window === 'undefined') return
try {
sessionStorage.setItem(GEO_PROMPT_SESSION_KEY, '1')
} catch {
// Ignore quota / private-mode failures.
}
}
/**
* Reads the permission without triggering a dialog. Safari shipped
* `navigator.permissions` without the `geolocation` descriptor for years, so
* `unknown` is a normal answer, not an error.
*/
export const readGeolocationPermissionState = async (): Promise<GeolocationPermissionState> => {
if (typeof window === 'undefined' || !('geolocation' in navigator)) return 'unknown'
if (!navigator.permissions?.query) return 'unknown'
try {
const status = await navigator.permissions.query({ name: 'geolocation' })
return status.state
} catch {
return 'unknown'
}
}
const failureReasonFor = (error: GeolocationPositionError): GeolocationFailureReason => {
if (error.code === error.PERMISSION_DENIED) return 'denied'
if (error.code === error.TIMEOUT) return 'timeout'
return 'unavailable'
}
/**
* One position request, resolving to a typed result instead of throwing — every
* failure mode is a rendered state in the card.
*
* High accuracy stays off: the answer is a município, and a GPS lock costs
* seconds and battery for precision that changes nothing here.
*/
export const requestCurrentPosition = (): Promise<GeolocationResult> => {
if (typeof window === 'undefined' || !navigator.geolocation?.getCurrentPosition) {
return Promise.resolve({ ok: false, reason: 'unsupported' })
}
return new Promise<GeolocationResult>((resolve) => {
navigator.geolocation.getCurrentPosition(
(position) =>
resolve({
ok: true,
fix: {
lat: position.coords.latitude,
lng: position.coords.longitude,
accuracyM: position.coords.accuracy,
},
}),
(error) => resolve({ ok: false, reason: failureReasonFor(error) }),
{
enableHighAccuracy: false,
timeout: POSITION_TIMEOUT_MS,
maximumAge: POSITION_MAX_AGE_MS,
},
)
})
}

View file

@ -17,7 +17,7 @@ type MunicipalityForIbgeIndex = {
}
export const buildMunicipalitiesByIbgeCode = (
municipalities: MunicipalityForIbgeIndex[],
municipalities: readonly MunicipalityForIbgeIndex[],
): MunicipalitiesByIbgeCode => {
const byIbge: MunicipalitiesByIbgeCode = {}

View file

@ -0,0 +1,230 @@
import type { Locator, Page } from '@playwright/test'
import { loadMunicipalityGeometryModule } from '../../src/lib/bahiaGeometries.js'
import type { BahiaMunicipalityFeature } from '../../src/lib/bahiaGeometriesTypes.js'
import { getMunicipalityCatalogEntry } from '../../src/lib/municipalityCatalog.js'
import {
featureCentroid,
featureContainsPoint,
haversineKm,
type GeoPoint,
} from '../../src/lib/municipalityProximity.js'
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
* a card the user can act on instead of a spinner.
*/
const SALVADOR_CENTRE: GeoPoint = { lat: -12.973, lng: -38.5121 }
/**
* 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
* copy — DOM order puts the live region first.
*/
const visibleText = (scope: Locator, text: string) => scope.getByText(text).last()
/** Assertions belong to the card, not to "somewhere on the dashboard". */
const geoCard = (page: Page) => page.locator('[data-slot="card"]').filter({ hasText: 'Onde estou' })
/**
* Coarse grid scan for a point that is really inside the polygon — a centroid can
* fall outside a concave município, and the fixture hands out an arbitrary one.
*/
const interiorPointOf = (feature: BahiaMunicipalityFeature): GeoPoint => {
const rings =
feature.geometry.type === 'Polygon'
? [feature.geometry.coordinates]
: feature.geometry.coordinates
const positions = rings.flat(2)
const longitudes = positions.map(([lng]) => lng)
const latitudes = positions.map(([, lat]) => lat)
const west = Math.min(...longitudes)
const east = Math.max(...longitudes)
const south = Math.min(...latitudes)
const north = Math.max(...latitudes)
const steps = 24
for (let row = 1; row < steps; row += 1) {
for (let column = 1; column < steps; column += 1) {
const point = {
lng: west + ((east - west) * column) / steps,
lat: south + ((north - south) * row) / steps,
}
if (featureContainsPoint(feature, point)) return point
}
}
throw new Error(`No interior point found for ${feature.properties.name}`)
}
/**
* Farthest and nearest other município from a given one, by centroid. The fixture
* hands out an arbitrary município, so the two fallback positions have to be
* derived from it — a fixed city would be inside the 150 km suggestion radius on
* some runs and outside it on others.
*/
const otherMunicipalitiesByDistance = (
features: readonly BahiaMunicipalityFeature[],
origin: BahiaMunicipalityFeature,
): { nearest: BahiaMunicipalityFeature; farthest: BahiaMunicipalityFeature } => {
const from = featureCentroid(origin)
const ranked = features
.filter((feature) => feature.properties.codarea !== origin.properties.codarea)
.map((feature) => ({ feature, distanceKm: haversineKm(from, featureCentroid(feature)) }))
.sort((left, right) => left.distanceKm - right.distanceKm)
return { nearest: ranked[0]!.feature, farthest: ranked[ranked.length - 1]!.feature }
}
/** Whole-municipality unit (not a Salvador zone), so containment maps to one slug. */
const claimWholeMunicipality = async (
claim: () => Promise<{ id: number; name: string; slug: string }>,
) => {
for (let attempt = 0; attempt < 30; attempt += 1) {
const municipality = await claim()
if (getMunicipalityCatalogEntry(municipality.slug)?.kind === 'municipio') return municipality
}
throw new Error('Could not claim a whole-municipality unit for the geo shortcut test.')
}
/*
* Serial with a wider timeout: all three tests land on the staff dashboard (map +
* 435 municípios), and simultaneous cold hits on the dev server time out the
* login navigation before anything about this card is exercised. The advisor test
* renders that dashboard four times, which does not fit the default 30s in dev.
*/
test.describe.configure({ mode: 'serial', timeout: 150_000 })
test.describe('Município mais próximo', () => {
test('advisor in his own município opens it, and is told when he is outside the portfolio', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const password = fixtures.value('senha')
const email = `${fixtures.value('geo-advisor')}@example.com`
const advisor = await campaign.payload.create({
collection: 'campaignUser',
data: {
name: fixtures.value('Assessor Geo'),
email,
password,
role: 'advisor',
},
depth: 0,
})
const municipality = await claimWholeMunicipality(() => fixtures.claimMunicipality())
await campaign.payload.update({
collection: 'municipality',
id: municipality.id,
data: { advisors: [advisor.id] },
depth: 0,
})
fixtures.touchMunicipality(municipality.id)
const { features, getMunicipalityFeature } = await loadMunicipalityGeometryModule()
const portfolio = getMunicipalityFeature(
getMunicipalityCatalogEntry(municipality.slug)!.ibgeCode,
)!
const { nearest, farthest } = otherMunicipalitiesByDistance(features, portfolio)
const inside = interiorPointOf(portfolio)
await page.context().grantPermissions(['geolocation'])
await page.context().setGeolocation({ latitude: inside.lat, longitude: inside.lng })
await campaign.login(page, email, password)
const card = geoCard(page)
await expect(visibleText(card, `Você está em ${municipality.name}`)).toBeVisible()
await expect(card.getByRole('link', { name: `Abrir ${municipality.name}` })).toBeVisible()
// A device-grade fix carries no caveat.
await expect(card.getByText('Localização aproximada')).toHaveCount(0)
// Next door but off his portfolio: named where he is, offered what he can open.
const nextDoor = interiorPointOf(nearest)
await page.context().setGeolocation({ latitude: nextDoor.lat, longitude: nextDoor.lng })
await page.reload()
await expect(visibleText(card, 'fora da sua carteira')).toBeVisible()
await expect(card.getByText(`Mais próximo na sua carteira: ${municipality.name}`)).toBeVisible()
await expect(card.getByRole('link', { name: `Abrir ${municipality.name}` })).toBeVisible()
// Across the state: suggesting the portfolio would be noise, not a shortcut.
const farAway = interiorPointOf(farthest)
await page.context().setGeolocation({ latitude: farAway.lat, longitude: farAway.lng })
await page.reload()
await expect(visibleText(card, 'fora da sua carteira')).toBeVisible()
await expect(card.getByText('Nenhum município da sua carteira por perto.')).toBeVisible()
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,
}) => {
const { fixtures } = campaign
const password = fixtures.value('senha')
const email = `${fixtures.value('geo-coordinator')}@example.com`
await campaign.payload.create({
collection: 'campaignUser',
data: {
name: fixtures.value('Coordenadora Geo'),
email,
password,
role: 'coordinator',
},
depth: 0,
})
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(page).toHaveURL(/\/campanha\/municipios\?.*q=Salvador/)
// Exact: "Salvador — ZE 1" is otherwise a prefix of ZE 12 and ZE 17.
await expect(page.getByRole('link', { name: 'Salvador — ZE 1', exact: true })).toBeVisible()
})
test('a refused permission leaves an actionable card instead of a spinner', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const password = fixtures.value('senha')
const email = `${fixtures.value('geo-blocked')}@example.com`
await campaign.payload.create({
collection: 'campaignUser',
data: {
name: fixtures.value('Coordenadora Bloqueada'),
email,
password,
role: 'coordinator',
},
depth: 0,
})
// No grantPermissions: Chromium refuses the position, the same as a user tapping "Block".
await campaign.login(page, email, password)
const card = geoCard(page)
await expect(visibleText(card, 'Localização bloqueada neste navegador')).toBeVisible()
await expect(card.getByRole('button', { name: 'Tentar de novo' })).toBeEnabled()
await expect(card.getByText('Localizando…')).toHaveCount(0)
})
})

View file

@ -9,6 +9,12 @@ import { describe, expect, it } from 'vitest'
import { loadMunicipalityGeometryModule, loadTerritoryGeometryModule } from '@/lib/bahiaGeometries'
import { bahiaMunicipalityCodes } from '@/lib/bahiaMunicipalityCodes'
import { bahiaIdentityTerritoryRecords } from '@/lib/bahiaTerritories'
import { municipalityCatalog } from '@/lib/municipalityCatalog'
import {
featureCentroid,
findContainingMunicipality,
resolveNearbyMunicipality,
} from '@/lib/municipalityProximity'
/** Soft ceiling against accidental unsimplified meshes (plan target ≤ ~600 KB). */
const MAX_TOPO_BYTES = 600 * 1024
@ -79,6 +85,87 @@ describe('Bahia static geometries', () => {
expect(getTerritoryFeature('99')).toBeUndefined()
})
/**
* B14 resolves "onde estou" against this same mesh, so the containment used in
* the field is pinned here with real coordinates — the synthetic squares in
* tests/unit/municipalityProximity cover the algorithm, not the data.
*/
describe('municipality containment (B14)', () => {
const cityCentres: ReadonlyArray<{ city: string; lat: number; lng: number }> = [
{ city: 'Salvador', lat: -12.973, lng: -38.5121 },
{ city: 'Feira de Santana', lat: -12.2664, lng: -38.9663 },
{ city: 'Vitória da Conquista', lat: -14.8615, lng: -40.8442 },
{ city: 'Seabra', lat: -12.4172, lng: -41.7702 },
]
it('places known city centres in their own município', async () => {
const { features } = await loadMunicipalityGeometryModule()
for (const { city, lat, lng } of cityCentres) {
const containing = findContainingMunicipality(features, { lat, lng })
expect(containing?.properties.name, city).toBe(city)
}
})
it('resolves nothing outside the state', async () => {
const { features } = await loadMunicipalityGeometryModule()
// Atlantic off Salvador and downtown São Paulo.
expect(findContainingMunicipality(features, { lat: -13, lng: -37.5 })).toBeUndefined()
expect(findContainingMunicipality(features, { lat: -23.55, lng: -46.63 })).toBeUndefined()
})
it('keeps every centroid inside its own bounding box', async () => {
const { features } = await loadMunicipalityGeometryModule()
for (const entry of features) {
const centroid = featureCentroid(entry)
const rings =
entry.geometry.type === 'Polygon'
? [entry.geometry.coordinates]
: entry.geometry.coordinates
const positions = rings.flat(2)
const longitudes = positions.map(([lng]) => lng)
const latitudes = positions.map(([, lat]) => lat)
expect(centroid.lng, entry.properties.name).toBeGreaterThanOrEqual(Math.min(...longitudes))
expect(centroid.lng, entry.properties.name).toBeLessThanOrEqual(Math.max(...longitudes))
expect(centroid.lat, entry.properties.name).toBeGreaterThanOrEqual(Math.min(...latitudes))
expect(centroid.lat, entry.properties.name).toBeLessThanOrEqual(Math.max(...latitudes))
}
})
it('sends a Salvador position to the zone list and an out-of-portfolio one to the nearest', async () => {
const geometry = await loadMunicipalityGeometryModule()
const salvadorZones = municipalityCatalog
.filter((entry) => entry.city === 'Salvador')
.map(({ slug, name, ibgeCode }) => ({ slug, name, ibgeCode }))
const seabra = municipalityCatalog.find((entry) => entry.slug === 'seabra')!
const salvadorPoint = { lat: -12.973, lng: -38.5121 }
expect(
resolveNearbyMunicipality({
point: salvadorPoint,
geometry,
accessible: salvadorZones,
}),
).toEqual({
kind: 'zoneCity',
city: 'Salvador',
ibgeCode: '2927408',
zoneCount: salvadorZones.length,
})
expect(
resolveNearbyMunicipality({
point: salvadorPoint,
geometry,
accessible: [{ slug: seabra.slug, name: seabra.name, ibgeCode: seabra.ibgeCode }],
}),
).toEqual({ kind: 'outOfScope', city: 'Salvador', nearestInScope: null })
})
})
it('exposes TopoJSON object names expected by consumers', async () => {
const [municipalityModule, territoryModule] = await Promise.all([
loadMunicipalityGeometryModule(),

View file

@ -0,0 +1,147 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
GEO_PROMPT_SESSION_KEY,
hasPromptedThisSession,
markPromptedThisSession,
readGeolocationPermissionState,
requestCurrentPosition,
} from '@/utilities/campaignGeolocation'
import { stub } from '../helpers/stub'
const defineNavigatorProperty = (property: 'geolocation' | 'permissions', value: unknown) => {
Object.defineProperty(navigator, property, { value, configurable: true, writable: true })
}
const removeNavigatorProperty = (property: 'geolocation' | 'permissions') => {
Reflect.deleteProperty(navigator, property)
}
const positionErrorOf = (code: number): GeolocationPositionError =>
stub<GeolocationPositionError>({
code,
message: 'stub',
PERMISSION_DENIED: 1,
POSITION_UNAVAILABLE: 2,
TIMEOUT: 3,
})
const stubPosition = (coords: { latitude: number; longitude: number; accuracy: number }) => {
const getCurrentPosition = vi.fn(
(
onSuccess: PositionCallback,
_onError?: PositionErrorCallback | null,
_options?: PositionOptions,
) =>
void onSuccess(
stub<GeolocationPosition>({ coords: stub<GeolocationCoordinates>(coords), timestamp: 0 }),
),
)
defineNavigatorProperty('geolocation', { getCurrentPosition })
return getCurrentPosition
}
const stubPositionError = (code: number) => {
defineNavigatorProperty('geolocation', {
getCurrentPosition: (_onSuccess: PositionCallback, onError: PositionErrorCallback) =>
void onError(positionErrorOf(code)),
})
}
describe('campaignGeolocation', () => {
afterEach(() => {
sessionStorage.clear()
removeNavigatorProperty('geolocation')
removeNavigatorProperty('permissions')
vi.restoreAllMocks()
})
describe('session prompt flag', () => {
it('starts unset and survives within the session', () => {
expect(hasPromptedThisSession()).toBe(false)
markPromptedThisSession()
expect(sessionStorage.getItem(GEO_PROMPT_SESSION_KEY)).toBe('1')
expect(hasPromptedThisSession()).toBe(true)
})
it('stays silent when storage throws (private mode)', () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('denied')
})
expect(hasPromptedThisSession()).toBe(true)
})
it('swallows write failures', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('quota')
})
expect(() => markPromptedThisSession()).not.toThrow()
})
})
describe('readGeolocationPermissionState', () => {
it('is unknown without the geolocation API', async () => {
defineNavigatorProperty('permissions', { query: vi.fn() })
await expect(readGeolocationPermissionState()).resolves.toBe('unknown')
})
it('is unknown when the Permissions API is absent (Safari)', async () => {
stubPosition({ latitude: -12.9, longitude: -38.5, accuracy: 30 })
await expect(readGeolocationPermissionState()).resolves.toBe('unknown')
})
it('reports the queried state', async () => {
stubPosition({ latitude: -12.9, longitude: -38.5, accuracy: 30 })
defineNavigatorProperty('permissions', {
query: vi.fn().mockResolvedValue({ state: 'granted' }),
})
await expect(readGeolocationPermissionState()).resolves.toBe('granted')
})
it('is unknown when the query rejects (unsupported descriptor)', async () => {
stubPosition({ latitude: -12.9, longitude: -38.5, accuracy: 30 })
defineNavigatorProperty('permissions', {
query: vi.fn().mockRejectedValue(new TypeError('unsupported')),
})
await expect(readGeolocationPermissionState()).resolves.toBe('unknown')
})
})
describe('requestCurrentPosition', () => {
it('returns a fix with low accuracy requested', async () => {
const getCurrentPosition = stubPosition({
latitude: -12.973,
longitude: -38.5121,
accuracy: 42,
})
await expect(requestCurrentPosition()).resolves.toEqual({
ok: true,
fix: { lat: -12.973, lng: -38.5121, accuracyM: 42 },
})
expect(getCurrentPosition.mock.calls[0]?.[2]).toMatchObject({ enableHighAccuracy: false })
})
it('reports an unsupported browser instead of throwing', async () => {
await expect(requestCurrentPosition()).resolves.toEqual({ ok: false, reason: 'unsupported' })
})
it.each([
[1, 'denied'],
[2, 'unavailable'],
[3, 'timeout'],
])('maps error code %i to %s', async (code, reason) => {
stubPositionError(code)
await expect(requestCurrentPosition()).resolves.toEqual({ ok: false, reason })
})
})
})

View file

@ -0,0 +1,299 @@
import { describe, expect, it } from 'vitest'
import type { BahiaMunicipalityFeature } from '@/lib/bahiaGeometriesTypes'
import {
featureCentroid,
featureContainsPoint,
findContainingMunicipality,
formatDistanceKm,
haversineKm,
resolveNearbyMunicipality,
type AccessibleMunicipality,
} from '@/lib/municipalityProximity'
/** Axis-aligned square in degrees — small enough that planar reasoning holds. */
const square = (
codarea: string,
name: string,
{ west, south, size }: { west: number; south: number; size: number },
): BahiaMunicipalityFeature => ({
type: 'Feature',
properties: { codarea, name },
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,
ibgeCode,
})
describe('municipalityProximity', () => {
describe('haversineKm', () => {
it('measures a known Bahia leg (Salvador → Feira de Santana ≈ 100 km)', () => {
const distance = haversineKm(
{ lat: -12.9714, lng: -38.5014 },
{ lat: -12.2664, lng: -38.9663 },
)
expect(distance).toBeGreaterThan(90)
expect(distance).toBeLessThan(110)
})
it('is zero for the same point', () => {
expect(haversineKm({ lat: -12, lng: -38 }, { lat: -12, lng: -38 })).toBe(0)
})
})
describe('featureContainsPoint', () => {
const cell = square('1', 'Quadrado', { west: -40, south: -13, size: 2 })
it('accepts an interior point and rejects an exterior one', () => {
expect(featureContainsPoint(cell, { lat: -12, lng: -39 })).toBe(true)
expect(featureContainsPoint(cell, { lat: -12, lng: -35 })).toBe(false)
})
it('treats a hole as outside', () => {
const donut: BahiaMunicipalityFeature = {
type: 'Feature',
properties: { codarea: '2', name: 'Rosca' },
geometry: {
type: 'Polygon',
coordinates: [
[
[-40, -13],
[-38, -13],
[-38, -11],
[-40, -11],
[-40, -13],
],
[
[-39.5, -12.5],
[-38.5, -12.5],
[-38.5, -11.5],
[-39.5, -11.5],
[-39.5, -12.5],
],
],
},
}
expect(featureContainsPoint(donut, { lat: -12, lng: -39 })).toBe(false)
expect(featureContainsPoint(donut, { lat: -12.8, lng: -39.8 })).toBe(true)
})
it('accepts a point in any part of a multipolygon', () => {
const island: BahiaMunicipalityFeature = {
type: 'Feature',
properties: { codarea: '3', name: 'Ilhas' },
geometry: {
type: 'MultiPolygon',
coordinates: [
[
[
[-40, -13],
[-39, -13],
[-39, -12],
[-40, -12],
[-40, -13],
],
],
[
[
[-38, -13],
[-37, -13],
[-37, -12],
[-38, -12],
[-38, -13],
],
],
],
},
}
expect(featureContainsPoint(island, { lat: -12.5, lng: -39.5 })).toBe(true)
expect(featureContainsPoint(island, { lat: -12.5, lng: -37.5 })).toBe(true)
expect(featureContainsPoint(island, { lat: -12.5, lng: -38.5 })).toBe(false)
})
})
describe('findContainingMunicipality', () => {
const west = square('1', 'Oeste', { west: -40, south: -13, size: 1 })
const east = square('2', 'Leste', { west: -39, south: -13, size: 1 })
it('gives a shared border to exactly one município', () => {
const features = [west, east]
const onBorder = { lat: -12.5, lng: -39 }
const matches = features.filter((feature) => featureContainsPoint(feature, onBorder))
expect(matches).toHaveLength(1)
// The border belongs to the feature whose interior is west of it, and the
// tie-break is feature order — not "whichever the scan happened to hit".
expect(findContainingMunicipality(features, onBorder)).toBe(east)
})
it('returns undefined outside every feature', () => {
expect(findContainingMunicipality([west, east], { lat: 0, lng: 0 })).toBeUndefined()
})
})
describe('featureCentroid', () => {
it('returns the centre of a square', () => {
const centroid = featureCentroid(square('1', 'Quadrado', { west: -40, south: -13, size: 2 }))
expect(centroid.lng).toBeCloseTo(-39, 6)
expect(centroid.lat).toBeCloseTo(-12, 6)
})
it('weights multipolygon parts by area', () => {
const lopsided: BahiaMunicipalityFeature = {
type: 'Feature',
properties: { codarea: '1', name: 'Desigual' },
geometry: {
type: 'MultiPolygon',
coordinates: [
[
[
[-40, -13],
[-36, -13],
[-36, -9],
[-40, -9],
[-40, -13],
],
],
[
[
[-35, -13],
[-34, -13],
[-34, -12],
[-35, -12],
[-35, -13],
],
],
],
},
}
// Big part centred at -38, small part at -34.5: area 16 vs 1.
expect(featureCentroid(lopsided).lng).toBeCloseTo((-38 * 16 + -34.5) / 17, 6)
})
})
describe('resolveNearbyMunicipality', () => {
const seabra = square('2929206', 'Seabra', { west: -42, south: -13, size: 1 })
const salvador = square('2927408', 'Salvador', { west: -38.6, south: -13, size: 0.4 })
const jequie = square('2918001', 'Jequié', { west: -41, south: -13.4, size: 0.6 })
const features = [seabra, salvador, jequie]
// The real caller hands over the loaded mesh module, index included.
const geometry = {
features,
getMunicipalityFeature: (codarea: string) =>
features.find((feature) => feature.properties.codarea === codarea),
}
it('resolves a município in scope', () => {
const seabraEntry = accessible('seabra', 'Seabra', '2929206')
expect(
resolveNearbyMunicipality({
point: { lat: -12.5, lng: -41.5 },
geometry,
accessible: [seabraEntry],
}),
).toEqual({ kind: 'inScope', municipality: seabraEntry })
})
it('falls back to the filtered list for a multi-zone city', () => {
expect(
resolveNearbyMunicipality({
point: { lat: -12.9, lng: -38.5 },
geometry,
accessible: [
accessible('salvador-ze-1', 'Salvador — ZE 1', '2927408'),
accessible('salvador-ze-2', 'Salvador — ZE 2', '2927408'),
],
}),
).toEqual({ kind: 'zoneCity', city: 'Salvador', ibgeCode: '2927408', zoneCount: 2 })
})
it('names where the actor is and offers the closest município in scope', () => {
const jequieEntry = accessible('jequie', 'Jequié', '2918001')
const resolution = resolveNearbyMunicipality({
point: { lat: -12.5, lng: -41.5 },
geometry,
accessible: [jequieEntry],
})
expect(resolution.kind).toBe('outOfScope')
if (resolution.kind !== 'outOfScope') return
expect(resolution.city).toBe('Seabra')
expect(resolution.nearestInScope?.municipality).toEqual(jequieEntry)
expect(resolution.nearestInScope?.distanceKm).toBeGreaterThan(0)
})
it('omits the suggestion when the portfolio is too far to be a shortcut', () => {
const resolution = resolveNearbyMunicipality({
point: { lat: -12.9, lng: -38.5 },
geometry,
accessible: [accessible('seabra', 'Seabra', '2929206')],
})
expect(resolution).toEqual({ kind: 'outOfScope', city: 'Salvador', nearestInScope: null })
})
it('reports a position outside Bahia, still suggesting a near portfolio', () => {
const salvadorEntry = accessible('salvador-ze-1', 'Salvador — ZE 1', '2927408')
const resolution = resolveNearbyMunicipality({
// Just offshore from the Salvador square above.
point: { lat: -12.9, lng: -38.1 },
geometry,
accessible: [salvadorEntry],
})
expect(resolution.kind).toBe('outsideBahia')
if (resolution.kind !== 'outsideBahia') return
expect(resolution.nearestInScope?.municipality).toEqual(salvadorEntry)
})
it('reports a distant position outside Bahia without a suggestion', () => {
expect(
resolveNearbyMunicipality({
point: { lat: -23.55, lng: -46.63 },
geometry,
accessible: [accessible('seabra', 'Seabra', '2929206')],
}),
).toEqual({ kind: 'outsideBahia', nearestInScope: null })
})
})
describe('formatDistanceKm', () => {
it('collapses sub-kilometre precision', () => {
expect(formatDistanceKm(0.42)).toBe('menos de 1 km')
})
it('keeps one decimal while it is felt', () => {
expect(formatDistanceKm(8.34)).toBe('8,3 km')
})
it('does not force a decimal that carries no information', () => {
expect(formatDistanceKm(8)).toBe('8 km')
})
it('rounds once the decimal stops mattering', () => {
expect(formatDistanceKm(38.6)).toBe('39 km')
})
})
})