Deliver B8 F1 plaza zone neighborhoods catalog and absorb simplify debts.

Ship static zone-to-neighborhood mapping for Salvador/Camaçari Praças-zona, overview UI card, tests/fixture, and roadmap/plan updates including F2 prep follow-ups from capture-review-debts.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-21 16:08:34 -03:00
parent 7f437741ba
commit e497fc3982
8 changed files with 936 additions and 23 deletions

View file

@ -8,6 +8,7 @@ alwaysApply: false
## Status
- **B6 entregue em código (2026-07-21; deploy pendente com remodelagem):** `BahiaMap` setStyle incremental — layer GeoJSON estável entre troca de ano/métrica/escala; `pathByKeyRef` + restyle O(2) no hover/select; `fitBounds` só em `mode`/`highlightKeys`; helpers puros em `bahiaMapStyle.ts` + unit `bahiaMapStyle.unit.spec.ts`. Plano: [`escala-dry-pos-b3.md`](../../../docs/plans/escala-dry-pos-b3.md). Fecha débitos de perf de **B10** e **B11**.
- **B8 F1 entregue em código (2026-07-21; deploy pendente com remodelagem):** catálogo estático `plazaZoneNeighborhoods` (21 Praças-zona SSA/CMS; Salvador = TRE-BA RA 02/2017; Camaçari = locais de votação + curadoria) + fixture `plaza-zone-neighborhoods.official.json` + int `plazaZoneNeighborhoods.int.spec.ts`; card **Bairros desta Praça** na aba Visão geral de `/campanha/pracas/[slug]` quando `kind === 'zona'` (staff e líder). Plano: [`poligonos-pracas-zona.md`](../../../docs/plans/poligonos-pracas-zona.md). **F2 pendente** (dissolve IBGE → TopoJSON + mapa).
- **A9+ entregue em código (2026-07-21; deploy pendente com remodelagem):** loader compartilhado da lista — `loadPlazaListPageBundle` (1× `aggregatePledgesByPlaza`), `buildPlazaMapBundleFromPlazas`, `plazaRevalidation.ts` + hidden `plazaSlug`; int `plazaPageData.int.spec.ts`. Plano: [`escala-dry-pos-a9.md`](../../../docs/plans/escala-dry-pos-a9.md). Débito restante: twin `listFormActions` ↔ `/editar` → **C8** F4.
- **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).
- **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.
@ -127,5 +128,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 / 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).
- **A9 / B6 / B9 / B7 / B10 / B11 / B8 F1 / filtros-auto entregues (2026-07-21; deploy pendente):** ver bullets no topo do Status. Próximos abertos Janela 1: **R6**, deploy+smoke remodelagem, **B12**; Janela 3: **B8 F2** (polígonos no mapa).
- **B8 registrado (2026-07-21); F1 entregue (2026-07-21):** polígonos das Praças-zona Salvador/Camaçari. F1: catálogo `plazaZoneNeighborhoods` + card na Praça. F2 pendente: dissolve malha IBGE → TopoJSON no mapa. Plano: [`docs/plans/poligonos-pracas-zona.md`](../../../docs/plans/poligonos-pracas-zona.md).

View file

@ -1,7 +1,9 @@
# Polígonos das Praças-zona (Salvador / Camaçari)
Status: rascunho
Status: F1 entregue em código (2026-07-21); F2 pendente
Atualizado em: 2026-07-21
Revisão 2026-07-21: F1 implementada — `plazaZoneNeighborhoods.ts`, fixture+int, `PlazaZoneNeighborhoodsCard` no overview da Praça-zona; mapa inalterado (F2).
Revisão 2026-07-21 (pós-`/simplify` + capture-review-debts): débitos S1–S3 absorvidos como **F2 prep** (~½ dia, dentro do appetite F2); S4–S10 em Explicitamente fora / Adiado; JR1–JR7 em Já resolvido (não reabrir no simplify).
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B8)
Impeccable: B — encaixe em `/campanha/pracas/[slug]` (lista de bairros) + `PlazaMapPanel`/`BahiaMap` (polígonos); sem rota nova
Appetite: ~2,5–3,5 dias eng; F1 catálogo+UI (~1 dia) shipável sozinha; F2 dissolve+TopoJSON+mapa (~1,5–2,5 dias)
@ -76,7 +78,7 @@ flowchart TD
Componentes:
- **`src/lib/plazaZoneNeighborhoods.ts`** — mapa estático `{ city: 'Salvador'|'Camaçari', zoneNumber, neighborhoods: string[] }` indexado por `plazaSlug` (via `plazaCatalog`); helpers `neighborhoodsForPlazaSlug(slug)`; cabeçalho de proveniência (URL + data da resolução / lista de locais). Fixture `tests/fixtures/plaza-zone-neighborhoods.official.json` + int (19+2 zonas, sem bairro órfão duplicado entre ZE do mesmo município quando a fonte for exclusiva).
- **`src/lib/plazaZoneNeighborhoods.ts`** — mapa estático `{ city: 'Salvador'|'Camaçari', zoneNumber, neighborhoods: string[] }` indexado por `plazaSlug`; helper `plazaZoneNeighborhoodEntryForSlug(slug)` + `plazaZoneNeighborhoodSourceLabel(source)`; cabeçalho de proveniência (URL + data da resolução / lista de locais). Fixture `tests/fixtures/plaza-zone-neighborhoods.official.json` + int (19+2 zonas, sem bairro órfão duplicado entre ZE do mesmo município quando a fonte for exclusiva). **F2 prep:** refatorar para autorar só `{ plazaSlug, source, neighborhoods[] }` e hidratar `city`/`zoneNumber` de `getPlazaCatalogEntry` (ver seção abaixo).
- **UI detalhe** (`pracas/[slug]/page.tsx` overview): se `view.kind === 'zona'`, card/lista “Bairros desta Praça” a partir do helper (sem Payload). Copy: fonte TRE / aproximação Camaçari.
- **`scripts/build-plaza-zone-geometries.mjs`** (`pnpm build:plaza-zone-geometries`): baixa/cache malha de bairros (padrão `downloadToBuffer` / cache `data/geometries/`), filtra município, dissolve por zona via catálogo reconciliado (`topojson` merge como TIs), emite `src/lib/geometries/bahia-plaza-zones.topo.json`; reporta bairros sem match.
- **`src/lib/bahiaGeometries.ts`** (ou módulo irmão profundo, sem pass-through): `getPlazaZoneFeature(slug)` + tipo da topologia; lazy com o mapa (alinhar B5 F1 se ainda aberto).
@ -86,10 +88,17 @@ Componentes:
### Fases
| Fase | Entrega | Appetite |
| ---- | ------------------------------------------------ | ------------- |
| F1 | Catálogo + testes + bloco na Praça | ~1 dia |
| F2 | Spike cobertura malha → script → TopoJSON → mapa | ~1,5–2,5 dias |
| Fase | Entrega | Appetite |
| -------- | ----------------------------------------------------------------------- | ------------- |
| F1 | Catálogo + testes + bloco na Praça | ~1 dia |
| F2 prep | Hidratação via `plazaCatalog` + canônico JSON→TS + teste ordem de slugs | ~½ dia |
| F2 | Spike cobertura malha → script → TopoJSON → mapa | ~1,5–2,5 dias |
**F2 prep** (débitos pós-`/simplify`, capture-review-debts 2026-07-21 — não item paralelo no roadmap):
1. **S1 — Hidratar identidade do catálogo:** autorar entradas como `{ plazaSlug, source, neighborhoods[] }`; derivar `city` e `zoneNumber` de `getPlazaCatalogEntry(slug)` em `entry()` ou no read path (padrão `plazaElectionGeographyForSlug`). Reduz ~42 campos duplicados e risco de drift antes do dissolve.
2. **S2 — Fonte única TS ↔ fixture:** o script `build-plaza-zone-geometries.mjs` (ou gerador irmão) emite `plazaZoneNeighborhoods.ts` a partir de `plaza-zone-neighborhoods.official.json` (ou o inverso com JSON como canônico de evidência). Elimina ~300 linhas espelhadas; `evidenceSha256` permanece no fixture.
3. **S3 — Ordem de slugs:** int leve `plazaZoneNeighborhoods.map(e => e.plazaSlug)` vs slugs `kind === 'zona'` de `plazaCatalog` (alinha ao snapshot SHA de `plazaCatalog.int.spec.ts`).
## Dependências
@ -112,8 +121,29 @@ Componentes:
- **Desenhar 19 polígonos Salvador à mão no QGIS sem catálogo.** Irrepetível e sem teste. **Mitigação:** F1 obrigatória; manual só para gaps Camaçari/ unmatched.
- **Tratar polígono derivado como limite oficial TSE.** Risco jurídico/comunicação. **Mitigação:** copy “aproximação a partir de bairros / locais de votação; não é limite oficial”.
## Já resolvido no simplify/critique (não reabrir)
- **JR1** — `PlazaZoneNeighborhoodsCard`: um lookup `plazaZoneNeighborhoodEntryForSlug` (sem duplo hit no `entryBySlug`).
- **JR2** — export `neighborhoodsForPlazaSlug` removido (wrapper redundante).
- **JR3** — guard do card: `!entry?.neighborhoods.length`.
- **JR4** — `assertPlazaZoneNeighborhoodCatalogIntegrity` só no módulo (não exportado).
- **JR5** — testes redundantes de sort e checksum duplicado removidos do int spec.
- **JR6** — cobertura usa `zonePlazas.length` em vez de `21` fixo.
- **JR7** — comentário no spec: sem teste de exclusividade Camaçari (fonte curada/aproximada).
- Impeccable compacto na F1: sem snapshot `.impeccable/critique/` nem P0–P3 abertos nesta superfície; polish visual amplo → **R6**.
## Explicitamente fora (skips `/simplify` + descartes do triage)
- **S4 — Remover assert de integridade no import.** Fail-fast no import é aceitável aqui; int spec já cobre slugs zona. Não mover só para spec sem motivo de perf.
- **S5 — Incluir `source` no SHA-256 canônico.** `deepEqual` fixture↔código já detecta mudança de `source`; refresh de `evidenceSha256` seria churn sem ganho.
- **S7 — `PlazaZoneNeighborhoodCity` vs `ZONE_PLAZA_CITIES`.** Absorvido por S1 se F2 prep rodar; não tipo paralelo isolado.
- **S8 — Proveniência em 3 camadas** (header TS, fixture `provenance`, `plazaZoneNeighborhoodSourceLabel`). Padrão aceitável para catálogo com copy UI distinta por `source`.
- **S9 — Double gate** `view.kind === 'zona'` na página + card `null`. Defesa em profundidade; não simplificar só por DRY.
- **S10 — `key={neighborhood}` no card.** Seguro com exclusividade Salvador testada; prefixar slug só se QA reportar colisão intra-zona.
## Adiado com gatilho
- **S6 — CSS compartilhado lista zona** (`PlazaMapPanel` zone breakdown ↔ `PlazaZoneNeighborhoodsCard`). **Gatilho:** F2 alterar `PlazaMapPanel`/`BahiaMap` ou R6 citar duplicação visual em ≥2 superfícies de zona.
- **Malha GeoSalvador em vez de IBGE.** Revisitar quando: spike F2 mostrar unmatched críticos em Salvador (>10% bairros TRE sem polígono).
- **Refino fino de Camaçari (povoados).** Revisitar quando: coordenação de campo reportar confusão operacional entre ZE 170/171 no mapa.
- **Camada ZE estadual (B4 antigo).** Revisitar quando: produto pedir mapa de ZE fora de SSA/CMS (improvável neste ciclo).

View file

@ -1,6 +1,6 @@
# Roadmap — Teqo
Atualizado em: 2026-07-21 (B6 entregue em código; B12 registrado — aproximar mapa ao filtro; A9+/B7/B9–B11/filtros-auto entregues; débitos B9 → C8 F4)
Atualizado em: 2026-07-21 (B6 + B8 F1 entregues em código; débitos B8 F1 pós-/simplify absorvidos no plano F2 prep; B12 registrado — aproximar mapa ao filtro; A9+/B7/B9–B11/filtros-auto entregues; débitos B9 → C8 F4)
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).
@ -114,6 +114,7 @@ Paralelizáveis agora: **B12** (viewport do mapa; B7 já entregue), fill-ins da
- **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).
- **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).
- **B6 (2026-07-21)** — **`BahiaMap` setStyle incremental** (layer GeoJSON estável entre troca de ano/métrica/escala; `pathByKeyRef` + restyle O(2) no hover/select; `fitBounds` só em `mode`/`highlightKeys`; helpers em `bahiaMapStyle.ts`) — [plano](plans/escala-dry-pos-b3.md).
- **B8 F1 (2026-07-21)** — **Catálogo bairros das Praças-zona** (`plazaZoneNeighborhoods`: Salvador TRE-BA RA 02/2017 + Camaçari curado; fixture+int; card **Bairros desta Praça** no overview de Praças `kind=zona`; sem migration) — [plano](plans/poligonos-pracas-zona.md). **F2 pendente** (F2 prep catálogo ~½ dia + TopoJSON + mapa). Débitos pós-`/simplify`: hidratação via `plazaCatalog`, canônico JSON→TS, teste ordem slugs → F2 prep no plano (S6 CSS compartilhado adiado até F2/R6).
- **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)
@ -136,7 +137,7 @@ Paralelizáveis agora: **B12** (viewport do mapa; B7 já entregue), fill-ins da
- **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)
- **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)
- **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~~ (entregue 2026-07-21); F2 prep catálogo (~½ dia) + dissolve IBGE/malha → TopoJSON no mapa · Janela 3 · cortável (F2) · [plano](plans/poligonos-pracas-zona.md)
- **B12** Aproximar o Mapa das Praças ao footprint filtrado (`fitToKeys` no `BahiaMap`; sem re-zoom em Ano/Escala) · Janela 1 · cortável · gatilho do B7 · [plano](plans/aproximar-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)

View file

@ -12,6 +12,7 @@ import { PlazaCandidateComparisonTable } from '@/components/campaign/PlazaCandid
import { PlazaLeadershipsPanel } from '@/components/campaign/PlazaLeadershipsPanel'
import { PlazaPledgesPanel } from '@/components/campaign/PlazaPledgesPanel'
import { PlazaStrategyCard } from '@/components/campaign/PlazaStrategyCard'
import { PlazaZoneNeighborhoodsCard } from '@/components/campaign/PlazaZoneNeighborhoodsCard'
import { PlazaTabNav } from '@/components/campaign/PlazaTabNav'
import { PlazaUpdateFeed } from '@/components/campaign/PlazaUpdateFeed'
import { PlazaUpdateForm } from '@/components/campaign/PlazaUpdateForm'
@ -153,11 +154,15 @@ const OverviewTab = async ({
isStaffView: boolean
payloadUser: PayloadUser
}) => {
const zoneNeighborhoodsCard =
view.kind === 'zona' ? <PlazaZoneNeighborhoodsCard plazaSlug={view.slug} /> : null
if (isStaffView && view.strategy) {
const pledges = await loadPlazaPledges(payload, user, view.id)
const leadershipVoteTotal = sumStaffPledgeEffectiveTotal(pledges)
return (
<div className="flex flex-col gap-6">
{zoneNeighborhoodsCard}
<PlazaStrategyCard
strategy={view.strategy}
plazaSlug={view.slug}
@ -173,19 +178,22 @@ const OverviewTab = async ({
const ownPledge = leaderPledges.find((pledge) => pledge.plazaID === view.id) ?? null
return (
<section className="flex flex-col gap-4 rounded-xl border p-4">
<div className="flex flex-col gap-1">
<h2 className="text-base font-medium">Seus votos nesta Praça</h2>
<p className="text-sm text-muted-foreground">
Informe quantos votos você está trazendo. Você pode atualizar o número quando quiser.
</p>
</div>
<DeclareVotesForm
plazaID={view.id}
currentDeclaredVotes={ownPledge?.declaredVotes ?? null}
formAction={declareVotesFormAction}
/>
</section>
<div className="flex flex-col gap-6">
{zoneNeighborhoodsCard}
<section className="flex flex-col gap-4 rounded-xl border p-4">
<div className="flex flex-col gap-1">
<h2 className="text-base font-medium">Seus votos nesta Praça</h2>
<p className="text-sm text-muted-foreground">
Informe quantos votos você está trazendo. Você pode atualizar o número quando quiser.
</p>
</div>
<DeclareVotesForm
plazaID={view.id}
currentDeclaredVotes={ownPledge?.declaredVotes ?? null}
formAction={declareVotesFormAction}
/>
</section>
</div>
)
}

View file

@ -0,0 +1,39 @@
import {
plazaZoneNeighborhoodEntryForSlug,
plazaZoneNeighborhoodSourceLabel,
} from '@/lib/plazaZoneNeighborhoods'
export const PlazaZoneNeighborhoodsCard = ({ plazaSlug }: { plazaSlug: string }) => {
const entry = plazaZoneNeighborhoodEntryForSlug(plazaSlug)
if (!entry?.neighborhoods.length) return null
const { neighborhoods, source } = entry
return (
<section
aria-labelledby="plaza-zone-neighborhoods-title"
className="flex flex-col gap-4 rounded-xl border p-4"
>
<div className="flex flex-col gap-1">
<h2 id="plaza-zone-neighborhoods-title" className="text-base font-medium">
Bairros desta Praça
</h2>
<p className="text-sm text-muted-foreground">
{neighborhoods.length}{' '}
{neighborhoods.length === 1 ? 'bairro' : 'bairros'} ·{' '}
{plazaZoneNeighborhoodSourceLabel(source)}
</p>
</div>
<ul className="grid gap-1.5 sm:grid-cols-2">
{neighborhoods.map((neighborhood) => (
<li
key={neighborhood}
className="rounded-md border px-3 py-2 text-sm text-foreground"
>
{neighborhood}
</li>
))}
</ul>
</section>
)
}

View file

@ -0,0 +1,321 @@
/**
* Static catalog of neighborhoods per Praça-zona (Salvador ZE 1–19, Camaçari ZE 170/171).
*
* Salvador source (official):
* - TRE-BA Resolução Administrativa nº 2/2017, Anexo I — circunscrição por bairro
* https://www.tre-ba.jus.br/legislacao/compilada/resolucao/2017/resolucao-administrativa-no-2-de-10-de-maio-de-2017
* (accessed 2026-07-21)
*
* Camaçari source (approximate):
* - Localidades derivadas de locais de votação TRE-BA (ZE 170/171) + curadoria manual;
* não há lista oficial zona↔bairro comparável a Salvador.
* https://www.tre-ba.jus.br/servicos-eleitorais/titulo-e-local-de-votacao
* (accessed 2026-07-21)
*
* Coverage is independently checked by tests/fixtures/plaza-zone-neighborhoods.official.json.
*/
import { getPlazaCatalogEntry } from '@/lib/plazaCatalog'
export type PlazaZoneNeighborhoodSource = 'tre-ra-02-2017' | 'tre-voting-locations-curated'
export type PlazaZoneNeighborhoodCity = 'Salvador' | 'Camaçari'
export type PlazaZoneNeighborhoodEntry = {
readonly plazaSlug: string
readonly city: PlazaZoneNeighborhoodCity
readonly zoneNumber: number
readonly source: PlazaZoneNeighborhoodSource
readonly neighborhoods: readonly string[]
}
const sortNeighborhoods = (names: readonly string[]): readonly string[] =>
[...names].sort((left, right) => left.localeCompare(right, 'pt-BR'))
const entry = (
plazaSlug: string,
city: PlazaZoneNeighborhoodCity,
zoneNumber: number,
source: PlazaZoneNeighborhoodSource,
neighborhoods: readonly string[],
): PlazaZoneNeighborhoodEntry => ({
plazaSlug,
city,
zoneNumber,
source,
neighborhoods: sortNeighborhoods(neighborhoods),
})
export const plazaZoneNeighborhoods: readonly PlazaZoneNeighborhoodEntry[] = [
entry('salvador-ze-1', 'Salvador', 1, 'tre-ra-02-2017', [
'Alto das Pombas',
'Barra',
'Calabar',
'Canela',
'Centro',
'Centro Histórico',
'Garcia',
'Graça',
'Vitória',
]),
entry('salvador-ze-2', 'Salvador', 2, 'tre-ra-02-2017', [
'Amaralina',
'Chapada do Rio Vermelho',
'Engenho Velho da Federação',
'Federação',
'Ondina',
'Rio Vermelho',
]),
entry('salvador-ze-3', 'Salvador', 3, 'tre-ra-02-2017', [
'Baixa de Quintas',
'Cidade Nova',
'Fazenda Grande do Retiro',
'IAPI',
'Pau Miúdo',
'Pero Vaz',
'Santa Mônica',
]),
entry('salvador-ze-4', 'Salvador', 4, 'tre-ra-02-2017', [
'Coutos',
'Fazenda Coutos',
'Ilha de Bom Jesus dos Passos',
'Ilha de Maré',
'Ilha dos Frades',
'Nova Constituinte',
'Paripe',
'Periperi (parte ao norte da Rua das Pedrinhas)',
'São Tomé',
]),
entry('salvador-ze-5', 'Salvador', 5, 'tre-ra-02-2017', [
'Barreiras',
'Cabula',
'Engomadeira',
'Mata Escura',
'Resgate',
'Retiro',
'São Gonçalo',
]),
entry('salvador-ze-6', 'Salvador', 6, 'tre-ra-02-2017', [
'Acupe',
'Boa Vista de Brotas',
'Brotas',
'Candeal',
'Engenho Velho de Brotas',
]),
entry('salvador-ze-7', 'Salvador', 7, 'tre-ra-02-2017', [
'Barbalho',
'Barris',
'Comércio',
'Cosme de Farias',
'Luiz Anselmo',
'Macaúbas',
'Matatu',
'Nazaré',
'Santo Agostinho',
'Santo Antônio',
'Saúde',
'Tororó',
'Vila Laura',
]),
entry('salvador-ze-8', 'Salvador', 8, 'tre-ra-02-2017', [
'Cajazeiras II',
'Cajazeiras IV',
'Cajazeiras V',
'Cajazeiras VI',
'Cajazeiras VII',
'Cajazeiras VIII',
'Campinas de Pirajá',
'Marechal Rondon',
'Moradas da Lagoa',
'Palestina',
'Pirajá',
'Valéria',
]),
entry('salvador-ze-9', 'Salvador', 9, 'tre-ra-02-2017', [
'Boa Viagem',
'Bonfim',
'Jardim Cruzeiro',
'Mangueira',
'Massaranduba',
'Monte Serrat',
'Ribeira',
'Santa Luzia',
'Uruguai',
'Vila Ruy Barbosa',
]),
entry('salvador-ze-10', 'Salvador', 10, 'tre-ra-02-2017', [
'Bairro da Paz',
'Boca do Rio',
'Costa Azul',
'Imbuí',
'Jardim Armação',
'Patamares',
'Piatã',
'Pituaçu',
'STIEP',
]),
entry('salvador-ze-11', 'Salvador', 11, 'tre-ra-02-2017', [
'Águas Claras',
'Calabetão',
'Castelo Branco',
'Dom Avelar',
'Granjas Rurais Presidente Vargas',
'Jardim Cajazeiras',
'Jardim Santo Inácio',
'Pau da Lima',
'Porto Seco Pirajá',
'Sete de Abril',
'Vila Canária',
]),
entry('salvador-ze-12', 'Salvador', 12, 'tre-ra-02-2017', [
'Aeroporto',
'Alto do Coqueirinho',
'Areia Branca',
'Itapuã',
'Itinga',
'Jardim das Margaridas',
'São Cristóvão',
'Stella Maris',
]),
entry('salvador-ze-13', 'Salvador', 13, 'tre-ra-02-2017', [
'Caminho das Árvores',
'Itaigara',
'Nordeste de Amaralina',
'Pituba',
'Santa Cruz',
'Vale das Pedrinhas',
]),
entry('salvador-ze-14', 'Salvador', 14, 'tre-ra-02-2017', [
'Arenoso',
'Novo Horizonte',
'São Marcos',
'Sussuarana',
'Tancredo Neves',
]),
entry('salvador-ze-15', 'Salvador', 15, 'tre-ra-02-2017', [
'Arraial do Retiro',
'Boa Vista de São Caetano',
'Bom Juá',
'Capelinha',
'Lobato',
'São Caetano',
]),
entry('salvador-ze-16', 'Salvador', 16, 'tre-ra-02-2017', [
'Cabula VI',
'Canabrava',
'Centro Administrativo da Bahia',
'Doron',
'Narandiba',
'Nova Sussuarana',
'Pernambués',
'Saboeiro',
'São Rafael',
'Saramandaia',
'Vale dos Lagos',
]),
entry('salvador-ze-17', 'Salvador', 17, 'tre-ra-02-2017', [
'Alto da Terezinha',
'Alto do Cabrito',
'Itacaranha',
'Periperi (parte ao sul da Rua das Pedrinhas)',
'Plataforma',
'Praia Grande',
'Rio Sena',
'São João do Cabrito',
]),
entry('salvador-ze-18', 'Salvador', 18, 'tre-ra-02-2017', [
"Caixa D'água",
'Calçada',
'Caminho de Areia',
'Curuzu',
'Lapinha',
'Liberdade',
'Mares',
'Roma',
]),
entry('salvador-ze-19', 'Salvador', 19, 'tre-ra-02-2017', [
'Boca da Mata',
'Cajazeiras X',
'Cajazeiras XI',
'Cassange',
'Fazenda Grande I',
'Fazenda Grande II',
'Fazenda Grande III',
'Fazenda Grande IV',
'Jaguaripe I',
'Jardim Nova Esperança',
'Mussurunga',
'Nova Brasília',
'Nova Esperança',
'Novo Marotinho',
'Trobogy',
]),
entry('camacari-ze-170', 'Camaçari', 170, 'tre-voting-locations-curated', [
'Alto da Cruz',
'Bairro do Natal',
'Centro',
'Camaçari de Dentro',
'Dois de Julho',
'Gleba A',
'Gleba B',
'Gleba C',
'Gleba E',
'Gleba H',
'Gravatá',
'Inocoop',
'Jardim Limoeiro',
'Lama Preta',
'Limoeiro',
'Mangueiral',
'Nova Vitória',
'Novo Horizonte',
'Parque Verde',
'Phoc I',
'Phoc II',
'Phoc III',
'Polo Petroquímico',
'Triângulo',
]),
entry('camacari-ze-171', 'Camaçari', 171, 'tre-voting-locations-curated', [
'Abrantes',
'Arembepe',
'Barra do Jacuípe',
'Barra do Pojuca',
'Catu de Abrantes',
'Guarajuba',
'Itacimirim',
'Jauá',
'Monte Gordo',
'Povoado de Areias',
'Vila de Abrantes',
]),
]
const entryBySlug = new Map(plazaZoneNeighborhoods.map((record) => [record.plazaSlug, record]))
export const plazaZoneNeighborhoodEntryForSlug = (
slug: string,
): PlazaZoneNeighborhoodEntry | undefined => entryBySlug.get(slug)
export const plazaZoneNeighborhoodSourceLabel = (source: PlazaZoneNeighborhoodSource): string => {
switch (source) {
case 'tre-ra-02-2017':
return 'Bairros conforme circunscrição oficial do TRE-BA (Resolução Administrativa nº 2/2017).'
case 'tre-voting-locations-curated':
return 'Lista aproximada a partir de locais de votação do TRE-BA; não há circunscrição oficial zona↔bairro em Camaçari.'
}
}
const assertPlazaZoneNeighborhoodCatalogIntegrity = (): void => {
for (const record of plazaZoneNeighborhoods) {
const catalogEntry = getPlazaCatalogEntry(record.plazaSlug)
if (!catalogEntry || catalogEntry.kind !== 'zona') {
throw new Error(`Missing zona Praça catalog entry for slug: ${record.plazaSlug}`)
}
if (catalogEntry.city !== record.city || catalogEntry.zoneNumber !== record.zoneNumber) {
throw new Error(`Catalog mismatch for slug: ${record.plazaSlug}`)
}
}
}
assertPlazaZoneNeighborhoodCatalogIntegrity()

View file

@ -0,0 +1,387 @@
{
"provenance": {
"salvador": {
"description": "Circunscrição oficial por bairro das ZE 1–19, transcrita do Anexo I da Resolução Administrativa TRE-BA nº 2/2017.",
"url": "https://www.tre-ba.jus.br/legislacao/compilada/resolucao/2017/resolucao-administrativa-no-2-de-10-de-maio-de-2017",
"resolution": "RA 02/2017",
"accessed": "2026-07-21"
},
"camacari": {
"description": "Lista aproximada derivada de localidades nos locais de votação TRE-BA (ZE 170/171) e curadoria manual — não há circunscrição oficial zona↔bairro.",
"url": "https://www.tre-ba.jus.br/servicos-eleitorais/titulo-e-local-de-votacao",
"accessed": "2026-07-21"
}
},
"zoneCount": 21,
"assignments": [
{
"plazaSlug": "salvador-ze-1",
"city": "Salvador",
"zoneNumber": 1,
"source": "tre-ra-02-2017",
"neighborhoods": [
"Alto das Pombas",
"Barra",
"Calabar",
"Canela",
"Centro",
"Centro Histórico",
"Garcia",
"Graça",
"Vitória"
]
},
{
"plazaSlug": "salvador-ze-2",
"city": "Salvador",
"zoneNumber": 2,
"source": "tre-ra-02-2017",
"neighborhoods": [
"Amaralina",
"Chapada do Rio Vermelho",
"Engenho Velho da Federação",
"Federação",
"Ondina",
"Rio Vermelho"
]
},
{
"plazaSlug": "salvador-ze-3",
"city": "Salvador",
"zoneNumber": 3,
"source": "tre-ra-02-2017",
"neighborhoods": [
"Baixa de Quintas",
"Cidade Nova",
"Fazenda Grande do Retiro",
"IAPI",
"Pau Miúdo",
"Pero Vaz",
"Santa Mônica"
]
},
{
"plazaSlug": "salvador-ze-4",
"city": "Salvador",
"zoneNumber": 4,
"source": "tre-ra-02-2017",
"neighborhoods": [
"Coutos",
"Fazenda Coutos",
"Ilha de Bom Jesus dos Passos",
"Ilha de Maré",
"Ilha dos Frades",
"Nova Constituinte",
"Paripe",
"Periperi (parte ao norte da Rua das Pedrinhas)",
"São Tomé"
]
},
{
"plazaSlug": "salvador-ze-5",
"city": "Salvador",
"zoneNumber": 5,
"source": "tre-ra-02-2017",
"neighborhoods": [
"Barreiras",
"Cabula",
"Engomadeira",
"Mata Escura",
"Resgate",
"Retiro",
"São Gonçalo"
]
},
{
"plazaSlug": "salvador-ze-6",
"city": "Salvador",
"zoneNumber": 6,
"source": "tre-ra-02-2017",
"neighborhoods": [
"Acupe",
"Boa Vista de Brotas",
"Brotas",
"Candeal",
"Engenho Velho de Brotas"
]
},
{
"plazaSlug": "salvador-ze-7",
"city": "Salvador",
"zoneNumber": 7,
"source": "tre-ra-02-2017",
"neighborhoods": [
"Barbalho",
"Barris",
"Comércio",
"Cosme de Farias",
"Luiz Anselmo",
"Macaúbas",
"Matatu",
"Nazaré",
"Santo Agostinho",
"Santo Antônio",
"Saúde",
"Tororó",
"Vila Laura"
]
},
{
"plazaSlug": "salvador-ze-8",
"city": "Salvador",
"zoneNumber": 8,
"source": "tre-ra-02-2017",
"neighborhoods": [
"Cajazeiras II",
"Cajazeiras IV",
"Cajazeiras V",
"Cajazeiras VI",
"Cajazeiras VII",
"Cajazeiras VIII",
"Campinas de Pirajá",
"Marechal Rondon",
"Moradas da Lagoa",
"Palestina",
"Pirajá",
"Valéria"
]
},
{
"plazaSlug": "salvador-ze-9",
"city": "Salvador",
"zoneNumber": 9,
"source": "tre-ra-02-2017",
"neighborhoods": [
"Boa Viagem",
"Bonfim",
"Jardim Cruzeiro",
"Mangueira",
"Massaranduba",
"Monte Serrat",
"Ribeira",
"Santa Luzia",
"Uruguai",
"Vila Ruy Barbosa"
]
},
{
"plazaSlug": "salvador-ze-10",
"city": "Salvador",
"zoneNumber": 10,
"source": "tre-ra-02-2017",
"neighborhoods": [
"Bairro da Paz",
"Boca do Rio",
"Costa Azul",
"Imbuí",
"Jardim Armação",
"Patamares",
"Piatã",
"Pituaçu",
"STIEP"
]
},
{
"plazaSlug": "salvador-ze-11",
"city": "Salvador",
"zoneNumber": 11,
"source": "tre-ra-02-2017",
"neighborhoods": [
"Águas Claras",
"Calabetão",
"Castelo Branco",
"Dom Avelar",
"Granjas Rurais Presidente Vargas",
"Jardim Cajazeiras",
"Jardim Santo Inácio",
"Pau da Lima",
"Porto Seco Pirajá",
"Sete de Abril",
"Vila Canária"
]
},
{
"plazaSlug": "salvador-ze-12",
"city": "Salvador",
"zoneNumber": 12,
"source": "tre-ra-02-2017",
"neighborhoods": [
"Aeroporto",
"Alto do Coqueirinho",
"Areia Branca",
"Itapuã",
"Itinga",
"Jardim das Margaridas",
"São Cristóvão",
"Stella Maris"
]
},
{
"plazaSlug": "salvador-ze-13",
"city": "Salvador",
"zoneNumber": 13,
"source": "tre-ra-02-2017",
"neighborhoods": [
"Caminho das Árvores",
"Itaigara",
"Nordeste de Amaralina",
"Pituba",
"Santa Cruz",
"Vale das Pedrinhas"
]
},
{
"plazaSlug": "salvador-ze-14",
"city": "Salvador",
"zoneNumber": 14,
"source": "tre-ra-02-2017",
"neighborhoods": [
"Arenoso",
"Novo Horizonte",
"São Marcos",
"Sussuarana",
"Tancredo Neves"
]
},
{
"plazaSlug": "salvador-ze-15",
"city": "Salvador",
"zoneNumber": 15,
"source": "tre-ra-02-2017",
"neighborhoods": [
"Arraial do Retiro",
"Boa Vista de São Caetano",
"Bom Juá",
"Capelinha",
"Lobato",
"São Caetano"
]
},
{
"plazaSlug": "salvador-ze-16",
"city": "Salvador",
"zoneNumber": 16,
"source": "tre-ra-02-2017",
"neighborhoods": [
"Cabula VI",
"Canabrava",
"Centro Administrativo da Bahia",
"Doron",
"Narandiba",
"Nova Sussuarana",
"Pernambués",
"Saboeiro",
"São Rafael",
"Saramandaia",
"Vale dos Lagos"
]
},
{
"plazaSlug": "salvador-ze-17",
"city": "Salvador",
"zoneNumber": 17,
"source": "tre-ra-02-2017",
"neighborhoods": [
"Alto da Terezinha",
"Alto do Cabrito",
"Itacaranha",
"Periperi (parte ao sul da Rua das Pedrinhas)",
"Plataforma",
"Praia Grande",
"Rio Sena",
"São João do Cabrito"
]
},
{
"plazaSlug": "salvador-ze-18",
"city": "Salvador",
"zoneNumber": 18,
"source": "tre-ra-02-2017",
"neighborhoods": [
"Caixa D'água",
"Calçada",
"Caminho de Areia",
"Curuzu",
"Lapinha",
"Liberdade",
"Mares",
"Roma"
]
},
{
"plazaSlug": "salvador-ze-19",
"city": "Salvador",
"zoneNumber": 19,
"source": "tre-ra-02-2017",
"neighborhoods": [
"Boca da Mata",
"Cajazeiras X",
"Cajazeiras XI",
"Cassange",
"Fazenda Grande I",
"Fazenda Grande II",
"Fazenda Grande III",
"Fazenda Grande IV",
"Jaguaripe I",
"Jardim Nova Esperança",
"Mussurunga",
"Nova Brasília",
"Nova Esperança",
"Novo Marotinho",
"Trobogy"
]
},
{
"plazaSlug": "camacari-ze-170",
"city": "Camaçari",
"zoneNumber": 170,
"source": "tre-voting-locations-curated",
"neighborhoods": [
"Alto da Cruz",
"Bairro do Natal",
"Camaçari de Dentro",
"Centro",
"Dois de Julho",
"Gleba A",
"Gleba B",
"Gleba C",
"Gleba E",
"Gleba H",
"Gravatá",
"Inocoop",
"Jardim Limoeiro",
"Lama Preta",
"Limoeiro",
"Mangueiral",
"Nova Vitória",
"Novo Horizonte",
"Parque Verde",
"Phoc I",
"Phoc II",
"Phoc III",
"Polo Petroquímico",
"Triângulo"
]
},
{
"plazaSlug": "camacari-ze-171",
"city": "Camaçari",
"zoneNumber": 171,
"source": "tre-voting-locations-curated",
"neighborhoods": [
"Abrantes",
"Arembepe",
"Barra do Jacuípe",
"Barra do Pojuca",
"Catu de Abrantes",
"Guarajuba",
"Itacimirim",
"Jauá",
"Monte Gordo",
"Povoado de Areias",
"Vila de Abrantes"
]
}
],
"evidenceSha256": "1879319cb12b85697a60e6903f633ef20d23bc031361f862991f26ffb6168187"
}

View file

@ -0,0 +1,126 @@
// @vitest-environment node
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { plazaCatalog } from '@/lib/plazaCatalog'
import {
plazaZoneNeighborhoodEntryForSlug,
plazaZoneNeighborhoodSourceLabel,
plazaZoneNeighborhoods,
} from '@/lib/plazaZoneNeighborhoods'
type OfficialEvidence = {
zoneCount: number
assignments: Array<{
plazaSlug: string
city: 'Salvador' | 'Camaçari'
zoneNumber: number
source: 'tre-ra-02-2017' | 'tre-voting-locations-curated'
neighborhoods: string[]
}>
evidenceSha256: string
}
const officialEvidence = JSON.parse(
readFileSync(new URL('../fixtures/plaza-zone-neighborhoods.official.json', import.meta.url), 'utf8'),
) as OfficialEvidence
const canonicalEvidenceRows = (assignments: OfficialEvidence['assignments']): string =>
[...assignments]
.sort((left, right) => left.plazaSlug.localeCompare(right.plazaSlug, 'pt-BR'))
.map(
({ plazaSlug, city, zoneNumber, neighborhoods }) =>
`${plazaSlug}\t${city}\t${zoneNumber}\t${[...neighborhoods]
.sort((left, right) => left.localeCompare(right, 'pt-BR'))
.join('|')}\n`,
)
.join('')
describe('Praça-zona neighborhood catalog (Salvador + Camaçari)', () => {
const zonePlazas = plazaCatalog.filter((entry) => entry.kind === 'zona')
it('covers every zona Praça with a non-empty neighborhood list', () => {
expect(plazaZoneNeighborhoods).toHaveLength(zonePlazas.length)
expect(new Set(plazaZoneNeighborhoods.map((entry) => entry.plazaSlug)).size).toBe(
zonePlazas.length,
)
for (const plaza of zonePlazas) {
const record = plazaZoneNeighborhoodEntryForSlug(plaza.slug)
expect(record, plaza.slug).toBeDefined()
expect(record?.city).toBe(plaza.city)
expect(record?.zoneNumber).toBe(plaza.zoneNumber)
expect(record?.neighborhoods.length, plaza.slug).toBeGreaterThan(0)
}
})
it('keeps Salvador neighborhoods exclusive across zones (Camaçari is curated — no cross-zone exclusivity test)', () => {
const seen = new Map<string, string>()
for (const record of plazaZoneNeighborhoods.filter((entry) => entry.city === 'Salvador')) {
for (const neighborhood of record.neighborhoods) {
const previous = seen.get(neighborhood)
expect(previous, `${neighborhood} in ZE ${record.zoneNumber}`).toBeUndefined()
seen.set(neighborhood, record.plazaSlug)
}
}
})
it('matches the independently transcribed official evidence fixture', () => {
expect(officialEvidence.zoneCount).toBe(zonePlazas.length)
expect(
officialEvidence.assignments.map(
({ plazaSlug, city, zoneNumber, source, neighborhoods }) => ({
plazaSlug,
city,
zoneNumber,
source,
neighborhoods,
}),
),
).toEqual(
plazaZoneNeighborhoods.map(({ plazaSlug, city, zoneNumber, source, neighborhoods }) => ({
plazaSlug,
city,
zoneNumber,
source,
neighborhoods: [...neighborhoods],
})),
)
})
it('matches every assignment by a fixed evidence checksum', () => {
const digest = createHash('sha256')
.update(
canonicalEvidenceRows(
plazaZoneNeighborhoods.map(
({ plazaSlug, city, zoneNumber, source, neighborhoods }) => ({
plazaSlug,
city,
zoneNumber,
source,
neighborhoods: [...neighborhoods],
}),
),
),
)
.digest('hex')
expect(digest).toBe(officialEvidence.evidenceSha256)
})
it('exposes lookup helpers for zona slugs only', () => {
expect(plazaZoneNeighborhoodEntryForSlug('salvador-ze-3')?.neighborhoods).toContain(
'Santa Mônica',
)
expect(plazaZoneNeighborhoodEntryForSlug('camacari-ze-171')?.neighborhoods).toContain(
'Arembepe',
)
expect(plazaZoneNeighborhoodEntryForSlug('feira-de-santana')).toBeUndefined()
expect(plazaZoneNeighborhoodEntryForSlug('salvador-ze-1')?.source).toBe('tre-ra-02-2017')
expect(plazaZoneNeighborhoodSourceLabel('tre-ra-02-2017')).toMatch(/Resolução Administrativa/)
expect(plazaZoneNeighborhoodSourceLabel('tre-voting-locations-curated')).toMatch(/aproximada/)
})
})