Remove the redundant zone list under the campaign map.

After B8 F2 the choropleth already opens each Salvador ZE; drop the textual list and zoneBreakdown from the bundle, keeping the mesh provenance caveat via hasZoneMunicipalities.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-28 15:33:32 -03:00
parent 853e486747
commit 73b9ab84ed
8 changed files with 55 additions and 127 deletions

File diff suppressed because one or more lines are too long

View file

@ -14,21 +14,21 @@ CI (`.github/workflows/ci.yml`) runs lint (zero warnings), typecheck, knip, unit
Modules the 2026-07 hardening phases touch, and where their behavior is pinned:
| Module | Pinned behaviors | Test files | Gaps |
| ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------- |
| RBAC — `src/utilities/access/*` (split 2026-07-23; `campaignAccess.ts` is the re-export surface) | per-role municipality/leadership/pledge/supporter/demand/plan scoping; leader lockdown; staff field asymmetry | `campaignMunicipalityAccess`, `campaignVotePledge`, `campaignSupporter`, `campaignUserPhone`, `campaignActionPlan`, `campaignDemandWorkflow`, `collectionAccessLockdown` (int) | no dedicated per-export suite; covered through collection behavior |
| Collection access lockdown | campaign JWT denied on `users`/PII/CMS writes; anonymous read of public content; admin unaffected | `collectionAccessLockdown.int.spec.ts` | REST-transport variant untested (Local API with `overrideAccess: false` is equivalent) |
| `municipalityMapData.ts` | bundle scoping by URL filters + advisor access; `validVotesByYear` incl. 2026 reuse of 2022; null on empty scope | `municipalityMapData.int.spec.ts` | zone breakdown shape |
| `votePledgeData.ts` | `estimated[scenario] ?? declared` aggregation; role scoping; leader denial | `campaignVotePledge.int.spec.ts` | |
| Supporter import (token/bulk) | preview→confirm flow, dedup by phone, consent fail-closed | `campaignSupporter.int.spec.ts` | `supporterImportBulk`/`supporterImportToken` internals only via flow |
| Leader supporter create | linked-municipality scope; out-of-scope rejection; staff rejection | `collectionAccessLockdown.int.spec.ts` | |
| Posts visibility/caching | `isPostVisible` fail-closed; cached listing | (indirect via routes) | no dedicated `posts` spec — backlog |
| List URL contracts (Pass 2 W0, pinned 2026-07-25) | `campaignListUrl` canonicalize/clamp/redirect; supporter + actionPlan parse/serialize/href; 5 entity parsers | `campaignListUrl`, `supporterUi`, `actionPlanUi`, `campaignEntityListParsers` (unit) | municipality list URL pinned separately in `municipalityUi.unit.spec.ts` |
| Form-action ladders (Pass 2 W4d) | `runCampaignFormAction`: success spread, boundary/zod/safe/generic mapping, values+revision echo | `campaignFormActions.unit.spec.ts` | per-route ladders are thin shells; behavior pinned at the wrapper |
| WhatsApp subscription consent (Pass 2 D3) | fail-closed without `whatsapp-inscricao`; happy path links subscription → keyed consent | `submitWhatsapp.int.spec.ts` | |
| Vote trend + election number format (Pass 2 W4a survivors) | `computeVoteTrend` pair priority/±10% band; `formatElectionNumber` E18 fraction defect | `voteTrend.unit.spec.ts` | |
| Entity list loaders (Pass 2 W0) | scope (leader empty/denied), marker-narrowed q, kind/status filters, pagination shape | `campaignEntityListData.int.spec.ts` | advisor loader runs post-gate with `overrideAccess: true` (gate asserted upstream) |
| Codebase conventions (tooling guards, 2026-07-25) | `server-only` on Payload-coupled utilities; Praça/Núcleo copy ban; eslint legacy-ignore existence; formActions wrapper usage | `codebaseConventions.unit.spec.ts` | boundary rules themselves live in ESLint (`no-restricted-imports`) + madge in CI |
| Module | Pinned behaviors | Test files | Gaps |
| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------- |
| RBAC — `src/utilities/access/*` (split 2026-07-23; `campaignAccess.ts` is the re-export surface) | per-role municipality/leadership/pledge/supporter/demand/plan scoping; leader lockdown; staff field asymmetry | `campaignMunicipalityAccess`, `campaignVotePledge`, `campaignSupporter`, `campaignUserPhone`, `campaignActionPlan`, `campaignDemandWorkflow`, `collectionAccessLockdown` (int) | no dedicated per-export suite; covered through collection behavior |
| Collection access lockdown | campaign JWT denied on `users`/PII/CMS writes; anonymous read of public content; admin unaffected | `collectionAccessLockdown.int.spec.ts` | REST-transport variant untested (Local API with `overrideAccess: false` is equivalent) |
| `municipalityMapData.ts` | bundle scoping by URL filters + advisor access; `validVotesByYear` incl. 2026 reuse of 2022; null on empty scope; `hasZoneMunicipalities` + per-zone map keys | `municipalityMapData.int.spec.ts` | |
| `votePledgeData.ts` | `estimated[scenario] ?? declared` aggregation; role scoping; leader denial | `campaignVotePledge.int.spec.ts` | |
| Supporter import (token/bulk) | preview→confirm flow, dedup by phone, consent fail-closed | `campaignSupporter.int.spec.ts` | `supporterImportBulk`/`supporterImportToken` internals only via flow |
| Leader supporter create | linked-municipality scope; out-of-scope rejection; staff rejection | `collectionAccessLockdown.int.spec.ts` | |
| Posts visibility/caching | `isPostVisible` fail-closed; cached listing | (indirect via routes) | no dedicated `posts` spec — backlog |
| List URL contracts (Pass 2 W0, pinned 2026-07-25) | `campaignListUrl` canonicalize/clamp/redirect; supporter + actionPlan parse/serialize/href; 5 entity parsers | `campaignListUrl`, `supporterUi`, `actionPlanUi`, `campaignEntityListParsers` (unit) | municipality list URL pinned separately in `municipalityUi.unit.spec.ts` |
| Form-action ladders (Pass 2 W4d) | `runCampaignFormAction`: success spread, boundary/zod/safe/generic mapping, values+revision echo | `campaignFormActions.unit.spec.ts` | per-route ladders are thin shells; behavior pinned at the wrapper |
| WhatsApp subscription consent (Pass 2 D3) | fail-closed without `whatsapp-inscricao`; happy path links subscription → keyed consent | `submitWhatsapp.int.spec.ts` | |
| Vote trend + election number format (Pass 2 W4a survivors) | `computeVoteTrend` pair priority/±10% band; `formatElectionNumber` E18 fraction defect | `voteTrend.unit.spec.ts` | |
| Entity list loaders (Pass 2 W0) | scope (leader empty/denied), marker-narrowed q, kind/status filters, pagination shape | `campaignEntityListData.int.spec.ts` | advisor loader runs post-gate with `overrideAccess: true` (gate asserted upstream) |
| Codebase conventions (tooling guards, 2026-07-25) | `server-only` on Payload-coupled utilities; Praça/Núcleo copy ban; eslint legacy-ignore existence; formActions wrapper usage | `codebaseConventions.unit.spec.ts` | boundary rules themselves live in ESLint (`no-restricted-imports`) + madge in CI |
## Characterization Backlog

View file

@ -1,17 +1,21 @@
# Remover lista "Municípios por zona eleitoral" do Início
Status: rascunho
Atualizado em: 2026-07-26
Status: entregue
Atualizado em: 2026-07-28
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins)
Impeccable: B — encaixe em `MunicipalityMapPanel` no Início (`/campanha`); sem rota nova
Appetite: ~0,25–0,5 dia eng; remove bloco UI + payload `zoneBreakdown` do bundle; sem migration
Responsável: —
Revisão 2026-07-28 (auditoria pré-implementação): **B8+ F3 ✓** já landou — a ressalva de aproximação vive sob o mapa gated por `bundle.hasZoneMunicipalities` (inclusive em comparação). Residual deste item = apagar o `<ul>` + dropar `zoneBreakdown` / `zoneVotes*BySlug` do bundle. Questões em aberto fechadas por evidência.
**Entrega 2026-07-28:** lista "Municípios por zona eleitoral" removida do Início; `zoneBreakdown` / `MunicipalityZoneBreakdownRow` / `zoneVotes*BySlug` saíram do contrato e do loader; `hasZoneMunicipalities` + ressalva B8+ F3 intactos; int `municipalityMapData` e `TESTING.md` atualizados. Sem migration. Gate: tsc/lint/format/cycles; knip P3 pré-existente; int do loader 4/4; Aikido 0 findings nos módulos de contrato/loader.
## Design (Impeccable)
Âncoras: `PRODUCT.md` (clareza sob pressão; anti dashboard clutter) / `DESIGN.md` (register `product`; Field Desk) · tema `data-theme='campaign'` · shells `MunicipalityMapPanel` + `BahiaMap` + `MapFeatureReadout`.
Na implementação (`implement-roadmap-item`): craft compacto → critique → polish (só remoção de densidade morta + relocação de uma frase de ressalva; sem redesign do mapa).
Na implementação (`implement-roadmap-item`): craft compacto → critique → polish (só remoção de densidade morta; a ressalva já está no lugar via B8+ F3 ✓ — sem redesign do mapa).
Brief compacto:
@ -34,48 +38,47 @@ Até o **B8 F2 ✓** (2026-07-26), Salvador era **um** polígono no mapa; a úni
Com o artefato `bahia-municipality-zones.topo.json` e o re-keying por map key, cada ZE é pintável, hoverável e clicável. Pedido de produto (2026-07-26): **remover a seção** — não precisamos mais dela.
O fill-in de engenharia **B8+** ([escala-dry-pos-b8f2.md](escala-dry-pos-b8f2.md)) F3 ainda assume a lista viva (só move a ressalva “é aproximado…” para fora do gate `!comparisonActive`). Este item **supersede** essa premissa: a lista sai; a ressalva fica atrelada à **malha** (decisão já travada no B8+), não a um `<ul>` de zonas.
O fill-in de engenharia **B8+** ([escala-dry-pos-b8f2.md](escala-dry-pos-b8f2.md)) F3 ✓ (2026-07-27) já moveu a ressalva para fora do gate `!comparisonActive` e a condicionou a `bundle.hasZoneMunicipalities` (campo próprio — não `zoneBreakdown.length`). Este item só remove a lista + o campo do bundle; a ressalva atrelada à malha permanece.
## Objetivos
- Em `/campanha` (staff, `MunicipalityMapPanel`): sumir o bloco "Municípios por zona eleitoral" (título, lista, números).
- Remover `zoneBreakdown` do contrato do bundle e do loader (e os intermediários `zoneVotesBySlug` / `zoneVotes2026BySlug` se só servirem a ele) — dead code dies.
- Manter **uma** ressalva de aproximação (TRE/IBGE, não limite oficial TSE) visível quando a malha de zonas está em jogo — inclusive no modo comparação (alinha B8+ F3).
- Atualizar int `municipalityMapData.int.spec.ts` (pins de `zoneBreakdown`) e qualquer copy/comentário que ainda diga que a lista é a navegação N>1.
- Manter a ressalva de aproximação já entregue por B8+ F3 (`hasZoneMunicipalities`, inclusive em comparação) — **não** reinseri-la.
- Atualizar int `municipalityMapData.int.spec.ts` (pins de `zoneBreakdown` → `hasZoneMunicipalities` / map keys) e qualquer copy/comentário que ainda diga que a lista é a navegação N>1.
- Guardrails: sem migration, sem collection, sem Consent, sem server action; malha B8 / `BahiaMap` / catálogo `kind === 'zona'` intactos; card de bairros no detalhe (F1) intacto.
## Decisões travadas
- **Fill-in com plano próprio (sem ID B novo).** Quick win de densidade no Início pós-B8 F2; ~¼–½ dia; paralelizável; cortável. (2026-07-26, classificação roadmap-item.) **Rejeitado:** B43 de trilha (infla grafo para delete de bloco); absorver _só_ como nota no B8+ sem plano de produto (pedido explícito de produto merece plano próprio + link no Fill-ins); deixar para R6 (atrasa).
- **Remover UI + dropar `zoneBreakdown` do bundle — não só esconder.** Payload RSC do Início carrega 19 rows por visita; knip/engineering-standards: o que a mudança orfanou morre no mesmo PR. **Rejeitado:** `display: none` / flag; manter array “por se” sem consumidor.
- **Ressalva de aproximação sobrevive, atrelada à malha.** Uma frase muted sob o mapa (ou junto à legenda), gated por presença de ZE no escopo (`salvador-ze-*` em `municipalitiesByMapKey` **ou** boolean derivado no painel) e **visível também em comparação**. Fonte: decisão B8+ F3 + rabbit hole B8 (“tratar polígono derivado como limite oficial”). **Rejeitado:** apagar a ressalva com a lista; duplicar copy em dois braços.
- **Ressalva de aproximação sobrevive, atrelada à malha.** Entregue por B8+ F3 ✓ como `<p>` gated por `bundle.hasZoneMunicipalities` (publicado pelo loader a partir de `kind === 'zona'`), visível também em comparação. Fonte: decisão B8+ F3 + rabbit hole B8 (“tratar polígono derivado como limite oficial”). **Rejeitado:** apagar a ressalva com a lista; duplicar copy em dois braços; re-derivar “é zona?” da forma da chave no cliente.
- **Lista de bairros no detalhe da ZE (B8 F1) permanece.** Geografia operacional no município ≠ lista redundante no dashboard. **Rejeitado:** cascatear remoção para `MunicipalityZoneNeighborhoodsCard`.
- **i18n e naming** (AGENTS.md): identificadores `zoneBreakdown` / `MunicipalityZoneBreakdownRow` saem; strings pt-BR da seção saem; disclaimer permanece em pt-BR.
## Questões em aberto
- **Gate da ressalva: “há ZE no escopo do ator” vs “malha de zonas carregou no cliente”?** **Opções:** A) keys `salvador-ze-*` em `municipalitiesByMapKey` (server/escopo) | B) flag do `BahiaMap` após load da malha | C) sempre no painel. **Recomendação:** **A** — assessor sem Salvador na carteira não vê frase sobre ZE; se o chunk falhar, B8+ F3 degrada o mapa e a ressalva some junto com as zonas no escopo visual (aceitável). _(assumido)_
- **Quem landa primeiro: este fill-in ou B8+ F3?** **Opções:** A) este fill-in primeiro (B8+ F3 vira “não há lista; só degradar chunk + ressalva já no lugar”) | B) B8+ primeiro (este fill-in remove o `<ul>` e mantém a frase onde F3 a colocou). **Recomendação:** **A** se o pedido de produto for o próximo a implementar; senão B — o plano do B8+ foi atualizado para não depender da lista. _(ordem = quem puxar o PR)_
- **Gate da ressalva.** **Resolvido 2026-07-27 (B8+ F3 ✓):** `bundle.hasZoneMunicipalities` (server, `kind === 'zona'` no escopo). Assessor sem Salvador não vê a frase; chunk das zonas que falha degrada o mapa (aceitável). **Rejeitado no as-built do B8+:** derivar da forma da chave no cliente; gatear em `zoneBreakdown.length`.
- **Ordem vs B8+ F3.** **Resolvido 2026-07-27:** B8+ landou primeiro. Este fill-in só apaga o `<ul>` + o campo do bundle e mantém a frase onde F3 a colocou.
## Abordagem proposta
```mermaid
flowchart LR
panel["MunicipalityMapPanel"] -->|remove| list["ul zoneBreakdown"]
panel -->|keep relocated| note["Ressalva aproximação"]
panel -->|keep| note["Ressalva hasZoneMunicipalities"]
contract["municipalityMapContract"] -->|drop| zb["zoneBreakdown"]
loader["municipalityMapData"] -->|drop| maps["zoneVotes*BySlug"]
tests["municipalityMapData.int"] -->|rewrite| scope["pins de escopo / map key"]
tests["municipalityMapData.int"] -->|rewrite| scope["pins hasZoneMunicipalities / map key"]
```
Componentes:
- **`MunicipalityMapPanel.tsx`**: apagar o bloco ~L593–625; comentário “zone breakdown below”; atualizar o comentário de `MunicipalityMapSelection` que menciona “zone list”. Inserir a ressalva (uma `<p className="text-sm text-muted-foreground">`) fora do gate de comparação, condicionada ao escopo com ZE.
- **`municipalityMapContract.ts`**: remover `MunicipalityZoneBreakdownRow` e `zoneBreakdown` de `MunicipalityMapBundle`.
- **`municipalityMapData.ts`**: parar de montar `zoneBreakdown` / `zoneVotesBySlug` / `zoneVotes2026BySlug` (verificar que nenhum outro campo os lê — hoje só alimentam a lista).
- **`tests/int/municipalityMapData.int.spec.ts`**: trocar expects de `zoneBreakdown` por asserts de escopo já existentes (ex.: keys `salvador-ze-*` em `municipalitiesByMapKey` / length de unidades no escopo).
- **`docs/TESTING.md`**: dropar a coluna “zone breakdown shape” da linha do loader se ainda apontar para o campo.
- **B8+ / B8 planos:** ver Dependências — notas de coordenação, não reescrever histórico F2.
- **`MunicipalityMapPanel.tsx`**: apagar o bloco do `<ul>` (~L603–629); manter a ressalva B8+ F3 (~L595–601); atualizar o comentário de `MunicipalityMapSelection` que menciona “zone list”.
- **`municipalityMapContract.ts`**: remover `MunicipalityZoneBreakdownRow` e `zoneBreakdown` de `MunicipalityMapBundle`; manter `hasZoneMunicipalities`.
- **`municipalityMapData.ts`**: parar de montar `zoneBreakdown` / `zoneVotesBySlug` / `zoneVotes2026BySlug` (só alimentavam a lista; o coroplético usa `valuesByYear` por map key).
- **`tests/int/municipalityMapData.int.spec.ts`**: trocar expects de `zoneBreakdown` por `hasZoneMunicipalities` + keys de zona em `municipalitiesByMapKey` / `valuesByYear`.
- **`docs/TESTING.md`**: coluna “zone breakdown shape” → pin de `hasZoneMunicipalities` / map keys.
- **Migration**: Sem migration, sem collection, sem server action.
Depth check: delete/estreitamento nos módulos que já são donos do bundle e do painel — sem wrapper novo.
@ -83,8 +86,8 @@ Depth check: delete/estreitamento nos módulos que já são donos do bundle e do
## Dependências
- **Dura:** ~~B8 F2~~ ✓ (polígonos — sem eles a lista ainda seria a única visão por ZE).
- Soft: **B8+** F3 ([escala-dry-pos-b8f2.md](escala-dry-pos-b8f2.md)) — mesma ressalva / mesmo arquivo; quem entrar 2º só ajusta.
- Reusa: `MunicipalityMapPanel`, `BahiaMap`, `municipalitiesByMapKey`, `formatElectionNumber` (só se algum residual precisar — provavelmente some do painel).
- Soft: ~~**B8+** F3~~ ✓ (2026-07-27) — ressalva já no lugar via `hasZoneMunicipalities`; este item só removeu a lista + o campo.
- Reusa: `MunicipalityMapPanel`, `BahiaMap`, `municipalitiesByMapKey`, `hasZoneMunicipalities`.
## Não escopo

File diff suppressed because one or more lines are too long

View file

@ -1,6 +1,5 @@
'use client'
import Link from 'next/link'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { useCallback, useMemo, useRef, useState, useTransition } from 'react'
@ -589,9 +588,9 @@ export const MunicipalityMapPanel = ({
}
/>
{/* B8+ F3 — tied to the MESH, not to the list below: the comparison mode
paints Salvador from the same approximate polygons, so the caveat has
to survive with or without comparison. */}
{/* B8+ F3 — tied to the MESH: the comparison mode paints Salvador from
the same approximate polygons, so the caveat has to survive with or
without comparison. */}
{bundle.hasZoneMunicipalities ? (
<p className="text-sm text-muted-foreground">
Salvador é pintada zona por zona. O desenho vem dos bairros de cada circunscrição
@ -599,34 +598,6 @@ export const MunicipalityMapPanel = ({
TSE.
</p>
) : null}
{bundle.zoneBreakdown.length > 0 && !comparisonActive ? (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-medium">Municípios por zona eleitoral</h3>
<ul className="grid gap-1 sm:grid-cols-2">
{bundle.zoneBreakdown.map((zone) => (
<li
key={zone.slug}
className="flex items-center justify-between gap-2 rounded-md border px-3 py-2"
>
<Link
href={`/campanha/municipios/${zone.slug}`}
className="min-h-11 content-center text-sm font-medium text-primary underline-offset-4 hover:underline"
>
{zone.name}
</Link>
<span className="text-sm tabular-nums text-muted-foreground">
{formatElectionNumber(
year === 2026
? (zone.votes2026ByScenario[estimateScenario] ?? 0)
: (zone.votesByYear[String(year)] ?? 0),
)}
</span>
</li>
))}
</ul>
</div>
) : null}
</div>
</section>
)
@ -634,8 +605,8 @@ export const MunicipalityMapPanel = ({
/**
* Selection/hover live here so a mouseover re-renders only the map and the
* readout — not the whole panel (controls, legend, zone list). The Leaflet
* paint path itself stays ref-based inside BahiaMap (O(2) restyle).
* readout — not the whole panel (controls, legend). The Leaflet paint path
* itself stays ref-based inside BahiaMap (O(2) restyle).
*/
const MunicipalityMapSelection = ({
displayValues,

View file

@ -79,13 +79,6 @@ export const municipalityMapYearLabels: Record<MunicipalityMapYear, string> = {
2026: '2026 (estimativas)',
}
type MunicipalityZoneBreakdownRow = {
slug: string
name: string
votesByYear: Record<string, number>
votes2026ByScenario: Record<VoteEstimateScenario, number>
}
export type MunicipalityMapComparison = {
candidateNumber: number
candidateName: string
@ -130,13 +123,9 @@ export type MunicipalityMapBundle = {
* approximate ZE mesh is painted at all — which is what the map's provenance
* caveat is about. Published rather than re-derived on the client so `kind`
* stays the single answer to "is this a zona": reading it off the key's shape
* would be a second encoding of the rule in `mapKeyForMunicipality`, and
* `zoneBreakdown` is scheduled for deletion by the fill-in that removes the
* textual zone list.
* would be a second encoding of the rule in `mapKeyForMunicipality`.
*/
hasZoneMunicipalities: boolean
/** Zone municipalities in scope (Salvador) with per-year values. */
zoneBreakdown: MunicipalityZoneBreakdownRow[]
candidateName: string
comparison: MunicipalityMapComparison | null
}

View file

@ -106,7 +106,6 @@ const buildMunicipalityMapBundleFromMunicipalities = async (
const validVotesByYear: Record<string, Record<string, number>> = {}
const statewideShareByYear: Record<string, number> = {}
const competitiveRankByYear: Record<string, Record<string, FederalCompetitiveRank>> = {}
const zoneVotesBySlug = new Map<string, Record<string, number>>()
// One polygon per catalog unit since B8 F2, so this index — the same one the
// map navigates by — is also the key of every rollup below.
const municipalitiesByMapKey = buildMunicipalitiesByMapKey(municipalities)
@ -122,11 +121,6 @@ const buildMunicipalityMapBundleFromMunicipalities = async (
const votes = baseline.votesByYear[String(year)] ?? 0
values[mapKey] = votes
validValues[mapKey] = baseline.validVotesByYear[String(year)] ?? 0
if (municipality.kind === 'zona') {
const bySlug = zoneVotesBySlug.get(municipality.slug) ?? {}
bySlug[String(year)] = votes
zoneVotesBySlug.set(municipality.slug, bySlug)
}
}
valuesByYear[String(year)] = values
validVotesByYear[String(year)] = validValues
@ -163,12 +157,6 @@ const buildMunicipalityMapBundleFromMunicipalities = async (
const pledgeValuesByScenario = Object.fromEntries(
VOTE_ESTIMATE_SCENARIOS.map((scenario) => [scenario, {} as Record<string, number>]),
) as Record<VoteEstimateScenario, Record<string, number>>
const zoneVotes2026BySlug = new Map<string, Record<VoteEstimateScenario, number>>()
const emptyZoneScenarioVotes = (): Record<VoteEstimateScenario, number> => ({
pessimistic: 0,
central: 0,
optimistic: 0,
})
for (const municipality of municipalities) {
const aggregate = pledgeAggregates.get(municipality.id) ?? emptyMunicipalityPledgeAggregate
@ -181,30 +169,10 @@ const buildMunicipalityMapBundleFromMunicipalities = async (
if (votes > 0) {
pledgeValuesByScenario[scenario][mapKeyForMunicipality(municipality)] = votes
}
if (municipality.kind === 'zona') {
const byScenario = zoneVotes2026BySlug.get(municipality.slug) ?? emptyZoneScenarioVotes()
byScenario[scenario] = votes
zoneVotes2026BySlug.set(municipality.slug, byScenario)
if (scenario === DEFAULT_VOTE_ESTIMATE_SCENARIO) {
const bySlug = zoneVotesBySlug.get(municipality.slug) ?? {}
bySlug['2026'] = votes
zoneVotesBySlug.set(municipality.slug, bySlug)
}
}
}
}
valuesByYear['2026'] = pledgeValuesByScenario[DEFAULT_VOTE_ESTIMATE_SCENARIO]
const zoneBreakdown = municipalities
.filter((municipality) => municipality.kind === 'zona')
.map((municipality) => ({
slug: municipality.slug,
name: municipality.name,
votesByYear: zoneVotesBySlug.get(municipality.slug) ?? {},
votes2026ByScenario: zoneVotes2026BySlug.get(municipality.slug) ?? emptyZoneScenarioVotes(),
}))
.sort((left, right) => left.name.localeCompare(right.name, 'pt-BR'))
let comparison: MunicipalityMapComparison | null = null
const compareCandidate = state.compare
if (compareCandidate) {
@ -263,7 +231,6 @@ const buildMunicipalityMapBundleFromMunicipalities = async (
projectedValidVotesByMapKey,
municipalitiesByMapKey,
hasZoneMunicipalities: municipalities.some((municipality) => municipality.kind === 'zona'),
zoneBreakdown,
candidateName: BASELINE_TICKET_2022.candidate.name,
comparison,
}

View file

@ -34,8 +34,8 @@ describe('loadMunicipalityMapBundle — list URL filters', () => {
const bundle = await loadMunicipalityMapBundle(payload, coordinator, { kind: 'zona' })
expect(bundle).not.toBeNull()
expect(bundle!.zoneBreakdown).toHaveLength(ZONE_MUNICIPALITY_COUNT)
expect(bundle!.hasZoneMunicipalities).toBe(true)
expect(Object.keys(bundle!.municipalitiesByMapKey)).toHaveLength(ZONE_MUNICIPALITY_COUNT)
const mapKeys = Object.keys(bundle!.valuesByYear['2022'] ?? {})
expect(mapKeys.length).toBeGreaterThan(0)
@ -67,14 +67,11 @@ describe('loadMunicipalityMapBundle — list URL filters', () => {
const included = await loadMunicipalityMapBundle(payload, advisor, { q: administered.name })
expect(included).not.toBeNull()
expect(included!.zoneBreakdown.some((row) => row.slug === administered.slug)).toBe(
administered.kind === 'zona',
)
// The map's provenance caveat follows the actor's scope, not the list below it.
expect(included!.hasZoneMunicipalities).toBe(administered.kind === 'zona')
expect(Object.keys(included!.valuesByYear['2022'] ?? {})).toEqual([
administered.kind === 'zona' ? administered.slug : administered.ibgeCode,
])
// Hand-written map key — must not call mapKeyForMunicipality (independent expectation).
const expectedMapKey = administered.kind === 'zona' ? administered.slug : administered.ibgeCode
expect(Object.keys(included!.municipalitiesByMapKey)).toEqual([expectedMapKey])
expect(Object.keys(included!.valuesByYear['2022'] ?? {})).toEqual([expectedMapKey])
const excluded =
administered.kind === 'zona'