diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index 2cb963e0..7670f384 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -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). diff --git a/docs/plans/poligonos-pracas-zona.md b/docs/plans/poligonos-pracas-zona.md index d71e70ba..11b2cce6 100644 --- a/docs/plans/poligonos-pracas-zona.md +++ b/docs/plans/poligonos-pracas-zona.md @@ -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). diff --git a/docs/roadmap.md b/docs/roadmap.md index 346aa213..c41e44dc 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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) diff --git a/src/app/(campaign)/campanha/(app)/pracas/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/pracas/[slug]/page.tsx index 32c6bdb1..70b928b1 100644 --- a/src/app/(campaign)/campanha/(app)/pracas/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/pracas/[slug]/page.tsx @@ -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' ? : null + if (isStaffView && view.strategy) { const pledges = await loadPlazaPledges(payload, user, view.id) const leadershipVoteTotal = sumStaffPledgeEffectiveTotal(pledges) return (
+ {zoneNeighborhoodsCard} pledge.plazaID === view.id) ?? null return ( -
-
-

Seus votos nesta Praça

-

- Informe quantos votos você está trazendo. Você pode atualizar o número quando quiser. -

-
- -
+
+ {zoneNeighborhoodsCard} +
+
+

Seus votos nesta Praça

+

+ Informe quantos votos você está trazendo. Você pode atualizar o número quando quiser. +

+
+ +
+
) } diff --git a/src/components/campaign/PlazaZoneNeighborhoodsCard.tsx b/src/components/campaign/PlazaZoneNeighborhoodsCard.tsx new file mode 100644 index 00000000..cac2b0ac --- /dev/null +++ b/src/components/campaign/PlazaZoneNeighborhoodsCard.tsx @@ -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 ( +
+
+

+ Bairros desta Praça +

+

+ {neighborhoods.length}{' '} + {neighborhoods.length === 1 ? 'bairro' : 'bairros'} ·{' '} + {plazaZoneNeighborhoodSourceLabel(source)} +

+
+
    + {neighborhoods.map((neighborhood) => ( +
  • + {neighborhood} +
  • + ))} +
+
+ ) +} diff --git a/src/lib/plazaZoneNeighborhoods.ts b/src/lib/plazaZoneNeighborhoods.ts new file mode 100644 index 00000000..900de1d8 --- /dev/null +++ b/src/lib/plazaZoneNeighborhoods.ts @@ -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() diff --git a/tests/fixtures/plaza-zone-neighborhoods.official.json b/tests/fixtures/plaza-zone-neighborhoods.official.json new file mode 100644 index 00000000..81b46e7b --- /dev/null +++ b/tests/fixtures/plaza-zone-neighborhoods.official.json @@ -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" +} diff --git a/tests/int/plazaZoneNeighborhoods.int.spec.ts b/tests/int/plazaZoneNeighborhoods.int.spec.ts new file mode 100644 index 00000000..bd2a172d --- /dev/null +++ b/tests/int/plazaZoneNeighborhoods.int.spec.ts @@ -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() + + 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/) + }) +})