diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc
index e07e33fd..44819b7d 100644
--- a/.cursor/rules/projects/nucleos-eleitorais.mdc
+++ b/.cursor/rules/projects/nucleos-eleitorais.mdc
@@ -37,7 +37,9 @@ alwaysApply: false
- **B1 Overview da lista** — painel agregado entre filtros e lista em `/campanha/nucleos` (`NucleusListOverview`, `loadNucleusListOverviewData`): estimativa de votos confirmada + % + sugestões pendentes (staff), cobertura de coordenador e prévia de 3 atualizações, tudo sobre o conjunto **filtrado** inteiro (`buildNucleusListWhere`, `pagination: false`), oculto quando vazio. `lideranca` vê o overview com select reduzido, sem contagem de sugestões e só as próprias atualizações.
- **C1 Compartilhar núcleo** — `ShareNucleusDialogShell` no header do detalhe; destinatários carregados sob demanda por Server Action de leitura (`loadNucleusShareRecipients`), não no DTO da página. Loader confirma acesso ao núcleo (`overrideAccess: false`) e então lê nome+telefone com `overrideAccess: true` (evita N+1 de field access); lideranças só `engajado`; seção de lideranças omitida para o papel `lideranca`; `canReadCampaignUserPhone` passou a liberar telefone de `geral` para qualquer usuário de campanha. Compartilhar envia só o link — não cria convite nem concede acesso.
- **D1 PWA** — manifest + SW servidos por route handlers sob `/campanha` (script em `campaignPwa.ts`), cache `campanha-` com `skipWaiting`/`claim`, navegação network-first com fallback `/campanha/offline`, RSC/Flight e `/campanha/convite/*` jamais cacheados; ícones em `public/campaign-icons/`; `InstallPwaToast` (Android `beforeinstallprompt`, iOS Drawer, dispensa em `sessionStorage`); logout limpa Cache API no cliente antes da action.
- - **A3 Baseline TSE 2022 Fase 1** — collections `electionTally`/`electionCandidateVote`/`electionCandidate` (grupo `Dados Eleitorais`, leitura para `campaignUser`/`users` via `canReadElectionData`, escrita admin-only), migration `20260718_195854_add_election_results`, seed local `pnpm db:seed:tse` (idempotente por escopo year/office/turn, delete+bulk insert via drizzle, guard de banco local). Sem PII (sem CPF/título); identidade cross-ano por `identityKey` hash. UI fica no A4.
+ - **A3 Baseline TSE 2022 Fase 1** — collections `electionTally`/`electionCandidateVote`/`electionCandidate` (grupo `Dados Eleitorais`, leitura para `campaignUser`/`users` via `canReadElectionData`, escrita admin-only), migration `20260718_195854_add_election_results`, seed local `pnpm db:seed:tse` (idempotente por escopo year/office/turn, delete+bulk insert via drizzle, guard de banco local). Sem PII (sem CPF/título); identidade cross-ano por `identityKey` hash.
+ - **A4 Baseline no produto + Gap vs 2022** — agregação `nucleusElectoralBaseline` + `computeGapVs2022`; bloco "Baseline eleitoral 2022" e insight Gap na aba Visão geral; card "Baseline 2022" no overview da lista. Sem migration.
+ - **A7 Escala e DRY pós-A4 registrado (2026-07-19)** — débitos do `/simplify` fora do cleanup: agregar ranking federal do detalhe (não puxar todas as rows nominais), filtro por `cityCode` TSE, Alert `confirmed` + `Progress`. Plano: [`docs/plans/escala-dry-pos-a4.md`](../../../docs/plans/escala-dry-pos-a4.md).
- **Em 2026-07-18, C2 Cadastro nominal de apoiadores — engenharia pronta e mesclada em `main` (pendente deploy + lote jurídico).** Collection `supporter` (join `Contact`↔campanha, núcleo opcional), migration `20260718_222656_add_supporter` com `UNIQUE NULLS NOT DISTINCT (contact_id, nucleus_id)`, consent por chaves `apoiador-cadastro` / `apoiador-intencao-voto` via `campaignConsent.ts` genérico, actions (create / intenção / import CSV só `geral` / remover dados), UI `/campanha/apoiadores` (lista+KPIs, ficha, wizard import, kit mínimo `wa.me`). Telefone obrigatório; `lideranca` sem acesso à área. Produção com dados reais espera deploy (build Vercel aplica a migration) + Consent keys + aprovação jurídica (Onda 0).
- **Em 2026-07-18, C6 Escala e DRY pós-C2 registrado no roadmap** (após `/simplify` do C2): shells compartilhados com listas de núcleos, import bulk drizzle + skip phone-invariant, preview token, KPI agregado SQL, StrictCombobox/AlertDialog. Plano: [`docs/plans/escala-dry-pos-c2.md`](../../../docs/plans/escala-dry-pos-c2.md). Depende do merge de C2; fases 3–5 cortáveis se a base permanecer pequena.
diff --git a/AGENTS.md b/AGENTS.md
index d894982a..3659faff 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -138,7 +138,7 @@ Static Bahia map foundation for future Leaflet surfaces (roadmap B2; UI is B3).
## Election baseline data (TSE 2022)
-Public TSE 2022 results back the electoral baseline (roadmap item A3; UI lands with A4). Three collections in admin group `Dados Eleitorais`: `electionTally` (per city×zone totals: eligible voters, turnout, valid/blank/null votes), `electionCandidateVote` (nominal votes per candidate×city×zone), and `electionCandidate` (candidate registry with a cross-year `identityKey` hash — no CPF or voter ID is ever persisted). Read access requires an authenticated `campaignUser` or `users` account (`canReadElectionData`); writes are Payload-admin-only, and the import runs via CLI with `overrideAccess: true`.
+Public TSE 2022 results back the electoral baseline (roadmap items A3 + A4). Three collections in admin group `Dados Eleitorais`: `electionTally` (per city×zone totals: eligible voters, turnout, valid/blank/null votes), `electionCandidateVote` (nominal votes per candidate×city×zone), and `electionCandidate` (candidate registry with a cross-year `identityKey` hash — no CPF or voter ID is ever persisted). Read access requires an authenticated `campaignUser` or `users` account (`canReadElectionData`); writes are Payload-admin-only, and the import runs via CLI with `overrideAccess: true`. **A4 (2026-07-18):** `getNucleusElectoralBaseline` / `computeGapVs2022` surface the baseline on the nucleus overview tab (`NucleusElectoralBaseline` + `NucleusInsights` Gap vs 2022) and a "Baseline 2022" card on the filtered nuclei list overview.
`pnpm db:seed:tse` (`scripts/seed-tse-results.mjs`) downloads the TSE open-data zips (provenance URLs + SHA-256 in the script header), parses the Bahia scope, and imports via `payload.db.drizzle` in a transaction — idempotent by replace-per-scope `(year, office, turn)`, only `voteType: 'nominal'` rows in v1. It refuses a non-local `DATABASE_URL` (same guard family as the other seeds; override with `ALLOW_REMOTE_DB=true`) and caches downloads under `data/tse/` (gitignored). No revalidate step is needed: campaign pages are dynamic with auth, not ISR-cached.
diff --git a/docs/plans/baseline-eleitoral-tse.md b/docs/plans/baseline-eleitoral-tse.md
index d1293715..8e207b63 100644
--- a/docs/plans/baseline-eleitoral-tse.md
+++ b/docs/plans/baseline-eleitoral-tse.md
@@ -1,6 +1,6 @@
# Baseline eleitoral TSE 2022
-Status: Fase 1 (A3) implementada — modelo + import
+Status: Fases 1–4 implementadas (A3 modelo+import; A4 detalhe + overview + Gap vs 2022)
Atualizado em: 2026-07-18
Item do roadmap: [docs/roadmap.md](../roadmap.md) (A3/A4 Baseline TSE 2022)
Responsável: —
@@ -53,9 +53,9 @@ Candidatos de interesse (destaques na UI): **Solla = dep. federal BA, PT, nº 13
## Questões em aberto
- ~~Mapeamento `CD_MUNICIPIO` (TSE) → nome canônico~~ — resolvido na Fase 1 via `canonicalizeMunicipalityName` + `cityCode` persistido.
-- Limiares de classificação territorial (defesa/ataque/indecisa/perdida) — definir com produto.
-- Mostrar 1º e 2º turno para presidente/governador, ou só o decisivo? Recomendação: ambos, com destaque ao decisivo.
-- `lideranca` vê ranking/competidor? Recomendação: sim, é dado público e útil ao engajamento.
+- Limiares de classificação territorial (defesa/ataque/indecisa/perdida) — definir com produto (A5).
+- ~~Mostrar 1º e 2º turno para presidente/governador, ou só o decisivo?~~ — A4 assume o **turno decisivo** (2º se houver votos; senão 1º) para Lula/Jerônimo; federal usa 1º. _(assumido — validar com produto)_
+- ~~`lideranca` vê ranking/competidor?~~ — A4 mostra o bloco baseline + Gap vs 2022 para `geral`/`coordenador`/`lideranca` (dado público + estimativa já visível). Ranking detalhado / inteligência competitiva fica em A5. _(assumido — validar com produto)_
- `identityKey` por nome de urna + naturalidade + partido pode gerar colisões (homônimos) ou falsos negativos (mudança de partido). Mitigação: revisão manual admin do match 2022→2026 antes de fixar `runningAgain2026=sim|nao`, com `desconhecido` como padrão.
> Nota: limiares de classificação territorial, taxonomia de alinhamento político (para dobradinha) e limiares de conversão ficam nos planos separados dos respectivos insights no roadmap.
@@ -93,16 +93,16 @@ flowchart LR
PerCity["para cada cidade C:
zones = nucleus.tseZones ∩ tseZonesForCity(C)"]
Query["electionTally + electionCandidateVote
where city=C, zone in zones, office, turn"]
Sum["soma votos por candidato/tipo"]
- Baseline["NucleusElectoralBaseline
solla/lula/jeronimo/validos/brancos/nulos/winner/rank"]
+ Baseline["NucleusElectoralBaseline
candidate/president/governor/validos/brancos/nulos/winner/rank"]
Nuc --> Cities --> PerCity --> Query --> Sum --> Baseline
```
### Fases
1. **Modelo + import** — **feito (A3, 2026-07-18).** Collections `electionTally` / `electionCandidateVote` / `electionCandidate`, migration `20260718_195854_add_election_results`, `pnpm db:seed:tse`, helpers em `src/lib/electionResults*.ts` + `electionCandidateIdentity.ts`, import drizzle em `src/utilities/electionResultsImport.ts`, testes unit/int com fixtures em `tests/fixtures/tse/`. Seed local BA 2022: **211.014** votos nominais, **2.700** apurações, **1.654** candidatos (6 escopos office×turn).
-2. **Detalhe do núcleo** — bloco "Baseline eleitoral 2022" na aba overview; view model `NucleusElectoralBaselineViewModel` em `src/utilities/nucleusViewModels.ts`; componente `src/components/campaign/NucleusElectoralBaseline.tsx`.
-3. **Overview da lista** — estende `overview-lista-nucleos.md`: somar baselines sobre o conjunto filtrado (`buildNucleusListWhere`); novo bloco no `NucleusListOverview`.
-4. **Insights automáticos (Gap vs 2022)** — implementa somente o insight "Gap vs 2022" (ver seção "Insights automáticos" abaixo). `src/components/campaign/NucleusInsights.tsx` + `src/lib/electionInsights.ts`. Os demais insights viram itens separados no roadmap com planos próprios.
+2. **Detalhe do núcleo** — **feito (A4, 2026-07-18).** Bloco "Baseline eleitoral 2022" na aba overview; view model `NucleusElectoralBaselineViewModel` em `src/utilities/nucleusViewModels.ts`; agregação em `src/utilities/nucleusElectoralBaseline.ts`; componente `src/components/campaign/NucleusElectoralBaseline.tsx`.
+3. **Overview da lista** — **feito (A4, 2026-07-18).** Soma baselines sobre o conjunto filtrado (`buildNucleusListWhere` + `loadNucleusBaseline2022Overview`); card "Baseline 2022" no `NucleusListOverview`.
+4. **Insights automáticos (Gap vs 2022)** — **feito (A4, 2026-07-18).** `src/components/campaign/NucleusInsights.tsx` + `src/lib/electionInsights.ts` (`computeGapVs2022`). Os demais insights ficam nos planos `insight-*.md` (A5).
5. **Prontidão 2026** — `identityKey` já em 2022; `scripts/reconcile-running-again.mjs` marca `runningAgain2026` quando 2026 publicar; UI sinaliza quem volta a concorrer. (O insight de dobradinha, que depende deste, é plano separado no roadmap.)
## Insights automáticos
@@ -125,7 +125,7 @@ Cada insight é uma **derivação de leitura** (sem escrita, sem `Consent`, sem
- núcleo sem geografia → "Sem baseline TSE (informe território/município)".
- **Output no overview da lista:** sobre o conjunto filtrado: `gapTotal = Σ confirmedVoteEstimate − Σ sollaVotes2022`, `nucleiAbove2022` (contagem com `gap ≥ 0`), `nucleiBelow2022` (contagem com `gap < 0`). Renderizado no bloco "Baseline 2022" do `NucleusListOverview`.
- **Componente:** `src/components/campaign/NucleusInsights.tsx` (server; começa só com este insight; arquitetura extensível para os demais).
-- **Helper:** `src/lib/electionInsights.ts` exportando `computeGapVs2022(baseline, confirmedVoteEstimate)` → `{ gap, ratio, status: 'above'|'below'|'noBaseline'|'noEstimate'|'noSolla2022' }`.
+- **Helper:** `src/lib/electionInsights.ts` exportando `computeGapVs2022(baseline, confirmedVoteEstimate)` → `{ gap, ratio, status: 'above'|'below'|'noBaseline'|'noEstimate'|'noCandidateVotes' }` (chapa/candidato configurados em `BASELINE_TICKET_2022`, `src/lib/electionResults.ts`, para reuso em eleições futuras).
- **Visibilidade:** `geral`/`coordenador`/`lideranca` (dado público + estimativa já visível ao papel).
- **Teste int:** cenários com gap negativo, positivo, `sollaVotes2022=0`, `confirmedVoteEstimate=null` e sem geografia.
@@ -175,9 +175,13 @@ Cada insight é uma **derivação de leitura** (sem escrita, sem `Consent`, sem
- Criar: `src/collections/ElectionTally.ts`, `ElectionCandidateVote.ts`, `ElectionCandidate.ts`; `src/lib/electionResults.ts`, `electionResultsParse.ts`, `electionResultsBuild.ts`, `electionResultsCsv.ts`, `electionResultsZip.ts`, `electionCandidateIdentity.ts`; `src/utilities/electionResultsImport.ts`; `scripts/seed-tse-results.mjs`; migration `20260718_195854_add_election_results`; fixtures `tests/fixtures/tse/*`; testes unit/int.
- Alterar: `src/payload.config.ts`, `src/utilities/campaignAccess.ts` (`canReadElectionData` / `canMutateElectionData`), `package.json` (`db:seed:tse`), `scripts/seed-loader.mjs` (stub `server-only`), `.gitignore` (`/data/tse/`).
-**Fases 2–5 (A4+) — ainda a criar:**
+**Fases 2–4 (A4) — feitos (2026-07-18):**
-- `src/lib/electionInsights.ts`, `src/utilities/nucleusElectoralBaseline.ts`, `src/components/campaign/NucleusElectoralBaseline.tsx`, `NucleusInsights.tsx`, `scripts/reconcile-running-again.mjs`; alterar páginas do núcleo / overview.
+- `src/lib/electionInsights.ts`, `src/utilities/nucleusElectoralBaseline.ts`, `src/components/campaign/NucleusElectoralBaseline.tsx`, `NucleusInsights.tsx`; overview estendido em `nucleusListOverviewPageData.ts` / `NucleusListOverview.tsx`; overview tab em `nucleusDetailPageData.ts` / `NucleusActiveTab.tsx`.
+
+**Fase 5 (A4+/A6) — ainda a criar:**
+
+- `scripts/reconcile-running-again.mjs` quando o TSE publicar 2026.
## Dependências
diff --git a/docs/plans/escala-dry-pos-a4.md b/docs/plans/escala-dry-pos-a4.md
new file mode 100644
index 00000000..2f7ac1ce
--- /dev/null
+++ b/docs/plans/escala-dry-pos-a4.md
@@ -0,0 +1,103 @@
+# Escala e DRY pós-A4 (baseline TSE no produto)
+
+Status: registrado no roadmap (fases pendentes)
+Atualizado em: 2026-07-19
+Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha A, item A7)
+Responsável: —
+
+## Contexto
+
+O A4 ([baseline-eleitoral-tse.md](baseline-eleitoral-tse.md) Fases 2–4) entregou o baseline TSE 2022 no detalhe do núcleo e no overview da lista: `getNucleusElectoralBaseline` / `loadNucleusBaseline2022Overview`, `computeGapVs2022`, UI `NucleusElectoralBaseline` + `NucleusInsights`, card "Baseline 2022". Três passagens `/simplify` (2026-07-18) já limparam o que cabia em cleanup: papéis genéricos (`BASELINE_TICKET_2022`), `geographyWhere` por cidade com `zoneNumber in […]`, overview só consulta núcleos com estimativa confirmada, helpers compartilhados (`toNucleusElectionGeographyInput`, `formatElectionNumber`, `NO_ELECTION_BASELINE_MESSAGE`).
+
+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` em `src/utilities/nucleusElectoralBaseline.ts` buscam o ranking local inteiro para `winnerFederal` e `candidate.rank`. Em núcleo multi-município / TI inteiro isso pode ser milhares de rows por abertura da aba Visão geral (`pagination: false`).
+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).
+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 `Alert` já tem o precedente `pending` espelhando o Badge; falta a variante simétrica. A barra do candidato no card também reimplementa `role="progressbar"` em vez de reusar `Progress` (`NucleusListOverview`).
+
+**Explicitamente fora (revisores pediram skip no simplify):** 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), e o cast `as unknown as RawOverviewNucleus` da B1 (não é débito novo do A4).
+
+## Objetivos
+
+- Abrir a aba Visão geral de um núcleo com TI/multi-município permanece barato: o loader de baseline não transferir o conjunto completo de candidatos federais da geografia.
+- Filtros de geografia eleitoral preferem `cityCode` TSE quando o mapa nome→código existir, sem mudar a UX (continua resolvendo a partir de `cities` / `regions` do núcleo).
+- Gap "acima" usa a mesma família de variantes do Alert que o âmbar (`pending`); a barra do candidato reusa `Progress`.
+- Guardrails: preferir **sem migration** nas Fases 1 e 3; Fase 2 pode ser só tabela estática (como `bahiaMunicipalityCodes` / seed TSE) — se precisar de índice novo no Postgres, migration dedicada e cortável. Sem Consent (dado público TSE). Access continua `overrideAccess: false` + `canReadElectionData`.
+
+## Decisões travadas
+
+- **Um item A7, três fases ordenadas.** Mesmo racional do B5/C7/C8: um ID de roadmap, PRs por fase. Ordem: agregação do detalhe (custo real) → `cityCode` (escala BA-wide) → DRY de Alert/Progress (barato, UI).
+- **Dependência dura de A4.** Só faz sentido com o loader/UI já no produto; não reabre o escopo do Gap vs 2022 nem do card Baseline.
+- **Fase 1 não remove o "mais votado aqui" da UI.** O produto do A4 inclui `winnerFederal`; a otimização agrega no SQL/drizzle (ou limita o payload), não corta a feature. Se produto decidir que rank/winner saem do card, a F1 encolhe para "só o candidato da chapa + tallies".
+- **Cortável se a geografia real dos núcleos permanecer pequena** (1–2 municípios tipados). Vira não-cortável de qualidade quando A5/B3 passarem a martelar o mesmo helper em TI inteiro.
+- **Não registrar no A7 o cast `as unknown` do overview B1** — débito de tipagem do select Payload da lista; só tocar se um PR de A7 já estiver no arquivo.
+- **i18n e naming** (AGENTS.md): identificadores em inglês (`aggregateFederalTotalsByGeography`, `tseCityCodeForMunicipality`, `alertVariants.confirmed`), strings visíveis em pt-BR inalteradas.
+
+## Questões em aberto
+
+- **Fase 1: Local API `find` + agregação em memória vs `payload.db.drizzle` `GROUP BY`?** **Recomendação:** drizzle (como o seed TSE) para `SUM(votes) GROUP BY candidate_number` no escopo federal T1 + geografia, mais uma query estreita para presidente/governador da chapa; manter `overrideAccess`/ACL lendo via Local API só se o drizzle for privilegiado — espelhar o padrão do import (`overrideAccess: true` só em CLI). No app, preferir Local API com `select` mínimo **ou** uma query drizzle encapsulada que ainda respeite `canReadElectionData` (checagem explícita antes). Definir no PR da F1 com um teste int de TI grande (fixture expandida ou assert de limite de rows).
+- **Fase 2: de onde vem o mapa nome canônico → `CD_MUNICIPIO` TSE?** **Recomendação:** extrair do próprio seed/`electionTally`/`electionCandidateVote` (já têm `cityCode`+`cityName`) para uma tabela estática gerada (`src/lib/bahiaTseCityCodes.ts`) no mesmo espírito de `bahiaMunicipalityCodes` (IBGE) — **não** confundir com código IBGE. Script re-executável opcional; sem migration se for só arquivo versionado.
+- **Fase 3: nome da variante Alert — `confirmed` ou `estimate-confirmed`?** **Recomendação:** `confirmed`, alinhado aos tokens CSS `--estimate-confirmed` / classe utilitária já usada pelo Badge `estimate-confirmed`, sem inventar terceira nomenclatura.
+
+## Abordagem proposta
+
+```mermaid
+flowchart TD
+ A4["A4 Baseline no produto ✓"] --> F1
+ F1["Fase 1 — Agregar federal no detalhe
(GROUP BY / payload estreito)"]
+ F1 --> F2["Fase 2 — Geografia por cityCode TSE"]
+ F1 --> F3["Fase 3 — Alert confirmed + Progress"]
+ F1 -.mesmo loader.-> A5["A5 Insights"]
+ F1 -.métricas.-> B3["B3 Leaflet coroplético"]
+```
+
+### Fase 1 — Agregação do ranking federal no detalhe
+
+- Em `src/utilities/nucleusElectoralBaseline.ts`: deixar de materializar todas as rows nominais federais da geografia só para `aggregateFederalCandidateTotals`.
+- Caminho preferido: query agregada (drizzle) → lista `{ candidateNumber, name, party, votes }` já somada; candidato da chapa + winner/rank derivados dessa lista curta (N candidatos distintos, não N×zonas).
+- Manter query estreita para presidente/governador da `BASELINE_TICKET_2022` (já filtrada por número).
+- Tallies: inalterado (já é 1 row por city×zone federal T1).
+- Testes: unit da agregação pura; int com geografia multi-zona assertando winner/rank/solla votes **e** teto de rows transferidas (ou mock do finder).
+- Critério: abrir overview de núcleo com ≥2 municípios / TI não escala linearmente com (#candidatos × #zonas) no wire.
+
+### Fase 2 — Filtro por `cityCode` TSE
+
+- 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` usa `cityCode: { equals|in }` + `zoneNumber: { in }`.
+- 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`).
+
+### Fase 3 — Alert `confirmed` + `Progress` no card
+
+- `src/components/ui/Alert.tsx`: adicionar variant `confirmed` espelhando `pending` com tokens `--estimate-confirmed` / foreground.
+- `NucleusInsights`: trocar o `cn(...)` ad-hoc por `variant={gap.status === 'above' ? 'confirmed' : 'pending'}` (ou `default` só se neutro — manter pending para noBaseline/noEstimate).
+- `NucleusElectoralBaseline`: substituir a barra custom por `Progress` (já usado no overview), preservando `aria-label`.
+
+**Migration:** nenhuma nas Fases 1 e 3. Fase 2 preferencialmente só artefato estático versionado; índice Postgres extra só se o EXPLAIN ainda doer após `cityCode` (aí `pnpm migrate:create` cortável).
+
+## Dependências
+
+- **Dura:** A4 Baseline no produto + Gap vs 2022 — implementado (loader + UI).
+- **Suave:** A5 Insights e B3 coroplético — consomem/amplificam o mesmo helper; se A5 chegar antes da F1, herda o custo.
+- Reusa: `BASELINE_TICKET_2022`, `ba2022Scope` / `geographyWhere`, collections `electionCandidateVote` / `electionTally`, padrão drizzle do `scripts/seed-tse-results.mjs`, tokens `estimate-confirmed` / `estimate-pending`, `Progress`.
+
+## Não escopo
+
+- Novos insights de produto → **A5** (planos `insight-*.md`).
+- Dobradinha 2026 → **A6**.
+- Lazy load de geometrias / cache CLI → **B5**.
+- Escala de apoiadores / agenda → **C6/C7/C8**.
+- Remover rank/winner da UI por decisão de produto sem agregação — fora; se produto cortar, fechar F1 como "só chapa + tallies".
+- Tipagem genérica dos `select` Payload do overview B1.
+
+## Referências
+
+- `docs/roadmap.md` (Trilha A, item A7; A4/A5/B3)
+- `docs/plans/baseline-eleitoral-tse.md` — plano pai do A3/A4
+- `docs/plans/escala-dry-pos-b2.md` / `escala-dry-pos-c3.md` / `escala-dry-pos-c6.md` — precedente pós-`/simplify`
+- `src/utilities/nucleusElectoralBaseline.ts` — `detailVoteWhere`, `loadElectionVotes`, `aggregateFederalCandidateTotals`
+- `src/lib/electionInsights.ts` / `src/components/campaign/NucleusInsights.tsx` — Gap UI
+- `src/components/campaign/NucleusElectoralBaseline.tsx` — barra do candidato
+- `src/components/ui/Alert.tsx` — variant `pending` a espelhar
+- `src/components/ui/Progress.tsx` — reuso no card
+- AGENTS.md — Election baseline data; Local API `overrideAccess: false`; naming inglês
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 52e918e2..379cf2c7 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,6 +1,6 @@
# Roadmap — Teqo
-Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6 Escala e DRY pós-C2 implementado e mesclado em `main` (Fases 1–5 + simplify); C7 registrado — escala/DRY pós-C3; C8 registrado — escala/DRY pós-C6 do `/simplify`; B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`)
+Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; A4 Baseline no produto + Gap vs 2022 implementado; A7 registrado — escala/DRY pós-A4 do `/simplify`; C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6 Escala e DRY pós-C2 implementado e mesclado em `main` (Fases 1–5 + simplify); C7 registrado — escala/DRY pós-C3; C8 registrado — escala/DRY pós-C6 do `/simplify`; B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`)
Registro canônico no repositório dos planos futuros e débitos conhecidos. Status operacional do ciclo atual de Núcleos fica em [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc); este arquivo lista o que ainda é futuro ou bloqueador, **em ordem de execução**, com dependências e paralelismo explícitos.
@@ -60,6 +60,11 @@ MVP de território + reporte implementado e enviado (ondas 1–8 + refactors de
- **A2 Zonas TSE + sugestões cruzadas** — cadastro estático `bahiaTseZones` (TSE 2024 `detalhe_votacao_munzona` BA, 417 municípios), motor puro `territorySuggestions` (inclui `outsideZones`), coordenador `NucleusTerritoryAndZonesFields` com chips `{rótulo} +` opt-in (município/TI → ZEs; irmãos do TI e cidades da ZE → Municípios), `TseZoneInput` controlado. Sem migration, sem igualdade forçada no save. [Plano](plans/zonas-por-municipio.md).
+### Ciclo 2+ — A4 implementado (2026-07-18)
+
+- **A4 Baseline no produto + Gap vs 2022** — agregação `getNucleusElectoralBaseline` / `loadNucleusBaseline2022Overview` + `computeGapVs2022`; bloco "Baseline eleitoral 2022" e insight Gap na aba Visão geral do núcleo; card "Baseline 2022" no overview da lista filtrada. Sem migration, sem Consent (dado público TSE). [Plano](plans/baseline-eleitoral-tse.md).
+- **A7 Escala e DRY pós-A4 registrado (2026-07-19)** — débitos do `/simplify` que não entraram no cleanup: agregar ranking federal do detalhe (não puxar todas as rows nominais), filtro geográfico por `cityCode` TSE, variant Alert `confirmed` + `Progress` no card. [Plano](plans/escala-dry-pos-a4.md).
+
### Ciclo 2+ — B2 fundação de geometrias do mapa (2026-07-18)
- **B2 Mapa Fase 1 (geometrias)** — TopoJSON estático de municípios IBGE (`bahia-municipalities.topo.json`) + Territórios de Identidade por dissolução (`bahia-identity-territories.topo.json`), tabela `bahiaMunicipalityCodes` (nome canônico → `codarea`), helpers `bahiaGeometries` (`getMunicipalityFeature` / `getTerritoryFeature`), script re-executável `pnpm build:geometries`. Sem migration, sem UI (Leaflet = B3). [Plano](plans/mapa-bahia-geometrias.md).
@@ -82,6 +87,7 @@ Os designs gerados pelo UX Pilot para os próximos ciclos estão em [`docs/desig
| A2 Zonas TSE + sugestões cruzadas | `Formulario-Territorio` (seção Zonas TSE + chips `{rótulo} +`) | [zonas-por-municipio.md](plans/zonas-por-municipio.md) |
| A3/A4 Baseline TSE 2022 + Gap vs 2022 | `Baseline-Eleitoral-2022` (+ bloco "Baseline 2022" em `Lista-Nucleos-Overview`) | [baseline-eleitoral-tse.md](plans/baseline-eleitoral-tse.md) |
| A5 Insights (5 planos) | `Baseline-Eleitoral-2022` (card "Insights do território", uma linha por insight) | [conversão](plans/insight-taxa-conversao.md) · [classificação](plans/insight-classificacao-territorial.md) · [alavancagem](plans/insight-alavancagem-chapa.md) · [mobilização](plans/insight-mobilizacao-brancos-nulos.md) · [competitiva](plans/insight-inteligencia-competitiva.md) |
+| A7 Escala e DRY pós-A4 | — (sem design novo; reusa baseline A4 + Alert/Progress) | [escala-dry-pos-a4.md](plans/escala-dry-pos-a4.md) |
| C2 Cadastro nominal de apoiadores | `Apoiadores-Lista`, `Apoiador-Ficha`, `Importar-CSV-Apoiadores` | [cadastro-nominal-apoiadores.md](plans/cadastro-nominal-apoiadores.md) |
| C6 Escala e DRY pós-C2 | — (sem design novo; reusa os de C2 + padrões de núcleos) | [escala-dry-pos-c2.md](plans/escala-dry-pos-c2.md) |
| C3 Eventos / agenda de mobilização | `Planos-de-Acao`, `Novo-Plano-de-Acao` | [eventos-agenda-mobilizacao.md](plans/eventos-agenda-mobilizacao.md) |
@@ -109,7 +115,8 @@ flowchart TD
A1["A1 Território multi-município/bairro
(migration) ✓"]
A2["A2 Zonas TSE + sugestões cruzadas
(chips opt-in território↔ZE) ✓"]
A3["A3 Baseline TSE 2022 — Fase 1
(import, collections) ✓"]
- A4["A4 Baseline no produto + Gap vs 2022
(detalhe, overview)"]
+ A4["A4 Baseline no produto + Gap vs 2022
(detalhe, overview) ✓"]
+ A7["A7 Escala e DRY pós-A4
(agregar federal / cityCode / Alert)"]
A5["A5 Insights: conversão, classificação,
alavancagem, mobilização, competitiva"]
A6["A6 Dobradinha 2026"]
end
@@ -125,7 +132,7 @@ flowchart TD
subgraph TrilhaC["Trilha C — operação de campo"]
C1["C1 Compartilhar página ✓"]
C2["C2 Cadastro nominal de apoiadores ✓"]
- C6["C6 Escala e DRY pós-C2
(import/listas/forms)"]
+ C6["C6 Escala e DRY pós-C2
(import/listas/forms) ✓"]
C3["C3 Eventos / agenda de mobilização ✓"]
C7["C7 Escala e DRY pós-C3
(território/contato/leituras)"]
C8["C8 Escala e DRY pós-C6
(locks bulk / leituras / forms DRY)"]
@@ -142,8 +149,10 @@ flowchart TD
A3 --> A4
B1 --> A4
A2 -.qualidade de tseZones.-> A4
+ A4 --> A7
A4 --> A5
A4 --> A6
+ A7 -.mesmo loader.-> A5
TSE2026["TSE publica candidaturas 2026
(externo, após 15/08)"] --> A6
B1 --> B3
B2 --> B3
@@ -167,7 +176,7 @@ flowchart TD
JUR -.chave de push.-> D2
```
-Itens sem seta de entrada (**paralelizáveis a qualquer momento**): fill-ins (visitados recentemente, listas globais, reset de senha, higiene PascalCase). **Entregues e mesclados em `main` em 2026-07-18** (✓ no grafo): A1, A2, A3, B1, B2, C1, C2, C3 e D1 — ver "Ciclo 2" e "Ciclo 2+" acima. C2 segue com produção bloqueada pelo lote jurídico (Onda 0: Consent keys `apoiador-cadastro` / `apoiador-intencao-voto` + aprovação). Destravados por eles: A4 (A2/A3 + B1 prontos), B3 (B1 ✓ + B2 ✓; suave: A4 para coroplético e B5 F1 para lazy load), B5 (após B2; preferencialmente com B3), C4 (A1 + C3 prontos), C6 (merge de C2 ✓), C7 (após C3; suave: C6 F2), C8 (merge de C6 ✓; cortável se a base nominal permanecer pequena) e D2 push (D1 pronto; falta a chave jurídica).
+Itens sem seta de entrada (**paralelizáveis a qualquer momento**): fill-ins (visitados recentemente, listas globais, reset de senha, higiene PascalCase). **Entregues e mesclados em `main` em 2026-07-18/19** (✓ no grafo): A1, A2, A3, A4, B1, B2, C1, C2, C3, C6 e D1 — ver "Ciclo 2" e "Ciclo 2+" acima. C2 segue com produção bloqueada pelo lote jurídico (Onda 0: Consent keys `apoiador-cadastro` / `apoiador-intencao-voto` + aprovação). Destravados por eles: A5/A6 (após A4 ✓), A7 (após A4 ✓; preferencialmente antes ou com A5/B3), B3 (B1 ✓ + B2 ✓; suave: A4 ✓ para coroplético e B5 F1 para lazy load), B5 (após B2; preferencialmente com B3), C4 (A1 + C3 prontos), C7 (após C3; suave: C6 F2 ✓), C8 (merge de C6 ✓; cortável se a base nominal permanecer pequena) e D2 push (D1 pronto; falta a chave jurídica).
### Sequência de execução por janela do calendário
@@ -188,7 +197,7 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): fill-ins (vi
| ----- | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | -------------------------------- | ----------------- |
| 7 | C2 Cadastro nominal de apoiadores _(engenharia pronta e mesclada em `main` 2026-07-18 — deploy + Consent keys ainda pendentes)_ | [detalhes](plans/cadastro-nominal-apoiadores.md) | produção: lote jurídico (Onda 0) | A4, C3, C6, C7 |
| 8 | C6 Escala e DRY pós-C2 (import em massa, KPI, shells compartilhados com núcleos) **(implementado e mesclado em `main` 2026-07-19 — Fases 1–5 + simplify)** | [detalhes](plans/escala-dry-pos-c2.md) | C2 (merge) | A4, C3, C7, C8 |
-| 9 | A4 Baseline no produto + insight Gap vs 2022 | [detalhes](plans/baseline-eleitoral-tse.md) | A3 + B1 (suave: A2) | C2, C3, C6, C7, C8 |
+| 9 | A4 Baseline no produto + insight Gap vs 2022 **(implementado 2026-07-18)** | [detalhes](plans/baseline-eleitoral-tse.md) | A3 + B1 (suave: A2) | C2, C3, C6, C7, C8 |
| 10 | C3 Eventos / agenda de mobilização (`actionPlan`) **(implementado e mesclado em `main` 2026-07-18)** | [detalhes](plans/eventos-agenda-mobilizacao.md) | A1 | C2, A4, C6, C7, C8 |
| 11 | C7 Escala e DRY pós-C3 (território/contato compostos, leituras por aba, RMW/índice) | [detalhes](plans/escala-dry-pos-c3.md) | C3 (suave: C6 F2) | A4, C6, C8 |
| 12 | C8 Escala e DRY pós-C6 (locks bulk em 1 RT, leituras/`pg_trgm`, helpers drizzle, forms DRY) _(cortável se a base nominal permanecer pequena)_ | [detalhes](plans/escala-dry-pos-c6.md) | C6 (merge) | A4, C3, C7 |
@@ -197,21 +206,22 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): fill-ins (vi
| Ordem | Item | Plano | Depende de | Paralelizável com |
| ----- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ----------------- |
-| 13 | A5 Insights derivados do baseline (5 itens, paralelizáveis entre si; conversão e classificação exigem limiares de produto) | [conversão](plans/insight-taxa-conversao.md) · [classificação](plans/insight-classificacao-territorial.md) · [alavancagem](plans/insight-alavancagem-chapa.md) · [mobilização](plans/insight-mobilizacao-brancos-nulos.md) · [competitiva](plans/insight-inteligencia-competitiva.md) | A4 | B3, B5, C4, D1 |
-| 14 | B2 Mapa Fase 1 (geometrias) **(entregue 2026-07-18)** + B3 Leaflet nas superfícies | [detalhes](plans/mapa-bahia-geometrias.md) | B3 ← B1 ✓ + B2 ✓ (suave: A4 coroplético; B5 F1 lazy geometrias) | A5, B5, C4, D1 |
-| 15 | B5 Escala e DRY pós-B2 (lazy geometrias + cache CLI) | [detalhes](plans/escala-dry-pos-b2.md) | B2 ✓ (F1 preferencialmente com B3) | A5, B3, C4, D1 |
-| 16 | C4 Demandas | [detalhes](plans/demandas-campanha.md) | A1 (suave: C3 para a relação `actionPlan`) | A5, B3, B5, D1 |
-| 17 | D1 PWA `/campanha` **(entregue antecipado em 2026-07-18)** | [detalhes](plans/pwa-campanha.md) | — | tudo |
-| 18 | D2 Notificações (push + sino) — sino não depende do PWA; push sim | [detalhes](plans/notifications.md) | D1 ✓ (push) + chave `campanha-notificacoes-push` do lote jurídico | A5, B3, B5, C4 |
+| 13 | A5 Insights derivados do baseline (5 itens, paralelizáveis entre si; conversão e classificação exigem limiares de produto) | [conversão](plans/insight-taxa-conversao.md) · [classificação](plans/insight-classificacao-territorial.md) · [alavancagem](plans/insight-alavancagem-chapa.md) · [mobilização](plans/insight-mobilizacao-brancos-nulos.md) · [competitiva](plans/insight-inteligencia-competitiva.md) | A4 | A7, B3, B5, C4, D1 |
+| 14 | A7 Escala e DRY pós-A4 (agregar federal no detalhe, `cityCode` TSE, Alert `confirmed` + Progress) | [detalhes](plans/escala-dry-pos-a4.md) | A4 ✓ (suave: A5/B3 amplificam o custo) | A5, B3, B5, C4, D1 |
+| 15 | B2 Mapa Fase 1 (geometrias) **(entregue 2026-07-18)** + B3 Leaflet nas superfícies | [detalhes](plans/mapa-bahia-geometrias.md) | B3 ← B1 ✓ + B2 ✓ (suave: A4 coroplético; B5 F1 lazy geometrias) | A5, A7, B5, C4, D1 |
+| 16 | B5 Escala e DRY pós-B2 (lazy geometrias + cache CLI) | [detalhes](plans/escala-dry-pos-b2.md) | B2 ✓ (F1 preferencialmente com B3) | A5, A7, B3, C4, D1 |
+| 17 | C4 Demandas | [detalhes](plans/demandas-campanha.md) | A1 (suave: C3 para a relação `actionPlan`) | A5, A7, B3, B5, D1 |
+| 18 | D1 PWA `/campanha` **(entregue antecipado em 2026-07-18)** | [detalhes](plans/pwa-campanha.md) | — | tudo |
+| 19 | D2 Notificações (push + sino) — sino não depende do PWA; push sim | [detalhes](plans/notifications.md) | D1 ✓ (push) + chave `campanha-notificacoes-push` do lote jurídico | A5, A7, B3, B5, C4 |
**Janela 4 — set → 04/10 (reta final): dobradinha, dia D e estabilização.**
| Ordem | Item | Plano | Depende de |
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
-| 19 | A6 Insight dobradinha 2026 | [detalhes](plans/insight-dobradinha-2026.md) | A4 + TSE publicar candidaturas 2026 (externo, após 15/08) + taxonomia de alinhamento (produto) |
-| 20 | B4 Camada de zonas TSE no mapa | ciclo seguinte do [plano do mapa](plans/mapa-bahia-geometrias.md) | A2 + B3 |
-| 21 | C5 **Operação dia D / GOTV** _(item proposto nesta revisão — validar com produto)_: confirmação de comparecimento da base nominal em 04/10 (lista de apoiadores por núcleo/zona com marcação "confirmou que vai votar / votou", visão de cobertura para a coordenação). É o uso final da base construída em C2; a literatura de campanha trata a mobilização do dia como onde eleições apertadas se decidem. Sem plano ainda. | design: [`Dia-D-GOTV`](design-refs/latest/Dia-D-GOTV.png) | C2 (suave: C6 para escala da base) |
-| 22 | Congelamento: a partir de ~20/09, só correção de bug e dados; nada de migration arriscada perto do dia D. | — | — |
+| 20 | A6 Insight dobradinha 2026 | [detalhes](plans/insight-dobradinha-2026.md) | A4 + TSE publicar candidaturas 2026 (externo, após 15/08) + taxonomia de alinhamento (produto) |
+| 21 | B4 Camada de zonas TSE no mapa | ciclo seguinte do [plano do mapa](plans/mapa-bahia-geometrias.md) | A2 + B3 |
+| 22 | C5 **Operação dia D / GOTV** _(item proposto nesta revisão — validar com produto)_: confirmação de comparecimento da base nominal em 04/10 (lista de apoiadores por núcleo/zona com marcação "confirmou que vai votar / votou", visão de cobertura para a coordenação). É o uso final da base construída em C2; a literatura de campanha trata a mobilização do dia como onde eleições apertadas se decidem. Sem plano ainda. | design: [`Dia-D-GOTV`](design-refs/latest/Dia-D-GOTV.png) | C2 (suave: C6 para escala da base) |
+| 23 | Congelamento: a partir de ~20/09, só correção de bug e dados; nada de migration arriscada perto do dia D. | — | — |
**Fill-ins (qualquer janela, quando houver folga; nenhum bloqueia nada):**
@@ -220,7 +230,7 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): fill-ins (vi
- **Reset de senha self-service + foto de perfil** (UX adiada do ciclo 1). _(plano MVP)_
- **Higiene de código:** varredura PascalCase dos componentes legados. _(notebook Núcleos)_
-**Cortes seguros se o prazo apertar** (nesta ordem): B4 camada de zonas, B5 F2 (cache CLI — duplicação pequena), B3 mapa Leaflet (preferir levar B5 F1 lazy load junto se B3 entrar), C4 demandas, D2 push (mantendo o sino), C6 fases 3–5 (import bulk / preview token / KPI — só se a base nominal permanecer pequena), C7 fases 4–5 (leituras/RMW/índice — só se a agenda permanecer pequena), C8 (escala/DRY pós-C6 — só se a base nominal permanecer pequena; Fases 3–4 DRY podem entrar isoladas), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2, C7 fases 1–2 e C8 fases 3–4 (DRY de helpers/forms) se houver folga. **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap — são respectivamente o risco legal, a base de dados, a operação da propaganda e o instrumento de alocação de esforço.
+**Cortes seguros se o prazo apertar** (nesta ordem): B4 camada de zonas, B5 F2 (cache CLI — duplicação pequena), A7 F2–F3 (`cityCode` / Alert DRY — só se a geografia real dos núcleos permanecer 1–2 municípios), B3 mapa Leaflet (preferir levar B5 F1 lazy load junto se B3 entrar), C4 demandas, D2 push (mantendo o sino), C6 fases 3–5 (import bulk / preview token / KPI — só se a base nominal permanecer pequena), C7 fases 4–5 (leituras/RMW/índice — só se a agenda permanecer pequena), C8 (escala/DRY pós-C6 — só se a base nominal permanecer pequena; Fases 3–4 DRY podem entrar isoladas), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2, C7 fases 1–2, C8 fases 3–4 (DRY de helpers/forms) e **A7 F1** (agregar federal no detalhe) se A5/B3 forem entrar — o custo de I/O do loader do A4 é herdado por eles. **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap — são respectivamente o risco legal, a base de dados, a operação da propaganda e o instrumento de alocação de esforço.
### Itens consolidados/removidos nesta revisão (2026-07-17)
diff --git a/src/components/campaign/NucleusActiveTab.tsx b/src/components/campaign/NucleusActiveTab.tsx
index 6bd38670..8ac86006 100644
--- a/src/components/campaign/NucleusActiveTab.tsx
+++ b/src/components/campaign/NucleusActiveTab.tsx
@@ -11,6 +11,8 @@ import {
import { createNucleusUpdateFormAction } from '@/app/(campaign)/campanha/(app)/nucleos/[slug]/nucleusUpdateFormActions'
import { searchPrimaryContactOptionsFormAction } from '@/app/(campaign)/campanha/(app)/nucleos/[slug]/primaryContactSearchActions'
import { LeadershipNetwork } from '@/components/campaign/LeadershipNetwork'
+import { NucleusElectoralBaseline } from '@/components/campaign/NucleusElectoralBaseline'
+import { NucleusInsights } from '@/components/campaign/NucleusInsights'
import { NucleusIntelligenceDialogShell } from '@/components/campaign/NucleusIntelligenceDialogShell'
import { NucleusUpdateFeed } from '@/components/campaign/NucleusUpdateFeed'
import { NucleusUpdateFormShell } from '@/components/campaign/NucleusUpdateFormShell'
@@ -31,6 +33,7 @@ import type { AccessibleNucleusContext } from '@/utilities/nucleusPageData'
import { formatNucleusTerritoryLabel } from '@/utilities/nucleusUi'
import type {
NucleusDetailViewModel,
+ NucleusElectoralBaselineViewModel,
NucleusTabsViewModel,
StaffNucleusTabsViewModel,
} from '@/utilities/nucleusViewModels'
@@ -45,14 +48,18 @@ import {
import { buildWhatsAppUrl } from '@/utilities/phone'
const OverviewContent = ({
+ baseline,
canEditIntelligence,
+ confirmedVoteEstimate,
context,
nucleus,
primaryContact,
searchParams,
updatePreview,
}: {
+ baseline: NucleusElectoralBaselineViewModel | null
canEditIntelligence: boolean
+ confirmedVoteEstimate: number | null
context: AccessibleNucleusContext
nucleus: NucleusTabsViewModel
primaryContact: { id: number; name: string; phone: string } | null
@@ -197,6 +204,11 @@ const OverviewContent = ({
)}
+
+
+
+
+
Últimas atualizações
@@ -349,7 +361,9 @@ export const NucleusActiveTab = async ({
if (data.tab === 'overview') {
return (
{
+ if (scale <= 0 || votes <= 0) return 0
+ return Math.min(100, Math.round((votes / scale) * 100))
+}
+
+export const NucleusElectoralBaseline = ({
+ baseline,
+}: {
+ baseline: NucleusElectoralBaselineViewModel | null
+}) => {
+ if (!baseline) {
+ return (
+
+
+
+
+ Baseline eleitoral 2022
+
+ Deputado Federal · Bahia
+
+
+ {NO_ELECTION_BASELINE_MESSAGE}
+
+
+ )
+ }
+
+ const scale = Math.max(
+ baseline.electorate.validos,
+ baseline.candidate.votes,
+ baseline.winnerFederal?.votes ?? 0,
+ 1,
+ )
+ const candidateBar = barPercent(baseline.candidate.votes, scale)
+
+ return (
+
+
+
+
+
+ Baseline eleitoral 2022
+
+ Deputado Federal · Bahia
+
+
+
+
+
+
+
+
{ticket.candidate.name}
+
+ {ticket.candidate.party} · {ticket.candidate.officeLabel}
+
+
+
+
+ {formatElectionNumber(baseline.candidate.votes)}
+
+
votos · 2022
+
+
+
+
+
+ {baseline.president ? (
+
+
+
{ticket.president.name}
+
+ {ticket.president.party} · Presidente (
+ {baseline.president.turn === 2 ? '2º turno' : '1º turno'})
+
+
+
+ {formatElectionNumber(baseline.president.votes)}
+
+
+ ) : null}
+
+ {baseline.governor ? (
+
+
+
{ticket.governor.name}
+
+ {ticket.governor.party} · Governador (
+ {baseline.governor.turn === 2 ? '2º turno' : '1º turno'})
+
+
+
+ {formatElectionNumber(baseline.governor.votes)}
+
+
+ ) : null}
+
+
+
+ Eleitorado 2022
+
+
+
+ Votos válidos
+
+ {formatElectionNumber(baseline.electorate.validos)}
+
+
+
+ Brancos
+
+ {formatElectionNumber(baseline.electorate.brancos)}
+
+
+
+ Nulos
+
+ {formatElectionNumber(baseline.electorate.nulos)}
+
+
+
+ Abstenções
+
+ {formatElectionNumber(baseline.electorate.abstencoes)}
+
+
+
+
+
+ {baseline.winnerFederal ? (
+
+
+
+
+
Mais votado aqui em 2022
+
+ {baseline.winnerFederal.name} ({baseline.winnerFederal.party})
+
+
+
+
+ {formatElectionNumber(baseline.winnerFederal.votes)}
+
+
+ ) : null}
+
+
+
+ )
+}
diff --git a/src/components/campaign/NucleusInsights.tsx b/src/components/campaign/NucleusInsights.tsx
new file mode 100644
index 00000000..b5ac073f
--- /dev/null
+++ b/src/components/campaign/NucleusInsights.tsx
@@ -0,0 +1,43 @@
+import { CheckCircle2Icon, TriangleAlertIcon } from 'lucide-react'
+
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
+import { computeGapVs2022, formatElectionNumber } from '@/lib/electionInsights'
+import { cn } from '@/lib/utils'
+import type { NucleusElectoralBaselineViewModel } from '@/utilities/nucleusViewModels'
+
+export const NucleusInsights = ({
+ baseline,
+ confirmedVoteEstimate,
+}: {
+ baseline: NucleusElectoralBaselineViewModel | null
+ confirmedVoteEstimate: number | null
+}) => {
+ const gap = computeGapVs2022(baseline, confirmedVoteEstimate)
+ const Icon = gap.status === 'above' ? CheckCircle2Icon : TriangleAlertIcon
+
+ let supportLine: string | null = null
+ if ((gap.status === 'above' || gap.status === 'below') && baseline && confirmedVoteEstimate !== null) {
+ supportLine = `Estimativa atual (${formatElectionNumber(confirmedVoteEstimate)}) · resultado 2022 (${formatElectionNumber(baseline.candidate.votes)})`
+ }
+
+ return (
+
+
+ Insights do território
+
+
+
+ {gap.message}
+ {supportLine ? {supportLine} : null}
+
+
+ )
+}
diff --git a/src/components/campaign/NucleusListOverview.tsx b/src/components/campaign/NucleusListOverview.tsx
index ba236b0d..1594566f 100644
--- a/src/components/campaign/NucleusListOverview.tsx
+++ b/src/components/campaign/NucleusListOverview.tsx
@@ -86,6 +86,37 @@ export const NucleusListOverview = ({
+
+ {view.baseline2022 ? (
+
+
+ Baseline 2022
+
+ {view.baseline2022.gapTotal === null
+ ? '—'
+ : `${view.baseline2022.gapTotal >= 0 ? '+' : ''}${numberFormatter.format(view.baseline2022.gapTotal)}`}
+
+
+
+
+ {view.baseline2022.gapTotal === null
+ ? 'Sem estimativas comparáveis ao patamar de 2022'
+ : view.baseline2022.gapTotal >= 0
+ ? 'votos acima do patamar'
+ : 'votos abaixo do patamar'}
+
+
+
+ {numberFormatter.format(view.baseline2022.above)} acima
+
+ {' · '}
+
+ {numberFormatter.format(view.baseline2022.below)} abaixo
+
+
+
+
+ ) : null}
@@ -157,7 +188,5 @@ export const NucleusListOverview = ({
)}
-
- {/* Extension points for future roadmap domains (Demandas): plug new cards here when those collections exist. */}
)
diff --git a/src/lib/electionInsights.ts b/src/lib/electionInsights.ts
new file mode 100644
index 00000000..c286e3a4
--- /dev/null
+++ b/src/lib/electionInsights.ts
@@ -0,0 +1,84 @@
+import { BASELINE_TICKET_2022 } from '@/lib/electionResults'
+
+export const NO_ELECTION_BASELINE_MESSAGE =
+ 'Sem baseline TSE (informe território/município)' as const
+
+export type GapVs2022Status =
+ | 'above'
+ | 'below'
+ | 'noBaseline'
+ | 'noEstimate'
+ | 'noCandidateVotes'
+
+export type GapVs2022Result = {
+ gap: number | null
+ ratio: number | null
+ status: GapVs2022Status
+ message: string
+}
+
+/** Minimal baseline shape for Gap vs 2022 (full detail VM is assignable). */
+export type GapVs2022Baseline = {
+ candidate: { votes: number }
+} | null
+
+const numberFormatter = new Intl.NumberFormat('pt-BR')
+
+export const formatElectionNumber = (value: number): string => numberFormatter.format(value)
+
+/**
+ * Compare a confirmed vote estimate against the campaign candidate's 2022
+ * votes in the same geography.
+ */
+export const computeGapVs2022 = (
+ baseline: GapVs2022Baseline,
+ confirmedVoteEstimate: number | null,
+): GapVs2022Result => {
+ if (!baseline) {
+ return {
+ gap: null,
+ ratio: null,
+ status: 'noBaseline',
+ message: NO_ELECTION_BASELINE_MESSAGE,
+ }
+ }
+
+ if (confirmedVoteEstimate === null) {
+ return {
+ gap: null,
+ ratio: null,
+ status: 'noEstimate',
+ message: 'Sem estimativa confirmada para comparar',
+ }
+ }
+
+ const candidateVotes2022 = baseline.candidate.votes
+ if (candidateVotes2022 <= 0) {
+ return {
+ gap: null,
+ ratio: null,
+ status: 'noCandidateVotes',
+ message: `${BASELINE_TICKET_2022.candidate.name} não recebeu votos aqui em 2022 — território novo a abrir`,
+ }
+ }
+
+ const gap = confirmedVoteEstimate - candidateVotes2022
+ const ratio = confirmedVoteEstimate / candidateVotes2022
+
+ if (gap < 0) {
+ return {
+ gap,
+ ratio,
+ status: 'below',
+ message: `Faltam ${formatElectionNumber(Math.abs(gap))} votos para o patamar de 2022`,
+ }
+ }
+
+ const percentAbove = Math.round((ratio - 1) * 100)
+ return {
+ gap,
+ ratio,
+ status: 'above',
+ message: `Já superamos 2022 em ${percentAbove}%`,
+ }
+}
diff --git a/src/lib/electionResults.ts b/src/lib/electionResults.ts
index a6e9b87e..cc73afb4 100644
--- a/src/lib/electionResults.ts
+++ b/src/lib/electionResults.ts
@@ -46,10 +46,22 @@ export const ELECTION_RUNNING_AGAIN_OPTIONS = [
export const ELECTION_RUNNING_AGAIN = ELECTION_RUNNING_AGAIN_OPTIONS.map((o) => o.value)
export type ElectionRunningAgain = (typeof ELECTION_RUNNING_AGAIN)[number]
-/** Solla — dep. federal BA, PT, nº 1313 (eleito em 2022). */
-export const SOLLA_CANDIDATE_NUMBER = 1313
-export const LULA_CANDIDATE_NUMBER = 13
-export const JERONIMO_CANDIDATE_NUMBER = 13
+/**
+ * Campaign ticket for the electoral baseline, keyed by role.
+ * Swap this block (and the election year) for a future cycle — aggregation/UI stay put.
+ * 2022: Jorge Solla (dep. federal BA nº 1313) / Lula (pres. nº 13) / Jerônimo (gov. nº 13).
+ */
+export const BASELINE_TICKET_2022 = {
+ candidate: {
+ candidateNumber: 1313,
+ office: 'deputado_federal',
+ name: 'Jorge Solla',
+ party: 'PT',
+ officeLabel: 'Dep. Federal',
+ },
+ president: { candidateNumber: 13, name: 'Lula', party: 'PT' },
+ governor: { candidateNumber: 13, name: 'Jerônimo Rodrigues', party: 'PT' },
+} as const
/** TSE CD_CARGO → office enum used in collections. */
export const TSE_CARGO_TO_OFFICE: Readonly> = {
diff --git a/src/utilities/nucleusDetailPageData.ts b/src/utilities/nucleusDetailPageData.ts
index 169b6c80..855d5e10 100644
--- a/src/utilities/nucleusDetailPageData.ts
+++ b/src/utilities/nucleusDetailPageData.ts
@@ -8,6 +8,10 @@ import {
} from '@/utilities/leadershipPageData'
import { parseLeadershipFilterState, parseLeadershipPanelState } from '@/utilities/leadershipUi'
import { getNucleusCoordinatorAssignmentPageData } from '@/utilities/nucleusCoordinatorAssignmentPageData'
+import {
+ getNucleusElectoralBaseline,
+ toNucleusElectionGeographyInput,
+} from '@/utilities/nucleusElectoralBaseline'
import {
getNucleusDetailPageData,
resolveAccessibleNucleusContext,
@@ -49,11 +53,12 @@ export const loadNucleusActiveTabPageData = async (
searchParams: NucleusDetailSearchParams,
) => {
if (activeTab === 'overview') {
- const [primaryContactPageData, updatePreview] = await Promise.all([
+ const [primaryContactPageData, updatePreview, baseline] = await Promise.all([
getNucleusPrimaryContactPageData(payload, user, context),
getNucleusUpdatesPreviewData(payload, user, context),
+ getNucleusElectoralBaseline(payload, user, toNucleusElectionGeographyInput(context.document)),
])
- return { tab: activeTab, primaryContactPageData, updatePreview } as const
+ return { tab: activeTab, primaryContactPageData, updatePreview, baseline } as const
}
if (activeTab === 'leaderships') {
diff --git a/src/utilities/nucleusElectoralBaseline.ts b/src/utilities/nucleusElectoralBaseline.ts
new file mode 100644
index 00000000..b647ccf0
--- /dev/null
+++ b/src/utilities/nucleusElectoralBaseline.ts
@@ -0,0 +1,528 @@
+import type { Payload, Where } from 'payload'
+
+import { citiesForTerritory, isBahiaIdentityTerritory } from '@/lib/bahiaTerritories'
+import { tseZonesForCity } from '@/lib/bahiaTseZones'
+import {
+ BASELINE_TICKET_2022,
+ ELECTION_YEAR_2022,
+ type ElectionOffice,
+ type ElectionTurn,
+} from '@/lib/electionResults'
+import type { CampaignUser, User } from '@/payload-types'
+import { normalizeTerritoryTextArray } from '@/utilities/campaignTerritoryValidation'
+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 | null
+}): NucleusElectionGeographyInput => ({
+ cities: normalizeTerritoryTextArray(nucleus.cities),
+ regions: normalizeTerritoryTextArray(nucleus.regions),
+ tseZones: (nucleus.tseZones ?? []).map((zone) =>
+ typeof zone === 'number' ? zone : zone.zoneNumber,
+ ),
+})
+
+export type NucleusElectionGeography = {
+ cities: string[]
+ zonesByCity: Map
+ cityZonePairs: Array<{ cityName: string; zoneNumber: number }>
+}
+
+export type ElectionVoteAggregateRow = {
+ office: ElectionOffice
+ turn: ElectionTurn
+ cityName: string
+ zoneNumber: number
+ candidateNumber: number
+ candidateName: string
+ party: string
+ votes: number
+}
+
+export type ElectionTallyAggregateRow = {
+ office: ElectionOffice
+ turn: ElectionTurn
+ cityName: string
+ zoneNumber: number
+ aptos: number
+ votosValidos: number
+ votosBranco: number
+ votosNulo: number
+ abstencoes: number
+}
+
+type ElectionReader = CampaignUser | User
+
+const cityZoneKey = (cityName: string, zoneNumber: number): string =>
+ `${cityName}::${zoneNumber}`
+
+const uniqueSortedCities = (values: Iterable): 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()
+ 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 { cities: [...zonesByCity.keys()], zonesByCity, cityZonePairs }
+}
+
+const matchesGeography = (
+ row: { cityName: string; zoneNumber: number },
+ geography: NucleusElectionGeography,
+): boolean => {
+ const zones = geography.zonesByCity.get(row.cityName)
+ return Boolean(zones?.includes(row.zoneNumber))
+}
+
+const sumCandidateVotes = (
+ votes: readonly ElectionVoteAggregateRow[],
+ geography: NucleusElectionGeography,
+ office: ElectionOffice,
+ turn: ElectionTurn,
+ candidateNumber: number,
+): number =>
+ votes
+ .filter(
+ (row) =>
+ row.office === office &&
+ row.turn === turn &&
+ row.candidateNumber === candidateNumber &&
+ matchesGeography(row, geography),
+ )
+ .reduce((sum, row) => sum + row.votes, 0)
+
+const decisiveTicketVotes = (
+ votes: readonly ElectionVoteAggregateRow[],
+ geography: NucleusElectionGeography,
+ office: 'presidente' | 'governador',
+ candidateNumber: number,
+): { votes: number; turn: 1 | 2 } | null => {
+ const turn2 = sumCandidateVotes(votes, geography, office, '2', candidateNumber)
+ if (turn2 > 0) return { votes: turn2, turn: 2 }
+ const turn1 = sumCandidateVotes(votes, geography, office, '1', candidateNumber)
+ if (turn1 > 0) return { votes: turn1, turn: 1 }
+ return null
+}
+
+const aggregateFederalCandidateTotals = (
+ votes: readonly ElectionVoteAggregateRow[],
+ geography: NucleusElectionGeography,
+): Array<{ candidateNumber: number; name: string; party: string; votes: number }> => {
+ const byCandidate = new Map<
+ number,
+ { candidateNumber: number; name: string; party: string; votes: number }
+ >()
+
+ for (const row of votes) {
+ if (row.office !== CANDIDATE_OFFICE || row.turn !== '1') continue
+ if (!matchesGeography(row, geography)) continue
+
+ const existing = byCandidate.get(row.candidateNumber)
+ if (existing) {
+ existing.votes += row.votes
+ continue
+ }
+ byCandidate.set(row.candidateNumber, {
+ candidateNumber: row.candidateNumber,
+ name: row.candidateName,
+ party: row.party,
+ votes: row.votes,
+ })
+ }
+
+ return [...byCandidate.values()].sort((left, right) => {
+ if (right.votes !== left.votes) return right.votes - left.votes
+ return left.candidateNumber - right.candidateNumber
+ })
+}
+
+const aggregateElectorate = (
+ tallies: readonly ElectionTallyAggregateRow[],
+ geography: NucleusElectionGeography,
+): NucleusElectoralBaselineViewModel['electorate'] => {
+ let aptos = 0
+ let validos = 0
+ let brancos = 0
+ let nulos = 0
+ let abstencoes = 0
+
+ for (const row of tallies) {
+ if (row.office !== CANDIDATE_OFFICE || row.turn !== '1') continue
+ if (!matchesGeography(row, geography)) continue
+ aptos += row.aptos
+ validos += row.votosValidos
+ brancos += row.votosBranco
+ nulos += row.votosNulo
+ abstencoes += row.abstencoes
+ }
+
+ return { aptos, validos, brancos, nulos, abstencoes }
+}
+
+export const aggregateNucleusElectoralBaseline = (
+ geography: NucleusElectionGeography,
+ votes: readonly ElectionVoteAggregateRow[],
+ tallies: readonly ElectionTallyAggregateRow[],
+): NucleusElectoralBaselineViewModel => {
+ const federalTotals = aggregateFederalCandidateTotals(votes, geography)
+ const candidateIndex = federalTotals.findIndex(
+ (candidate) => candidate.candidateNumber === BASELINE_TICKET_2022.candidate.candidateNumber,
+ )
+ const candidateRow = federalTotals[candidateIndex]
+ const winner = federalTotals[0] ?? null
+
+ return {
+ candidate: {
+ votes: candidateRow?.votes ?? 0,
+ rank: candidateIndex >= 0 ? candidateIndex + 1 : null,
+ },
+ president: decisiveTicketVotes(
+ votes,
+ geography,
+ 'presidente',
+ BASELINE_TICKET_2022.president.candidateNumber,
+ ),
+ governor: decisiveTicketVotes(
+ votes,
+ geography,
+ 'governador',
+ BASELINE_TICKET_2022.governor.candidateNumber,
+ ),
+ electorate: aggregateElectorate(tallies, geography),
+ winnerFederal: winner
+ ? { name: winner.name, votes: winner.votes, party: winner.party }
+ : null,
+ }
+}
+
+const ba2022Scope = (): Where[] => [
+ { year: { equals: ELECTION_YEAR_2022 } },
+ { 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 } }],
+ }),
+ ),
+})
+
+/** Votes for the detail baseline card (federal ranking + president/governor tickets). */
+const detailVoteWhere = (geography: NucleusElectionGeography): Where => ({
+ and: [
+ ...ba2022Scope(),
+ { voteType: { equals: 'nominal' } },
+ geographyWhere(geography),
+ {
+ or: [
+ {
+ and: [{ office: { equals: CANDIDATE_OFFICE } }, { turn: { equals: '1' } }],
+ },
+ {
+ and: [
+ { office: { equals: 'presidente' } },
+ { candidateNumber: { equals: BASELINE_TICKET_2022.president.candidateNumber } },
+ ],
+ },
+ {
+ and: [
+ { office: { equals: 'governador' } },
+ { candidateNumber: { equals: BASELINE_TICKET_2022.governor.candidateNumber } },
+ ],
+ },
+ ],
+ },
+ ],
+})
+
+const loadElectionVotes = async (
+ payload: Pick,
+ user: ElectionReader,
+ geography: NucleusElectionGeography,
+): Promise => {
+ const result = await payload.find({
+ collection: 'electionCandidateVote',
+ where: detailVoteWhere(geography),
+ depth: 0,
+ pagination: false,
+ select: {
+ office: true,
+ turn: true,
+ cityName: true,
+ zoneNumber: true,
+ candidateNumber: true,
+ candidateName: true,
+ party: true,
+ votes: true,
+ },
+ user,
+ overrideAccess: false,
+ })
+
+ return result.docs.map((doc) => ({
+ office: doc.office,
+ turn: doc.turn,
+ cityName: doc.cityName,
+ zoneNumber: doc.zoneNumber,
+ candidateNumber: doc.candidateNumber,
+ candidateName: doc.candidateName,
+ party: doc.party ?? '',
+ votes: doc.votes,
+ }))
+}
+
+const loadElectionTallies = async (
+ payload: Pick,
+ user: ElectionReader,
+ geography: NucleusElectionGeography,
+): Promise => {
+ const result = await payload.find({
+ collection: 'electionTally',
+ where: {
+ and: [
+ ...ba2022Scope(),
+ { office: { equals: CANDIDATE_OFFICE } },
+ { turn: { equals: '1' } },
+ geographyWhere(geography),
+ ],
+ },
+ depth: 0,
+ pagination: false,
+ select: {
+ office: true,
+ turn: true,
+ cityName: true,
+ zoneNumber: true,
+ aptos: true,
+ votosValidos: true,
+ votosBranco: true,
+ votosNulo: true,
+ abstencoes: true,
+ },
+ user,
+ overrideAccess: false,
+ })
+
+ return result.docs.map((doc) => ({
+ office: doc.office,
+ turn: doc.turn,
+ cityName: doc.cityName,
+ zoneNumber: doc.zoneNumber,
+ aptos: doc.aptos,
+ votosValidos: doc.votosValidos,
+ votosBranco: doc.votosBranco,
+ votosNulo: doc.votosNulo,
+ abstencoes: doc.abstencoes,
+ }))
+}
+
+/** Overview Gap: campaign candidate 1º-turno votes indexed by city×zone. */
+const loadCandidateVotesByCityZone = async (
+ payload: Pick,
+ user: ElectionReader,
+ geography: NucleusElectionGeography,
+): Promise