Deliver B11 percent-of-valid-votes scale on Praças map.

Adds valid-votes denominators to the map bundle, a Total vs % dos válidos selector with fixed 0–100% coloring, and readout/legend alignment via scaleMax; updates roadmap and post-simplify debt docs.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-21 13:12:05 -03:00
parent 248f896d05
commit 9db80d815b
15 changed files with 278 additions and 49 deletions

View file

@ -7,6 +7,7 @@ alwaysApply: false
## Status
- **B11 entregue em código (2026-07-21; deploy pendente com remodelagem):** escala **% dos válidos** no Mapa das Praças — seletor `Total (votos)` / `% dos válidos` (default %); domínio 0–100% fixo (`scaleMax` em `BahiaMap`); `validVotesByYear` no bundle; 2026 usa válidos 2022; compare desliga %; readout em % + votos absolutos; `Promise.all` nos loads TSE. Plano: [`escala-percentual-mapa-pracas.md`](../../../docs/plans/escala-percentual-mapa-pracas.md). Débito perf troca `scaleMode` → **B6** [`escala-dry-pos-b3.md`](../../../docs/plans/escala-dry-pos-b3.md).
- **Fill-in filtros-auto entregue em código (2026-07-21; deploy pendente com remodelagem):** debounce 1s + Enter imediato na busca de `/campanha/pracas`; remove Buscar; pending UI alinhado a `ActionPlanFilters`; `shouldUpdatePlazaSearchUrl` + no-op `buildPlazaFiltersKey`. Plano: [`filtros-auto-pracas.md`](../../../docs/plans/filtros-auto-pracas.md). Débitos pós-`/simplify` (sync back/forward, shell pending) → gatilhos no plano.
- **B10 entregue em código (2026-07-21; deploy pendente com remodelagem):** hover/tap no Mapa das Praças — destaque + `MapFeatureReadout`; desktop click navega; mobile 2º tap; SSA/CMS N>1 foca `zoneBreakdown`; `plazasByIbgeCode` / `resolvePlazaMapNavigation`. Plano: [`hover-mapa-pracas.md`](../../../docs/plans/hover-mapa-pracas.md). Débito perf hover O(n) → **B6** [`escala-dry-pos-b3.md`](../../../docs/plans/escala-dry-pos-b3.md).
- **B9 entregue em código (2026-07-21; deploy pendente com remodelagem):** edição rápida na lista — Assessores / Tendência / `expectedVotes` via `PlazaList*Control` + `listFormActions`; sem migration. Plano: [`edicao-rapida-lista-pracas.md`](../../../docs/plans/edicao-rapida-lista-pracas.md). Débitos `/simplify` → **A9+** F2 + **C8** F4.
@ -124,6 +125,5 @@ alwaysApply: false
- Em 2026-07-17 o roadmap foi repriorizado orientado ao calendário eleitoral (Res. TSE 23.760/2026: propaganda a partir de 16/08, 1º turno 04/10), com ordem de execução, trilhas paralelas (A dados eleitorais/território, B superfícies de coordenação, C operação de campo, D plataforma) e grafo de dependências. Decisões de sequenciamento: lançar o MVP antes da migração multi-município; multi-município antes de zonas-por-municipio/eventos/demandas; baseline TSE independente de zonas-por-municipio (dependência rebaixada a suave); WhatsApp Business API movido a fora de escopo (vedação Meta + Res. 23.610 art. 33).
- Backlog consolidado do produto (admin RBAC, mapa, eventos, demandas, apoiadores, listas globais, site, white-label): [`docs/roadmap.md`](../../../docs/roadmap.md).
- **B7 entregue (2026-07-21):** mapa em `/campanha/pracas` usa `buildPlazaListWhere` (lista/overview/mapa alinhados). Plano: [`docs/plans/mapa-pracas-filtrado.md`](../../../docs/plans/mapa-pracas-filtrado.md). Débito perf triplicado → **A9+**.
- **A9 / B9 / B7 / B10 / filtros-auto entregues (2026-07-21; deploy pendente):** ver bullets no topo do Status. Próximos abertos Janela 1: **B11**, **R6**, deploy+smoke remodelagem; Janela 3: **B6** (setStyle incremental pós-B10), **B8**.
- **A9 / B9 / B7 / B10 / B11 / filtros-auto entregues (2026-07-21; deploy pendente):** ver bullets no topo do Status. Próximos abertos Janela 1: **R6**, deploy+smoke remodelagem; Janela 3: **B6** (setStyle incremental pós-B10/B11), **B8**.
- **B8 registrado (2026-07-21):** polígonos das Praças-zona Salvador/Camaçari. Pesquisa: SSA tem lista oficial TRE-BA zona↔bairro (RA 02/2017); CMS **não** — só locais de votação por ZE 170/171 + curadoria; TSE/TRE não publicam polígonos de ZE. Plano: F1 catálogo+UI na Praça; F2 dissolve malha de bairros (IBGE/GeoSalvador) → TopoJSON. Janela 3; F2 cortável. Plano: [`docs/plans/poligonos-pracas-zona.md`](../../../docs/plans/poligonos-pracas-zona.md).
- **B11 registrado (2026-07-21):** opção de escala no Mapa das Praças — cor por % dos votos válidos da Praça (0–100% fixos; denominador `electionTally.votosValidos`; 2026 usa válidos 2022); absoluto permanece. Plano: [`docs/plans/escala-percentual-mapa-pracas.md`](../../../docs/plans/escala-percentual-mapa-pracas.md).

View file

@ -47,6 +47,8 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap.
**Recently resolved (2026-07-19):** **C6** Escala e DRY pós-C2 shipped and merged to `main` — bulk supporter import via `payload.db.drizzle` inside the existing Payload transaction (`supporterImportBulk.ts`, `ON CONFLICT DO NOTHING` on `(contact_id, nucleus_id)`, 500-row chunks), `skipContactPhoneInvariant` opt-in on `Contact.enforceUniqueContactPhone` (fail-closed: only honored inside an active `req.transactionID`), single-SQL KPI aggregate (`supporterListOverviewAggregate.ts`, `COUNT(*) FILTER` mirroring `buildSupporterListWhere` + access constraint), HMAC-SHA256 single-use import token + ephemeral `supporterImportBatch` staging collection (migration `20260719_011015_add_supporter_import_batch`, TTL 10 min, actor-bound, consumed on confirm), and shared shells (`campaignListUrl.ts`, `CampaignListPagination`, `campaignFormFields.ts`, `mapCampaignFormActionError.ts`) replacing the three per-list pagination components. A `/simplify` pass applied targeted cleanup (identity maps, dead branches, `relationshipId`/`canManageCampaignUsers` reuse, native `base64url`, `rowCount ?? 0` bug fix). Scale/DRY debits the simplify reviewers flagged as larger than cleanup (locks in 1 round-trip, `.returning()`, `pg_trgm`, shared `drizzleBulk.ts`, column-name assertion, migrating the remaining `formActions`) are registered as **C8** (`docs/plans/escala-dry-pos-c6.md`). Production use with real supporter data still waits on the Onda 0 legal batch (Consent keys `apoiador-cadastro` / `apoiador-intencao-voto`).
**Recently resolved (2026-07-21):** **B11** Escala % dos válidos no Mapa das Praças — `validVotesByYear` in `PlazaMapBundle`, `scaleMode` selector (`absolute` | `percentValid`, default %), `computeValidVoteShares`, readout em % com votos absolutos secundários; `/simplify` fix `scaleMax` (legenda 0–100% alinhada ao fill). Débito perf troca `scaleMode` → **B6** (`docs/plans/escala-dry-pos-b3.md`). Plano: `docs/plans/escala-percentual-mapa-pracas.md`.
## Posts & Tags (news / publications)
The public news/publications system is backed by two collections (both in the `Publicações` admin group) plus the helpers in `src/utilities/posts.ts`.
@ -108,7 +110,7 @@ The internal `/campanha` area is gated by its own authentication, deliberately k
- **Roles:** `coordinator` ("Coordenador Geral") sees/manages everything and is the only one who assigns advisors and decides escalated demands; `advisor` ("Assessor") sees and manages ONLY plazas listed in `plaza.advisors` (list, map, dashboard, leaderships, pledges, demands are all scoped); `leader` ("Liderança") sees only plazas linked through their engaged leadership, declares own votes, opens demands, and writes own updates.
- **Vote pledge asymmetry (product-critical):** the leader INFORMS `declaredVotes` per linked plaza and may update it; staff record `estimatedVotes`/`estimateNote`, which field access denies to leaders (same technique as `supportStatus`). Leader-facing view models never aggregate estimates; staff aggregates use `estimatedVotes ?? declaredVotes` (`src/utilities/votePledgeData.ts`). Guarded by `tests/int/campaignVotePledge.int.spec.ts`.
- **Political trend:** manual conjuncture reading on the plaza (staff-only group `politicalTrend`: `favoravel|neutra|desfavoravel` + note + derived author/date). The numeric series is deliberately labeled "Evolução de votos" in the UI to keep "tendência" for the political reading.
- **Elections intelligence:** plaza geography → election cells via `src/utilities/plazaElectionGeography.ts`; baseline + Solla series 2014/2018/2022 (`plazaElectoralBaseline.ts`); multi-candidate comparison table on the plaza detail (`plazaCandidateComparison.ts`, up to 5 candidates via `?compare=`); map (`plazaMapData.ts` + `PlazaMapPanel`) colors municipalities by year (2026 = pledge estimates) and has a diverging compare mode (campaign red `#c51414` where Solla leads ↔ white tie ↔ blue where the compared candidate leads). Zone Praças aggregate into the municipality polygon with a per-zone breakdown list (zone polygons remain a rabbit hole).
- **Elections intelligence:** plaza geography → election cells via `src/utilities/plazaElectionGeography.ts`; baseline + Solla series 2014/2018/2022 (`plazaElectoralBaseline.ts`); multi-candidate comparison table on the plaza detail (`plazaCandidateComparison.ts`, up to 5 candidates via `?compare=`); map (`plazaMapData.ts` + `PlazaMapPanel`) colors municipalities by year (2026 = pledge estimates) with scale selector **Total (votos)** | **% dos válidos** (default %; fixed 0–100% domain via `validVotesByYear` + `scaleMax` on `BahiaMap`; 2026 uses 2022 valid votes as denominator; compare mode hides % and stays absolute diff) and has a diverging compare mode (campaign red `#c51414` where Solla leads ↔ white tie ↔ blue where the compared candidate leads). Zone Praças aggregate into the municipality polygon with a per-zone breakdown list (zone polygons remain a rabbit hole).
- **URLs:** `/campanha/pracas[/slug]` (seeded slugs from the catalog), `/campanha/liderancas[/id]`, `/campanha/organizacoes[/slug]`, `/campanha/demandas[/slug]`, `/campanha/planos`, `/campanha/apoiadores`. Route params stay English keys; slugs/enums stay pt data values.
- **Invites:** unchanged mechanics (hash-only persistence, `NEXT_PUBLIC_SITE_URL` origin, `no-store` public pages, login invites only for `engajado`); advisor scope now follows leadership↔plaza intersection. Consent still resolves only by stable key `lideranca-autopreenchimento` (fail closed).
- **PWA (D1):** unchanged — installable `/campanha`, versioned cache, offline fallback; push is still D2.

View file

@ -1,7 +1,7 @@
# Escala e DRY pós-A9 (loader da lista de Praças)
Status: registrado no roadmap (Fase 1 pendente; **B7 entregue 2026-07-21** — prioridade sobe)
Atualizado em: 2026-07-21 (`capture-review-debts` pós-B7 + pós-B9 `/simplify`)
Atualizado em: 2026-07-21 (`capture-review-debts` pós-B11 `/simplify`: `Promise.all` TSE em `loadPlazaMapBundle` resolvido; pós-B7 + pós-B9)
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha A, fill-in **A9+** pós-A9)
Impeccable: A — N/A (sem superfície UI; otimização de loader)
Appetite: ~1–1,5 dia eng (F1 loader + F2 revalidate pós-B9; PR único ou dois commits)
@ -21,11 +21,11 @@ O revisor de **performance** marcou como **maior que simplify** o follow-up abai
3. **Revalidação full-page após edição inline (B9).** Cada save em `listFormActions.ts` chama `revalidatePath('/campanha/pracas', 'page')` (+ detalhe), o que reroda overview unpaginado, mapa e lista — custo amplificado quando o coordenador edita várias células seguidas (pós-B9).
**Já resolvido no simplify/critique (não reabrir):** parsing duplo do form `expectedVotes`; `hasStaffVoteData` derivado de `staffVoteTotal > 0`; `rollupPlazaStaffVotes` + `sumStaffPledgeEffectiveTotal`; `StaffPlazaVotesDisplay`; assinatura estreita de `resolvePlazaStaffVoteTotal`; testes unit do rollup. **Pós-B7 simplify:** `PlazaListSearchParams` exportado de `plazaUi.ts`; parse único no map loader (`loadScopedPlazas` recebe `PlazaListState`).
**Já resolvido no simplify/critique (não reabrir):** parsing duplo do form `expectedVotes`; `hasStaffVoteData` derivado de `staffVoteTotal > 0`; `rollupPlazaStaffVotes` + `sumStaffPledgeEffectiveTotal`; `StaffPlazaVotesDisplay`; assinatura estreita de `resolvePlazaStaffVoteTotal`; testes unit do rollup. **Pós-B7 simplify:** `PlazaListSearchParams` exportado de `plazaUi.ts`; parse único no map loader (`loadScopedPlazas` recebe `PlazaListState`). **Pós-B11 simplify:** `Promise.all` nos loads TSE por ano em `loadPlazaMapBundle` (votes + válidos em paralelo; branch `?compare=` também paraleliza os 3 `loadCandidateVotesByCityZone`) — micro-opt que estava fora do escopo A9+; resolvido no cleanup B11, não reabrir aqui.
**Explicitamente fora (triage `capture-review-debts` 2026-07-21 pós-A9):** semântica lista (só `expectedVotes` manual) vs mapa/overview (fallback) — decisão de produto A9; merge de forms Estratégia + Votos no `/editar`; grid cosmético do `PlazaStrategyCard`; `loadPlazaPledges` em abas distintas do detalhe (uma aba por request); débitos adiados/não escopo do plano A9 (nota/autor, chip auto-sugerir, histórico, filtro `?expectedVotes=`); critique Impeccable formal por superfície → **R6**.
**Explicitamente fora (triage `capture-review-debts` 2026-07-21 pós-B7):** `Promise.all` nos loads TSE por ano em `plazaMapData` (micro-opt pré-existente; gatilho abaixo); testes int extras em `plazaMapData` (`region`/`leader`/`compare`) — cobertura opcional no próximo toque no loader; split do teste advisor por `kind` (legibilidade).
**Explicitamente fora (triage `capture-review-debts` 2026-07-21 pós-B7):** testes int extras em `plazaMapData` (`region`/`leader`/`compare`) — cobertura opcional no próximo toque no loader; split do teste advisor por `kind` (legibilidade).
**Explicitamente fora (triage `capture-review-debts` 2026-07-21 pós-B9):** ~75 hooks `useActionState` por página (3 controles × 25 linhas × 2 views) — gatilho: reclamação de perf ou **R6**; merge `listFormActions` ↔ `/editar` → **C8 F4**; hook popover compartilhado / layout responsivo único / lazy advisor options → gatilhos no plano B9.
@ -108,7 +108,6 @@ flowchart TD
- **SQL aggregate espelhando `buildPlazaListWhere`.** Revisitar quando profiling mostrar `aggregatePledgesByPlaza` dominando TTFB com filtros amplos + mapa sempre montado.
- **Compartilhar bundle com dashboard geral.** Revisitar se o dashboard passar a carregar mapa+lista na mesma rota.
- **Loads TSE sequenciais por ano em `loadPlazaMapBundle`.** Revisitar com `Promise.all` só se profiling com `?compare=` mostrar latência dominante (pré-existente; fora do escopo A9+).
## Referências

View file

@ -1,7 +1,7 @@
# Escala e DRY pós-B3 (Leaflet / coroplético)
Status: registrado no roadmap (fases pendentes)
Atualizado em: 2026-07-21 (`capture-review-debts` pós-B10 `/simplify`: hover/select absorvido em Fase 1)
Atualizado em: 2026-07-21 (`capture-review-debts` pós-B11 `/simplify`: troca `scaleMode` absorvida em Fase 1; pós-B10: hover/select)
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B6)
Impeccable: A — N/A (sem superfície UI nova; otimização do renderer Leaflet existente)
Appetite: ~1–1,5 dia eng (Fase 1 única: métrica/ano + hover/select incremental; PR único)
@ -11,9 +11,9 @@ Responsável: —
O B3 ([mapa-bahia-geometrias.md](mapa-bahia-geometrias.md) Fase 2) entrega `BahiaMap` + embeds (`NucleusOverviewMap`, `NucleusDetailMap`, `DashboardMap`), agregação servidor em `nucleusChoropleth.ts` / `nucleusChoroplethPageData.ts`, e lazy load de geometrias (B5 F1 ✓). Duas passagens `/simplify` no mesmo branch já limparam o que cabia em cleanup: `createCampaignClientDynamic`, `resolveChoroplethNuclei` / single-pass bundle, remoção de `geographyForCityNames` duplicado, `toNucleusElectionGeographyInput` no dashboard, highlight O(k) via `getMunicipalityFeature` / `getTerritoryFeature`, `NucleusDetailMap` `useMemo`, alinhamento de tipos com `NucleusElectionGeographyInput`.
Os revisores (performance) marcaram como **importante e maior que simplify** os follow-ups abaixo. Sem registro, cada troca de métrica no coroplético (overview/dashboard) refaz decode + `L.geoJSON` completo em 417 ou 27 polígonos; e, desde **B10** ([hover-mapa-pracas.md](hover-mapa-pracas.md)), cada hover/select no mapa de Praças chama `applyLayerStyles` com `eachLayer` em ~417 municípios.
Os revisores (performance) marcaram como **importante e maior que simplify** os follow-ups abaixo. Sem registro, cada troca de métrica no coroplético (overview/dashboard) refaz decode + `L.geoJSON` completo em 417 ou 27 polígonos; e, desde **B10** ([hover-mapa-pracas.md](hover-mapa-pracas.md)), cada hover/select no mapa de Praças chama `applyLayerStyles` com `eachLayer` em ~417 municípios. Desde **B11** ([escala-percentual-mapa-pracas.md](escala-percentual-mapa-pracas.md)), trocar `scaleMode` (`absolute` ↔ `% dos válidos`) também altera `displayValues` e dispara o mesmo rebuild — mesmo com `scaleMax` corrigindo legenda vs fill.
1. **`BahiaMap` rebuild completo do layer GeoJSON** quando só `values` (métrica) ou `highlightKeys` mudam. O effect em `BahiaMap.tsx` depende de `[highlightKey, mode, values]` e, a cada mudança, remove o layer, recria `L.geoJSON` com todas as features e reaplica estilos — mesmo que `mode` e o módulo de geometria já estejam carregados. O caminho correto para troca de métrica é `layer.setStyle(...)` (e `fitBounds` só quando o highlight muda).
1. **`BahiaMap` rebuild completo do layer GeoJSON** quando só `values` (métrica/escala), `scaleMax` ou `highlightKeys` mudam. O effect em `BahiaMap.tsx` depende de `[highlightKey, mode, scaleMax, values]` e, a cada mudança, remove o layer, recria `L.geoJSON` com todas as features e reaplica estilos — mesmo que `mode` e o módulo de geometria já estejam carregados. O caminho correto para troca de métrica/escala é `layer.setStyle(...)` (e `fitBounds` só quando o highlight muda).
2. **Hover/select no mapa de Praças (B10) ainda faz full-scan de estilo.** `mouseover` / `selectedKey` disparam `refreshLayerStyles` → `applyLayerStyles` → `eachLayer` + `setStyle` em todos os polígonos. O simplify pós-B10 já deduplica hover repetido e evita double-refresh quando hover “possui” o estilo, mas o hot path continua O(n) por movimento do cursor. O caminho correto é restyle **incremental** (2-path: anterior + atual, ou `Map<featureKey, PathLayer>`) — mesma refatoração estrutural da Fase 1.
@ -23,7 +23,7 @@ Os revisores (performance) marcaram como **importante e maior que simplify** os
## Objetivos
- Trocar métrica (núcleos / estimativa / baseline 2022) ou modo município/TI no coroplético **não** recria o layer GeoJSON inteiro — apenas atualiza `fillColor` / borda via `setStyle` quando a geometria já está montada.
- Trocar métrica (núcleos / estimativa / baseline 2022), **escala** (`absolute` ↔ `% dos válidos` no mapa de Praças pós-B11) ou modo município/TI no coroplético **não** recria o layer GeoJSON inteiro — apenas atualiza `fillColor` / borda via `setStyle` quando a geometria já está montada.
- Hover/select no mapa de Praças (B10) restyla só o polígono anterior + o ativo — não `eachLayer` em ~417 paths por `mouseover`.
- `fitBounds` roda só quando `highlightKeys` ou `mode` mudam, não a cada alteração de `values`.
- Guardrails: sem migration, sem collection, sem Consent, sem server action. Client-only; manter acessibilidade (`role="img"`, `aria-busy`, `aria-label`, `aria-live` do readout B10 inalterado).
@ -36,6 +36,7 @@ Os revisores (performance) marcaram como **importante e maior que simplify** os
- **Factory mun/TI** near-duplicate → **B5 Fase 3** em [escala-dry-pos-b2.md](escala-dry-pos-b2.md).
- **Cortável** se o mapa for raramente usado ou se troca de métrica permanecer aceitável em campo; vira não-cortável quando coordenadores alternarem métricas com frequência no overview filtrado **ou** hover denso no mapa de Praças (B10 entregue).
- **Dependência suave de B10.** Hover/select já existe em `PlazaMapPanel` + `BahiaMap`; Fase 1 unifica o caminho de estilo incremental — não reabre escopo de navegação/readout do B10.
- **Dependência suave de B11.** Seletor `scaleMode` em `PlazaMapPanel` já altera `displayValues`/`scaleMax`; Fase 1 deve cobrir essa troca sem rebuild — não reabre escopo de % válidos nem `validVotesByYear`.
- **i18n e naming** (AGENTS.md): identificadores em inglês; strings visíveis em pt-BR inalteradas.
## Questões em aberto
@ -51,6 +52,7 @@ flowchart TD
B3["B3 Leaflet + coroplético"] --> F1
B5F1["B5 F1 lazy geometrias ✓"] --> F1
B10["B10 Hover/tap Praças ✓"] --> F1
B11["B11 Escala % válidos ✓"] --> F1
F1["Fase 1 — BahiaMap setStyle<br/>(split effects + hover incremental)"]
F1 -.não bloqueia.-> B4["B4 Zonas TSE no mapa"]
```
@ -74,6 +76,7 @@ flowchart TD
- **Suave:** B3 Mapa Fase 2 (Leaflet) — implementado no branch; merge pendente.
- **Suave:** B5 F1 lazy geometrias — entregue com B3.
- **Suave:** B10 hover/tap no mapa de Praças — entregue 2026-07-21; Fase 1 consolida o hot path de estilo sem alterar navegação/readout.
- **Suave:** B11 escala % válidos — entregue 2026-07-21; troca `scaleMode` é mais um gatilho de rebuild a eliminar via `setStyle`.
- Reusa: `choroplethFillColor` / `choroplethMaxValue` (`src/lib/choroplethColorScale.ts`), loaders `loadMunicipalityGeometryModule` / `loadTerritoryGeometryModule`, tipos `ChoroplethValues`, `emphasizeFeature` / `getFeatureStyle` (B10).
## Não escopo

View file

@ -1,7 +1,7 @@
# Escala percentual no Mapa das Praças (0–100% dos válidos)
Status: rascunho
Atualizado em: 2026-07-21
Status: entregue 2026-07-21
Atualizado em: 2026-07-21 (implementação B11 + `capture-review-debts` pós-`/simplify`)
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B11)
Impeccable: B — encaixe em `PlazaMapPanel` / `BahiaMap` / `ChoroplethLegend` (sem rota nova)
Appetite: ~0,5–1 dia eng; tally por ano no bundle + seletor de escala + legenda 0–100%
@ -28,6 +28,10 @@ Os votos nominais já vêm de `loadCandidateVotesByCityZone` + `sumVotesForGeogr
Não cobre: filtro URL (**B7**), hover (**B10**), `setStyle` incremental (**B6**), polígonos-zona (**B8**).
**Já resolvido no simplify/critique (não reabrir):** bug legenda 0–100% vs mapa normalizado pelo max local (`scaleMax` em `BahiaMap`); lookup 2026→2022 redundante no panel (bundle já aliasa `validVotesByYear['2026']`); `PLAZA_MAP_SCALE_MODES`; `displayMax` memoizado; `Promise.all` nos loads TSE por ano (+ compare) em `loadPlazaMapBundle`; guard `valid > 0` em `computeValidVoteShares`; polish leve de copy/controle (sem critique P0–P3 formal).
**Explicitamente fora (triage `capture-review-debts` 2026-07-21 pós-B11):** `formatValidVoteShare` compartilhado com `PlazaBaselineCard` (gatilho: 3ª superfície com % válidos); loader genérico `loadTallyByCityZone` (gatilho: 3ª variante de tally); primitivo `validVoteShare` em `electionInsights` (gatilho: 3º call site); `maxLabel` em vez de `formatMax` na legenda; mover `computeValidVoteShares` de módulo; int test de agregação share além de keys 2026→2022 (gatilho: próximo toque em `plazaMapData`); cache cross-request de tally (gatilho: profiling TTFB); rebuild GeoJSON ao trocar `scaleMode` → **B6** [escala-dry-pos-b3.md](escala-dry-pos-b3.md).
## Objetivos
- Controle no painel do mapa: escala **Total (votos)** | **% dos válidos** (rótulos pt-BR).

View file

@ -1,6 +1,6 @@
# Roadmap — Teqo
Atualizado em: 2026-07-21 (fill-in filtros-auto entregue em código; B9 + B10 entregues; débitos B9 → A9+/C8 F4; débitos B10 → B6)
Atualizado em: 2026-07-21 (fill-in filtros-auto + B11 entregues em código; B9 + B10 entregues; débitos B9 → A9+/C8 F4; débitos B10/B11 scale toggle → B6)
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).
@ -73,23 +73,24 @@ flowchart TD
R2 -.-> B7["B7 Mapa filtrado ✓"]
R2 -.-> B8["B8 Polígonos Praças-zona<br/>(SSA/CMS)"]
R2 -.-> B10["B10 Hover/tap mapa ✓"]
R2 -.-> B11["B11 Escala % válidos<br/>no mapa"]
R2 -.-> B11["B11 Escala % válidos<br/>no mapa ✓"]
A9 -.métrica 2026.-> B10
A9 -.numerador 2026.-> B11
B10 -.hover densos.-> B6["B6 setStyle incremental"]
B11 -.troca escala.-> B6
JUR -.chave push.-> D2
C2prod --> C5["C5 GOTV (validar)"]
```
Paralelizáveis agora: **B11**, fill-ins da lista (A9+, …). ~~**A9** / **B9** / **B7** / **B10** / **filtros-auto**~~ entregues 2026-07-21. **B6** absorve o hot path de hover pós-B10 (Janela 3 / gatilho de densidade).
Paralelizáveis agora: fill-ins da lista (A9+, …). ~~**A9** / **B9** / **B7** / **B10** / **B11** / **filtros-auto**~~ entregues 2026-07-21. **B6** absorve hot path de hover pós-B10 e troca de escala pós-B11 (Janela 3 / gatilho de densidade).
### Sequência por janela
**Janela 1 — agora → 05/08 (convenções):** ~~R0 → R1 → R2~~ entregues; ~~**A9** estimativa de votos~~ / ~~**B9** edição rápida na lista~~ / ~~**B7** mapa filtrado~~ / ~~**B10** hover+click-nav~~ / ~~**filtros-auto** lista de Praças~~ entregues; **deploy da remodelagem** (revisar SQL destrutivo da migração antes do build) + smoke em produção; **B11** escala % dos válidos; R6 critique/polish; Onda 0 jurídica em paralelo.
**Janela 1 — agora → 05/08 (convenções):** ~~R0 → R1 → R2~~ entregues; ~~**A9** estimativa de votos~~ / ~~**B9** edição rápida na lista~~ / ~~**B7** mapa filtrado~~ / ~~**B10** hover+click-nav~~ / ~~**B11** escala % dos válidos~~ / ~~**filtros-auto** lista de Praças~~ entregues; **deploy da remodelagem** (revisar SQL destrutivo da migração antes do build) + smoke em produção; R6 critique/polish; Onda 0 jurídica em paralelo.
**Janela 2 — 05/08 → 16/08 (pré-propaganda):** C2 dados reais assim que o jurídico liberar; D2 se sobrar folga.
**Janela 3 — 16/08 → set:** A6 dobradinha (pós-TSE 15/08), B6 `setStyle` incremental (troca de ano/métrica **e** hover denso no mapa de Praças pós-B10), **B8** polígonos das Praças-zona Salvador/Camaçari (F1 catálogo de bairros shipável antes; F2 dissolve), débitos sobreviventes (abaixo).
**Janela 3 — 16/08 → set:** A6 dobradinha (pós-TSE 15/08), B6 `setStyle` incremental (troca de ano/métrica/escala **e** hover denso no mapa de Praças pós-B10/B11), **B8** polígonos das Praças-zona Salvador/Camaçari (F1 catálogo de bairros shipável antes; F2 dissolve), débitos sobreviventes (abaixo).
**Janela 4 — set → 04/10:** C5 GOTV _(validar)_, congelamento ~20/09 (só bugfix/dados).
@ -97,7 +98,7 @@ Paralelizáveis agora: **B11**, fill-ins da lista (A9+, …). ~~**A9** / **B9**
**Não cortáveis:** Onda 0 (jurídico/Consent); R1–R2 (sem eles a vertical não reflete a operação real); C2 dados reais; assimetria declarado×estimado (relação de campo); ~~**A9**~~ (total esperado da Praça — entregue 2026-07-21).
**Cortes seguros** (se o prazo apertar, nesta ordem): R4 mapa comparativo (manter tabela comparativa); painel de detalhe por zona no mapa; R3 organizações (manter demandas); resultado de plano com mídia (manter texto); Eleitorado/IBGE na Praça; D2 push (manter sino); A6; B6; **B11** (mapa continua em escala absoluta); **B8** (F2 polígonos; manter F1 bairros na Praça se já entregue — mapa continua agregado no município); débitos/fill-ins. ~~**B9** / **B10**~~ (entregues — não cortar).
**Cortes seguros** (se o prazo apertar, nesta ordem): R4 mapa comparativo (manter tabela comparativa); painel de detalhe por zona no mapa; R3 organizações (manter demandas); resultado de plano com mídia (manter texto); Eleitorado/IBGE na Praça; D2 push (manter sino); A6; B6; **B8** (F2 polígonos; manter F1 bairros na Praça se já entregue — mapa continua agregado no município); débitos/fill-ins. ~~**B9** / **B10** / **B11**~~ (entregues — não cortar).
## Já entregue (resumo)
@ -108,6 +109,7 @@ Paralelizáveis agora: **B11**, fill-ins da lista (A9+, …). ~~**A9** / **B9**
- **B7 (2026-07-21)** — **Mapa das Praças filtrado pela lista** (`buildPlazaListWhere` em `loadPlazaMapBundle`; `rawSearchParams` na página; empty → omitir painel; int `plazaMapData.int.spec.ts`) — [plano](plans/mapa-pracas-filtrado.md).
- **B9 (2026-07-21)** — **Edição rápida na lista de Praças** (Assessores / Tendência / `expectedVotes` via Popovers em `PlazaList*Control`; `listFormActions`; sem migration) — [plano](plans/edicao-rapida-lista-pracas.md). Débitos `/simplify`: **A9+** F2 (revalidate) + **C8** F4 (DRY formActions).
- **B10 (2026-07-21)** — **Hover/tap no Mapa das Praças** (destaque + `MapFeatureReadout`; desktop click navega; mobile 2º tap; SSA/CMS N>1 → `zoneBreakdown`; `plazasByIbgeCode` / `resolvePlazaMapNavigation`) — [plano](plans/hover-mapa-pracas.md). Débito perf hover O(n) → **B6** [escala-dry-pos-b3.md](plans/escala-dry-pos-b3.md).
- **B11 (2026-07-21)** — **Escala % dos válidos no Mapa das Praças** (`validVotesByYear` no bundle; seletor `Total (votos)` / `% dos válidos`; domínio fixo 0–100%; 2026 usa válidos 2022; compare desliga %; readout em %) — [plano](plans/escala-percentual-mapa-pracas.md). Débito perf troca `scaleMode` → **B6** [escala-dry-pos-b3.md](plans/escala-dry-pos-b3.md).
- **Fill-in filtros-auto (2026-07-21)** — **Filtros auto-aplicados na lista de Praças** (`PlazaFilters`: debounce 1s no `q`, Enter imediato, selects imediatos, remove Buscar; `useTransition` + pending a11y; `shouldUpdatePlazaSearchUrl` + no-op via `buildPlazaFiltersKey`; sem migration) — [plano](plans/filtros-auto-pracas.md). Débitos pós-`/simplify`: sync back/forward `search`↔`state.q` e shell pending compartilhado — gatilhos no plano.
## Supersedidos pela remodelagem (2026-07-20)
@ -130,9 +132,8 @@ Paralelizáveis agora: **B11**, fill-ins da lista (A9+, …). ~~**A9** / **B9**
- **A6** dobradinha 2026 automática quando o TSE publicar candidaturas · gatilho externo: pós-15/08 · reenquadrar para Praça · [plano](plans/insight-dobradinha-2026.md)
- **B5 F2–F3** cache CLI compartilhado + factory mun/TI (scripts continuam) · [plano](plans/escala-dry-pos-b2.md)
- **B6** `BahiaMap` setStyle incremental (métrica/ano + hover/select pós-B10 ✓) · gatilho: troca frequente de ano/métrica ou hover denso no mapa de Praças · appetite ~1–1,5 dia · [plano](plans/escala-dry-pos-b3.md)
- **B6** `BahiaMap` setStyle incremental (métrica/ano/escala pós-B11 + hover/select pós-B10 ✓) · gatilho: troca frequente de ano/métrica/escala ou hover denso no mapa de Praças · appetite ~1–1,5 dia · [plano](plans/escala-dry-pos-b3.md)
- **B8** Polígonos das Praças-zona (Salvador ZE 1–19 / Camaçari ZE 170–171): F1 catálogo zona→bairros + UI na Praça; F2 dissolve IBGE/malha → TopoJSON no mapa · Janela 3 · cortável (F2) · [plano](plans/poligonos-pracas-zona.md)
- **B11** Escala de cor por **% dos votos válidos** da Praça (0–100%, não pelo total da maior Praça) · depende de R2 (entregue) · suave: A9 ✓ (numerador 2026) · paralelizável agora · appetite ~0,5–1 dia · [plano](plans/escala-percentual-mapa-pracas.md)
- **C5** operação dia D / GOTV _(validar com produto)_ · design [`Dia-D-GOTV`](design-refs/latest/Dia-D-GOTV.png) · depende de C2 dados reais
- **D2** push + sino in-app · soft: chave `campanha-notificacoes-push` (Onda 0) · [plano](plans/notifications.md)
- **R6** critique/polish visual da vertical remodelada (ciclo /impeccable completo por superfície; smoke visual coordenador feito em 2026-07-21) · gatilho: antes de 16/08 · (opcional: mover filtros acima do mapa)

View file

@ -70,6 +70,8 @@ type BahiaMapProps = {
values: ChoroplethValues
/** 'diverging': positive = campaign red, negative = blue, zero = white. */
fillMode?: BahiaMapFillMode
/** When set, overrides auto max from `values` (e.g. fixed 1 for 0–100% shares). */
scaleMax?: number
highlightKeys?: string[]
selectedKey?: string | null
onFeatureSelect?: (info: BahiaMapFeatureInfo | null) => void
@ -129,6 +131,7 @@ export const BahiaMap = ({
mode,
values,
fillMode = 'sequential',
scaleMax,
highlightKeys = [],
selectedKey = null,
onFeatureSelect,
@ -213,7 +216,8 @@ export const BahiaMap = ({
layerRef.current?.remove()
const max =
fillMode === 'diverging' ? choroplethMaxAbsValue(values) : choroplethMaxValue(values)
scaleMax ??
(fillMode === 'diverging' ? choroplethMaxAbsValue(values) : choroplethMaxValue(values))
const keyProperty: FeatureKeyProperty = mode === 'municipality' ? 'codarea' : 'code'
const highlightSet = new Set(
highlightKey.length > 0 ? highlightKey.split(',').filter(Boolean) : [],
@ -342,7 +346,7 @@ export const BahiaMap = ({
layerRef.current = null
styleContextRef.current = null
}
}, [fillMode, highlightKey, mode, values])
}, [fillMode, highlightKey, mode, scaleMax, values])
useEffect(() => {
const context = styleContextRef.current

View file

@ -4,9 +4,14 @@ import { formatElectionNumber } from '@/lib/electionInsights'
type ChoroplethLegendProps = {
max: number
metricLabel: string
formatMax?: (max: number) => string
}
export const ChoroplethLegend = ({ max, metricLabel }: ChoroplethLegendProps) => (
export const ChoroplethLegend = ({
max,
metricLabel,
formatMax = formatElectionNumber,
}: ChoroplethLegendProps) => (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<span className="w-8 shrink-0 text-right text-xs tabular-nums text-muted-foreground">0</span>
@ -14,10 +19,10 @@ export const ChoroplethLegend = ({ max, metricLabel }: ChoroplethLegendProps) =>
className="h-2.5 min-w-0 flex-1 rounded-full ring-1 ring-foreground/10"
style={{ background: choroplethGradientCss }}
role="img"
aria-label={`Escala de cor de 0 a ${formatElectionNumber(max)} para ${metricLabel}`}
aria-label={`Escala de cor de 0 a ${formatMax(max)} para ${metricLabel}`}
/>
<span className="w-12 shrink-0 text-xs tabular-nums text-muted-foreground">
{formatElectionNumber(max)}
{formatMax(max)}
</span>
</div>
<p className="text-xs text-muted-foreground">

View file

@ -4,17 +4,26 @@ import type { BahiaMapFeatureInfo } from '@/components/campaign/BahiaMap'
import { Button } from '@/components/ui/button'
import { formatElectionNumber } from '@/lib/electionInsights'
import type { PlazaMapNavigation } from '@/utilities/plazaMapNavigation'
import type { PlazaMapScaleMode } from '@/utilities/plazaMapData'
type MapFeatureReadoutProps = {
feature: BahiaMapFeatureInfo | null
metricValue: number | undefined
rawMetricValue: number | undefined
metricLabel: string
scaleMode: PlazaMapScaleMode
comparisonActive: boolean
navigation: PlazaMapNavigation | null
}
const percentFormatter = new Intl.NumberFormat('pt-BR', {
style: 'percent',
maximumFractionDigits: 1,
})
const formatMetricValue = (
value: number | undefined,
scaleMode: PlazaMapScaleMode,
comparisonActive: boolean,
): string => {
if (value === undefined) return 'Sem dados'
@ -22,13 +31,16 @@ const formatMetricValue = (
const prefix = value > 0 ? '+' : ''
return `${prefix}${formatElectionNumber(value)}`
}
if (scaleMode === 'percentValid') return percentFormatter.format(value)
return formatElectionNumber(value)
}
export const MapFeatureReadout = ({
feature,
metricValue,
rawMetricValue,
metricLabel,
scaleMode,
comparisonActive,
navigation,
}: MapFeatureReadoutProps) => {
@ -40,7 +52,13 @@ export const MapFeatureReadout = ({
)
}
const formattedValue = formatMetricValue(metricValue, comparisonActive)
const formattedValue = formatMetricValue(metricValue, scaleMode, comparisonActive)
const showPercentSecondary =
!comparisonActive &&
scaleMode === 'percentValid' &&
metricValue !== undefined &&
rawMetricValue !== undefined &&
rawMetricValue > 0
return (
<div
@ -52,13 +70,21 @@ export const MapFeatureReadout = ({
<p className="text-sm text-muted-foreground">
<span className="sr-only">{metricLabel}: </span>
<span className="tabular-nums">{formattedValue}</span>
{!comparisonActive && metricValue !== undefined ? (
{!comparisonActive && metricValue !== undefined && scaleMode === 'absolute' ? (
<span className="text-muted-foreground"> {metricLabel}</span>
) : null}
{!comparisonActive && metricValue !== undefined && scaleMode === 'percentValid' ? (
<span className="text-muted-foreground"> dos válidos</span>
) : null}
{comparisonActive && metricValue !== undefined ? (
<span className="text-muted-foreground"> (diferença)</span>
) : null}
</p>
{showPercentSecondary ? (
<p className="text-xs text-muted-foreground tabular-nums">
{formatElectionNumber(rawMetricValue)} votos
</p>
) : null}
{navigation?.kind === 'zones' ? (
<p className="text-xs text-muted-foreground">
Toque de novo no mapa ou role até as Praças por zona abaixo.

View file

@ -12,14 +12,18 @@ import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import {
choroplethMaxAbsValue,
choroplethMaxValue,
computeValidVoteShares,
divergingGradientCss,
} from '@/lib/choroplethColorScale'
import { formatElectionNumber } from '@/lib/electionInsights'
import type { FederalCandidateOption } from '@/utilities/electionCandidateOptions'
import {
PLAZA_MAP_SCALE_MODES,
PLAZA_MAP_YEARS,
plazaMapScaleModeLabels,
plazaMapYearLabels,
type PlazaMapBundle,
type PlazaMapScaleMode,
type PlazaMapYear,
} from '@/utilities/plazaMapData'
import { resolvePlazaMapNavigation } from '@/utilities/plazaMapNavigation'
@ -39,22 +43,53 @@ export const PlazaMapPanel = ({
const pathname = usePathname()
const searchParams = useSearchParams()
const [year, setYear] = useState<PlazaMapYear>(defaultYear)
const [scaleMode, setScaleMode] = useState<PlazaMapScaleMode>('percentValid')
const [selectedFeature, setSelectedFeature] = useState<BahiaMapFeatureInfo | null>(null)
const selectedKeyRef = useRef<string | null>(null)
const comparison = bundle.comparison
const comparisonActive = comparison !== null && year !== 2026
const percentScaleActive = !comparisonActive && scaleMode === 'percentValid'
const values = useMemo(() => {
const rawValues = useMemo(() => {
if (comparisonActive && comparison) {
return comparison.diffByYear[String(year)] ?? {}
}
return bundle.valuesByYear[String(year)] ?? {}
}, [bundle, comparison, comparisonActive, year])
const max = comparisonActive ? choroplethMaxAbsValue(values) : choroplethMaxValue(values)
const metricLabel =
year === 2026 ? 'votos estimados 2026' : `votos de ${bundle.candidateName} em ${year}`
const validVotesForYear = bundle.validVotesByYear[String(year)] ?? {}
const displayValues = useMemo(() => {
if (percentScaleActive) {
return computeValidVoteShares(rawValues, validVotesForYear)
}
return rawValues
}, [percentScaleActive, rawValues, validVotesForYear])
const displayMax = useMemo(
() =>
comparisonActive
? choroplethMaxAbsValue(displayValues)
: percentScaleActive
? 1
: choroplethMaxValue(displayValues),
[comparisonActive, displayValues, percentScaleActive],
)
const metricLabel = useMemo(() => {
if (comparisonActive) {
return `diferença de votos em ${year}`
}
if (percentScaleActive) {
if (year === 2026) {
return 'participação nos válidos 2022 (estimativas 2026)'
}
return `participação nos válidos (${year})`
}
if (year === 2026) return 'votos estimados 2026'
return `votos de ${bundle.candidateName} em ${year}`
}, [bundle.candidateName, comparisonActive, percentScaleActive, year])
const setCompare = (candidateNumber: string) => {
const params = new URLSearchParams(searchParams.toString())
@ -89,7 +124,12 @@ export const PlazaMapPanel = ({
)
const selectedMetricValue =
selectedFeature && selectedFeature.key in values ? values[selectedFeature.key] : undefined
selectedFeature && selectedFeature.key in displayValues
? displayValues[selectedFeature.key]
: undefined
const selectedRawMetricValue =
selectedFeature && selectedFeature.key in rawValues ? rawValues[selectedFeature.key] : undefined
const selectedNavigation = useMemo(
() =>
@ -112,10 +152,12 @@ export const PlazaMapPanel = ({
<p className="text-sm text-muted-foreground">
{comparisonActive && comparison
? `Comparação ${bundle.candidateName} × ${comparison.candidateName} em ${year}.`
: `Cor pela votação de ${bundle.candidateName} — em 2026, pelas estimativas da campanha.`}
: percentScaleActive
? `Cor pela participação nos votos válidos — em 2026, estimativas sobre válidos de 2022.`
: `Cor pela votação de ${bundle.candidateName} — em 2026, pelas estimativas da campanha.`}
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row">
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
<Field className="w-full sm:w-44">
<FieldLabel htmlFor="plaza-map-year">Ano</FieldLabel>
<NativeSelect
@ -131,6 +173,23 @@ export const PlazaMapPanel = ({
))}
</NativeSelect>
</Field>
{!comparisonActive ? (
<Field className="w-full sm:w-44">
<FieldLabel htmlFor="plaza-map-scale">Escala</FieldLabel>
<NativeSelect
id="plaza-map-scale"
value={scaleMode}
onChange={(event) => setScaleMode(event.target.value as PlazaMapScaleMode)}
className="min-h-11 w-full"
>
{PLAZA_MAP_SCALE_MODES.map((mode) => (
<NativeSelectOption key={mode} value={mode}>
{plazaMapScaleModeLabels[mode]}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
) : null}
<Field className="w-full sm:w-72">
<FieldLabel htmlFor="plaza-map-compare">Comparar com</FieldLabel>
<NativeSelect
@ -170,11 +229,15 @@ export const PlazaMapPanel = ({
</div>
<p className="text-xs text-muted-foreground">
Vermelho: {bundle.candidateName} na frente · Branco: empate · Azul:{' '}
{comparison.candidateName} na frente.
{comparison.candidateName} na frente. Comparação usa diferença absoluta.
</p>
</div>
) : max > 0 ? (
<ChoroplethLegend max={max} metricLabel={metricLabel} />
) : displayMax > 0 ? (
<ChoroplethLegend
max={displayMax}
metricLabel={metricLabel}
formatMax={percentScaleActive ? () => '100%' : undefined}
/>
) : (
<p className="text-sm text-muted-foreground">Sem dados para este ano no seu escopo.</p>
)}
@ -188,7 +251,8 @@ export const PlazaMapPanel = ({
<BahiaMap
mode="municipality"
values={values}
values={displayValues}
scaleMax={displayMax > 0 ? displayMax : undefined}
fillMode={comparisonActive ? 'diverging' : 'sequential'}
selectedKey={selectedFeature?.key ?? null}
onFeatureSelect={handleFeatureSelect}
@ -203,7 +267,9 @@ export const PlazaMapPanel = ({
<MapFeatureReadout
feature={selectedFeature}
metricValue={selectedMetricValue}
rawMetricValue={selectedRawMetricValue}
metricLabel={metricLabel}
scaleMode={comparisonActive ? 'absolute' : scaleMode}
comparisonActive={comparisonActive}
navigation={selectedNavigation}
/>

View file

@ -47,3 +47,18 @@ export const choroplethMaxAbsValue = (values: Record<string, number>): number =>
const entries = Object.values(values).map((value) => Math.abs(value))
return entries.length > 0 ? Math.max(...entries) : 0
}
/** Share of valid votes per geography (0–1). Omits entries with no valid denominator or zero votes. */
export const computeValidVoteShares = (
votes: Record<string, number>,
validVotes: Record<string, number>,
): Record<string, number> => {
const shares: Record<string, number> = {}
for (const [code, voteCount] of Object.entries(votes)) {
const valid = validVotes[code]
if (valid > 0 && voteCount > 0) {
shares[code] = voteCount / valid
}
}
return shares
}

View file

@ -209,6 +209,38 @@ export const sumVotesForGeography = (
return total
}
/** cityCode:zone → votosValidos map for one year (federal T1 tally). */
export const loadValidVotesByCityZone = async (
payload: Payload,
user: ElectionReader,
{ year }: { year: number },
): Promise<Map<string, number>> => {
assertCanReadElectionData(user)
const result = await payload.find({
collection: 'electionTally',
where: {
and: [
{ year: { equals: year } },
{ office: { equals: FEDERAL_DEPUTY_OFFICE } },
{ turn: { equals: '1' } },
],
},
depth: 0,
limit: 0,
pagination: false,
select: { cityCode: true, zoneNumber: true, votosValidos: true },
overrideAccess: true,
})
const validVotes = new Map<string, number>()
for (const row of result.docs) {
const key = `${row.cityCode}:${row.zoneNumber}`
validVotes.set(key, (validVotes.get(key) ?? 0) + (row.votosValidos ?? 0))
}
return validVotes
}
/** cityCode:zone → votes map for one candidate/year (plaza-level slicing). */
export const loadCandidateVotesByCityZone = async (
payload: Payload,

View file

@ -9,6 +9,7 @@ import {
} from '@/utilities/plazaElectionGeography'
import {
loadCandidateVotesByCityZone,
loadValidVotesByCityZone,
sumVotesForGeography,
} from '@/utilities/plazaElectoralBaseline'
import { buildPlazasByIbgeCode, type PlazasByIbgeCode } from '@/utilities/plazaMapNavigation'
@ -29,6 +30,15 @@ export type { PlazasByIbgeCode, PlazaMapSlugEntry } from '@/utilities/plazaMapNa
export const PLAZA_MAP_YEARS = [...HISTORICAL_SERIES_YEARS, 2026] as const
export type PlazaMapYear = (typeof PLAZA_MAP_YEARS)[number]
export type PlazaMapScaleMode = 'absolute' | 'percentValid'
export const PLAZA_MAP_SCALE_MODES = ['percentValid', 'absolute'] as const satisfies readonly PlazaMapScaleMode[]
export const plazaMapScaleModeLabels: Record<PlazaMapScaleMode, string> = {
absolute: 'Total (votos)',
percentValid: '% dos válidos',
}
export const plazaMapYearLabels: Record<PlazaMapYear, string> = {
2014: '2014 (TSE)',
2018: '2018 (TSE)',
@ -52,6 +62,8 @@ export type PlazaMapComparison = {
export type PlazaMapBundle = {
/** year (as string) → codarea → value. 2026 = expectedVotes ?? pledge effective total. */
valuesByYear: Record<string, Record<string, number>>
/** year (as string) → codarea → votosValidos (federal T1). 2026 reuses 2022. */
validVotesByYear: Record<string, Record<string, number>>
/** IBGE codarea → accessible plaza slugs for map click navigation. */
plazasByIbgeCode: PlazasByIbgeCode
/** Zone plazas in scope (Salvador/Camaçari) with per-year values. */
@ -121,19 +133,32 @@ export const loadPlazaMapBundle = async (
if (plazas.length === 0) return null
const valuesByYear: Record<string, Record<string, number>> = {}
const validVotesByYear: Record<string, Record<string, number>> = {}
const zoneVotesBySlug = new Map<string, Record<string, number>>()
const sollaVotesByYear = new Map<number, Map<string, number>>()
for (const year of HISTORICAL_SERIES_YEARS) {
const votesByCityZone = await loadCandidateVotesByCityZone(payload, user, {
year,
candidateNumber: BASELINE_TICKET_2022.candidate.candidateNumber,
})
const yearLoads = await Promise.all(
HISTORICAL_SERIES_YEARS.map(async (year) => {
const [votesByCityZone, validByCityZone] = await Promise.all([
loadCandidateVotesByCityZone(payload, user, {
year,
candidateNumber: BASELINE_TICKET_2022.candidate.candidateNumber,
}),
loadValidVotesByCityZone(payload, user, { year }),
])
return { year, votesByCityZone, validByCityZone }
}),
)
for (const { year, votesByCityZone, validByCityZone } of yearLoads) {
sollaVotesByYear.set(year, votesByCityZone)
const values: Record<string, number> = {}
const validValues: Record<string, number> = {}
for (const plaza of plazas) {
const votes = sumVotesForGeography(votesByCityZone, plaza.geography)
values[plaza.ibgeCode] = (values[plaza.ibgeCode] ?? 0) + votes
const valid = sumVotesForGeography(validByCityZone, plaza.geography)
validValues[plaza.ibgeCode] = (validValues[plaza.ibgeCode] ?? 0) + valid
if (plaza.kind === 'zona') {
const bySlug = zoneVotesBySlug.get(plaza.slug) ?? {}
bySlug[String(year)] = votes
@ -141,7 +166,9 @@ export const loadPlazaMapBundle = async (
}
}
valuesByYear[String(year)] = values
validVotesByYear[String(year)] = validValues
}
validVotesByYear['2026'] = validVotesByYear['2022'] ?? {}
const pledgeAggregates = await aggregatePledgesByPlaza(
payload,
@ -191,11 +218,16 @@ export const loadPlazaMapBundle = async (
if (candidate) {
const diffByYear: Record<string, Record<string, number>> = {}
for (const year of HISTORICAL_SERIES_YEARS) {
const otherVotes = await loadCandidateVotesByCityZone(payload, user, {
year,
candidateNumber: state.compare,
})
const otherVotesByYear = await Promise.all(
HISTORICAL_SERIES_YEARS.map((year) =>
loadCandidateVotesByCityZone(payload, user, {
year,
candidateNumber: state.compare!,
}),
),
)
for (const [index, year] of HISTORICAL_SERIES_YEARS.entries()) {
const otherVotes = otherVotesByYear[index]!
const sollaVotes = sollaVotesByYear.get(year) ?? new Map<string, number>()
const values: Record<string, number> = {}
for (const plaza of plazas) {
@ -216,6 +248,7 @@ export const loadPlazaMapBundle = async (
return {
valuesByYear,
validVotesByYear,
plazasByIbgeCode: buildPlazasByIbgeCode(plazas),
zoneBreakdown,
candidateName: BASELINE_TICKET_2022.candidate.name,

View file

@ -72,4 +72,16 @@ describe('loadPlazaMapBundle — list URL filters', () => {
: await loadPlazaMapBundle(payload, advisor, { kind: 'zona' })
expect(excluded).toBeNull()
})
it('includes validVotesByYear for TSE years and reuses 2022 for 2026', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const bundle = await loadPlazaMapBundle(payload, coordinator, {})
expect(bundle).not.toBeNull()
expect(bundle!.validVotesByYear['2014']).toBeDefined()
expect(bundle!.validVotesByYear['2018']).toBeDefined()
expect(bundle!.validVotesByYear['2022']).toBeDefined()
expect(bundle!.validVotesByYear['2026']).toBe(bundle!.validVotesByYear['2022'])
})
})

View file

@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { computeValidVoteShares } from '@/lib/choroplethColorScale'
describe('computeValidVoteShares', () => {
it('computes share as votes divided by valid votes', () => {
expect(
computeValidVoteShares({ '2927408': 500 }, { '2927408': 1000 }),
).toEqual({ '2927408': 0.5 })
})
it('omits geographies with zero valid votes', () => {
expect(
computeValidVoteShares({ '2927408': 500 }, { '2927408': 0 }),
).toEqual({})
})
it('omits geographies with zero votes', () => {
expect(
computeValidVoteShares({ '2927408': 0 }, { '2927408': 1000 }),
).toEqual({})
})
it('omits geographies missing from valid votes', () => {
expect(computeValidVoteShares({ '2927408': 500 }, {})).toEqual({})
})
})