Deliver A7 Fase 2 cityCode TSE geography filters and absorb simplify debts.

Switch election geography queries from cityName to TSE cityCode via a static Bahia map and nucleusElectionGeography module, with simplify cleanup and plan updates for F3–F5 follow-ups.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-19 22:48:08 -03:00
parent 3f6220c9d4
commit c2381cecde
21 changed files with 2679 additions and 187 deletions

View file

@ -1,7 +1,7 @@
# Escala e DRY pós-A4 (baseline TSE no produto)
Status: Fase 1 implementada (Fases 2–5 pendentes; merge pendente)
Atualizado em: 2026-07-19 (Fase 5 lista flip/leverage + extensão F3 gap registradas via `capture-review-debts` pós-A5 `/simplify`; **pós-A5-2 classificação territorial** — absorções no mesmo plano via `capture-review-debts`)
Status: Fases 1–2 implementadas (Fases 3–5 pendentes; merge pendente)
Atualizado em: 2026-07-20 (`capture-review-debts` pós-A7 F2 `/simplify`: débitos absorvidos no plano; gatilhos defer no Explicitamente fora)
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha A, item A7)
Responsável: —
@ -12,7 +12,7 @@ O A4 ([baseline-eleitoral-tse.md](baseline-eleitoral-tse.md) Fases 2–4) entreg
Os revisores (performance / reuse / quality) marcaram como **importantes e maiores que simplify** os follow-ups abaixo. Sem registro, o A5 (insights) e o coroplético do B3 herdariam o mesmo custo de I/O no path do detalhe.
1. **Detalhe puxa todas as linhas nominais de dep. federal (1º turno) na geografia.** ~~`detailVoteWhere` + `loadElectionVotes`~~ **Resolvido na Fase 1 (2026-07-19):** `loadFederalCandidateTotalsAggregated` (`src/utilities/federalCandidateTotalsAggregate.ts`) agrega via drizzle `SUM(votes) GROUP BY candidate_number`; o detalhe usa Local API só para presidente/governador da chapa (`loadTicketOfficeVotes`). A série histórica E2 (`loadCandidateSeriesByGeography`) já era magra por `candidateNumber`.
2. **Filtro geográfico por `cityName` (texto), não por `cityCode` TSE.** O índice natural das collections de eleição é `(year, office, turn, cityCode, zoneNumber)`; filtrar por nome impede o plano ótimo e complica OR grandes na união do overview (já mitigado no simplify, mas o detalhe/TI ainda sofre).
2. **Filtro geográfico por `cityName` (texto), não por `cityCode` TSE.** ~~O índice natural das collections de eleição é `(year, office, turn, cityCode, zoneNumber)`; filtrar por nome impedia o plano ótimo e complicava OR grandes na união do overview.~~ **Resolvido na Fase 2 (2026-07-20):** mapa estático `bahiaTseCityCodes.ts` + `nucleusElectionGeography.ts`; `geographyWhere` e drizzle federal usam `cityCode` + `zoneNumber in […]`.
3. **DRY de UI do Gap "acima".** `NucleusInsights` usa `Alert variant="pending"` para abaixo/neutro, mas o estado "acima" aplica classes ad-hoc com tokens `--estimate-confirmed*`. O slice **A5-1 conversão** reutiliza o mesmo `confirmedInsightAlertClass` para o alerta de taxa de conversão (reduto/consolidado) — interim até a Fase 3. A barra do candidato no card também reimplementa `role="progressbar"` em vez de reusar `Progress` (`NucleusListOverview`).
4. **Query duplicada de votos 2022 na lista de núcleos com coroplético B3.** `nucleusListOverviewPageData.ts` chama em paralelo `loadNucleusListElectionOverview` (union → `loadCandidateSeriesByGeography` multi-ano) e `loadNucleusChoroplethBundle` → `loadBaseline2022VotesByCityNames` (segunda passagem `loadCandidateSeriesByGeography` só 2022 sobre geografia sobreposta). O simplify B3 unificou resolve interno do choropleth, mas não compartilha o resultado da série 2022 já obtida no loader de gap/tendência/conversão.
5. **N+1 de agregação federal no flip da lista (A5 Fase 2).** `loadNucleusListElectionOverview` chama `loadFederalCandidateTotalsAggregated` **uma vez por núcleo** com geografia resolvida para `computeTicketFlipForGeography`, enquanto série/ticket/majoritarian já vêm numa união. Com dezenas de núcleos filtrados, `Promise.all` paraleliza latência mas não reduz carga no Postgres — mesmo anti-padrão que A7 F1 resolveu no **detalhe**.
@ -24,7 +24,9 @@ Os revisores (performance / reuse / quality) marcaram como **importantes e maior
**Já resolvido no simplify pós-A5-2 classificação territorial (não reabrir):** `sumFederalTallyForGeography` único (substitui `sumElectorateForGeography` + helper paralelo); reuso de `classificationTalliesByCityZone` no loop de conversão (elimina 2º `loadElectionTallies` em `conversionUnion`); loop de classificação in-memory antes do `await` do flip N+1; push só de bandas comparáveis (`isComparableTerritorialClass`) — evita linha overview `0 defesa · …`; `TERRITORIAL_CLASS_UI` canônico + accessors finos; `TerritorialClassIcon` no detalhe.
**Explicitamente fora (revisores pediram skip no simplify ou descartados no triage):** map JSX de linhas Lula/Jerônimo, dropar `candidate.rank` / `electorate.aptos` / `ratio` do tipo público só por higiene (só fariam sentido se a F1 deixar de calcular rank), cast `as unknown as RawOverviewNucleus` da B1 (não é débito novo do A4), parity test drizzle vs `aggregateFederalCandidateTotals` (int + unit cobrem comportamento), typed drizzle rows/zod no `drizzleResultRows` (padrão plataforma C6/C8), `requirePostgresDrizzle` em `supporterListOverviewAggregate.ts` (fora do escopo A7 — DRY de ~6 linhas; ver C10 quando o plano existir), micro-opt `aggregateElectorate`/`matchesGeography` com `Set` (impacto negligível), `BahiaMap` `setStyle` incremental → **B6** ([escala-dry-pos-b3.md](escala-dry-pos-b3.md)), factory geometrias mun/TI → **B5 F3** ([escala-dry-pos-b2.md](escala-dry-pos-b2.md)); **pós-A5:** `buildUnionGeography` 2× (micro-opt), mover testes leverage/flip para `electionInsights.unit.spec.ts`, unions discriminadas `TicketLeverage`/`TicketFlip`, estreitar campos VM/result flip, `ticketFlipAlertVariant`/`formatPercent` export, `MajoritarianTallyRow` tipo separado, maps de ícones em `NucleusInsights`, wrapper `NucleusInsightAlert` além da F3 (E7 adia DRY do stack até mais insights); **pós-A5-2:** agregador genérico conversion+territorial, achatar `NucleusClassificationOverviewAggregate`, `percentValid` só em testes, merge CPU dos loops `sumCandidateVotesForGeography` trend+classificação, 4ª query `loadElectionTallies` no union (custo intencional A5-2), `territorialClassLabel` na linha do overview (conversão também hardcoded), componente compartilhado de linha de distribuição → **E6 F3**.
**Já resolvido no simplify pós-F2 `cityCode` (não reabrir):** `matchesGeography` permanece helper privado em `nucleusElectoralBaseline.ts` (não exportado de `nucleusElectionGeography`); consumers de geografia importam direto de `nucleusElectionGeography` (`nucleusChoropleth*`, `nucleusDetailPageData`, `nucleusListOverviewPageData`, `campaignDashboardPageData`, specs); re-exports mortos removidos do barrel baseline; testes de `resolveNucleusElectionGeography` em `tests/unit/nucleusElectionGeography.unit.spec.ts`; int spy federal T1 + tally `cityCode` fundidos num único teste (`tseCityCodeForMunicipality('Salvador')` em vez de código hardcoded); mock obsoleto `toNucleusElectionGeographyInput` removido de `campaignNucleusDetailTabs.unit.spec.ts`.
**Explicitamente fora (revisores pediram skip no simplify ou descartados no triage):** map JSX de linhas Lula/Jerônimo, dropar `candidate.rank` / `electorate.aptos` / `ratio` do tipo público só por higiene (só fariam sentido se a F1 deixar de calcular rank), cast `as unknown as RawOverviewNucleus` da B1 (não é débito novo do A4), parity test drizzle vs `aggregateFederalCandidateTotals` (int + unit cobrem comportamento), typed drizzle rows/zod no `drizzleResultRows` (padrão plataforma C6/C8), `requirePostgresDrizzle` em `supporterListOverviewAggregate.ts` (fora do escopo A7 — DRY de ~6 linhas; ver C10 quando o plano existir), micro-opt `aggregateElectorate`/`matchesGeography` com `Set` (impacto negligível), **cache `zonesByCityCode` no objeto `NucleusElectionGeography`** (defer: só se hot path comprovado ou ≥3 recomputações por request), **mover `buildUnionGeography` para `nucleusElectionGeography`** (defer: 3º call site de union geography ou refactor A7 F5), `BahiaMap` `setStyle` incremental → **B6** ([escala-dry-pos-b3.md](escala-dry-pos-b3.md)), factory geometrias mun/TI → **B5 F3** ([escala-dry-pos-b2.md](escala-dry-pos-b2.md)); **pós-A5:** `buildUnionGeography` 2× (micro-opt), mover testes leverage/flip para `electionInsights.unit.spec.ts`, unions discriminadas `TicketLeverage`/`TicketFlip`, estreitar campos VM/result flip, `ticketFlipAlertVariant`/`formatPercent` export, `MajoritarianTallyRow` tipo separado, maps de ícones em `NucleusInsights`, wrapper `NucleusInsightAlert` além da F3 (E7 adia DRY do stack até mais insights); **pós-A5-2:** agregador genérico conversion+territorial, achatar `NucleusClassificationOverviewAggregate`, `percentValid` só em testes, merge CPU dos loops `sumCandidateVotesForGeography` trend+classificação, 4ª query `loadElectionTallies` no union (custo intencional A5-2), `territorialClassLabel` na linha do overview (conversão também hardcoded), componente compartilhado de linha de distribuição → **E6 F3**; **pós-F2:** assertions redundantes no int `bahiaTseCityCodes` (padrão intencional espelhando fixture IBGE); falhas pré-existentes `campaignNucleusIntelligenceUi.unit.spec.ts` (fora do escopo A7 F2).
## Objetivos
@ -56,7 +58,7 @@ Os revisores (performance / reuse / quality) marcaram como **importantes e maior
flowchart TD
A4["A4 Baseline no produto ✓"] --> F1
F1["Fase 1 — Agregar federal no detalhe<br/>(GROUP BY / payload estreito) ✓"]
F1 --> F2["Fase 2 — Geografia por cityCode TSE"]
F1 --> F2["Fase 2 — Geografia por cityCode TSE ✓"]
F1 --> F3["Fase 3 — Alert confirmed + Progress<br/>+ gapVs2022 helpers"]
F1 --> F4["Fase 4 — Fetch 2022 único<br/>lista overview + coroplético"]
F1 --> F5["Fase 5 — Batch federal lista<br/>+ index union (A5 flip/leverage)"]
@ -72,13 +74,12 @@ flowchart TD
- `src/utilities/nucleusElectoralBaseline.ts`: `loadTicketOfficeVotes` (Local API magro) + `aggregateNucleusElectoralBaseline` recebe `federalTotals` já agregados; `getNucleusElectoralBaseline` widened para `Pick<Payload, 'find' | 'db'>`.
- Testes: unit `federalCandidateTotalsAggregate.unit.spec.ts`; int spy em `payload.find` garante que queries federal T1 no Local API sempre filtram `candidateNumber`.
### Fase 2 — Filtro por `cityCode` TSE
### Fase 2 — Filtro por `cityCode` TSE ✓ (2026-07-20)
- Extrair tipos/helpers de geografia para `src/utilities/nucleusElectionGeography.ts` (quebra o ciclo `federalCandidateTotalsAggregate` ↔ `nucleusElectoralBaseline`; hoje o aggregate importa `NucleusElectionGeography` do baseline).
- Gerar/commitar mapa canônico `cityName` → `cityCode` (BA 2022) a partir dos dados já importados.
- `resolveNucleusElectionGeography` (ou camada ao lado) passa a expor `cityZonePairs` com código; `geographyWhere` e `buildFederalCandidateTotalsSql` usam `cityCode: { equals|in }` + `zoneNumber: { in }` (unifica Payload `Where` e SQL cru).
- Overview union e detalhe compartilham o mesmo helper.
- Sem mudança de UX; fallback fail-closed se o município do núcleo não mapear (mesmo espírito de `canonicalizeMunicipalityName`).
- `src/lib/bahiaTseCityCodes.ts` + `pnpm build:tse-city-codes` (TSE 2022 `detalhe_votacao_munzona` BA).
- `src/utilities/nucleusElectionGeography.ts`: tipos/helpers extraídos; `cityZonePairs` com `cityCode`; `geographyWhere` + `zonesByCityCode`.
- `federalCandidateTotalsAggregate.ts`: SQL em `city_code`.
- Fail-closed quando município não mapeia; sem migration.
### Fase 3 — Alert `confirmed` + `Progress` + helpers Gap vs 2022

View file

@ -1,7 +1,7 @@
# Escala e DRY pós-B2 (geometrias + scripts CLI)
Status: Fase 1 entregue com B3 (2026-07-19); Fases 2–3 pendentes
Atualizado em: 2026-07-19 (Fase 3 factory geometrias registrada via `capture-review-debts` pós-B3)
Atualizado em: 2026-07-20 (nota cruzada `capture-review-debts` pós-A7 F2: `build-bahia-tse-city-codes.mjs` reforça gatilho F2)
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B5)
Responsável: —
@ -10,7 +10,7 @@ Responsável: —
O B2 ([mapa-bahia-geometrias.md](mapa-bahia-geometrias.md) Fase 1) entregou a fundação estática do mapa: `*.topo.json` de municípios e Territórios de Identidade, `bahiaMunicipalityCodes`, helpers em `bahiaGeometries.ts`, script `pnpm build:geometries`. A passagem `/simplify` de 2026-07-18 limpou dead code e churns baratos no script/codegen, mas **deixou de fora** dois follow-ups que os revisores (performance / reuse) marcaram como importantes e maiores que cleanup pontual:
1. **Carregamento eager de geometrias no cliente.** `src/lib/bahiaGeometries.ts` importa os dois TopoJSON, roda `topojson-client` `feature()` em 417+27 polígonos e monta índices no load do módulo — custo de bundle/memória antes de qualquer superfície Leaflet (B3).
2. **Cache de download duplicado nos CLIs.** `ensureCachedJson` em `scripts/build-bahia-geometries.mjs` é estruturalmente o mesmo que `ensureCachedZip` em `scripts/seed-tse-results.mjs` (mkdir → hit → `downloadToBuffer` → SHA-256 → write), só muda extensão/parse.
2. **Cache de download duplicado nos CLIs.** `ensureCachedJson` em `scripts/build-bahia-geometries.mjs` é estruturalmente o mesmo que `ensureCachedZip` em `scripts/seed-tse-results.mjs` (mkdir → hit → `downloadToBuffer` → SHA-256 → write), só muda extensão/parse. **Pós-A7 F2:** `scripts/build-bahia-tse-city-codes.mjs` (`pnpm build:tse-city-codes`) clona o mesmo padrão `ensureCachedZip` — terceiro call site, reforça prioridade da Fase 2 deste plano sem novo ID.
3. **Factory duplicada mun/TI pós-B3 F1.** `bahiaMunicipalityGeometries.ts` e `bahiaTerritoryGeometries.ts` repetem o mesmo pipeline `topojson.feature` → array → `Map` por chave → export do módulo indexado — só mudam topology object, propriedade de chave (`codarea` vs `code`) e getter.
Este plano é o registro canônico desses follow-ups. Sem ele, o B3 pode embarcar o mapa com payload desnecessário no path default, e o próximo script de dado aberto volta a clonar o cache.
@ -63,7 +63,7 @@ flowchart TD
- Extrair `ensureCachedDownload({ cacheDir, key, url, ext?, logPrefix })` a partir de `ensureCachedJson` / `ensureCachedZip`.
- Retorno: `{ url, buffer, hash }` (parse JSON/ZIP fica no caller — `build-bahia-geometries` já faz `JSON.parse`; seed TSE usa `readZipEntry`).
- Continuar usando `downloadToBuffer` (`src/lib/electionResultsZip.ts`).
- Migrar `scripts/build-bahia-geometries.mjs` e `scripts/seed-tse-results.mjs`; sem mudança de comportamento nem de pastas `data/geometries/` / `data/tse/`.
- Migrar `scripts/build-bahia-geometries.mjs`, `scripts/seed-tse-results.mjs` e `scripts/build-bahia-tse-city-codes.mjs`; sem mudança de comportamento nem de pastas `data/geometries/` / `data/tse/`.
### Fase 3 — Factory compartilhada mun/TI

View file

@ -29,6 +29,7 @@
"db:seed:tse": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-tse-results.mjs",
"db:seed:consent": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-consent.mjs",
"build:geometries": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-geometries.mjs",
"build:tse-city-codes": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-tse-city-codes.mjs",
"dev": "node scripts/guard-dev-db.mjs && cross-env NODE_OPTIONS=--no-deprecation next dev",
"devsafe": "rm -rf .next && node scripts/guard-dev-db.mjs && cross-env NODE_OPTIONS=--no-deprecation next dev",
"generate:importmap": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload generate:importmap",

View file

@ -0,0 +1,202 @@
/**
* Builds static Bahia municipality name → TSE city code (CD_MUNICIPIO) mapping.
*
* Provenance (do not strip):
* - TSE open data — Results 2022, detalhe da apuração por município e zona, UF BA:
* https://dadosabertos.tse.jus.br/dataset/resultados-2022
* https://cdn.tse.jus.br/estatistica/sead/odsele/detalhe_votacao_munzona/detalhe_votacao_munzona_2022.zip
* Entry: detalhe_votacao_munzona_2022_BA.csv
*
* Safety: does not touch any database — only downloads public TSE CSV and writes
* versioned artifacts under src/lib/ and tests/fixtures/.
*
* Usage:
* pnpm build:tse-city-codes
* TSE_CACHE_DIR=./data/tse pnpm build:tse-city-codes
*/
import { createHash } from 'node:crypto'
import { mkdir, readFile, writeFile, access } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const { canonicalizeMunicipalityName, UnknownMunicipalityError } = await import(
'../src/lib/electionResults.ts'
)
const { bahiaMunicipalities } = await import('../src/lib/bahiaTerritories.ts')
const { parseTseCsvBuffer } = await import('../src/lib/electionResultsCsv.ts')
const { downloadToBuffer, readZipEntry } = await import('../src/lib/electionResultsZip.ts')
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const SOURCE = {
key: 'detalhe_votacao_munzona_2022',
url: 'https://cdn.tse.jus.br/estatistica/sead/odsele/detalhe_votacao_munzona/detalhe_votacao_munzona_2022.zip',
csvName: 'detalhe_votacao_munzona_2022_BA.csv',
}
const die = (message) => {
console.error(`\n[build:tse-city-codes] ${message}\n`)
process.exit(1)
}
const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex')
const cacheDir = () => process.env.TSE_CACHE_DIR || join(ROOT, 'data', 'tse')
const ensureCachedZip = async () => {
const dir = cacheDir()
await mkdir(dir, { recursive: true })
const path = join(dir, `${SOURCE.key}.zip`)
try {
await access(path)
console.log(`[build:tse-city-codes] cache hit ${path}`)
const buffer = await readFile(path)
return { url: SOURCE.url, hash: sha256(buffer), buffer }
} catch (error) {
if (error?.code !== 'ENOENT') throw error
}
console.log(`[build:tse-city-codes] downloading ${SOURCE.url}`)
const buffer = await downloadToBuffer(SOURCE.url)
const hash = sha256(buffer)
await writeFile(path, buffer)
console.log(`[build:tse-city-codes] saved ${path} (${buffer.length} bytes, sha256=${hash})`)
return { url: SOURCE.url, hash, buffer }
}
const writeJson = async (relativePath, value) => {
const path = join(ROOT, relativePath)
await mkdir(dirname(path), { recursive: true })
const body = `${JSON.stringify(value, null, 2)}\n`
await writeFile(path, body)
console.log(`[build:tse-city-codes] wrote ${relativePath} (${Buffer.byteLength(body)} bytes)`)
}
const writeText = async (relativePath, body) => {
const path = join(ROOT, relativePath)
await mkdir(dirname(path), { recursive: true })
await writeFile(path, body)
console.log(`[build:tse-city-codes] wrote ${relativePath} (${Buffer.byteLength(body)} bytes)`)
}
const buildCityCodesModule = (entries, provenance) => {
const lines = entries
.map(([name, code]) => ` ${JSON.stringify(name)}: ${JSON.stringify(code)},`)
.join('\n')
return `/**
* Official Bahia municipality name → TSE city code (CD_MUNICIPIO) mapping.
*
* Source (TSE open data — Results 2022, detalhe da apuração por município e zona, UF BA; accessed ${provenance.accessed}):
* ${provenance.url}
* Download SHA-256: ${provenance.downloadSha256}
* CSV entry: ${provenance.csvName}
*
* This is the TSE electoral municipality code — NOT the IBGE 7-digit codarea
* (see bahiaMunicipalityCodes.ts). Election collections filter by this code.
*
* Municipality spellings follow the canonical application names in bahiaTerritories.
* TSE variants were reconciled via canonicalizeMunicipalityName. Coverage is
* independently checked by the official test evidence fixture.
*/
import { isBahiaMunicipality } from '@/lib/bahiaTerritories'
export const bahiaTseCityCodes: Readonly<Record<string, string>> = {
${lines}
}
export const tseCityCodeForMunicipality = (name: string): string | undefined => {
if (!isBahiaMunicipality(name)) return undefined
return bahiaTseCityCodes[name]
}
const municipalityByTseCityCode = new Map(
Object.entries(bahiaTseCityCodes).map(([name, code]) => [code, name]),
)
export const municipalityForTseCityCode = (cityCode: string): string | undefined =>
municipalityByTseCityCode.get(cityCode)
`
}
const main = async () => {
const { url, hash, buffer } = await ensureCachedZip()
const entry = await readZipEntry(buffer, SOURCE.csvName)
if (!entry) die(`Missing ${SOURCE.csvName} in TSE zip`)
const rows = parseTseCsvBuffer(entry.buffer)
/** @type {Map<string, string>} canonical name → TSE city code */
const codeByCanonical = new Map()
/** @type {Map<string, string>} TSE city code → canonical name */
const canonicalByCode = new Map()
for (const row of rows) {
if ((row.SG_UF ?? '').trim().toUpperCase() !== 'BA') continue
const rawCode = (row.CD_MUNICIPIO ?? '').trim()
if (!rawCode) continue
let canonical
try {
canonical = canonicalizeMunicipalityName(row.NM_MUNICIPIO ?? '')
} catch (error) {
if (!(error instanceof UnknownMunicipalityError)) throw error
die(`TSE name without canonical mapping: "${row.NM_MUNICIPIO}"`)
}
const existing = codeByCanonical.get(canonical)
if (existing && existing !== rawCode) {
die(`Conflicting TSE codes for ${canonical}: ${existing} vs ${rawCode}`)
}
const existingName = canonicalByCode.get(rawCode)
if (existingName && existingName !== canonical) {
die(`Conflicting municipalities for TSE code ${rawCode}: ${existingName} vs ${canonical}`)
}
codeByCanonical.set(canonical, rawCode)
canonicalByCode.set(rawCode, canonical)
}
if (codeByCanonical.size !== 417) {
die(`Expected 417 Bahia municipalities; parsed ${codeByCanonical.size} from TSE CSV`)
}
const missing = bahiaMunicipalities.filter((city) => !codeByCanonical.has(city))
if (missing.length > 0) {
die(`Missing TSE city codes for: ${missing.slice(0, 5).join(', ')}${missing.length > 5 ? '…' : ''}`)
}
const accessed = new Date().toISOString().slice(0, 10)
const provenance = { accessed, url, downloadSha256: hash, csvName: SOURCE.csvName }
const codeEntries = [...codeByCanonical.entries()].sort(([left], [right]) =>
left.localeCompare(right, 'pt-BR'),
)
await writeText('src/lib/bahiaTseCityCodes.ts', buildCityCodesModule(codeEntries, provenance))
const assignmentRows = codeEntries
.map(([municipality, code]) => `M\t${municipality}\t${code}\n`)
.join('')
const evidenceSha256 = createHash('sha256').update(assignmentRows).digest('hex')
await writeJson('tests/fixtures/bahia-tse-city-codes.official.json', {
provenance: {
description:
'Test-only evidence of Bahia municipality→TSE city code pairs derived independently from the TSE 2022 detalhe_votacao_munzona BA CSV. It was not generated from src/lib/bahiaTseCityCodes.ts.',
source: {
portal: 'https://dadosabertos.tse.jus.br/dataset/resultados-2022',
url,
downloadSha256: hash,
csvName: SOURCE.csvName,
},
},
municipalityCount: 417,
assignments: codeEntries.map(([municipality, code]) => ({ municipality, code })),
evidenceSha256,
})
console.log('[build:tse-city-codes] done — Salvador TSE code:', codeByCanonical.get('Salvador'))
}
main().catch((error) => {
console.error(error)
process.exit(1)
})

View file

@ -0,0 +1,449 @@
/**
* Official Bahia municipality name → TSE city code (CD_MUNICIPIO) mapping.
*
* Source (TSE open data — Results 2022, detalhe da apuração por município e zona, UF BA; accessed 2026-07-20):
* https://cdn.tse.jus.br/estatistica/sead/odsele/detalhe_votacao_munzona/detalhe_votacao_munzona_2022.zip
* Download SHA-256: 945741952e161453a8991c5a6f241f6ab8dc3992ae2060ea020cd24cfecf3b29
* CSV entry: detalhe_votacao_munzona_2022_BA.csv
*
* This is the TSE electoral municipality code — NOT the IBGE 7-digit codarea
* (see bahiaMunicipalityCodes.ts). Election collections filter by this code.
*
* Municipality spellings follow the canonical application names in bahiaTerritories.
* TSE variants were reconciled via canonicalizeMunicipalityName. Coverage is
* independently checked by the official test evidence fixture.
*/
import { isBahiaMunicipality } from '@/lib/bahiaTerritories'
export const bahiaTseCityCodes: Readonly<Record<string, string>> = {
"Abaíra": "33014",
"Abaré": "33030",
"Acajutiba": "33057",
"Adustina": "33065",
"Água Fria": "33073",
"Aiquara": "33111",
"Alagoinhas": "33138",
"Alcobaça": "33154",
"Almadina": "33170",
"Amargosa": "33197",
"Amélia Rodrigues": "33219",
"América Dourada": "30716",
"Anagé": "33235",
"Andaraí": "33251",
"Andorinha": "33260",
"Angical": "33278",
"Anguera": "33294",
"Antas": "33316",
"Antônio Cardoso": "33332",
"Antônio Gonçalves": "33359",
"Aporá": "33375",
"Apuarema": "33405",
"Araças": "33383",
"Aracatu": "33391",
"Araci": "33413",
"Aramari": "33430",
"Arataca": "30732",
"Aratuípe": "33456",
"Aurelino Leal": "33472",
"Baianópolis": "33499",
"Baixa Grande": "33510",
"Banzaê": "33529",
"Barra": "33537",
"Barra da Estiva": "33553",
"Barra do Choça": "33570",
"Barra do Mendes": "33596",
"Barra do Rocha": "33618",
"Barreiras": "33634",
"Barro Alto": "30759",
"Barro Preto": "33650",
"Barrocas": "30775",
"Belmonte": "33677",
"Belo Campo": "33693",
"Biritinga": "33715",
"Boa Nova": "33731",
"Boa Vista do Tupim": "33758",
"Bom Jesus da Lapa": "33774",
"Bom Jesus da Serra": "33782",
"Boninal": "33790",
"Bonito": "33804",
"Boquira": "33812",
"Botuporã": "33839",
"Brejões": "33855",
"Brejolândia": "33871",
"Brotas de Macaúbas": "33898",
"Brumado": "33910",
"Buerarema": "33936",
"Buritirama": "30791",
"Caatiba": "33952",
"Cabaceiras do Paraguaçu": "33960",
"Cachoeira": "33979",
"Caculé": "33995",
"Caém": "34010",
"Caetanos": "34029",
"Caetité": "34037",
"Cafarnaum": "34053",
"Cairu": "34070",
"Caldeirão Grande": "34096",
"Camacan": "34118",
"Camaçari": "34134",
"Camamu": "34150",
"Campo Alegre de Lourdes": "34177",
"Campo Formoso": "34193",
"Canápolis": "34215",
"Canarana": "34231",
"Canavieiras": "34258",
"Candeal": "34274",
"Candeias": "34290",
"Candiba": "34312",
"Cândido Sales": "34339",
"Cansanção": "34355",
"Canudos": "30856",
"Capela do Alto Alegre": "30813",
"Capim Grosso": "30830",
"Caraíbas": "34363",
"Caravelas": "34371",
"Cardeal da Silva": "34398",
"Carinhanha": "34410",
"Casa Nova": "34436",
"Castro Alves": "34452",
"Catolândia": "34479",
"Catu": "34495",
"Caturama": "34509",
"Central": "34517",
"Chorrochó": "34533",
"Cícero Dantas": "34550",
"Cipó": "34576",
"Coaraci": "34592",
"Cocos": "34614",
"Conceição da Feira": "34630",
"Conceição do Almeida": "34657",
"Conceição do Coité": "34673",
"Conceição do Jacuípe": "34690",
"Conde": "34711",
"Condeúba": "34738",
"Contendas do Sincorá": "34754",
"Coração de Maria": "34770",
"Cordeiros": "34797",
"Coribe": "34819",
"Coronel João Sá": "34835",
"Correntina": "34851",
"Cotegipe": "34878",
"Cravolândia": "34894",
"Crisópolis": "34916",
"Cristópolis": "34932",
"Cruz das Almas": "34959",
"Curaçá": "34975",
"Dário Meira": "34991",
"Dias d'Ávila": "30872",
"Dom Basílio": "35017",
"Dom Macedo Costa": "35033",
"Elísio Medrado": "35050",
"Encruzilhada": "35076",
"Entre Rios": "35092",
"Érico Cardoso": "33090",
"Esplanada": "35114",
"Euclides da Cunha": "35130",
"Eunápolis": "30058",
"Fátima": "30899",
"Feira da Mata": "35149",
"Feira de Santana": "35157",
"Filadélfia": "30910",
"Firmino Alves": "35173",
"Floresta Azul": "35190",
"Formosa do Rio Preto": "35211",
"Gandu": "35238",
"Gavião": "30937",
"Gentio do Ouro": "35254",
"Glória": "35270",
"Gongogi": "35297",
"Governador Mangabeira": "35319",
"Guajeru": "30953",
"Guanambi": "35335",
"Guaratinga": "35351",
"Heliópolis": "30970",
"Iaçu": "35378",
"Ibiassucê": "35394",
"Ibicaraí": "35416",
"Ibicoara": "35432",
"Ibicuí": "35459",
"Ibipeba": "35475",
"Ibipitanga": "35513",
"Ibiquera": "35530",
"Ibirapitanga": "35556",
"Ibirapuã": "35572",
"Ibirataia": "35599",
"Ibitiara": "35610",
"Ibititá": "35637",
"Ibotirama": "35653",
"Ichu": "35670",
"Igaporã": "35696",
"Igrapiúna": "35700",
"Iguaí": "35718",
"Ilhéus": "35734",
"Inhambupe": "35750",
"Ipecaetá": "35777",
"Ipiaú": "35793",
"Ipirá": "35815",
"Ipupiara": "35831",
"Irajuba": "35858",
"Iramaia": "35874",
"Iraquara": "35890",
"Irará": "35912",
"Irecê": "35939",
"Itabela": "35947",
"Itaberaba": "35955",
"Itabuna": "35971",
"Itacaré": "35998",
"Itaeté": "36013",
"Itagi": "36030",
"Itagibá": "36056",
"Itagimirim": "36072",
"Itaguaçu da Bahia": "36080",
"Itaju do Colônia": "36099",
"Itajuípe": "36110",
"Itamaraju": "36137",
"Itamari": "36153",
"Itambé": "36170",
"Itanagra": "36196",
"Itanhém": "36218",
"Itaparica": "36234",
"Itapé": "36250",
"Itapebi": "36277",
"Itapetinga": "36293",
"Itapicuru": "36315",
"Itapitanga": "36331",
"Itaquara": "36358",
"Itarantim": "36374",
"Itatim": "36382",
"Itiruçu": "36390",
"Itiúba": "36412",
"Itororó": "36439",
"Ituaçu": "36455",
"Ituberá": "36471",
"Iuiú": "30660",
"Jaborandi": "30678",
"Jacaraci": "36498",
"Jacobina": "36510",
"Jaguaquara": "36536",
"Jaguarari": "36552",
"Jaguaripe": "36579",
"Jandaíra": "36595",
"Jequié": "36617",
"Jeremoabo": "36633",
"Jiquiriçá": "36650",
"Jitaúna": "36676",
"João Dourado": "30996",
"Juazeiro": "36692",
"Jucuruçu": "36706",
"Jussara": "36714",
"Jussari": "30694",
"Jussiape": "36730",
"Lafaiete Coutinho": "36757",
"Lagoa Real": "36765",
"Laje": "36773",
"Lajedão": "36790",
"Lajedinho": "36811",
"Lajedo do Tabocal": "36820",
"Lamarão": "36838",
"Lapão": "39730",
"Lauro de Freitas": "36854",
"Lençóis": "36870",
"Licínio de Almeida": "36897",
"Livramento de Nossa Senhora": "36919",
"Luís Eduardo Magalhães": "30007",
"Macajuba": "36935",
"Macarani": "36951",
"Macaúbas": "36978",
"Macururé": "36994",
"Madre de Deus": "39748",
"Maetinga": "39756",
"Maiquinique": "37010",
"Mairi": "37036",
"Malhada": "37052",
"Malhada de Pedras": "37079",
"Manoel Vitorino": "37095",
"Mansidão": "39772",
"Maracás": "37117",
"Maragogipe": "37133",
"Maraú": "37150",
"Marcionílio Souza": "37176",
"Mascote": "37192",
"Mata de São João": "37214",
"Matina": "37222",
"Medeiros Neto": "37230",
"Miguel Calmon": "37257",
"Milagres": "37273",
"Mirangaba": "37290",
"Mirante": "37303",
"Monte Santo": "37311",
"Morpará": "37338",
"Morro do Chapéu": "37354",
"Mortugaba": "37370",
"Mucugê": "37397",
"Mucuri": "37419",
"Mulungu do Morro": "37427",
"Mundo Novo": "37435",
"Muniz Ferreira": "37451",
"Muquém de São Francisco": "37460",
"Muritiba": "37478",
"Mutuípe": "37494",
"Nazaré": "37516",
"Nilo Peçanha": "37532",
"Nordestina": "39799",
"Nova Canaã": "37559",
"Nova Fátima": "37567",
"Nova Ibiá": "39969",
"Nova Itarana": "37575",
"Nova Redenção": "37583",
"Nova Soure": "37591",
"Nova Viçosa": "37613",
"Novo Horizonte": "39942",
"Novo Triunfo": "37621",
"Olindina": "37630",
"Oliveira dos Brejinhos": "37656",
"Ouriçangas": "37672",
"Ourolândia": "37680",
"Palmas de Monte Alto": "37699",
"Palmeiras": "37710",
"Paramirim": "37737",
"Paratinga": "37753",
"Paripiranga": "37770",
"Pau Brasil": "37796",
"Paulo Afonso": "37818",
"Pé de Serra": "39810",
"Pedrão": "37834",
"Pedro Alexandre": "37850",
"Piatã": "37877",
"Pilão Arcado": "37893",
"Pindaí": "37915",
"Pindobaçu": "37931",
"Pintadas": "39837",
"Piraí do Norte": "37940",
"Piripá": "37958",
"Piritiba": "37974",
"Planaltino": "37990",
"Planalto": "38016",
"Poções": "38032",
"Pojuca": "38059",
"Ponto Novo": "38067",
"Porto Seguro": "38075",
"Potiraguá": "38091",
"Prado": "38113",
"Presidente Dutra": "38130",
"Presidente Jânio Quadros": "38156",
"Presidente Tancredo Neves": "38164",
"Queimadas": "38172",
"Quijingue": "38199",
"Quixabeira": "39845",
"Rafael Jambeiro": "39853",
"Remanso": "38210",
"Retirolândia": "38237",
"Riachão das Neves": "38253",
"Riachão do Jacuípe": "38270",
"Riacho de Santana": "38296",
"Ribeira do Amparo": "38318",
"Ribeira do Pombal": "38334",
"Ribeirão do Largo": "38342",
"Rio de Contas": "38350",
"Rio do Antônio": "38377",
"Rio do Pires": "38393",
"Rio Real": "38415",
"Rodelas": "38431",
"Ruy Barbosa": "38458",
"Salinas da Margarida": "38474",
"Salvador": "38490",
"Santa Bárbara": "38512",
"Santa Brígida": "38539",
"Santa Cruz Cabrália": "38555",
"Santa Cruz da Vitória": "38571",
"Santa Inês": "38598",
"Santa Luzia": "39870",
"Santa Maria da Vitória": "38636",
"Santa Rita de Cássia": "35491",
"Santa Teresinha": "38695",
"Santaluz": "38610",
"Santana": "38652",
"Santanópolis": "38679",
"Santo Amaro": "38717",
"Santo Antônio de Jesus": "38733",
"Santo Estêvão": "38750",
"São Desidério": "38776",
"São Domingos": "38806",
"São Felipe": "38814",
"São Félix": "38792",
"São Félix do Coribe": "38822",
"São Francisco do Conde": "38830",
"São Gabriel": "39896",
"São Gonçalo dos Campos": "38857",
"São José da Vitória": "39926",
"São José do Jacuípe": "38865",
"São Miguel das Matas": "38873",
"São Sebastião do Passé": "38890",
"Sapeaçu": "38911",
"Sátiro Dias": "38938",
"Saubara": "38946",
"Saúde": "38954",
"Seabra": "38970",
"Sebastião Laranjeiras": "38997",
"Senhor do Bonfim": "39012",
"Sento Sé": "39039",
"Serra do Ramalho": "39047",
"Serra Dourada": "39055",
"Serra Preta": "39071",
"Serrinha": "39098",
"Serrolândia": "39110",
"Simões Filho": "39136",
"Sítio do Mato": "39144",
"Sítio do Quinto": "39900",
"Sobradinho": "39985",
"Souto Soares": "39152",
"Tabocas do Brejo Velho": "39179",
"Tanhaçu": "39195",
"Tanque Novo": "39918",
"Tanquinho": "39217",
"Taperoá": "39233",
"Tapiramutá": "39250",
"Teixeira de Freitas": "39934",
"Teodoro Sampaio": "39276",
"Teofilândia": "39292",
"Teolândia": "39314",
"Terra Nova": "39330",
"Tremedal": "39357",
"Tucano": "39373",
"Uauá": "39390",
"Ubaíra": "39411",
"Ubaitaba": "39438",
"Ubatã": "39454",
"Uibaí": "39470",
"Umburanas": "39489",
"Una": "39497",
"Urandi": "39519",
"Uruçuca": "39535",
"Utinga": "39551",
"Valença": "39578",
"Valente": "39594",
"Várzea da Roça": "39977",
"Várzea do Poço": "39616",
"Várzea Nova": "39950",
"Varzedo": "39624",
"Vera Cruz": "39632",
"Vereda": "39640",
"Vitória da Conquista": "39659",
"Wagner": "39675",
"Wanderley": "39993",
"Wenceslau Guimarães": "39691",
"Xique-Xique": "39713",
}
export const tseCityCodeForMunicipality = (name: string): string | undefined => {
if (!isBahiaMunicipality(name)) return undefined
return bahiaTseCityCodes[name]
}
const municipalityByTseCityCode = new Map(
Object.entries(bahiaTseCityCodes).map(([name, code]) => [code, name]),
)
export const municipalityForTseCityCode = (cityCode: string): string | undefined =>
municipalityByTseCityCode.get(cityCode)

View file

@ -12,7 +12,7 @@ import {
} from '@/utilities/campaignDashboardViewModels'
import { getBahiaWeekRange } from '@/utilities/campaignTime'
import { loadCoordinatorSummaries } from '@/utilities/nucleusCoordinatorOptions'
import { toNucleusElectionGeographyInput } from '@/utilities/nucleusElectoralBaseline'
import { toNucleusElectionGeographyInput } from '@/utilities/nucleusElectionGeography'
import { nucleusVoteGoalsSelect } from '@/utilities/nucleusViewModels'
import { requireRelationshipId } from '@/utilities/relationship'
import { toVoteGoalsViewModel } from '@/utilities/voteGoals'

View file

@ -9,7 +9,10 @@ import {
type ElectionDataReader,
} from '@/utilities/campaignAccess'
import { drizzleResultRows, requirePostgresDrizzle } from '@/utilities/drizzleBulk'
import type { NucleusElectionGeography } from '@/utilities/nucleusElectoralBaseline'
import {
type NucleusElectionGeography,
zonesByCityCode,
} from '@/utilities/nucleusElectionGeography'
export type FederalCandidateTotal = {
candidateNumber: number
@ -25,8 +28,8 @@ export const loadFederalCandidateTotalsAggregated = async (
): Promise<FederalCandidateTotal[]> => {
assertCanReadElectionData(user)
const cityClauses = [...geography.zonesByCity.entries()].map(([cityName, zones]) =>
sql`("election_candidate_vote"."city_name" = ${cityName} AND "election_candidate_vote"."zone_number" IN (${sql.join(
const cityClauses = [...zonesByCityCode(geography).entries()].map(([cityCode, zones]) =>
sql`("election_candidate_vote"."city_code" = ${cityCode} AND "election_candidate_vote"."zone_number" IN (${sql.join(
zones.map((zone) => sql`${zone}`),
sql`, `,
)}))`,

View file

@ -5,7 +5,7 @@ import {
territoryForCity,
territoriesForCities,
} from '@/lib/bahiaTerritories'
import { resolveNucleusElectionGeography, type NucleusElectionGeographyInput } from '@/utilities/nucleusElectoralBaseline'
import { resolveNucleusElectionGeography, type NucleusElectionGeographyInput } from '@/utilities/nucleusElectionGeography'
import {
emptyNucleusChoroplethBundle,
type ChoroplethMetric,

View file

@ -1,4 +1,4 @@
import type { NucleusElectionGeographyInput } from '@/utilities/nucleusElectoralBaseline'
import type { NucleusElectionGeographyInput } from '@/utilities/nucleusElectionGeography'
export type ChoroplethMetric = 'nucleusCount' | 'confirmedEstimate' | 'baseline2022Votes'

View file

@ -8,10 +8,8 @@ import {
} from '@/utilities/leadershipPageData'
import { parseLeadershipFilterState, parseLeadershipPanelState } from '@/utilities/leadershipUi'
import { getNucleusCoordinatorAssignmentPageData } from '@/utilities/nucleusCoordinatorAssignmentPageData'
import {
getNucleusElectoralBaseline,
toNucleusElectionGeographyInput,
} from '@/utilities/nucleusElectoralBaseline'
import { getNucleusElectoralBaseline } from '@/utilities/nucleusElectoralBaseline'
import { toNucleusElectionGeographyInput } from '@/utilities/nucleusElectionGeography'
import {
getNucleusDetailPageData,
resolveAccessibleNucleusContext,

View file

@ -0,0 +1,120 @@
import type { Where } from 'payload'
import { tseCityCodeForMunicipality } from '@/lib/bahiaTseCityCodes'
import { citiesForTerritory, isBahiaIdentityTerritory } from '@/lib/bahiaTerritories'
import { tseZonesForCity } from '@/lib/bahiaTseZones'
import { normalizeTerritoryTextArray } from '@/utilities/campaignTerritoryValidation'
import { sortedUniqueZoneNumbers } from '@/utilities/tseZone'
export type NucleusElectionGeographyInput = {
cities: string[]
regions: string[]
tseZones: number[]
}
export const toNucleusElectionGeographyInput = (nucleus: {
cities?: string[] | null
regions?: string[] | null
tseZones?: Array<number | { zoneNumber: number }> | null
}): NucleusElectionGeographyInput => ({
cities: normalizeTerritoryTextArray(nucleus.cities),
regions: normalizeTerritoryTextArray(nucleus.regions),
tseZones: (nucleus.tseZones ?? []).map((zone) =>
typeof zone === 'number' ? zone : zone.zoneNumber,
),
})
export type NucleusCityZonePair = {
cityName: string
cityCode: string
zoneNumber: number
}
export type NucleusElectionGeography = {
zonesByCity: Map<string, number[]>
cityZonePairs: NucleusCityZonePair[]
}
const uniqueSortedCities = (values: Iterable<string>): string[] =>
[...new Set(values)].sort((left, right) => left.localeCompare(right, 'pt-BR'))
/**
* Resolve the effective city×zone geography for a nucleus baseline query.
* Returns null when the nucleus has no usable territory (no cities and no regions),
* or when no city maps to a TSE city code (fail-closed).
*
* Zone rules per city:
* - nucleus.tseZones empty → all official zones of the city (`tseZonesForCity`)
* - nucleus.tseZones non-empty → intersection with the city's official zones;
* if the intersection is empty (typed zones outside the city), fall back to all city zones
*/
export const resolveNucleusElectionGeography = (
nucleus: NucleusElectionGeographyInput,
): NucleusElectionGeography | null => {
const citiesFromNucleus = uniqueSortedCities(nucleus.cities.filter((city) => city.length > 0))
const cities =
citiesFromNucleus.length > 0
? citiesFromNucleus
: uniqueSortedCities(
nucleus.regions.flatMap((region) =>
isBahiaIdentityTerritory(region) ? citiesForTerritory(region) : [],
),
)
if (cities.length === 0) return null
const requestedZones = sortedUniqueZoneNumbers(
nucleus.tseZones.filter((zone) => Number.isInteger(zone) && zone >= 1 && zone <= 999),
)
const zonesByCity = new Map<string, number[]>()
const cityZonePairs: NucleusCityZonePair[] = []
for (const city of cities) {
const cityCode = tseCityCodeForMunicipality(city)
if (!cityCode) continue
const cityZones = [...tseZonesForCity(city)]
let effectiveZones = cityZones
if (requestedZones.length > 0) {
const cityZoneSet = new Set(cityZones)
const intersection = requestedZones.filter((zone) => cityZoneSet.has(zone))
if (intersection.length > 0) effectiveZones = intersection
}
if (effectiveZones.length === 0) continue
zonesByCity.set(city, effectiveZones)
for (const zoneNumber of effectiveZones) {
cityZonePairs.push({ cityName: city, cityCode, zoneNumber })
}
}
if (cityZonePairs.length === 0) return null
return { zonesByCity, cityZonePairs }
}
export const zonesByCityCode = (
geography: NucleusElectionGeography,
): Map<string, number[]> => {
const byCode = new Map<string, number[]>()
for (const { cityCode, zoneNumber } of geography.cityZonePairs) {
const zones = byCode.get(cityCode)
if (zones) zones.push(zoneNumber)
else byCode.set(cityCode, [zoneNumber])
}
for (const [cityCode, zones] of byCode) {
byCode.set(cityCode, sortedUniqueZoneNumbers(zones))
}
return byCode
}
/** One clause per TSE city code with `zoneNumber in […]`. */
export const geographyWhere = (geography: NucleusElectionGeography): Where => ({
or: [...zonesByCityCode(geography).entries()].map(
([cityCode, zones]): Where => ({
and: [{ cityCode: { equals: cityCode } }, { zoneNumber: { in: zones } }],
}),
),
})

View file

@ -1,7 +1,5 @@
import type { Payload, Where } from 'payload'
import { citiesForTerritory, isBahiaIdentityTerritory } from '@/lib/bahiaTerritories'
import { tseZonesForCity } from '@/lib/bahiaTseZones'
import {
BASELINE_TICKET_2022,
ELECTION_YEAR_2014,
@ -39,39 +37,20 @@ import {
type VoteTrendStatus,
} from '@/lib/electionInsights'
import { type ElectionDataReader } from '@/utilities/campaignAccess'
import { normalizeTerritoryTextArray } from '@/utilities/campaignTerritoryValidation'
import {
loadFederalCandidateTotalsAggregated,
type FederalCandidateTotal,
} from '@/utilities/federalCandidateTotalsAggregate'
import {
geographyWhere,
type NucleusCityZonePair,
type NucleusElectionGeography,
type NucleusElectionGeographyInput,
resolveNucleusElectionGeography,
} from '@/utilities/nucleusElectionGeography'
import type { NucleusElectoralBaselineViewModel } from '@/utilities/nucleusViewModels'
import { sortedUniqueZoneNumbers } from '@/utilities/tseZone'
const CANDIDATE_OFFICE = BASELINE_TICKET_2022.candidate.office
export type NucleusElectionGeographyInput = {
cities: string[]
regions: string[]
tseZones: number[]
}
export const toNucleusElectionGeographyInput = (nucleus: {
cities?: string[] | null
regions?: string[] | null
tseZones?: Array<number | { zoneNumber: number }> | null
}): NucleusElectionGeographyInput => ({
cities: normalizeTerritoryTextArray(nucleus.cities),
regions: normalizeTerritoryTextArray(nucleus.regions),
tseZones: (nucleus.tseZones ?? []).map((zone) =>
typeof zone === 'number' ? zone : zone.zoneNumber,
),
})
export type NucleusElectionGeography = {
zonesByCity: Map<string, number[]>
cityZonePairs: Array<{ cityName: string; zoneNumber: number }>
}
export type ElectionVoteAggregateRow = {
office: ElectionOffice
turn: ElectionTurn
@ -101,62 +80,6 @@ export type ElectionTallyAggregateRow = {
const cityZoneKey = (cityName: string, zoneNumber: number): string =>
`${cityName}::${zoneNumber}`
const uniqueSortedCities = (values: Iterable<string>): string[] =>
[...new Set(values)].sort((left, right) => left.localeCompare(right, 'pt-BR'))
/**
* Resolve the effective city×zone geography for a nucleus baseline query.
* Returns null when the nucleus has no usable territory (no cities and no regions).
*
* Zone rules per city:
* - nucleus.tseZones empty → all official zones of the city (`tseZonesForCity`)
* - nucleus.tseZones non-empty → intersection with the city's official zones;
* if the intersection is empty (typed zones outside the city), fall back to all city zones
*/
export const resolveNucleusElectionGeography = (
nucleus: NucleusElectionGeographyInput,
): NucleusElectionGeography | null => {
const citiesFromNucleus = uniqueSortedCities(nucleus.cities.filter((city) => city.length > 0))
const cities =
citiesFromNucleus.length > 0
? citiesFromNucleus
: uniqueSortedCities(
nucleus.regions.flatMap((region) =>
isBahiaIdentityTerritory(region) ? citiesForTerritory(region) : [],
),
)
if (cities.length === 0) return null
const requestedZones = sortedUniqueZoneNumbers(
nucleus.tseZones.filter((zone) => Number.isInteger(zone) && zone >= 1 && zone <= 999),
)
const zonesByCity = new Map<string, number[]>()
const cityZonePairs: Array<{ cityName: string; zoneNumber: number }> = []
for (const city of cities) {
const cityZones = [...tseZonesForCity(city)]
let effectiveZones = cityZones
if (requestedZones.length > 0) {
const cityZoneSet = new Set(cityZones)
const intersection = requestedZones.filter((zone) => cityZoneSet.has(zone))
if (intersection.length > 0) effectiveZones = intersection
}
if (effectiveZones.length === 0) continue
zonesByCity.set(city, effectiveZones)
for (const zoneNumber of effectiveZones) {
cityZonePairs.push({ cityName: city, zoneNumber })
}
}
if (cityZonePairs.length === 0) return null
return { zonesByCity, cityZonePairs }
}
const matchesGeography = (
row: { cityName: string; zoneNumber: number },
geography: NucleusElectionGeography,
@ -165,6 +88,8 @@ const matchesGeography = (
return Boolean(zones?.includes(row.zoneNumber))
}
const CANDIDATE_OFFICE = BASELINE_TICKET_2022.candidate.office
const sumCandidateVotes = (
votes: readonly ElectionVoteAggregateRow[],
geography: NucleusElectionGeography,
@ -480,15 +405,6 @@ const ba2022Scope = (): Where[] => [
{ state: { equals: 'BA' } },
]
/** One clause per city with `zoneNumber in […]` — avoids an OR per city×zone pair. */
const geographyWhere = (geography: NucleusElectionGeography): Where => ({
or: [...geography.zonesByCity.entries()].map(
([cityName, zones]): Where => ({
and: [{ cityName: { equals: cityName } }, { zoneNumber: { in: zones } }],
}),
),
})
/** President/governor ticket votes for the detail baseline card (lean Local API path). */
const ticketOfficeVoteWhere = (geography: NucleusElectionGeography): Where => ({
and: [
@ -653,7 +569,7 @@ const buildUnionGeography = (
geographies: ReadonlyArray<NucleusElectionGeography | null>,
indexes: readonly number[],
): NucleusElectionGeography | null => {
const unionPairs = new Map<string, { cityName: string; zoneNumber: number }>()
const unionPairs = new Map<string, NucleusCityZonePair>()
const zonesByCity = new Map<string, number[]>()
for (const index of indexes) {

View file

@ -2,10 +2,8 @@ import type { Payload } from 'payload'
import type { CampaignUser, NucleusUpdate } from '@/payload-types'
import { loadUpcomingActionPlansPreview } from '@/utilities/actionPlanUpcomingPreview'
import {
loadNucleusListElectionOverview,
toNucleusElectionGeographyInput,
} from '@/utilities/nucleusElectoralBaseline'
import { loadNucleusListElectionOverview } from '@/utilities/nucleusElectoralBaseline'
import { toNucleusElectionGeographyInput } from '@/utilities/nucleusElectionGeography'
import { loadNucleusChoroplethBundle } from '@/utilities/nucleusChoroplethPageData'
import {
buildNucleusListOverviewViewModel,

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,82 @@
// @vitest-environment node
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import {
bahiaTseCityCodes,
municipalityForTseCityCode,
tseCityCodeForMunicipality,
} from '@/lib/bahiaTseCityCodes'
import { bahiaMunicipalities } from '@/lib/bahiaTerritories'
type OfficialEvidence = {
municipalityCount: number
assignments: Array<{ municipality: string; code: string }>
evidenceSha256: string
}
const officialEvidence = JSON.parse(
readFileSync(
new URL('../fixtures/bahia-tse-city-codes.official.json', import.meta.url),
'utf8',
),
) as OfficialEvidence
describe('Bahia municipality name → TSE city code mapping', () => {
it('covers all 417 Bahia municipalities with TSE CD_MUNICIPIO codes', () => {
expect(Object.keys(bahiaTseCityCodes)).toHaveLength(417)
expect(bahiaMunicipalities).toHaveLength(417)
expect(new Set(Object.keys(bahiaTseCityCodes))).toHaveLength(417)
expect(new Set(Object.values(bahiaTseCityCodes))).toHaveLength(417)
for (const city of bahiaMunicipalities) {
const code = bahiaTseCityCodes[city]
expect(code, city).toBeDefined()
expect(code).toMatch(/^\d{5}$/)
}
})
it('is a bijection with the canonical bahiaMunicipalities set', () => {
expect(Object.keys(bahiaTseCityCodes).sort((left, right) => left.localeCompare(right, 'pt-BR'))).toEqual(
[...bahiaMunicipalities].sort((left, right) => left.localeCompare(right, 'pt-BR')),
)
})
it('matches the independently downloaded official municipality→code evidence', () => {
expect(officialEvidence.municipalityCount).toBe(417)
expect(
officialEvidence.assignments.map(({ municipality, code }) => ({ municipality, code })),
).toEqual(
Object.entries(bahiaTseCityCodes)
.sort(([left], [right]) => left.localeCompare(right, 'pt-BR'))
.map(([municipality, code]) => ({ municipality, code })),
)
})
it('matches every official assignment by a fixed evidence checksum', () => {
const assignmentRows = Object.entries(bahiaTseCityCodes)
.sort(([left], [right]) => left.localeCompare(right, 'pt-BR'))
.map(([municipality, code]) => `M\t${municipality}\t${code}\n`)
.join('')
expect(createHash('sha256').update(assignmentRows).digest('hex')).toBe(
officialEvidence.evidenceSha256,
)
})
it('supports bidirectional lookups and differs from IBGE codes', () => {
expect(tseCityCodeForMunicipality('Salvador')).toBe('38490')
expect(tseCityCodeForMunicipality('Feira de Santana')).toBe('35157')
expect(tseCityCodeForMunicipality("Dias d'Ávila")).toBe('30872')
expect(tseCityCodeForMunicipality('Araças')).toBe('33383')
expect(tseCityCodeForMunicipality('Município inexistente')).toBeUndefined()
expect(tseCityCodeForMunicipality('')).toBeUndefined()
expect(municipalityForTseCityCode('38490')).toBe('Salvador')
expect(municipalityForTseCityCode('35157')).toBe('Feira de Santana')
expect(municipalityForTseCityCode('00000')).toBeUndefined()
})
})

View file

@ -4,6 +4,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import { getPayload, type Payload } from 'payload'
import config from '@/payload.config'
import { tseCityCodeForMunicipality } from '@/lib/bahiaTseCityCodes'
import { BASELINE_TICKET_2022 } from '@/lib/electionResults'
import { computeGapVs2022 } from '@/lib/electionInsights'
import { loadNucleusActiveTabPageData } from '@/utilities/nucleusDetailPageData'
@ -143,8 +144,12 @@ describe('nucleus electoral baseline (A4)', () => {
expect(voteFindCalls.length).toBeGreaterThan(0)
const salvadorCityCode = tseCityCodeForMunicipality('Salvador')
expect(salvadorCityCode).toBeTruthy()
for (const call of voteFindCalls) {
const where = (call[0] as { where?: unknown }).where
expect(whereContainsField(where, 'cityCode')).toBe(true)
const queriesFederalTurn1 =
whereContainsFieldEquals(where, 'office', 'deputado_federal') &&
whereContainsFieldEquals(where, 'turn', '1')
@ -153,6 +158,20 @@ describe('nucleus electoral baseline (A4)', () => {
}
}
const tallyFindCalls = findSpy.mock.calls.filter(
(call) =>
typeof call[0] === 'object' &&
call[0] !== null &&
(call[0] as { collection?: string }).collection === 'electionTally',
)
expect(tallyFindCalls.length).toBeGreaterThan(0)
for (const call of tallyFindCalls) {
const where = (call[0] as { where?: unknown }).where
expect(whereContainsField(where, 'cityCode')).toBe(true)
expect(whereContainsFieldEquals(where, 'cityCode', salvadorCityCode)).toBe(true)
}
findSpy.mockRestore()
})

View file

@ -174,7 +174,7 @@ describe('loadNucleusListElectionOverview (E7 F2)', () => {
office: 'deputado_federal',
turn: '1',
state: 'BA',
cityCode: '2905701',
cityCode: '34134',
cityName: 'Camaçari',
zoneNumber: 170,
aptos: 8000,

View file

@ -28,7 +28,6 @@ vi.mock('@/utilities/nucleusUpdatePageData', () => ({
}))
vi.mock('@/utilities/nucleusElectoralBaseline', () => ({
getNucleusElectoralBaseline: loaders.getNucleusElectoralBaseline,
toNucleusElectionGeographyInput: () => ({ cities: [], regions: [], tseZones: [] }),
}))
import { NucleusTabNav } from '@/components/campaign/NucleusTabNav'

View file

@ -7,7 +7,7 @@ import type { CampaignUser, User } from '@/payload-types'
import { assertCanReadElectionData } from '@/utilities/campaignAccess'
import { loadFederalCandidateTotalsAggregated } from '@/utilities/federalCandidateTotalsAggregate'
import * as drizzleBulk from '@/utilities/drizzleBulk'
import { resolveNucleusElectionGeography } from '@/utilities/nucleusElectoralBaseline'
import { resolveNucleusElectionGeography } from '@/utilities/nucleusElectionGeography'
describe('assertCanReadElectionData', () => {
it('allows payload admins and campaign users to read election data', () => {
@ -79,6 +79,9 @@ describe('loadFederalCandidateTotalsAggregated', () => {
{ candidateNumber: 1234, name: 'OUTRO', party: 'PL', votes: 900 },
])
expect(execute).toHaveBeenCalledOnce()
const sqlPayload = JSON.stringify(execute.mock.calls[0]?.[0])
expect(sqlPayload).toContain('city_code')
expect(sqlPayload).not.toContain('city_name')
vi.restoreAllMocks()
})
})

View file

@ -0,0 +1,79 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { citiesForTerritory } from '@/lib/bahiaTerritories'
import { tseCityCodeForMunicipality } from '@/lib/bahiaTseCityCodes'
import { tseZonesForCity } from '@/lib/bahiaTseZones'
import { resolveNucleusElectionGeography } from '@/utilities/nucleusElectionGeography'
describe('resolveNucleusElectionGeography', () => {
it('intersects nucleus tseZones with official city zones', () => {
const salvadorZones = tseZonesForCity('Salvador')
expect(salvadorZones.length).toBeGreaterThan(1)
const firstZone = salvadorZones[0]!
const secondZone = salvadorZones[1]!
const geography = resolveNucleusElectionGeography({
cities: ['Salvador'],
regions: [],
tseZones: [firstZone, 999],
})
expect(geography).not.toBeNull()
expect([...geography!.zonesByCity.keys()]).toEqual(['Salvador'])
expect(geography?.zonesByCity.get('Salvador')).toEqual([firstZone])
expect(geography?.cityZonePairs).toEqual([
{ cityName: 'Salvador', cityCode: tseCityCodeForMunicipality('Salvador'), zoneNumber: firstZone },
])
expect(geography?.zonesByCity.get('Salvador')).not.toContain(secondZone)
})
it('falls back to all city zones when typed tseZones lie outside the city', () => {
const salvadorZones = [...tseZonesForCity('Salvador')]
const geography = resolveNucleusElectionGeography({
cities: ['Salvador'],
regions: [],
tseZones: [998, 999],
})
expect(geography?.zonesByCity.get('Salvador')).toEqual(salvadorZones)
})
it('expands territory regions when cities are empty', () => {
const cities = citiesForTerritory('Velho Chico')
const geography = resolveNucleusElectionGeography({
cities: [],
regions: ['Velho Chico'],
tseZones: [],
})
expect(geography).not.toBeNull()
expect([...geography!.zonesByCity.keys()].sort((a, b) => a.localeCompare(b, 'pt-BR'))).toEqual(
[...cities].sort((a, b) => a.localeCompare(b, 'pt-BR')),
)
expect(geography?.zonesByCity.get('Bom Jesus da Lapa')).toEqual([
...tseZonesForCity('Bom Jesus da Lapa'),
])
})
it('returns null without cities or regions', () => {
expect(
resolveNucleusElectionGeography({
cities: [],
regions: [],
tseZones: [1],
}),
).toBeNull()
})
it('returns null when cities have no TSE city code mapping', () => {
expect(
resolveNucleusElectionGeography({
cities: ['Município inexistente'],
regions: [],
tseZones: [],
}),
).toBeNull()
})
})

View file

@ -2,80 +2,19 @@
import { describe, expect, it } from 'vitest'
import { citiesForTerritory } from '@/lib/bahiaTerritories'
import { tseZonesForCity } from '@/lib/bahiaTseZones'
import { BASELINE_TICKET_2022 } from '@/lib/electionResults'
import { computeConversionRate, computeVoteTrend } from '@/lib/electionInsights'
import {
aggregateFederalCandidateTotals,
aggregateNucleusElectoralBaseline,
resolveNucleusElectionGeography,
type ElectionTallyAggregateRow,
type ElectionVoteAggregateRow,
} from '@/utilities/nucleusElectoralBaseline'
import { resolveNucleusElectionGeography } from '@/utilities/nucleusElectionGeography'
const ticketOfficeVotes = (votes: readonly ElectionVoteAggregateRow[]) =>
votes.filter((row) => row.office === 'presidente' || row.office === 'governador')
describe('resolveNucleusElectionGeography', () => {
it('intersects nucleus tseZones with official city zones', () => {
const salvadorZones = tseZonesForCity('Salvador')
expect(salvadorZones.length).toBeGreaterThan(1)
const firstZone = salvadorZones[0]!
const secondZone = salvadorZones[1]!
const geography = resolveNucleusElectionGeography({
cities: ['Salvador'],
regions: [],
tseZones: [firstZone, 999],
})
expect(geography).not.toBeNull()
expect([...geography!.zonesByCity.keys()]).toEqual(['Salvador'])
expect(geography?.zonesByCity.get('Salvador')).toEqual([firstZone])
expect(geography?.cityZonePairs).toEqual([{ cityName: 'Salvador', zoneNumber: firstZone }])
expect(geography?.zonesByCity.get('Salvador')).not.toContain(secondZone)
})
it('falls back to all city zones when typed tseZones lie outside the city', () => {
const salvadorZones = [...tseZonesForCity('Salvador')]
const geography = resolveNucleusElectionGeography({
cities: ['Salvador'],
regions: [],
tseZones: [998, 999],
})
expect(geography?.zonesByCity.get('Salvador')).toEqual(salvadorZones)
})
it('expands territory regions when cities are empty', () => {
const cities = citiesForTerritory('Velho Chico')
const geography = resolveNucleusElectionGeography({
cities: [],
regions: ['Velho Chico'],
tseZones: [],
})
expect(geography).not.toBeNull()
expect([...geography!.zonesByCity.keys()].sort((a, b) => a.localeCompare(b, 'pt-BR'))).toEqual(
[...cities].sort((a, b) => a.localeCompare(b, 'pt-BR')),
)
expect(geography?.zonesByCity.get('Bom Jesus da Lapa')).toEqual([
...tseZonesForCity('Bom Jesus da Lapa'),
])
})
it('returns null without cities or regions', () => {
expect(
resolveNucleusElectionGeography({
cities: [],
regions: [],
tseZones: [1],
}),
).toBeNull()
})
})
describe('aggregateNucleusElectoralBaseline', () => {
const geography = resolveNucleusElectionGeography({
cities: ['Salvador'],