From e482005ce087d6834a26d8e692734e335eb887eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Sun, 26 Jul 2026 17:30:09 -0300 Subject: [PATCH] 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. --- .cursor/rules/projects/nucleos-eleitorais.mdc | 9 + AGENTS.md | 1 + docs/plans/municipio-mais-proximo.md | 91 +++-- docs/roadmap.md | 12 +- .../campaign/dashboard/CampaignDashboard.tsx | 44 ++- .../dashboard/NearestMunicipalityCard.tsx | 371 ++++++++++++++++++ .../dashboard/RecentlyVisitedCard.tsx | 9 +- src/lib/municipalityProximity.ts | 261 ++++++++++++ src/utilities/campaignDashboardData.ts | 13 + src/utilities/campaignGeolocation.ts | 121 ++++++ src/utilities/municipalityMapNavigation.ts | 2 +- .../campaignNearestMunicipality.e2e.spec.ts | 230 +++++++++++ tests/int/bahiaGeometries.int.spec.ts | 87 ++++ tests/unit/campaignGeolocation.unit.spec.ts | 147 +++++++ tests/unit/municipalityProximity.unit.spec.ts | 299 ++++++++++++++ 15 files changed, 1646 insertions(+), 51 deletions(-) create mode 100644 src/components/campaign/dashboard/NearestMunicipalityCard.tsx create mode 100644 src/lib/municipalityProximity.ts create mode 100644 src/utilities/campaignGeolocation.ts create mode 100644 tests/e2e/campaignNearestMunicipality.e2e.spec.ts create mode 100644 tests/unit/campaignGeolocation.unit.spec.ts create mode 100644 tests/unit/municipalityProximity.unit.spec.ts diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index 4cc928d8..3df41655 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -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>` — 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. diff --git a/AGENTS.md b/AGENTS.md index 4b50a916..4f2dc1c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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). diff --git a/docs/plans/municipio-mais-proximo.md b/docs/plans/municipio-mais-proximo.md index 3b51e05c..aa07d17c 100644 --- a/docs/plans/municipio-mais-proximo.md +++ b/docs/plans/municipio-mais-proximo.md @@ -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` 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 ", "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 < limiar | nunca. **Recomendação:** mostrar quando a precisão/resolução for confiável (ex. < 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 > ~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
(B2, commitado)"] - Build["pnpm build:municipality-centroids
(CLI, sem DB)"] - Artifact["src/lib/municipalityCentroids/
bahia-municipality-centroids.json"] - Scope["Dashboard RSC
slugs acessíveis do ator"] - Card["NearestMunicipalityCard
(client island)"] - Geo["Geolocation + Permissions
+ sessionStorage prompt flag"] - Match["findNearestMunicipality
(Haversine puro)"] - Link["Link → /campanha/municipios/..."] - - Topo --> Build --> Artifact - Scope --> Card - Artifact --> Card - Card --> Geo --> Match --> Link + Scope["loadMunicipalityScope
(slug, name, ibgeCode)"] --> View["StaffDashboardView
accessibleMunicipalities"] + View --> Hrefs["CampaignDashboard (RSC)
zoneCityHrefs"] + Hrefs --> Card + View --> Card["NearestMunicipalityCard
(client island)"] + Card --> Perm["permissions + sessionStorage
prompt 1x por sessao"] + Perm --> Fix["getCurrentPosition"] + Fix --> Geo["loadMunicipalityGeometryModule
(chunk compartilhado com o mapa)"] + Geo --> Resolve["resolveNearbyMunicipality
(point-in-polygon + fallback)"] + Resolve --> Link["Link /campanha/municipios/[slug]
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`) diff --git a/docs/roadmap.md b/docs/roadmap.md index 41f4b1af..655cae5a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-26 (**B33+** novo fill-in de engenharia registrado no fechamento do B33 — `StateDeputyFilters` é o 3º call site do scaffold de navegação/debounce que `TerritoryFilters`/`MunicipalityFilters` já tinham, achado do `/simplify` rodada 2, [plano](plans/escala-dry-pos-b33.md); **B28 ✓** e-mail + celular na lista de lideranças entregue — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp, no padrão de `/campanha/assessores` (B19 ✓); `shared/CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`, `loadLeadershipDetail` deduplicado; [plano](plans/email-celular-lista-liderancas.md); **B24 ✓** auto-save + justificativa no popover de Tendência de `/campanha/municipios` — sem botão "Salvar", `POST /campanha/municipios/political-trend`, [plano](plans/autosave-tendencia-lista-municipios.md); **B13 ✓** escala relativa no mapa do Início entregue — quantis como novo default, padrão próprio/LQ nas âncoras do E10 e posição no município a partir do **artefato TSE v3** (`federalRankByIbgeCode`), mais bolhas opcionais por válidos projetados coloridas pela classe; `computeAggregateTerritorialClass` nasce aqui e o **E12** deve herdá-lo em vez de escrever um rollup próprio; `TERRITORIAL_CLASS_ANCHORS` mudou de casa para `src/lib/territorialClassAnchors.ts`; [plano](plans/escala-relativa-mapa.md); **B13+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B13 — teste de concordância entre as duas escritas do LQ (mapa cliente × classificador server-only, a rede de segurança do **E15**), escopo geográfico declarado no readout (posição é da cidade inteira, classe é do escopo do usuário), uma varredura da fatia TSE por ano no script de agregados em vez de três, e a tupla no `competitiveRankByYear` que corta ~metade dos ~63,6 KB somados ao payload RSC · [plano](plans/escala-dry-pos-b13.md); **débito de teste** afinado no ledger ([TECH-DEBT.md](TECH-DEBT.md), P3 → P2): `campaignMunicipalities`/`campaign-pwa` são flaky com 2 workers **em `main`** também — clique no checkbox de consentimento antes da hidratação e regex de fixture em que "Conde" casa com "Condeúba"; **B27 ✓** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` entregue — a checkbox list + botão "Salvar assessores" virou chips removíveis + `Command` com gravação por delta, `nextAdvisorIdsAfterMembership` movido para `src/lib/`, endpoint `POST /campanha/municipios/advisors` espelho do `expected-votes/`, [plano](plans/combobox-assessores-lista-municipios.md); **B23 ✓** tooltip de conteúdo nas células das listas entregue — a mecânica genérica (`cellTooltip`, `CampaignCellTooltip`, `CampaignHoverTooltip` em `shared/`) já tinha saído de graça no E10 ✓; este item fechou `openOnTouch`/`disabled` no primitivo (mais um terceiro comportamento do `/simplify`: `content` falsy renderiza o trigger sem wrapper) e os dois consumidores de `/campanha/municipios` (nomes de assessores + justificativa da tendência), [plano](plans/tooltip-celulas-listas.md); **B22 ✓** explicação por coluna no header das listas entregue — `description?: ReactNode` em `CampaignTableHead`/`MunicipalitySortableHead` (rename de `tooltip`, sublinhado pontilhado), `campaignConceptOneLiner` como fonte única para colunas cobertas pelo E18 ✓, `municipalityColumnDescriptions` cobrindo as 11 colunas de `/campanha/municipios`; sem migration/action, [plano](plans/explicacao-colunas-header-listas.md); 2026-07-25: **E10 ✓** classificação territorial relativa entregue — Reduto/Expansão/Manutenção/Marginal/Sem base sobre o artefato TSE, coluna+sort+filtro na lista, badge no card do E8 e na capa do dossiê, e o seam genérico `cellTooltip` da `CampaignTable` (núcleo do B23) com `CampaignHoverTooltip` promovido para `shared/`; **B13** liberado; **E10+** novo fill-in de engenharia com os quatro débitos do `/simplify` do E10 — a lista lendo `municipality` duas vezes por request (o que **dispara o "Adiado com gatilho" do A11**: `classe` é o 4º path derivado no loader), memo de processo dos potenciais por município, `TooltipProvider` único no layout e os multi-filtros do mobile saindo das definitions · [plano](plans/escala-dry-pos-e10.md); **C8 F4b** novo fill-in — o guard de `formActions` só vê arquivos chamados exatamente `formActions.ts`, achado do `/simplify` do C13; **B26 ✓** registrar sinal na célula "Último sinal" — [plano](plans/registrar-sinal-lista-municipios.md); **C13 ✓** "Plano de Ação" → **"Atividade"** renomeado em toda a vertical — collection `activity`, rota `/campanha/atividades`, sidebar "Atividades", migration data-preserving escrita à mão (4 tabelas, 3 enums, 2 colunas, 39 índices, 14 FKs, 2 sequences + os 8 índices fósseis `%plaza%` da remodelagem) e guard de vocabulário table-driven varrendo `src`+`tests`+`scripts`; [plano](plans/renomear-plano-acao-para-atividade.md); **B37** chips editáveis de municípios na lista de dobradinhas — coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`municipalityCount`) por chips com link (município) / território (`MapPinIcon`) e o mesmo Popover+combobox do **B34** (busca por município/TI/ZE, lote de território/zona), mas escrevendo do lado invertido (`municipality.stateDeputies`, sem piso mínimo, lock por município em vez de por dobradinha, sem `assertMunicipalitiesWithinScope` porque `canUpdateMunicipality` já escopa o assessor); reaproveita/promove a mesma lib do B19/B34 (quem chegar primeiro faz a renomeação); mesmo conflito registrado com o pedido literal (toggle "Editar" de tabela inteira) que B28/B31/B32/B34/B36 já resolveram na direção do Popover por célula — decisão marcada como _proposta, a validar com produto_; sem migration, [plano](plans/chips-municipios-lista-dobradinhas.md); **B36** coluna "Lideranças" em `/campanha/dobradinhas` com a mesma UI/mecânica da coluna "Municípios" de `/campanha/assessores` (B19 ✓) — chips + Popover + `Command` + auto-save por delta + "Ver mais…" (clamp de 3 linhas por `ResizeObserver`, necessário aqui porque um deputado popular pode acumular muito mais lideranças do que uma liderança acumula dobradinhas); pedido explícito de não duplicar lógica — reusa **sem alteração** a mutação/schema/lock que o **B31** desenha para o lado inverso da mesma relação `leadership.stateDeputies`, promovendo a célula desse item para um componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`) em vez de um segundo componente-irmão; corrida de extração com o B31 (quem entrar primeiro paga a peça compartilhada); sem migration, [plano](plans/chips-liderancas-lista-dobradinhas.md); **B34** chips editáveis de municípios na lista de lideranças — coluna "Municípios" de `/campanha/liderancas` ganha chips com link (município) / território (`MapPinIcon`), edição por **Popover disparado por célula** com chips removíveis + combobox `Command` (busca por município/TI/ZE, lote de território/zona), gravação **por delta** direto em `leadership.municipalities` com lock `leadership-municipalities:{id}` (piso de 1 e teto de 30 respeitados) e `revalidatePath` (`liderancas`, `liderancas/[id]`, `municipios/[slug]` dos tocados); promove a lógica pura de `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros, sem portar a UI com `ResizeObserver`; **conflito registrado com o pedido literal** (pede um toggle "Editar" de tabela inteira, que B28/B31/B32 já travaram como anti-goal nesta mesma tabela — este plano segue o precedente e marca a decisão como _proposta, a validar com produto_); sem migration, [plano](plans/chips-municipios-lista-liderancas.md); ~~**B33**~~ ✓ ordenação e filtro no header da lista de dobradinhas (entregue 2026-07-26) — `?sort=`/`?dir=` por Nome/Partido + Popover multi-seleção de Partido com "Sem partido", consumindo o chrome compartilhado do **B21 ✓** (a corrida de ordem de chegada com B29 foi encerrada); paridade mobile (`StateDeputyFilters`) entrou por decisão desta sessão; ordem dos nulos de `party` pinada por int test; contagens derivadas (`municipalityCount`/`leadershipCount`) seguem sem sort/filtro, como travado, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30** convite de completar cadastro por WhatsApp na lista de lideranças — ação ao fim de cada linha de `/campanha/liderancas` reusando `createCampaignInvite`/`kind: 'autopreenchimento'` (mesmo fluxo do detalhe), disabled quando `contact.phone` não normaliza (`normalizeBrazilianPhone`); ícone distinto do WhatsApp de contato do B28 (soft, mesma linha); sem migration/action nova, [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); ~~**B28**~~ ✓ e-mail + celular na lista de `/campanha/liderancas` (entregue 2026-07-26) — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp como em `/campanha/assessores` (B19 ✓); `CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`; edição in-list adiada; sem migration, [plano](plans/email-celular-lista-liderancas.md); ~~**B27**~~ ✓ combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/combobox-assessores-lista-municipios.md); ~~**B26**~~ ✓ registrar sinal na célula "Último sinal" (entregue 2026-07-25), [plano](plans/registrar-sinal-lista-municipios.md); **fill-in** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); glossário/KPI strips intactos; B16 ✓ liberou o nome ao remover "Assessoria", [plano](plans/renomear-coluna-cobertura-meta.md); **B25** link do território na lista de municípios — a célula da coluna "Território" vira link para a linha daquele TI em `/campanha/territorios` (âncora, sem rota de detalhe); dep dura B21, [plano](plans/link-territorio-lista-municipios.md); ~~**B24**~~ ✓ auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/autosave-tendencia-lista-municipios.md); **fill-in** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtro por Salvador cobre as zonas; drop também do filtro/sort/`?kind=` na URL da lista, schema `kind` intacto, [plano](plans/remover-coluna-tipo-municipios.md); ~~**B23**~~ ✓ tooltip de conteúdo nas células das listas (entregue 2026-07-26), [plano](plans/tooltip-celulas-listas.md); **fill-in** opções selecionadas no topo nos Popovers de filtro do header — helper puro reutilizável pelas demais listas, [plano](plans/selecionados-no-topo-filtros-header.md); ~~**B22**~~ ✓ explicação por coluna no header das listas (entregue 2026-07-26), [plano](plans/explicacao-colunas-header-listas.md); **B21** página própria dos Territórios de Identidade — rota `/campanha/territorios` com overview + tabela no sistema de listas (sort/filtro no header), sem migration, [plano](plans/pagina-territorios-identidade.md); **B20 ✓** Municípios prioritários no Início entregue 2026-07-25 — atalho ranqueado por déficit + markup sem bullets, [plano](plans/municipios-prioritarios-inicio.md); **Pass 2 ✓** consolidação de engenharia — listas genéricas/B16+ absorvido/B17-B18 triviais/E10 limpo/C8 F4 ✓/D3 consent WhatsApp; **B16 ✓**; **E16 ✓**; **E9 ✓**; **E18 ✓**; **B19 ✓**; **E8 ✓**; C12 ✓; B18; B17; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14; janela 1: smoke, R6) +Atualizado em: 2026-07-26 (**B14 ✓** município mais próximo entregue — card cliente "Onde estou" no Início staff resolvendo **point-in-polygon** sobre o chunk de geometria que o mapa do dashboard já carrega (a premissa "polígonos explodem o bundle" do plano de 24/07 caiu na medição: 41 KB gzip já estão no browser, então o artefato de centroides e seu script saíram de escopo), com fallback nomeando onde o assessor está e o município mais próximo **da carteira** dentro de 150 km; a posição nunca sai do aparelho (sem action, sem `Consent`); First Load JS de `/campanha` 265 → 268 kB depois de mover o href das zonas de Salvador para o servidor (`zoneCityHrefs`) — importar `buildMunicipalityListHref` no cliente custava **21 kB** por um link, porque arrasta `bahiaTerritories` + `municipalityCatalog`; [plano](plans/municipio-mais-proximo.md); **B33+** novo fill-in de engenharia registrado no fechamento do B33 — `StateDeputyFilters` é o 3º call site do scaffold de navegação/debounce que `TerritoryFilters`/`MunicipalityFilters` já tinham, achado do `/simplify` rodada 2, [plano](plans/escala-dry-pos-b33.md); **B28 ✓** e-mail + celular na lista de lideranças entregue — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp, no padrão de `/campanha/assessores` (B19 ✓); `shared/CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`, `loadLeadershipDetail` deduplicado; [plano](plans/email-celular-lista-liderancas.md); **B24 ✓** auto-save + justificativa no popover de Tendência de `/campanha/municipios` — sem botão "Salvar", `POST /campanha/municipios/political-trend`, [plano](plans/autosave-tendencia-lista-municipios.md); **B13 ✓** escala relativa no mapa do Início entregue — quantis como novo default, padrão próprio/LQ nas âncoras do E10 e posição no município a partir do **artefato TSE v3** (`federalRankByIbgeCode`), mais bolhas opcionais por válidos projetados coloridas pela classe; `computeAggregateTerritorialClass` nasce aqui e o **E12** deve herdá-lo em vez de escrever um rollup próprio; `TERRITORIAL_CLASS_ANCHORS` mudou de casa para `src/lib/territorialClassAnchors.ts`; [plano](plans/escala-relativa-mapa.md); **B13+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B13 — teste de concordância entre as duas escritas do LQ (mapa cliente × classificador server-only, a rede de segurança do **E15**), escopo geográfico declarado no readout (posição é da cidade inteira, classe é do escopo do usuário), uma varredura da fatia TSE por ano no script de agregados em vez de três, e a tupla no `competitiveRankByYear` que corta ~metade dos ~63,6 KB somados ao payload RSC · [plano](plans/escala-dry-pos-b13.md); **débito de teste** afinado no ledger ([TECH-DEBT.md](TECH-DEBT.md), P3 → P2): `campaignMunicipalities`/`campaign-pwa` são flaky com 2 workers **em `main`** também — clique no checkbox de consentimento antes da hidratação e regex de fixture em que "Conde" casa com "Condeúba"; **B27 ✓** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` entregue — a checkbox list + botão "Salvar assessores" virou chips removíveis + `Command` com gravação por delta, `nextAdvisorIdsAfterMembership` movido para `src/lib/`, endpoint `POST /campanha/municipios/advisors` espelho do `expected-votes/`, [plano](plans/combobox-assessores-lista-municipios.md); **B23 ✓** tooltip de conteúdo nas células das listas entregue — a mecânica genérica (`cellTooltip`, `CampaignCellTooltip`, `CampaignHoverTooltip` em `shared/`) já tinha saído de graça no E10 ✓; este item fechou `openOnTouch`/`disabled` no primitivo (mais um terceiro comportamento do `/simplify`: `content` falsy renderiza o trigger sem wrapper) e os dois consumidores de `/campanha/municipios` (nomes de assessores + justificativa da tendência), [plano](plans/tooltip-celulas-listas.md); **B22 ✓** explicação por coluna no header das listas entregue — `description?: ReactNode` em `CampaignTableHead`/`MunicipalitySortableHead` (rename de `tooltip`, sublinhado pontilhado), `campaignConceptOneLiner` como fonte única para colunas cobertas pelo E18 ✓, `municipalityColumnDescriptions` cobrindo as 11 colunas de `/campanha/municipios`; sem migration/action, [plano](plans/explicacao-colunas-header-listas.md); 2026-07-25: **E10 ✓** classificação territorial relativa entregue — Reduto/Expansão/Manutenção/Marginal/Sem base sobre o artefato TSE, coluna+sort+filtro na lista, badge no card do E8 e na capa do dossiê, e o seam genérico `cellTooltip` da `CampaignTable` (núcleo do B23) com `CampaignHoverTooltip` promovido para `shared/`; **B13** liberado; **E10+** novo fill-in de engenharia com os quatro débitos do `/simplify` do E10 — a lista lendo `municipality` duas vezes por request (o que **dispara o "Adiado com gatilho" do A11**: `classe` é o 4º path derivado no loader), memo de processo dos potenciais por município, `TooltipProvider` único no layout e os multi-filtros do mobile saindo das definitions · [plano](plans/escala-dry-pos-e10.md); **C8 F4b** novo fill-in — o guard de `formActions` só vê arquivos chamados exatamente `formActions.ts`, achado do `/simplify` do C13; **B26 ✓** registrar sinal na célula "Último sinal" — [plano](plans/registrar-sinal-lista-municipios.md); **C13 ✓** "Plano de Ação" → **"Atividade"** renomeado em toda a vertical — collection `activity`, rota `/campanha/atividades`, sidebar "Atividades", migration data-preserving escrita à mão (4 tabelas, 3 enums, 2 colunas, 39 índices, 14 FKs, 2 sequences + os 8 índices fósseis `%plaza%` da remodelagem) e guard de vocabulário table-driven varrendo `src`+`tests`+`scripts`; [plano](plans/renomear-plano-acao-para-atividade.md); **B37** chips editáveis de municípios na lista de dobradinhas — coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`municipalityCount`) por chips com link (município) / território (`MapPinIcon`) e o mesmo Popover+combobox do **B34** (busca por município/TI/ZE, lote de território/zona), mas escrevendo do lado invertido (`municipality.stateDeputies`, sem piso mínimo, lock por município em vez de por dobradinha, sem `assertMunicipalitiesWithinScope` porque `canUpdateMunicipality` já escopa o assessor); reaproveita/promove a mesma lib do B19/B34 (quem chegar primeiro faz a renomeação); mesmo conflito registrado com o pedido literal (toggle "Editar" de tabela inteira) que B28/B31/B32/B34/B36 já resolveram na direção do Popover por célula — decisão marcada como _proposta, a validar com produto_; sem migration, [plano](plans/chips-municipios-lista-dobradinhas.md); **B36** coluna "Lideranças" em `/campanha/dobradinhas` com a mesma UI/mecânica da coluna "Municípios" de `/campanha/assessores` (B19 ✓) — chips + Popover + `Command` + auto-save por delta + "Ver mais…" (clamp de 3 linhas por `ResizeObserver`, necessário aqui porque um deputado popular pode acumular muito mais lideranças do que uma liderança acumula dobradinhas); pedido explícito de não duplicar lógica — reusa **sem alteração** a mutação/schema/lock que o **B31** desenha para o lado inverso da mesma relação `leadership.stateDeputies`, promovendo a célula desse item para um componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`) em vez de um segundo componente-irmão; corrida de extração com o B31 (quem entrar primeiro paga a peça compartilhada); sem migration, [plano](plans/chips-liderancas-lista-dobradinhas.md); **B34** chips editáveis de municípios na lista de lideranças — coluna "Municípios" de `/campanha/liderancas` ganha chips com link (município) / território (`MapPinIcon`), edição por **Popover disparado por célula** com chips removíveis + combobox `Command` (busca por município/TI/ZE, lote de território/zona), gravação **por delta** direto em `leadership.municipalities` com lock `leadership-municipalities:{id}` (piso de 1 e teto de 30 respeitados) e `revalidatePath` (`liderancas`, `liderancas/[id]`, `municipios/[slug]` dos tocados); promove a lógica pura de `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros, sem portar a UI com `ResizeObserver`; **conflito registrado com o pedido literal** (pede um toggle "Editar" de tabela inteira, que B28/B31/B32 já travaram como anti-goal nesta mesma tabela — este plano segue o precedente e marca a decisão como _proposta, a validar com produto_); sem migration, [plano](plans/chips-municipios-lista-liderancas.md); ~~**B33**~~ ✓ ordenação e filtro no header da lista de dobradinhas (entregue 2026-07-26) — `?sort=`/`?dir=` por Nome/Partido + Popover multi-seleção de Partido com "Sem partido", consumindo o chrome compartilhado do **B21 ✓** (a corrida de ordem de chegada com B29 foi encerrada); paridade mobile (`StateDeputyFilters`) entrou por decisão desta sessão; ordem dos nulos de `party` pinada por int test; contagens derivadas (`municipalityCount`/`leadershipCount`) seguem sem sort/filtro, como travado, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30** convite de completar cadastro por WhatsApp na lista de lideranças — ação ao fim de cada linha de `/campanha/liderancas` reusando `createCampaignInvite`/`kind: 'autopreenchimento'` (mesmo fluxo do detalhe), disabled quando `contact.phone` não normaliza (`normalizeBrazilianPhone`); ícone distinto do WhatsApp de contato do B28 (soft, mesma linha); sem migration/action nova, [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); ~~**B28**~~ ✓ e-mail + celular na lista de `/campanha/liderancas` (entregue 2026-07-26) — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp como em `/campanha/assessores` (B19 ✓); `CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`; edição in-list adiada; sem migration, [plano](plans/email-celular-lista-liderancas.md); ~~**B27**~~ ✓ combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/combobox-assessores-lista-municipios.md); ~~**B26**~~ ✓ registrar sinal na célula "Último sinal" (entregue 2026-07-25), [plano](plans/registrar-sinal-lista-municipios.md); **fill-in** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); glossário/KPI strips intactos; B16 ✓ liberou o nome ao remover "Assessoria", [plano](plans/renomear-coluna-cobertura-meta.md); **B25** link do território na lista de municípios — a célula da coluna "Território" vira link para a linha daquele TI em `/campanha/territorios` (âncora, sem rota de detalhe); dep dura B21, [plano](plans/link-territorio-lista-municipios.md); ~~**B24**~~ ✓ auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/autosave-tendencia-lista-municipios.md); **fill-in** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtro por Salvador cobre as zonas; drop também do filtro/sort/`?kind=` na URL da lista, schema `kind` intacto, [plano](plans/remover-coluna-tipo-municipios.md); ~~**B23**~~ ✓ tooltip de conteúdo nas células das listas (entregue 2026-07-26), [plano](plans/tooltip-celulas-listas.md); **fill-in** opções selecionadas no topo nos Popovers de filtro do header — helper puro reutilizável pelas demais listas, [plano](plans/selecionados-no-topo-filtros-header.md); ~~**B22**~~ ✓ explicação por coluna no header das listas (entregue 2026-07-26), [plano](plans/explicacao-colunas-header-listas.md); **B21** página própria dos Territórios de Identidade — rota `/campanha/territorios` com overview + tabela no sistema de listas (sort/filtro no header), sem migration, [plano](plans/pagina-territorios-identidade.md); **B20 ✓** Municípios prioritários no Início entregue 2026-07-25 — atalho ranqueado por déficit + markup sem bullets, [plano](plans/municipios-prioritarios-inicio.md); **Pass 2 ✓** consolidação de engenharia — listas genéricas/B16+ absorvido/B17-B18 triviais/E10 limpo/C8 F4 ✓/D3 consent WhatsApp; **B16 ✓**; **E16 ✓**; **E9 ✓**; **E18 ✓**; **B19 ✓**; **E8 ✓**; C12 ✓; B18; B17; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14 ✓; janela 1: smoke, R6) Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc). ## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026) @@ -91,7 +91,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re - **D4** envio 1:1 pela sessão do ator · depende de D3 · [plano](plans/whatsapp-envio-liderancas.md) - **D5** inbox da própria sessão → rascunhos (`municipalityUpdate`/demanda) com humano no loop · depende de D3 · [plano](plans/whatsapp-sugestao-atualizacoes.md) - **R6** critique/polish visual da vertical remodelada (ciclo /impeccable completo por superfície; smoke visual coordenador feito em 2026-07-21) · absorve os débitos de produto/UX remanescentes de FD2 ([field-desk-ux-pos-critique.md](plans/field-desk-ux-pos-critique.md)): glossário inline (O3 — hipótese ainda sem evidência), triagem em lote, empty states de coordenador · gatilho: antes de 16/08 -- **B14** município mais próximo (geolocalização → atalho no Início staff) · pede permissão **1× por sessão** se ainda não concedida; matching client-side sobre centroides IBGE; Salvador multi-zona → lista filtrada até B8 F2 · ~1d · Janela 1–2 · sem deps duras · cortável · [plano](plans/municipio-mais-proximo.md) +- ~~**B14**~~ ✓ município mais próximo (geolocalização → card "Onde estou" no Início staff, entregue 2026-07-26) · pede permissão **1× por sessão** se ainda não concedida; matching client-side **por point-in-polygon** sobre o chunk de geometria do mapa (centroides só ordenam o fallback de 150 km na carteira); Salvador multi-zona → lista filtrada até B8 F2 · [plano](plans/municipio-mais-proximo.md) - ~~**B20** Municípios prioritários no Início~~ — **entregue 2026-07-25** (`pickDashboardPriorityMunicipalities` + amostra 8 por déficit `central`; Badge com total; “Ver todas” → `?priority=alta`; `list-none` na strip) · [plano](plans/municipios-prioritarios-inicio.md) - ~~**B21** página própria dos Territórios de Identidade~~ — **entregue 2026-07-25** (`/campanha/territorios` staff-only; 27 TIs + decomposição do Metropolitano; sort/filtros/busca canônicos na URL; drill para municípios; chrome extraído em `shared/CampaignSortableHead` + `CampaignHeaderFilterPopover`; painel de TI removido do Início por decisão da sessão) · sem migration/collection/action · [plano](plans/pagina-territorios-identidade.md) - **Downstream atualizado:** B25 teve a dependência dura atendida; B29 e B33 não pagam mais a extração do chrome compartilhado e agora implementam apenas política/URL/filtros dos próprios domínios. @@ -156,7 +156,7 @@ flowchart TD D3 --> D5["D5 WA → rascunhos"] D4 -.thread.-> D5 B8F2["B8 F2 Polígonos
zonas de Salvador"] - B14n["B14 Município mais próximo"] + B14n["B14 ✓ Município mais próximo"] B15n["B15 ✓ Ordenar lista
por coluna"] B16n["B16 ✓ Filtros no header
da lista"] B17n["B17 Seletor de colunas
da lista"] @@ -303,11 +303,11 @@ flowchart TD B8F2 -.ZE Salvador.-> B14n ``` -Paralelizáveis agora: **B13** (E10 ✓ entregou as classes; consome `computeMunicipalityTerritorialClass` como está), **E12** (E8 ✓), **E11** (E9 ✓/E10 ✓ entregues — livre), **B14** (atalho geo no Início; soft B8 F2 só para ZE Salvador), **B21** (página dos TIs; sem dep dura — E17 ✓/B15 ✓/B16 ✓ já entregues; E12 pousa nela depois; **B25** é o único item que espera B21 — o link do território na lista só existe depois do destino), **B17** (seletor de colunas; soft B15 ✓/B16 ✓ — pousa no header de coluna atual), **B18** (filtros salvos + submenu Municípios; soft B16 ✓/B17 — botão na barra), **B22** (explicação por coluna no header; sem dep dura — B15 ✓/B16 ✓/E18 ✓), **B23** (tooltip nas células; a mecânica genérica e a promoção do primitivo já saíram no E10 ✓ — restam os consumidores Assessores/Tendência e a convivência com o Popover), ~~**B24**~~ ✓ (auto-save + justificativa no popover de Tendência; entregue 2026-07-26), **B26** (registrar sinal na célula "Último sinal"; sem dep dura — E9 ✓/C12 ✓ já entregues; soft B24/B23 só pela coerência de contrato na mesma linha), **B27** (combobox + chips + auto-save no popover de Assessores; sem dep dura — B19 ✓ já deu o delta+lock e o padrão de chips; soft B24 pelo endpoint e B23 pelo gatilho), **B28** (e-mail + celular na lista de lideranças; sem dep dura — B19 ✓ é só o padrão soft de copy/WhatsApp; `phone` já está no view model), **B29** (sort + filtro no header da lista de lideranças; sem dep dura — B15 ✓/B16 ✓ deram o contrato e o popover; soft B21 e B28 só pela ordem de chegada: quem entrar depois herda o head compartilhado / encaixa as colunas do outro), fill-ins (renomear coluna Cobertura → quick win de densidade no header; remover coluna Tipo — quick win de densidade; ícone de prioridade na lista; Cenário junto aos filtros — encaixa na barra slim de B16 ✓ —; selecionados no topo nos filtros do header — helper puro que B21 reusa —; O0+, RS+). **B30** (convite WhatsApp na lista de lideranças; sem dep dura — reusa integralmente a action/collection existentes; soft B28 só pelo ícone na mesma linha, qualquer um pode ir primeiro), **B31** (coluna de dobradinhas na lista de lideranças; sem dep dura — o dado já está no view model e o catálogo já tem loader; soft B27 pela peça de célula, que só vira extração compartilhada no 3º call site, e B28/B29/B30 pela ordem de chegada na mesma tabela), **B32** (editar Status de apoio na célula da lista de lideranças; sem dep dura — o campo já existe e já é opcional no schema de update; soft B24 só pela ordem de extração do guard CSRF compartilhado, e B29/B31 só pela convenção "sem modo Editar" e a mesma tabela), **B33** (sort + filtro no header da lista de dobradinhas; sem dep dura — B15 ✓/B16 ✓ deram o contrato; soft B29/B21 só pela ordem de chegada na mesma corrida de extração do chrome compartilhado, quem entrar primeiro paga a peça e o outro só consome), **B34** (chips editáveis de municípios na lista de lideranças; sem dep dura — B19 ✓ já deu o delta+lock, o padrão de chips e o loader do catálogo a promover; soft B27/B31 pela mesma peça de célula editável — quem chegar depois herda o combobox/Popover em vez de reinventar —, e B28/B30/B32 só pela mesma tabela; _decisão a validar com produto_: Popover por célula em vez do toggle "Editar" pedido literalmente, mesmo precedente do B31/B32), **B37** (chips editáveis de municípios na lista de dobradinhas; sem dep dura — B19 ✓/B34 já dão a lib a promover e o desenho do Popover+combobox; soft B34 só pela ordem de chegada em quem faz a promoção da lib, B31 pela mesma família de pedido em outra tabela, B33 pela mesma tabela; _mesma decisão a validar com produto_ do B31/B32/B34: Popover por célula, não toggle). **B36** (coluna de lideranças na lista de dobradinhas; sem dep dura — reusa integralmente a mutação/schema/lock que o B31 desenha para a mesma relação, do lado oposto; soft B31 pela corrida de extração da célula/action compartilhadas, B19 ✓ pelo padrão de origem, B33 pela mesma tabela), **C13** (renomear Plano de Ação → Atividade; sem dep dura — não toca nenhuma das listas em curso (B26–B37 são municípios/lideranças/dobradinhas), então roda em paralelo a elas; a única ordem que importa é **antes de E13**, que constrói dentro da vertical renomeada, e antes do congelamento ~20/09 por carregar migration; PR mecânico e isolado, não misturar com feature). **D3** só após smoke + folga e só se o fluxo sede-digita (C12 ✓) não absorver os deltas do ZAP (não compete com E9). +Paralelizáveis agora: **B13** (E10 ✓ entregou as classes; consome `computeMunicipalityTerritorialClass` como está), **E12** (E8 ✓), **E11** (E9 ✓/E10 ✓ entregues — livre), ~~**B14**~~ ✓ (atalho geo no Início entregue 2026-07-26; B8 F2 segue como gatilho só para desambiguar as ZE de Salvador), **B21** (página dos TIs; sem dep dura — E17 ✓/B15 ✓/B16 ✓ já entregues; E12 pousa nela depois; **B25** é o único item que espera B21 — o link do território na lista só existe depois do destino), **B17** (seletor de colunas; soft B15 ✓/B16 ✓ — pousa no header de coluna atual), **B18** (filtros salvos + submenu Municípios; soft B16 ✓/B17 — botão na barra), **B22** (explicação por coluna no header; sem dep dura — B15 ✓/B16 ✓/E18 ✓), **B23** (tooltip nas células; a mecânica genérica e a promoção do primitivo já saíram no E10 ✓ — restam os consumidores Assessores/Tendência e a convivência com o Popover), ~~**B24**~~ ✓ (auto-save + justificativa no popover de Tendência; entregue 2026-07-26), **B26** (registrar sinal na célula "Último sinal"; sem dep dura — E9 ✓/C12 ✓ já entregues; soft B24/B23 só pela coerência de contrato na mesma linha), **B27** (combobox + chips + auto-save no popover de Assessores; sem dep dura — B19 ✓ já deu o delta+lock e o padrão de chips; soft B24 pelo endpoint e B23 pelo gatilho), **B28** (e-mail + celular na lista de lideranças; sem dep dura — B19 ✓ é só o padrão soft de copy/WhatsApp; `phone` já está no view model), **B29** (sort + filtro no header da lista de lideranças; sem dep dura — B15 ✓/B16 ✓ deram o contrato e o popover; soft B21 e B28 só pela ordem de chegada: quem entrar depois herda o head compartilhado / encaixa as colunas do outro), fill-ins (renomear coluna Cobertura → quick win de densidade no header; remover coluna Tipo — quick win de densidade; ícone de prioridade na lista; Cenário junto aos filtros — encaixa na barra slim de B16 ✓ —; selecionados no topo nos filtros do header — helper puro que B21 reusa —; O0+, RS+). **B30** (convite WhatsApp na lista de lideranças; sem dep dura — reusa integralmente a action/collection existentes; soft B28 só pelo ícone na mesma linha, qualquer um pode ir primeiro), **B31** (coluna de dobradinhas na lista de lideranças; sem dep dura — o dado já está no view model e o catálogo já tem loader; soft B27 pela peça de célula, que só vira extração compartilhada no 3º call site, e B28/B29/B30 pela ordem de chegada na mesma tabela), **B32** (editar Status de apoio na célula da lista de lideranças; sem dep dura — o campo já existe e já é opcional no schema de update; soft B24 só pela ordem de extração do guard CSRF compartilhado, e B29/B31 só pela convenção "sem modo Editar" e a mesma tabela), **B33** (sort + filtro no header da lista de dobradinhas; sem dep dura — B15 ✓/B16 ✓ deram o contrato; soft B29/B21 só pela ordem de chegada na mesma corrida de extração do chrome compartilhado, quem entrar primeiro paga a peça e o outro só consome), **B34** (chips editáveis de municípios na lista de lideranças; sem dep dura — B19 ✓ já deu o delta+lock, o padrão de chips e o loader do catálogo a promover; soft B27/B31 pela mesma peça de célula editável — quem chegar depois herda o combobox/Popover em vez de reinventar —, e B28/B30/B32 só pela mesma tabela; _decisão a validar com produto_: Popover por célula em vez do toggle "Editar" pedido literalmente, mesmo precedente do B31/B32), **B37** (chips editáveis de municípios na lista de dobradinhas; sem dep dura — B19 ✓/B34 já dão a lib a promover e o desenho do Popover+combobox; soft B34 só pela ordem de chegada em quem faz a promoção da lib, B31 pela mesma família de pedido em outra tabela, B33 pela mesma tabela; _mesma decisão a validar com produto_ do B31/B32/B34: Popover por célula, não toggle). **B36** (coluna de lideranças na lista de dobradinhas; sem dep dura — reusa integralmente a mutação/schema/lock que o B31 desenha para a mesma relação, do lado oposto; soft B31 pela corrida de extração da célula/action compartilhadas, B19 ✓ pelo padrão de origem, B33 pela mesma tabela), **C13** (renomear Plano de Ação → Atividade; sem dep dura — não toca nenhuma das listas em curso (B26–B37 são municípios/lideranças/dobradinhas), então roda em paralelo a elas; a única ordem que importa é **antes de E13**, que constrói dentro da vertical renomeada, e antes do congelamento ~20/09 por carregar migration; PR mecânico e isolado, não misturar com feature). **D3** só após smoke + folga e só se o fluxo sede-digita (C12 ✓) não absorver os deltas do ZAP (não compete com E9). ### Sequência por janela (só pendentes) -**Janela 1 — agora → 05/08 (convenções):** smoke pós-deploy em produção + onboarding do time (Onda 0 §2/§4); **E4R ✓** em código (aplicar `pnpm db:seed:projecao` em produção após smoke); **E8 ✓** conta da cadeira entregue; **E17 ✓** tabela TI no Início; **B15 ✓** (sort por coluna) e **A11 ✓** (posição em votos + `sort=votos`; o default do staff passou a `deficit` no E9 ✓) entregues 2026-07-24; **E18 ✓** documentação de conceitos (`/campanha/conceitos`, v1 só E8) entregue 2026-07-24; **B19 ✓** gerenciar assessores (caminho do onboarding sem `/admin` — criar/ativar contas e carteiras) entregue 2026-07-24; **B16 ✓** filtros no header entregue 2026-07-25; **B20 ✓** Municípios prioritários no Início entregue 2026-07-25; **B21** (página dos Territórios de Identidade — a leitura regional sai do painel do Início e ganha tela própria com sort/filtro; útil já no onboarding para dividir carteiras) se folga — e, logo atrás dela, **B25** (link do território na célula da lista: quarto de dia, é o que faz a página nova ser encontrada de dentro do trabalho); **B22** (explicação por coluna no header — a tabela deixa de exigir o autor do E8/E9 ao lado para ser lida; quick win de onboarding, meio dia); **B23** (tooltip nas células — nomes dos assessores no hover da coluna, hoje só iniciais, e a justificativa da tendência, hoje invisível na lista; capacidade genérica da célula, meio dia, casa com B22 pelo mesmo primitivo); ~~**B24**~~ ✓ (auto-save + justificativa no popover de Tendência — entregue 2026-07-26; a mesa corrige em massa as notas semeadas pelo E4R ✓ sem abrir 435 telas de edição); **B26** (registrar sinal na própria célula "Último sinal" — fecha o laço do E9 ✓: hoje a fila diz onde está frio e obriga a sair dela para registrar qualquer coisa; ~0,75 dia, e é o item que mais aumenta a chance do gate de adoção abaixo, porque o registro passa a caber no gesto de varrer a lista, inclusive no celular); **B27** (combobox + chips + auto-save nos Assessores — é literalmente a tela em que o CG divide as carteiras no onboarding: hoje procura o nome numa coluna de checkboxes e confirma num botão; ~0,75 dia, casa com B24 pelo mesmo endpoint e com B23 pelo mesmo gatilho); **B28** (e-mail + celular na lista de lideranças — copy + WhatsApp como em assessores, sem abrir a ficha; ~0,5–0,75 dia, quick win de campo no onboarding da rede); **B30** (convidar para completar cadastro por WhatsApp na mesma lista — reusa o convite que já existe no detalhe, sem abrir a ficha; ~0,25–0,5 dia, o quick win mais barato desta leva porque não toca action/collection nenhuma; casa com B28 pela mesma linha, ícone distinto); **B31** (coluna de dobradinhas na mesma lista — é o preenchimento em lote que o onboarding exige: o E4R ✓ deixou dobradinha só no município e as fichas vazias, e completar uma a uma pelo detalhe é o gesto que trava a mesa; ~1 dia, casa com B27 pela mesma peça de célula — quem for primeiro define o padrão do chip+combobox in-list); **B32** (editar Status de apoio na célula da mesma lista — pedido de produto explícito para não repetir o toggle "Editar" do B19 ✓; fecha o adiado do próprio B29; ~0,4–0,5 dia, o quick win mais barato entre os popovers de célula desta leva, casa com B24 pelo guard CSRF compartilhado); **B34** (chips editáveis de municípios na mesma lista — a coluna "Municípios" ganha o mesmo tratamento da coluna homônima em Assessores (B19 ✓): chips com link + Popover de edição com combobox, em vez do texto morto atual; ~1–1,25 dia, casa com B27/B31 pela mesma peça de célula editável — quem entrar antes define o padrão do combobox+chip in-list que os outros herdam); **B37** (o mesmo tratamento de chips editáveis na coluna "Municípios" de `/campanha/dobradinhas` — hoje um número morto, `municipalityCount`; ~0,75–1 dia, herda a lib promovida pelo B34 se ele já tiver landado, senão promove ela mesma); **B36** (a coluna "Lideranças" da mesma lista ganha o mesmo tratamento — chips nomeados + Popover + busca + auto-save por delta, reusando sem alteração a mutação que o B31 desenha para o lado oposto da mesma relação; ~0,5–1,5 dia dependendo de quem entre B31/B36 chega primeiro na corrida de extração da célula compartilhada); **B33** (sort + filtro no header de `/campanha/dobradinhas` — pedido explícito de reuso: Nome/Partido no header e filtro por Partido, sem duplicar o chrome de `/campanha/municipios`; ~0,5–1,5 dia dependendo de quem chega primeiro na corrida de extração do chrome compartilhado com B29/B21; entra perto de B29 por reusar a mesma peça); **B29** (sort + filtro no header da mesma lista — é a tela onde o assessor recebe a carteira e precisa recortar "quem ainda está a abordar aqui" / "quem ainda não tem acesso ao app" antes de convidar; ~1,5–2 dias porque paga também a extração do head compartilhado, então entra depois dos quick wins — mas antes se B21 for adiado, para o head nascer compartilhado); **B17** (seletor de colunas) / **B18** (filtros salvos + atalho sidebar) se folga de UX da lista no onboarding; fill-in **renomear coluna Cobertura** (header curto na fila — quarto de dia; glossário/KPI intactos); fill-in **remover coluna Tipo** (densidade morta na fila — quarto/meio dia; Salvador via slugs cobre ZE); fill-in **selecionados no topo nos filtros do header** (desmarcar sem caçar checkbox; helper puro que B21 herda — quarto de dia); **C13 ✓** (renomeado "Plano de Ação" → "Atividade" em toda a vertical, incluindo a migration data-preserving — entregue 2026-07-25); gate de adoção: sinal Little Hire — ≥1 update espontâneo até 30/07, cobrança da tabela dispensada (planilhas já em `docs/sheets/`) — acompanhamento em [IMPROVE-APP-PLAN.md](IMPROVE-APP-PLAN.md); **R6** critique/polish; **E9 ✓** entregue no mesmo dia; **B14** (atalho geo) se sobrar folga de campo no onboarding; Onda 0 jurídica em paralelo (externa). +**Janela 1 — agora → 05/08 (convenções):** smoke pós-deploy em produção + onboarding do time (Onda 0 §2/§4); **E4R ✓** em código (aplicar `pnpm db:seed:projecao` em produção após smoke); **E8 ✓** conta da cadeira entregue; **E17 ✓** tabela TI no Início; **B15 ✓** (sort por coluna) e **A11 ✓** (posição em votos + `sort=votos`; o default do staff passou a `deficit` no E9 ✓) entregues 2026-07-24; **E18 ✓** documentação de conceitos (`/campanha/conceitos`, v1 só E8) entregue 2026-07-24; **B19 ✓** gerenciar assessores (caminho do onboarding sem `/admin` — criar/ativar contas e carteiras) entregue 2026-07-24; **B16 ✓** filtros no header entregue 2026-07-25; **B20 ✓** Municípios prioritários no Início entregue 2026-07-25; **B21** (página dos Territórios de Identidade — a leitura regional sai do painel do Início e ganha tela própria com sort/filtro; útil já no onboarding para dividir carteiras) se folga — e, logo atrás dela, **B25** (link do território na célula da lista: quarto de dia, é o que faz a página nova ser encontrada de dentro do trabalho); **B22** (explicação por coluna no header — a tabela deixa de exigir o autor do E8/E9 ao lado para ser lida; quick win de onboarding, meio dia); **B23** (tooltip nas células — nomes dos assessores no hover da coluna, hoje só iniciais, e a justificativa da tendência, hoje invisível na lista; capacidade genérica da célula, meio dia, casa com B22 pelo mesmo primitivo); ~~**B24**~~ ✓ (auto-save + justificativa no popover de Tendência — entregue 2026-07-26; a mesa corrige em massa as notas semeadas pelo E4R ✓ sem abrir 435 telas de edição); **B26** (registrar sinal na própria célula "Último sinal" — fecha o laço do E9 ✓: hoje a fila diz onde está frio e obriga a sair dela para registrar qualquer coisa; ~0,75 dia, e é o item que mais aumenta a chance do gate de adoção abaixo, porque o registro passa a caber no gesto de varrer a lista, inclusive no celular); **B27** (combobox + chips + auto-save nos Assessores — é literalmente a tela em que o CG divide as carteiras no onboarding: hoje procura o nome numa coluna de checkboxes e confirma num botão; ~0,75 dia, casa com B24 pelo mesmo endpoint e com B23 pelo mesmo gatilho); **B28** (e-mail + celular na lista de lideranças — copy + WhatsApp como em assessores, sem abrir a ficha; ~0,5–0,75 dia, quick win de campo no onboarding da rede); **B30** (convidar para completar cadastro por WhatsApp na mesma lista — reusa o convite que já existe no detalhe, sem abrir a ficha; ~0,25–0,5 dia, o quick win mais barato desta leva porque não toca action/collection nenhuma; casa com B28 pela mesma linha, ícone distinto); **B31** (coluna de dobradinhas na mesma lista — é o preenchimento em lote que o onboarding exige: o E4R ✓ deixou dobradinha só no município e as fichas vazias, e completar uma a uma pelo detalhe é o gesto que trava a mesa; ~1 dia, casa com B27 pela mesma peça de célula — quem for primeiro define o padrão do chip+combobox in-list); **B32** (editar Status de apoio na célula da mesma lista — pedido de produto explícito para não repetir o toggle "Editar" do B19 ✓; fecha o adiado do próprio B29; ~0,4–0,5 dia, o quick win mais barato entre os popovers de célula desta leva, casa com B24 pelo guard CSRF compartilhado); **B34** (chips editáveis de municípios na mesma lista — a coluna "Municípios" ganha o mesmo tratamento da coluna homônima em Assessores (B19 ✓): chips com link + Popover de edição com combobox, em vez do texto morto atual; ~1–1,25 dia, casa com B27/B31 pela mesma peça de célula editável — quem entrar antes define o padrão do combobox+chip in-list que os outros herdam); **B37** (o mesmo tratamento de chips editáveis na coluna "Municípios" de `/campanha/dobradinhas` — hoje um número morto, `municipalityCount`; ~0,75–1 dia, herda a lib promovida pelo B34 se ele já tiver landado, senão promove ela mesma); **B36** (a coluna "Lideranças" da mesma lista ganha o mesmo tratamento — chips nomeados + Popover + busca + auto-save por delta, reusando sem alteração a mutação que o B31 desenha para o lado oposto da mesma relação; ~0,5–1,5 dia dependendo de quem entre B31/B36 chega primeiro na corrida de extração da célula compartilhada); **B33** (sort + filtro no header de `/campanha/dobradinhas` — pedido explícito de reuso: Nome/Partido no header e filtro por Partido, sem duplicar o chrome de `/campanha/municipios`; ~0,5–1,5 dia dependendo de quem chega primeiro na corrida de extração do chrome compartilhado com B29/B21; entra perto de B29 por reusar a mesma peça); **B29** (sort + filtro no header da mesma lista — é a tela onde o assessor recebe a carteira e precisa recortar "quem ainda está a abordar aqui" / "quem ainda não tem acesso ao app" antes de convidar; ~1,5–2 dias porque paga também a extração do head compartilhado, então entra depois dos quick wins — mas antes se B21 for adiado, para o head nascer compartilhado); **B17** (seletor de colunas) / **B18** (filtros salvos + atalho sidebar) se folga de UX da lista no onboarding; fill-in **renomear coluna Cobertura** (header curto na fila — quarto de dia; glossário/KPI intactos); fill-in **remover coluna Tipo** (densidade morta na fila — quarto/meio dia; Salvador via slugs cobre ZE); fill-in **selecionados no topo nos filtros do header** (desmarcar sem caçar checkbox; helper puro que B21 herda — quarto de dia); **C13 ✓** (renomeado "Plano de Ação" → "Atividade" em toda a vertical, incluindo a migration data-preserving — entregue 2026-07-25); gate de adoção: sinal Little Hire — ≥1 update espontâneo até 30/07, cobrança da tabela dispensada (planilhas já em `docs/sheets/`) — acompanhamento em [IMPROVE-APP-PLAN.md](IMPROVE-APP-PLAN.md); **R6** critique/polish; **E9 ✓** entregue no mesmo dia; **B14 ✓** (atalho geo entregue 2026-07-26); Onda 0 jurídica em paralelo (externa). **Janela 2 — 05/08 → 16/08 (pré-propaganda):** C2 dados reais assim que o jurídico liberar; ~~**E9** fila de alocação~~ (entregue 2026-07-24, antecipada da janela 2); ~~**E16** dossiê do município~~ (entregue 2026-07-25, antecipado da janela 2); D2 se sobrar folga. @@ -319,7 +319,7 @@ Paralelizáveis agora: **B13** (E10 ✓ entregou as classes; consome `computeMun **Não cortáveis:** Onda 0 (jurídico/Consent); ~~**E4R** seed da planilha~~ (entregue 2026-07-24 — ainda aplicar em produção após smoke); ~~**E8** conta da cadeira~~ (entregue 2026-07-24); ~~**C12** registro-fundação~~ (entregue 2026-07-24); C2 dados reais; assimetria declarado×estimado (relação de campo); ~~**E9** fila de alocação~~ (entregue 2026-07-24 — era o mínimo de "inteligência, não planilha"); ~~**B19** gerenciar assessores~~ (entregue 2026-07-24 — CG/candidato não entram em `/admin`). -**Cortes seguros** (se o prazo apertar, nesta ordem): **D5** inbox→rascunhos (manter registro manual + `wa.me`); **D4** envio bridge (manter `wa.me`); **D3** fundação do canal (atalho `wa.me` continua); **E12** camada TI (rollup manual por lista; **E17** já dá a leitura regional básica no Início); **E13** planejador de giros (rebaixado na fila de corte em 2026-07-24: "perna"/agenda é a restrição dominante nomeada em campo — cortar só depois de E12; agenda segue manual com J-A/J-B como guia); **E15** backtest (pós-eleição por definição — cortar = perder o aprendizado 2030); **E11** motor v1 (manter a fila E9 ✓ sem sugestões); **B13** símbolo proporcional (manter quantis/LQ como escala); **E14** níveis (manter `priority` alta/normal); **B8 F2** polígonos (mapa continua agregado no município; manter F1 bairros); **D2** push (adiar); **A6**; **B14** município mais próximo (lista/busca e Recentes continuam); **B18** filtros salvos (lista + filtros manuais continuam; Visitados cobrem “voltar”); **B21** página dos Territórios de Identidade (o painel do E17 ✓ no Início continua entregando a leitura regional) — **B25** cai junto por definição (sem destino, sem link); **B17** seletor de colunas (tabela completa continua; scroll horizontal); ~~**B22** explicação no header~~ (entregue 2026-07-26); ~~**B23** tooltip nas células~~ (entregue 2026-07-26); ~~**B24** auto-save + justificativa na Tendência~~ (entregue 2026-07-26); **B27** combobox + chips nos Assessores (a lista de checkboxes com "Salvar" continua atribuindo — cortar custa a mesma coerência do B24 mais o tempo do CG procurando nomes numa coluna durante o onboarding; corte junto com B24, já que dividem o endpoint); **B28** e-mail + celular na lista de lideranças (a ficha `/liderancas/[id]` continua mostrando o contato — cortar custa o gesto de copiar/abrir ZAP na varredura; quick win barato de cortar e de fazer); **B30** convite de completar cadastro por WhatsApp na lista (o botão do detalhe continua gerando o convite; cortar custa só o atalho sem abrir a ficha — é o corte mais barato desta faixa, porque não toca nenhuma action/collection); **B31** coluna de dobradinhas nas lideranças (o `RelationMultiSelect` da ficha interna continua vinculando — cortar custa o preenchimento em lote do onboarding, uma ficha aberta por liderança, e mantém o vínculo invisível na varredura; corte junto com B27, já que dividem a peça de célula, e antes de B29 só se a mesa já tiver preenchido as dobradinhas à mão); **B32** editar Status de apoio na célula (a ficha `/liderancas/[id]` continua trocando o status pelo `NativeSelect` do formulário — cortar custa só o atalho de um clique a mais por linha; é o corte mais barato desta faixa, junto com B30, porque não toca nenhum contrato de dado); **B34** chips editáveis de municípios na lista de lideranças (o texto simples `row.municipalityNames.join(', ')` continua legível e a ficha `/liderancas/[id]` continua vinculando município via formulário — cortar custa o link direto ao município, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha; corte junto com B31, já que dividem a mesma peça de célula editável, e antes de B29 pelo mesmo motivo de coerência); **B37** chips editáveis de municípios na lista de dobradinhas (o número `municipalityCount` continua legível e a ficha de município continua vinculando dobradinha pelo `MunicipalityStrategyForm` — cortar custa o link direto, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha do município; corte junto com B34, já que dividem a mesma lib e a mesma decisão de container); **B36** coluna de lideranças na lista de dobradinhas (a contagem `leadershipCount` continua legível e a ficha `/liderancas/[id]` continua vinculando dobradinha via formulário — cortar custa o link direto à liderança, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha; corte junto com B31, já que dividem a mesma mutação/schema/lock); ~~**B33** sort + filtro no header das dobradinhas~~ (entregue 2026-07-26); **B29** sort + filtro no header das lideranças (a busca por nome continua funcionando e o escopo do assessor já reduz a lista — cortar custa o recorte por status/setor/município no onboarding da rede e mantém o header rico como cópia única de municípios; por ser o item mais caro desta faixa (~1,5–2 dias com a extração do head), cai **antes** do B26, que é o que sustenta o gate de adoção); **B26** registrar sinal na célula (o formulário completo em `/[slug]?tab=updates` continua sendo o caminho — cortar custa o laço da fila do E9 ✓ e o registro em campo pelo celular, que é justamente onde o gate de adoção se decide; por isso cai **depois** de B22/B23/B24); ~~**C13** renomear Plano de Ação → Atividade~~ (entregue 2026-07-25); **B20** prioritários no Início (lista `?priority=alta` continua; só perde o atalho — mas a seção atual mente, então cortar = conviver com o bug); fill-in **renomear coluna Cobertura** (a lista continua legível com o rótulo longo — corte = header mais largo); fill-in **remover coluna Tipo** (a lista continua completa; só mantém densidade morta — corte = conviver com a coluna); ~~**B16** filtros no header~~ (entregue 2026-07-25); ~~**B15** sort por coluna~~ (entregue 2026-07-24); ~~**A11**/**E17**~~ (entregues 2026-07-24); ~~**E18** documentação de conceitos~~ (entregue 2026-07-24); ~~**E16** dossiê~~ (entregue 2026-07-25); demais débitos/fill-ins. +**Cortes seguros** (se o prazo apertar, nesta ordem): **D5** inbox→rascunhos (manter registro manual + `wa.me`); **D4** envio bridge (manter `wa.me`); **D3** fundação do canal (atalho `wa.me` continua); **E12** camada TI (rollup manual por lista; **E17** já dá a leitura regional básica no Início); **E13** planejador de giros (rebaixado na fila de corte em 2026-07-24: "perna"/agenda é a restrição dominante nomeada em campo — cortar só depois de E12; agenda segue manual com J-A/J-B como guia); **E15** backtest (pós-eleição por definição — cortar = perder o aprendizado 2030); **E11** motor v1 (manter a fila E9 ✓ sem sugestões); **B13** símbolo proporcional (manter quantis/LQ como escala); **E14** níveis (manter `priority` alta/normal); **B8 F2** polígonos (mapa continua agregado no município; manter F1 bairros); **D2** push (adiar); **A6**; ~~**B14** município mais próximo~~ (entregue 2026-07-26); **B18** filtros salvos (lista + filtros manuais continuam; Visitados cobrem “voltar”); **B21** página dos Territórios de Identidade (o painel do E17 ✓ no Início continua entregando a leitura regional) — **B25** cai junto por definição (sem destino, sem link); **B17** seletor de colunas (tabela completa continua; scroll horizontal); ~~**B22** explicação no header~~ (entregue 2026-07-26); ~~**B23** tooltip nas células~~ (entregue 2026-07-26); ~~**B24** auto-save + justificativa na Tendência~~ (entregue 2026-07-26); **B27** combobox + chips nos Assessores (a lista de checkboxes com "Salvar" continua atribuindo — cortar custa a mesma coerência do B24 mais o tempo do CG procurando nomes numa coluna durante o onboarding; corte junto com B24, já que dividem o endpoint); **B28** e-mail + celular na lista de lideranças (a ficha `/liderancas/[id]` continua mostrando o contato — cortar custa o gesto de copiar/abrir ZAP na varredura; quick win barato de cortar e de fazer); **B30** convite de completar cadastro por WhatsApp na lista (o botão do detalhe continua gerando o convite; cortar custa só o atalho sem abrir a ficha — é o corte mais barato desta faixa, porque não toca nenhuma action/collection); **B31** coluna de dobradinhas nas lideranças (o `RelationMultiSelect` da ficha interna continua vinculando — cortar custa o preenchimento em lote do onboarding, uma ficha aberta por liderança, e mantém o vínculo invisível na varredura; corte junto com B27, já que dividem a peça de célula, e antes de B29 só se a mesa já tiver preenchido as dobradinhas à mão); **B32** editar Status de apoio na célula (a ficha `/liderancas/[id]` continua trocando o status pelo `NativeSelect` do formulário — cortar custa só o atalho de um clique a mais por linha; é o corte mais barato desta faixa, junto com B30, porque não toca nenhum contrato de dado); **B34** chips editáveis de municípios na lista de lideranças (o texto simples `row.municipalityNames.join(', ')` continua legível e a ficha `/liderancas/[id]` continua vinculando município via formulário — cortar custa o link direto ao município, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha; corte junto com B31, já que dividem a mesma peça de célula editável, e antes de B29 pelo mesmo motivo de coerência); **B37** chips editáveis de municípios na lista de dobradinhas (o número `municipalityCount` continua legível e a ficha de município continua vinculando dobradinha pelo `MunicipalityStrategyForm` — cortar custa o link direto, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha do município; corte junto com B34, já que dividem a mesma lib e a mesma decisão de container); **B36** coluna de lideranças na lista de dobradinhas (a contagem `leadershipCount` continua legível e a ficha `/liderancas/[id]` continua vinculando dobradinha via formulário — cortar custa o link direto à liderança, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha; corte junto com B31, já que dividem a mesma mutação/schema/lock); ~~**B33** sort + filtro no header das dobradinhas~~ (entregue 2026-07-26); **B29** sort + filtro no header das lideranças (a busca por nome continua funcionando e o escopo do assessor já reduz a lista — cortar custa o recorte por status/setor/município no onboarding da rede e mantém o header rico como cópia única de municípios; por ser o item mais caro desta faixa (~1,5–2 dias com a extração do head), cai **antes** do B26, que é o que sustenta o gate de adoção); **B26** registrar sinal na célula (o formulário completo em `/[slug]?tab=updates` continua sendo o caminho — cortar custa o laço da fila do E9 ✓ e o registro em campo pelo celular, que é justamente onde o gate de adoção se decide; por isso cai **depois** de B22/B23/B24); ~~**C13** renomear Plano de Ação → Atividade~~ (entregue 2026-07-25); **B20** prioritários no Início (lista `?priority=alta` continua; só perde o atalho — mas a seção atual mente, então cortar = conviver com o bug); fill-in **renomear coluna Cobertura** (a lista continua legível com o rótulo longo — corte = header mais largo); fill-in **remover coluna Tipo** (a lista continua completa; só mantém densidade morta — corte = conviver com a coluna); ~~**B16** filtros no header~~ (entregue 2026-07-25); ~~**B15** sort por coluna~~ (entregue 2026-07-24); ~~**A11**/**E17**~~ (entregues 2026-07-24); ~~**E18** documentação de conceitos~~ (entregue 2026-07-24); ~~**E16** dossiê~~ (entregue 2026-07-25); demais débitos/fill-ins. ## Bloqueadores atuais diff --git a/src/components/campaign/dashboard/CampaignDashboard.tsx b/src/components/campaign/dashboard/CampaignDashboard.tsx index dc199cbf..8227423b 100644 --- a/src/components/campaign/dashboard/CampaignDashboard.tsx +++ b/src/components/campaign/dashboard/CampaignDashboard.tsx @@ -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 => { + const hrefs: Record = {} + + 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 = ({ ) : null} - + {/* + * 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. + */} +
+ + +
+) + +const OpenMunicipalityButton = ({ slug, name }: { slug: string; name: string }) => ( + +) + +const Explanation = ({ children }: { children: ReactNode }) => ( +

{children}

+) + +const CoarseFixCaveat = () => ( + + Localização aproximada (o navegador não usou o GPS) — confira o município antes de agir. + +) + +const renderBody = ( + state: CardState, + locate: () => void, + zoneCityHrefs: Readonly>, +): ReactNode => { + if (state.status === 'starting' || state.status === 'ready') { + return ( + <> + Abra a ficha do município em que você está. + {/* No CTA while permission is unread: it would become a spinner under the finger. */} + {state.status === 'ready' ? ( + + ) : null} + + ) + } + + if (state.status === 'locating') { + return ( +

+ {/* The live region above announces the wait; Spinner's own role="status" would repeat it. */} +

+ ) + } + + if (state.status === 'failed') { + const copy = failureCopy[state.reason] + if (!copy) return null + + return ( + <> +

{copy.title}

+ {copy.hint} + + + ) + } + + return ( + <> + + {/* Between "where you are" and what to do about it — a hedge after the CTA is a footnote. */} + {state.coarse ? : 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>, +): ReactNode => { + const nearest = 'nearestInScope' in resolution ? resolution.nearestInScope : null + const nearestDetail = nearest ? ( + <> + + Mais próximo na sua carteira: {nearest.municipality.name}, a{' '} + {formatDistanceKm(nearest.distanceKm)}. + + + + ) : null + + switch (resolution.kind) { + case 'inScope': + return ( + + ) + + 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 ( + <> + + Escolha entre as {resolution.zoneCount} zonas — a localização não distingue a zona. + + {zonesHref ? ( + + ) : null} + + ) + } + + case 'outOfScope': + return nearestDetail ?? Nenhum município da sua carteira por perto. + + case 'outsideBahia': + return ( + nearestDetail ?? ( + <> + Sua posição está longe do território da campanha. + + + ) + ) + } +} diff --git a/src/components/campaign/dashboard/RecentlyVisitedCard.tsx b/src/components/campaign/dashboard/RecentlyVisitedCard.tsx index 906449ce..bab45d34 100644 --- a/src/components/campaign/dashboard/RecentlyVisitedCard.tsx +++ b/src/components/campaign/dashboard/RecentlyVisitedCard.tsx @@ -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([]) useEffect(() => { - const visits = listRecentVisits() - setEntries(visits) - onVisitCountChange?.(visits.length) - }, [onVisitCountChange]) + setEntries(listRecentVisits()) + }, []) const handleClear = () => { clearRecentVisits() setEntries([]) - onVisitCountChange?.(0) } if (!entries.length) return null diff --git a/src/lib/municipalityProximity.ts b/src/lib/municipalityProximity.ts new file mode 100644 index 00000000..148008b7 --- /dev/null +++ b/src/lib/municipalityProximity.ts @@ -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` +} diff --git a/src/utilities/campaignDashboardData.ts b/src/utilities/campaignDashboardData.ts index 2cbc379e..fb2a1aac 100644 --- a/src/utilities/campaignDashboardData.ts +++ b/src/utilities/campaignDashboardData.ts @@ -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) diff --git a/src/utilities/campaignGeolocation.ts b/src/utilities/campaignGeolocation.ts new file mode 100644 index 00000000..b2342f54 --- /dev/null +++ b/src/utilities/campaignGeolocation.ts @@ -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 => { + 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 => { + if (typeof window === 'undefined' || !navigator.geolocation?.getCurrentPosition) { + return Promise.resolve({ ok: false, reason: 'unsupported' }) + } + + return new Promise((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, + }, + ) + }) +} diff --git a/src/utilities/municipalityMapNavigation.ts b/src/utilities/municipalityMapNavigation.ts index a1231882..53f2adc8 100644 --- a/src/utilities/municipalityMapNavigation.ts +++ b/src/utilities/municipalityMapNavigation.ts @@ -17,7 +17,7 @@ type MunicipalityForIbgeIndex = { } export const buildMunicipalitiesByIbgeCode = ( - municipalities: MunicipalityForIbgeIndex[], + municipalities: readonly MunicipalityForIbgeIndex[], ): MunicipalitiesByIbgeCode => { const byIbge: MunicipalitiesByIbgeCode = {} diff --git a/tests/e2e/campaignNearestMunicipality.e2e.spec.ts b/tests/e2e/campaignNearestMunicipality.e2e.spec.ts new file mode 100644 index 00000000..672186bf --- /dev/null +++ b/tests/e2e/campaignNearestMunicipality.e2e.spec.ts @@ -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) + }) +}) diff --git a/tests/int/bahiaGeometries.int.spec.ts b/tests/int/bahiaGeometries.int.spec.ts index 58c7dbea..a5dfb4ef 100644 --- a/tests/int/bahiaGeometries.int.spec.ts +++ b/tests/int/bahiaGeometries.int.spec.ts @@ -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(), diff --git a/tests/unit/campaignGeolocation.unit.spec.ts b/tests/unit/campaignGeolocation.unit.spec.ts new file mode 100644 index 00000000..278664e4 --- /dev/null +++ b/tests/unit/campaignGeolocation.unit.spec.ts @@ -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({ + 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({ coords: stub(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 }) + }) + }) +}) diff --git a/tests/unit/municipalityProximity.unit.spec.ts b/tests/unit/municipalityProximity.unit.spec.ts new file mode 100644 index 00000000..8b26bafe --- /dev/null +++ b/tests/unit/municipalityProximity.unit.spec.ts @@ -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') + }) + }) +})