diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index d4236e8e..3e6ad9e7 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -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). diff --git a/AGENTS.md b/AGENTS.md index 43aee503..8fc1edbd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/docs/plans/escala-dry-pos-a9.md b/docs/plans/escala-dry-pos-a9.md index 3892bcd8..80fce601 100644 --- a/docs/plans/escala-dry-pos-a9.md +++ b/docs/plans/escala-dry-pos-a9.md @@ -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 diff --git a/docs/plans/escala-dry-pos-b3.md b/docs/plans/escala-dry-pos-b3.md index 268fbca3..ce0f1398 100644 --- a/docs/plans/escala-dry-pos-b3.md +++ b/docs/plans/escala-dry-pos-b3.md @@ -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`) — 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
(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 diff --git a/docs/plans/escala-percentual-mapa-pracas.md b/docs/plans/escala-percentual-mapa-pracas.md index e27285ca..89e09cff 100644 --- a/docs/plans/escala-percentual-mapa-pracas.md +++ b/docs/plans/escala-percentual-mapa-pracas.md @@ -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). diff --git a/docs/roadmap.md b/docs/roadmap.md index b074919a..42d5bcc5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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
(SSA/CMS)"] R2 -.-> B10["B10 Hover/tap mapa ✓"] - R2 -.-> B11["B11 Escala % válidos
no mapa"] + R2 -.-> B11["B11 Escala % válidos
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) diff --git a/src/components/campaign/BahiaMap.tsx b/src/components/campaign/BahiaMap.tsx index 455eb0ca..a801e876 100644 --- a/src/components/campaign/BahiaMap.tsx +++ b/src/components/campaign/BahiaMap.tsx @@ -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 diff --git a/src/components/campaign/ChoroplethLegend.tsx b/src/components/campaign/ChoroplethLegend.tsx index a6eea542..84b6a6cf 100644 --- a/src/components/campaign/ChoroplethLegend.tsx +++ b/src/components/campaign/ChoroplethLegend.tsx @@ -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) => (
0 @@ -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}`} /> - {formatElectionNumber(max)} + {formatMax(max)}

diff --git a/src/components/campaign/MapFeatureReadout.tsx b/src/components/campaign/MapFeatureReadout.tsx index c257116d..b0a1073d 100644 --- a/src/components/campaign/MapFeatureReadout.tsx +++ b/src/components/campaign/MapFeatureReadout.tsx @@ -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 (

{metricLabel}: {formattedValue} - {!comparisonActive && metricValue !== undefined ? ( + {!comparisonActive && metricValue !== undefined && scaleMode === 'absolute' ? ( {metricLabel} ) : null} + {!comparisonActive && metricValue !== undefined && scaleMode === 'percentValid' ? ( + dos válidos + ) : null} {comparisonActive && metricValue !== undefined ? ( (diferença) ) : null}

+ {showPercentSecondary ? ( +

+ {formatElectionNumber(rawMetricValue)} votos +

+ ) : null} {navigation?.kind === 'zones' ? (

Toque de novo no mapa ou role até as Praças por zona abaixo. diff --git a/src/components/campaign/PlazaMapPanel.tsx b/src/components/campaign/PlazaMapPanel.tsx index 1944fa17..37bdf60c 100644 --- a/src/components/campaign/PlazaMapPanel.tsx +++ b/src/components/campaign/PlazaMapPanel.tsx @@ -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(defaultYear) + const [scaleMode, setScaleMode] = useState('percentValid') const [selectedFeature, setSelectedFeature] = useState(null) const selectedKeyRef = useRef(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 = ({

{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.`}

-
+
Ano + {!comparisonActive ? ( + + Escala + setScaleMode(event.target.value as PlazaMapScaleMode)} + className="min-h-11 w-full" + > + {PLAZA_MAP_SCALE_MODES.map((mode) => ( + + {plazaMapScaleModeLabels[mode]} + + ))} + + + ) : null} Comparar com

Vermelho: {bundle.candidateName} na frente · Branco: empate · Azul:{' '} - {comparison.candidateName} na frente. + {comparison.candidateName} na frente. Comparação usa diferença absoluta.

- ) : max > 0 ? ( - + ) : displayMax > 0 ? ( + '100%' : undefined} + /> ) : (

Sem dados para este ano no seu escopo.

)} @@ -188,7 +251,8 @@ export const PlazaMapPanel = ({ 0 ? displayMax : undefined} fillMode={comparisonActive ? 'diverging' : 'sequential'} selectedKey={selectedFeature?.key ?? null} onFeatureSelect={handleFeatureSelect} @@ -203,7 +267,9 @@ export const PlazaMapPanel = ({ diff --git a/src/lib/choroplethColorScale.ts b/src/lib/choroplethColorScale.ts index 1bc950bf..98101bb5 100644 --- a/src/lib/choroplethColorScale.ts +++ b/src/lib/choroplethColorScale.ts @@ -47,3 +47,18 @@ export const choroplethMaxAbsValue = (values: Record): 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, + validVotes: Record, +): Record => { + const shares: Record = {} + for (const [code, voteCount] of Object.entries(votes)) { + const valid = validVotes[code] + if (valid > 0 && voteCount > 0) { + shares[code] = voteCount / valid + } + } + return shares +} diff --git a/src/utilities/plazaElectoralBaseline.ts b/src/utilities/plazaElectoralBaseline.ts index d8331655..238b9a56 100644 --- a/src/utilities/plazaElectoralBaseline.ts +++ b/src/utilities/plazaElectoralBaseline.ts @@ -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> => { + 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() + 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, diff --git a/src/utilities/plazaMapData.ts b/src/utilities/plazaMapData.ts index 29f1b237..feace2d4 100644 --- a/src/utilities/plazaMapData.ts +++ b/src/utilities/plazaMapData.ts @@ -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 = { + absolute: 'Total (votos)', + percentValid: '% dos válidos', +} + export const plazaMapYearLabels: Record = { 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> + /** year (as string) → codarea → votosValidos (federal T1). 2026 reuses 2022. */ + validVotesByYear: Record> /** 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> = {} + const validVotesByYear: Record> = {} const zoneVotesBySlug = new Map>() const sollaVotesByYear = new Map>() - 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 = {} + const validValues: Record = {} 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> = {} - 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() const values: Record = {} 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, diff --git a/tests/int/plazaMapData.int.spec.ts b/tests/int/plazaMapData.int.spec.ts index ab9c947e..ddbfef16 100644 --- a/tests/int/plazaMapData.int.spec.ts +++ b/tests/int/plazaMapData.int.spec.ts @@ -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']) + }) }) diff --git a/tests/unit/choroplethPercentScale.unit.spec.ts b/tests/unit/choroplethPercentScale.unit.spec.ts new file mode 100644 index 00000000..9e852563 --- /dev/null +++ b/tests/unit/choroplethPercentScale.unit.spec.ts @@ -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({}) + }) +})