Add A4 electoral baseline in product with Gap vs 2022, and register A7.

Surface TSE 2022 aggregation on nucleus detail and list overview; document scale/DRY follow-ups from the simplify pass.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-18 22:50:10 -03:00
parent b12c4766a1
commit 1f8b0c8c72
25 changed files with 1771 additions and 67 deletions

View file

@ -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-<buildId>` 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.

View file

@ -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.

View file

@ -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:<br/>zones = nucleus.tseZones ∩ tseZonesForCity(C)"]
Query["electionTally + electionCandidateVote<br/>where city=C, zone in zones, office, turn"]
Sum["soma votos por candidato/tipo"]
Baseline["NucleusElectoralBaseline<br/>solla/lula/jeronimo/validos/brancos/nulos/winner/rank"]
Baseline["NucleusElectoralBaseline<br/>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

View file

@ -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<br/>(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

View file

@ -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<br/>(migration) ✓"]
A2["A2 Zonas TSE + sugestões cruzadas<br/>(chips opt-in território↔ZE) ✓"]
A3["A3 Baseline TSE 2022 — Fase 1<br/>(import, collections) ✓"]
A4["A4 Baseline no produto + Gap vs 2022<br/>(detalhe, overview)"]
A4["A4 Baseline no produto + Gap vs 2022<br/>(detalhe, overview) ✓"]
A7["A7 Escala e DRY pós-A4<br/>(agregar federal / cityCode / Alert)"]
A5["A5 Insights: conversão, classificação,<br/>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<br/>(import/listas/forms)"]
C6["C6 Escala e DRY pós-C2<br/>(import/listas/forms) ✓"]
C3["C3 Eventos / agenda de mobilização ✓"]
C7["C7 Escala e DRY pós-C3<br/>(território/contato/leituras)"]
C8["C8 Escala e DRY pós-C6<br/>(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<br/>(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)

View file

@ -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 = ({
</Card>
)}
<div className="mt-4 flex flex-col gap-4">
<NucleusElectoralBaseline baseline={baseline} />
<NucleusInsights baseline={baseline} confirmedVoteEstimate={confirmedVoteEstimate} />
</div>
<Card className="mt-4">
<CardHeader>
<CardTitle>Últimas atualizações</CardTitle>
@ -349,7 +361,9 @@ export const NucleusActiveTab = async ({
if (data.tab === 'overview') {
return (
<OverviewContent
baseline={data.baseline}
canEditIntelligence={user.role !== 'lideranca'}
confirmedVoteEstimate={view.confirmedVoteEstimate}
context={context}
nucleus={view.tabs}
primaryContact={data.primaryContactPageData.current}

View file

@ -0,0 +1,173 @@
import { ChartColumnIcon, TrophyIcon } from 'lucide-react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { formatElectionNumber, NO_ELECTION_BASELINE_MESSAGE } from '@/lib/electionInsights'
import { BASELINE_TICKET_2022 } from '@/lib/electionResults'
import type { NucleusElectoralBaselineViewModel } from '@/utilities/nucleusViewModels'
const ticket = BASELINE_TICKET_2022
const barPercent = (votes: number, scale: number): number => {
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 (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ChartColumnIcon aria-hidden="true" className="size-4 text-primary" />
Baseline eleitoral 2022
</CardTitle>
<CardDescription>Deputado Federal · Bahia</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{NO_ELECTION_BASELINE_MESSAGE}</p>
</CardContent>
</Card>
)
}
const scale = Math.max(
baseline.electorate.validos,
baseline.candidate.votes,
baseline.winnerFederal?.votes ?? 0,
1,
)
const candidateBar = barPercent(baseline.candidate.votes, scale)
return (
<Card className="overflow-hidden">
<CardHeader className="border-b">
<div className="flex flex-wrap items-center justify-between gap-2">
<CardTitle className="flex items-center gap-2">
<ChartColumnIcon aria-hidden="true" className="size-4 text-primary" />
Baseline eleitoral 2022
</CardTitle>
<CardDescription>Deputado Federal · Bahia</CardDescription>
</div>
</CardHeader>
<CardContent className="p-0">
<div className="divide-y">
<div className="border-l-4 border-primary bg-primary/5 px-4 py-3.5">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-bold">{ticket.candidate.name}</p>
<p className="text-xs font-semibold text-primary">
{ticket.candidate.party} · {ticket.candidate.officeLabel}
</p>
</div>
<div className="text-right">
<p className="text-lg font-extrabold tabular-nums text-primary">
{formatElectionNumber(baseline.candidate.votes)}
</p>
<p className="text-xs text-muted-foreground">votos · 2022</p>
</div>
</div>
<div
className="mt-2 h-1.5 overflow-hidden rounded-full bg-primary/20"
role="progressbar"
aria-valuenow={candidateBar}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`Participação de ${ticket.candidate.name}: ${candidateBar}%`}
>
<div
className="h-full rounded-full bg-primary"
style={{ width: `${candidateBar}%` }}
/>
</div>
</div>
{baseline.president ? (
<div className="flex items-center justify-between gap-3 px-4 py-3">
<div>
<p className="text-sm font-semibold">{ticket.president.name}</p>
<p className="text-xs text-muted-foreground">
{ticket.president.party} · Presidente (
{baseline.president.turn === 2 ? '2º turno' : '1º turno'})
</p>
</div>
<p className="text-sm font-bold tabular-nums">
{formatElectionNumber(baseline.president.votes)}
</p>
</div>
) : null}
{baseline.governor ? (
<div className="flex items-center justify-between gap-3 px-4 py-3">
<div>
<p className="text-sm font-semibold">{ticket.governor.name}</p>
<p className="text-xs text-muted-foreground">
{ticket.governor.party} · Governador (
{baseline.governor.turn === 2 ? '2º turno' : '1º turno'})
</p>
</div>
<p className="text-sm font-bold tabular-nums">
{formatElectionNumber(baseline.governor.votes)}
</p>
</div>
) : null}
<div className="bg-muted/40 px-4 py-3">
<p className="mb-2.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Eleitorado 2022
</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">Votos válidos</span>
<span className="text-xs font-bold tabular-nums">
{formatElectionNumber(baseline.electorate.validos)}
</span>
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">Brancos</span>
<span className="text-xs font-bold tabular-nums">
{formatElectionNumber(baseline.electorate.brancos)}
</span>
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">Nulos</span>
<span className="text-xs font-bold tabular-nums">
{formatElectionNumber(baseline.electorate.nulos)}
</span>
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">Abstenções</span>
<span className="text-xs font-bold tabular-nums">
{formatElectionNumber(baseline.electorate.abstencoes)}
</span>
</div>
</div>
</div>
{baseline.winnerFederal ? (
<div className="flex items-center justify-between gap-3 px-4 py-3">
<div className="flex items-start gap-2">
<TrophyIcon
aria-hidden="true"
className="mt-0.5 size-4 shrink-0 text-[color:var(--estimate-pending-foreground)]"
/>
<div>
<p className="text-xs text-muted-foreground">Mais votado aqui em 2022</p>
<p className="text-sm font-semibold">
{baseline.winnerFederal.name} ({baseline.winnerFederal.party})
</p>
</div>
</div>
<p className="text-sm font-bold tabular-nums">
{formatElectionNumber(baseline.winnerFederal.votes)}
</p>
</div>
) : null}
</div>
</CardContent>
</Card>
)
}

View file

@ -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 (
<section aria-labelledby="nucleus-insights-heading" className="flex flex-col gap-3">
<h3 id="nucleus-insights-heading" className="sr-only">
Insights do território
</h3>
<Alert
data-insight="gap-vs-2022"
variant={gap.status === 'above' ? 'default' : 'pending'}
className={cn(
'rounded-xl px-3.5 py-3',
gap.status === 'above' &&
'border-[color:var(--estimate-confirmed)] bg-[color:var(--estimate-confirmed)] text-[color:var(--estimate-confirmed-foreground)] *:data-[slot=alert-description]:text-[color:var(--estimate-confirmed-foreground)]',
)}
>
<Icon aria-hidden="true" />
<AlertTitle className="font-bold">{gap.message}</AlertTitle>
{supportLine ? <AlertDescription>{supportLine}</AlertDescription> : null}
</Alert>
</section>
)
}

View file

@ -86,6 +86,37 @@ export const NucleusListOverview = ({
</p>
</CardContent>
</Card>
{view.baseline2022 ? (
<Card>
<CardHeader>
<CardDescription>Baseline 2022</CardDescription>
<CardTitle className="text-3xl tabular-nums">
{view.baseline2022.gapTotal === null
? '—'
: `${view.baseline2022.gapTotal >= 0 ? '+' : ''}${numberFormatter.format(view.baseline2022.gapTotal)}`}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
<p className="text-sm text-muted-foreground">
{view.baseline2022.gapTotal === null
? 'Sem estimativas comparáveis ao patamar de 2022'
: view.baseline2022.gapTotal >= 0
? 'votos acima do patamar'
: 'votos abaixo do patamar'}
</p>
<p className="text-sm">
<span className="font-semibold text-[color:var(--estimate-confirmed-foreground)]">
{numberFormatter.format(view.baseline2022.above)} acima
</span>
{' · '}
<span className="font-semibold text-[color:var(--estimate-pending-foreground)]">
{numberFormatter.format(view.baseline2022.below)} abaixo
</span>
</p>
</CardContent>
</Card>
) : null}
</div>
<Card>
@ -157,7 +188,5 @@ export const NucleusListOverview = ({
)}
</CardContent>
</Card>
{/* Extension points for future roadmap domains (Demandas): plug new cards here when those collections exist. */}
</section>
)

View file

@ -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}%`,
}
}

View file

@ -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<Record<string, ElectionOffice>> = {

View file

@ -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') {

View file

@ -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<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 = {
cities: string[]
zonesByCity: Map<string, number[]>
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>): 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 { 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<Payload, 'find'>,
user: ElectionReader,
geography: NucleusElectionGeography,
): Promise<ElectionVoteAggregateRow[]> => {
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<Payload, 'find'>,
user: ElectionReader,
geography: NucleusElectionGeography,
): Promise<ElectionTallyAggregateRow[]> => {
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<Payload, 'find'>,
user: ElectionReader,
geography: NucleusElectionGeography,
): Promise<Map<string, number>> => {
const result = await payload.find({
collection: 'electionCandidateVote',
where: {
and: [
...ba2022Scope(),
{ voteType: { equals: 'nominal' } },
{ office: { equals: CANDIDATE_OFFICE } },
{ turn: { equals: '1' } },
{ candidateNumber: { equals: BASELINE_TICKET_2022.candidate.candidateNumber } },
geographyWhere(geography),
],
},
depth: 0,
pagination: false,
select: {
cityName: true,
zoneNumber: true,
votes: true,
},
user,
overrideAccess: false,
})
const byCityZone = new Map<string, number>()
for (const doc of result.docs) {
const key = cityZoneKey(doc.cityName, doc.zoneNumber)
byCityZone.set(key, (byCityZone.get(key) ?? 0) + doc.votes)
}
return byCityZone
}
const sumCandidateVotesForGeography = (
geography: NucleusElectionGeography,
candidateVotesByCityZone: ReadonlyMap<string, number>,
): number =>
geography.cityZonePairs.reduce(
(sum, { cityName, zoneNumber }) =>
sum + (candidateVotesByCityZone.get(cityZoneKey(cityName, zoneNumber)) ?? 0),
0,
)
export const getNucleusElectoralBaseline = async (
payload: Pick<Payload, 'find'>,
user: ElectionReader,
nucleus: NucleusElectionGeographyInput,
): Promise<NucleusElectoralBaselineViewModel | null> => {
const geography = resolveNucleusElectionGeography(nucleus)
if (!geography) return null
const [votes, tallies] = await Promise.all([
loadElectionVotes(payload, user, geography),
loadElectionTallies(payload, user, geography),
])
return aggregateNucleusElectoralBaseline(geography, votes, tallies)
}
export type NucleusBaseline2022OverviewInput = NucleusElectionGeographyInput & {
confirmedVoteEstimate: number | null
}
export type NucleusBaseline2022OverviewAggregate = {
gapTotal: number | null
above: number
below: number
}
/**
* Aggregate Gap vs 2022 over a filtered nucleus set.
* One candidate-only vote query for the union geography, then O(pairs) sums per nucleus.
*/
export const loadNucleusBaseline2022Overview = async (
payload: Pick<Payload, 'find'>,
user: ElectionReader,
nuclei: readonly NucleusBaseline2022OverviewInput[],
): Promise<NucleusBaseline2022OverviewAggregate | null> => {
const geographies = nuclei.map((nucleus) => resolveNucleusElectionGeography(nucleus))
const comparableIndexes: number[] = []
for (let index = 0; index < nuclei.length; index += 1) {
if (!geographies[index]) continue
if (nuclei[index]?.confirmedVoteEstimate == null) continue
comparableIndexes.push(index)
}
// Geography exists but nothing to compare — keep the "—" card without a TSE query.
if (comparableIndexes.length === 0) {
return geographies.some(Boolean) ? { gapTotal: null, above: 0, below: 0 } : null
}
const unionPairs = new Map<string, { cityName: string; zoneNumber: number }>()
const zonesByCity = new Map<string, number[]>()
for (const index of comparableIndexes) {
const geography = geographies[index]
if (!geography) continue
for (const pair of geography.cityZonePairs) {
const key = cityZoneKey(pair.cityName, pair.zoneNumber)
if (unionPairs.has(key)) continue
unionPairs.set(key, pair)
const zones = zonesByCity.get(pair.cityName)
if (zones) zones.push(pair.zoneNumber)
else zonesByCity.set(pair.cityName, [pair.zoneNumber])
}
}
for (const [cityName, zones] of zonesByCity) {
zonesByCity.set(cityName, sortedUniqueZoneNumbers(zones))
}
const unionGeography: NucleusElectionGeography = {
cities: uniqueSortedCities(zonesByCity.keys()),
zonesByCity,
cityZonePairs: [...unionPairs.values()],
}
const candidateVotesByCityZone = await loadCandidateVotesByCityZone(payload, user, unionGeography)
let estimateSum = 0
let candidateVotesSum = 0
let comparableCount = 0
let above = 0
let below = 0
for (const index of comparableIndexes) {
const geography = geographies[index]
const estimate = nuclei[index]?.confirmedVoteEstimate
if (!geography || estimate == null) continue
const candidateVotes = sumCandidateVotesForGeography(geography, candidateVotesByCityZone)
if (candidateVotes <= 0) continue
if (estimate >= candidateVotes) above += 1
else below += 1
estimateSum += estimate
candidateVotesSum += candidateVotes
comparableCount += 1
}
return {
gapTotal: comparableCount > 0 ? estimateSum - candidateVotesSum : null,
above,
below,
}
}

View file

@ -2,6 +2,10 @@ import type { Payload } from 'payload'
import type { CampaignUser, NucleusUpdate } from '@/payload-types'
import { loadUpcomingActionPlansPreview } from '@/utilities/actionPlanUpcomingPreview'
import {
loadNucleusBaseline2022Overview,
toNucleusElectionGeographyInput,
} from '@/utilities/nucleusElectoralBaseline'
import {
buildNucleusListOverviewViewModel,
nucleusListOverviewPreviewLimit,
@ -16,6 +20,9 @@ const overviewStaffNucleusSelect = {
slug: true,
name: true,
coordinators: true,
cities: true,
regions: true,
tseZones: { zoneNumber: true },
confirmedVoteEstimate: true,
proposedVoteEstimate: true,
} as const
@ -24,6 +31,9 @@ const overviewLeadershipNucleusSelect = {
slug: true,
name: true,
coordinators: true,
cities: true,
regions: true,
tseZones: { zoneNumber: true },
confirmedVoteEstimate: true,
} as const
@ -39,6 +49,9 @@ type RawOverviewNucleus = {
slug: string
name: string
coordinators?: Array<number | { id: number }>
cities?: string[] | null
regions?: string[] | null
tseZones?: Array<{ zoneNumber: number }> | null
confirmedVoteEstimate?: number | null
proposedVoteEstimate?: number | null
}
@ -79,27 +92,34 @@ export const loadNucleusListOverviewData = async (
const rawNuclei = nucleusResult.docs as unknown as RawOverviewNucleus[]
if (rawNuclei.length === 0) return null
const nuclei: NucleusListOverviewNucleusRecord[] = rawNuclei.map((nucleus) => ({
id: nucleus.id,
slug: nucleus.slug,
name: nucleus.name,
coordinators: nucleus.coordinators ?? [],
confirmedVoteEstimate: nucleus.confirmedVoteEstimate ?? null,
proposedVoteEstimate: nucleus.proposedVoteEstimate ?? null,
}))
const nuclei: NucleusListOverviewNucleusRecord[] = rawNuclei.map((nucleus) => {
const geography = toNucleusElectionGeographyInput(nucleus)
return {
id: nucleus.id,
slug: nucleus.slug,
name: nucleus.name,
coordinators: nucleus.coordinators ?? [],
...geography,
confirmedVoteEstimate: nucleus.confirmedVoteEstimate ?? null,
proposedVoteEstimate: nucleus.proposedVoteEstimate ?? null,
}
})
const nucleiById = new Map(nuclei.map((nucleus) => [nucleus.id, nucleus]))
const updateResult = await payload.find({
collection: 'nucleusUpdate',
where: { nucleus: { in: nuclei.map(({ id }) => id) } },
depth: 0,
limit: nucleusListOverviewPreviewLimit,
page: 1,
sort: '-createdAt',
select: overviewUpdateSelect,
user,
overrideAccess: false,
})
const [updateResult, baseline2022] = await Promise.all([
payload.find({
collection: 'nucleusUpdate',
where: { nucleus: { in: nuclei.map(({ id }) => id) } },
depth: 0,
limit: nucleusListOverviewPreviewLimit,
page: 1,
sort: '-createdAt',
select: overviewUpdateSelect,
user,
overrideAccess: false,
}),
loadNucleusBaseline2022Overview(payload, user, nuclei),
])
const updates = updateResult.docs as unknown as RawOverviewUpdate[]
const authorIds = [...new Set(updates.map(({ author }) => requireRelationshipId(author)))]
@ -144,5 +164,6 @@ export const loadNucleusListOverviewData = async (
recentUpdates,
role: user.role,
upcomingActionPlans,
baseline2022,
})
}

View file

@ -1,5 +1,6 @@
import type { CampaignUser, NucleusUpdate } from '@/payload-types'
import type { ActionPlanUpcomingPreviewRecord } from '@/utilities/actionPlanUpcomingPreview'
import type { NucleusBaseline2022OverviewAggregate } from '@/utilities/nucleusElectoralBaseline'
export const nucleusListOverviewPreviewLimit = 3
@ -8,10 +9,15 @@ export type NucleusListOverviewNucleusRecord = {
slug: string
name: string
coordinators: Array<number | { id: number }>
cities: string[]
regions: string[]
tseZones: number[]
confirmedVoteEstimate: number | null
proposedVoteEstimate: number | null
}
export type NucleusListOverviewBaseline2022 = NucleusBaseline2022OverviewAggregate
export type NucleusListOverviewUpdateRecord = {
id: number
nucleusSlug: string
@ -34,6 +40,8 @@ export type NucleusListOverviewViewModel = {
coordinatedCount: number
percent: number
}
/** Null when no nucleus in the filtered set has resolvable TSE geography. */
baseline2022: NucleusListOverviewBaseline2022 | null
recentUpdates: NucleusListOverviewUpdateRecord[]
upcomingActionPlans: ActionPlanUpcomingPreviewRecord[]
}
@ -46,11 +54,13 @@ export const buildNucleusListOverviewViewModel = ({
recentUpdates,
role,
upcomingActionPlans,
baseline2022 = null,
}: {
nuclei: NucleusListOverviewNucleusRecord[]
recentUpdates: NucleusListOverviewUpdateRecord[]
role: CampaignUser['role']
upcomingActionPlans: ActionPlanUpcomingPreviewRecord[]
baseline2022?: NucleusListOverviewBaseline2022 | null
}): NucleusListOverviewViewModel => {
const totalFiltered = nuclei.length
const confirmedCount = nuclei.filter(
@ -80,6 +90,7 @@ export const buildNucleusListOverviewViewModel = ({
coordinatedCount,
percent: percentage(coordinatedCount, totalFiltered),
},
baseline2022,
recentUpdates,
upcomingActionPlans,
}

View file

@ -196,6 +196,25 @@ export type LeadershipNucleusDetailViewModel = NucleusDetailBaseViewModel & {
export type NucleusDetailViewModel = StaffNucleusDetailViewModel | LeadershipNucleusDetailViewModel
/**
* Aggregated TSE 2022 baseline for a nucleus geography (A4). Non-null implies geography resolved.
* Keys are ticket roles (see BASELINE_TICKET_2022), not candidate names, so the
* same shape carries over to future elections.
*/
export type NucleusElectoralBaselineViewModel = {
candidate: { votes: number; rank: number | null }
president: { votes: number; turn: 1 | 2 } | null
governor: { votes: number; turn: 1 | 2 } | null
electorate: {
aptos: number
validos: number
brancos: number
nulos: number
abstencoes: number
}
winnerFederal: { name: string; votes: number; party: string } | null
}
const relationshipName = (relationship: number | CampaignUser | null | undefined): string | null =>
isPopulatedRelationship<CampaignUser>(relationship) ? relationship.name : null

View file

@ -234,7 +234,7 @@ test.describe('Campaign nucleus journeys', () => {
`${campaign.baseURL}/campanha/nucleos?q=${encodeURIComponent(nucleusName)}&page=3`,
)
await page.waitForLoadState('networkidle')
await page.getByLabel('Cobertura').selectOption('sem_coordenador')
await page.getByLabel('Cobertura', { exact: true }).selectOption('sem_coordenador')
await expect
.poll(() => new URL(page.url()).searchParams.get('coverage'), { timeout: 45_000 })
.toBe('sem_coordenador')
@ -245,7 +245,7 @@ test.describe('Campaign nucleus journeys', () => {
await page.goto(`${campaign.baseURL}/campanha/nucleos/${nucleus.slug}`)
await page.goBack()
await expect(page).toHaveURL(filteredURL)
await expect(page.getByLabel('Cobertura')).toHaveValue('sem_coordenador')
await expect(page.getByLabel('Cobertura', { exact: true })).toHaveValue('sem_coordenador')
await page.goForward()
await expect(page).toHaveURL(
`${campaign.baseURL}/campanha/nucleos/${nucleus.slug}?tab=overview`,
@ -316,4 +316,45 @@ test.describe('Campaign nucleus journeys', () => {
else expect(geometry.scrollWidth).toBeGreaterThan(geometry.clientWidth)
}
})
test('renders Baseline eleitoral 2022 and Gap insight on the overview tab', async ({
campaign,
page,
}) => {
test.slow()
const email = `${campaign.fixtures.value('baseline')}@example.com`
const password = campaign.fixtures.value('BaselinePassword')
const nucleus = await campaign.transaction(async (payload, req) => {
await payload.create({
collection: 'campaignUser',
data: {
name: campaign.fixtures.value('Coordenação baseline'),
email,
password,
role: 'geral',
},
depth: 0,
req,
})
return payload.create({
collection: 'electoralNucleus',
data: {
name: campaign.fixtures.value('Núcleo baseline'),
cities: ['Salvador'],
organizationKind: 'territorial',
} as never,
depth: 0,
req,
})
})
await campaign.login(page, email, password)
await page.goto(`${campaign.baseURL}/campanha/nucleos/${nucleus.slug}?tab=overview`)
await page.waitForLoadState('networkidle')
await expect(page.getByText('Baseline eleitoral 2022')).toBeVisible({ timeout: 20_000 })
await expect(page.locator('[data-insight="gap-vs-2022"]')).toBeVisible()
await page.goto(`${campaign.baseURL}/campanha/nucleos`)
await page.waitForLoadState('networkidle')
await expect(page.getByText('Baseline 2022')).toBeVisible({ timeout: 20_000 })
})
})

View file

@ -4,7 +4,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { getPayload, type Payload } from 'payload'
import config from '@/payload.config'
import { SOLLA_CANDIDATE_NUMBER } from '@/lib/electionResults'
import { BASELINE_TICKET_2022 } from '@/lib/electionResults'
import {
buildImportBundles,
importElectionBundles,
@ -72,7 +72,7 @@ describe('election results import', () => {
.filter(
(doc) =>
doc.office === 'deputado_federal' &&
Number(doc.candidateNumber) === SOLLA_CANDIDATE_NUMBER,
Number(doc.candidateNumber) === BASELINE_TICKET_2022.candidate.candidateNumber,
)
.reduce((sum, doc) => sum + Number(doc.votes), 0)
expect(sollaVotes).toBe(TSE_FIXTURE_EXPECTED.sollaVotesTotal)

View file

@ -0,0 +1,219 @@
// @vitest-environment node
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { getPayload, type Payload } from 'payload'
import config from '@/payload.config'
import { BASELINE_TICKET_2022 } from '@/lib/electionResults'
import { computeGapVs2022 } from '@/lib/electionInsights'
import { loadNucleusActiveTabPageData } from '@/utilities/nucleusDetailPageData'
import { getNucleusElectoralBaseline } from '@/utilities/nucleusElectoralBaseline'
import { loadNucleusListOverviewData } from '@/utilities/nucleusListOverviewPageData'
import { parseNucleusListParams } from '@/utilities/nucleusUi'
import {
buildImportBundles,
importElectionBundles,
} from '@/utilities/electionResultsImport'
import { loadTseFixtureResults, TSE_FIXTURE_EXPECTED } from '../helpers/tseFixtures'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
const deleteAllElectionData = async () => {
for (const collection of [
'electionCandidateVote',
'electionTally',
'electionCandidate',
] as const) {
const existing = await payload.find({
collection,
depth: 0,
limit: 0,
pagination: false,
overrideAccess: true,
})
for (const doc of existing.docs) {
await payload.delete({
collection,
id: doc.id,
overrideAccess: true,
})
}
}
}
describe('nucleus electoral baseline (A4)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
await deleteAllElectionData()
const built = loadTseFixtureResults()
await importElectionBundles(payload, buildImportBundles(built))
})
afterAll(async () => {
await deleteAllElectionData()
})
it('aggregates Salvador zones for a campaign user and computes gap states', async () => {
const fixtures = campaignFixtures()
const user = await fixtures.createCampaignUser('geral', {
name: fixtures.value('Baseline geral'),
email: `${fixtures.value('baseline-geral')}@example.com`,
password: fixtures.value('password'),
})
const baseline = await getNucleusElectoralBaseline(payload, user, {
cities: ['Salvador'],
regions: ['Metropolitano de Salvador'],
tseZones: [1, 2],
})
expect(baseline).not.toBeNull()
expect(baseline?.candidate.votes).toBe(1200 + 900)
expect(baseline?.president).toEqual({ votes: TSE_FIXTURE_EXPECTED.lulaTurn2SalvadorZ1, turn: 2 })
expect(baseline?.governor).toEqual({
votes: TSE_FIXTURE_EXPECTED.jeronimoTurn2SalvadorZ1,
turn: 2,
})
expect(baseline?.electorate.aptos).toBe(10000 + 9000)
expect(baseline?.winnerFederal?.name).toBeTruthy()
expect(baseline?.candidate.rank).toBe(1)
expect(computeGapVs2022(baseline, null).status).toBe('noEstimate')
expect(computeGapVs2022(baseline, 1500).status).toBe('below')
expect(computeGapVs2022(baseline, 3000).status).toBe('above')
expect(BASELINE_TICKET_2022.candidate.candidateNumber).toBe(1313)
})
it('returns null without geography and loads baseline on the overview tab', async () => {
const fixtures = campaignFixtures()
const user = await fixtures.createCampaignUser('coordenador', {
name: fixtures.value('Baseline coord'),
email: `${fixtures.value('baseline-coord')}@example.com`,
password: fixtures.value('password'),
})
expect(
await getNucleusElectoralBaseline(payload, user, {
cities: [],
regions: [],
tseZones: [],
}),
).toBeNull()
const nucleus = await fixtures.createNucleus({
name: fixtures.value('Núcleo baseline detalhe'),
regions: ['Metropolitano de Salvador'],
cities: ['Salvador'],
tseZones: [{ zoneNumber: 1 }],
organizationKind: 'territorial',
coordinators: [user.id],
})
await payload.update({
collection: 'electoralNucleus',
id: nucleus.id,
data: { confirmedVoteEstimate: 1500 },
depth: 0,
overrideAccess: true,
})
const { resolveAccessibleNucleusContext } = await import('@/utilities/nucleusPageData')
const context = await resolveAccessibleNucleusContext(payload, user, nucleus.slug, 'overview')
const tabData = await loadNucleusActiveTabPageData(
payload,
user,
context,
'overview',
{},
)
expect(tabData.tab).toBe('overview')
if (tabData.tab !== 'overview') return
expect(tabData.baseline?.candidate.votes).toBe(1200)
expect(computeGapVs2022(tabData.baseline, 1500).status).toBe('above')
})
it('aggregates Baseline 2022 on the filtered list overview for leadership and staff', async () => {
const fixtures = campaignFixtures()
const general = await fixtures.createCampaignUser('geral', {
name: fixtures.value('Overview baseline geral'),
email: `${fixtures.value('overview-baseline-geral')}@example.com`,
password: fixtures.value('password'),
})
const leadershipUser = await fixtures.createCampaignUser('lideranca', {
name: fixtures.value('Overview baseline lider'),
password: fixtures.value('password'),
})
const marker = fixtures.value('A4Overview')
const above = await fixtures.createNucleus({
name: `${marker} acima`,
regions: ['Metropolitano de Salvador'],
cities: ['Salvador'],
tseZones: [{ zoneNumber: 1 }],
organizationKind: 'territorial',
})
const below = await fixtures.createNucleus({
name: `${marker} abaixo`,
regions: ['Metropolitano de Salvador'],
cities: ['Salvador'],
tseZones: [{ zoneNumber: 2 }],
organizationKind: 'territorial',
})
await payload.update({
collection: 'electoralNucleus',
id: above.id,
data: { confirmedVoteEstimate: 2000 },
depth: 0,
overrideAccess: true,
})
await payload.update({
collection: 'electoralNucleus',
id: below.id,
data: { confirmedVoteEstimate: 100 },
depth: 0,
overrideAccess: true,
})
const contact = await fixtures.createContact({
name: fixtures.value('Lider overview baseline'),
phone: leadershipUser.phone ?? fixtures.phone(),
})
await fixtures.createLeadership({
contact: contact.id,
nucleus: above.id,
supportStatus: 'engajado',
user: leadershipUser.id,
})
const filteredOverview = await loadNucleusListOverviewData(
payload,
general,
parseNucleusListParams({ q: marker }),
)
expect(filteredOverview?.totalFiltered).toBe(2)
expect(filteredOverview?.baseline2022).toEqual({
// (2000 + 100) − (1200 + 900)
gapTotal: 0,
above: 1,
below: 1,
})
const leadershipOverview = await loadNucleusListOverviewData(
payload,
leadershipUser,
parseNucleusListParams({}),
)
expect(leadershipOverview?.baseline2022).not.toBeNull()
expect(leadershipOverview?.totalFiltered).toBe(1)
expect(leadershipOverview?.baseline2022?.above).toBe(1)
})
})

View file

@ -9,6 +9,7 @@ const loaders = vi.hoisted(() => ({
getNucleusPrimaryContactPageData: vi.fn(),
getNucleusUpdatesPreviewData: vi.fn(),
getNucleusUpdatesPageData: vi.fn(),
getNucleusElectoralBaseline: vi.fn(),
}))
vi.mock('@/utilities/campaignInvitePageData', () => ({
@ -25,6 +26,10 @@ vi.mock('@/utilities/nucleusUpdatePageData', () => ({
getNucleusUpdatesPreviewData: loaders.getNucleusUpdatesPreviewData,
getNucleusUpdatesPageData: loaders.getNucleusUpdatesPageData,
}))
vi.mock('@/utilities/nucleusElectoralBaseline', () => ({
getNucleusElectoralBaseline: loaders.getNucleusElectoralBaseline,
toNucleusElectionGeographyInput: () => ({ cities: [], regions: [], tseZones: [] }),
}))
import { NucleusTabNav } from '@/components/campaign/NucleusTabNav'
import {
@ -62,6 +67,7 @@ describe('campaign nucleus URL-driven detail tabs', () => {
})
loaders.getNucleusPrimaryContactPageData.mockResolvedValue({ current: null, options: [] })
loaders.getNucleusUpdatesPreviewData.mockResolvedValue([])
loaders.getNucleusElectoralBaseline.mockResolvedValue(null)
loaders.getNucleusUpdatesPageData.mockResolvedValue({
updates: [],
page: 1,
@ -183,6 +189,7 @@ describe('campaign nucleus URL-driven detail tabs', () => {
expect(loaders.getNucleusUpdatesPreviewData).toHaveBeenCalledOnce()
expect(loaders.getNucleusPrimaryContactPageData).toHaveBeenCalledOnce()
expect(loaders.getNucleusElectoralBaseline).toHaveBeenCalledOnce()
expect(loaders.getNucleusLeadershipPageData).not.toHaveBeenCalled()
expect(loaders.getCampaignInviteConsentState).not.toHaveBeenCalled()
expect(loaders.getNucleusUpdatesPageData).not.toHaveBeenCalled()
@ -190,6 +197,7 @@ describe('campaign nucleus URL-driven detail tabs', () => {
tab: 'overview',
primaryContactPageData: { current: null, options: [] },
updatePreview: [],
baseline: null,
})
expect(JSON.stringify(result)).not.toContain('leaderships')
expect(JSON.stringify(result)).not.toContain('"updates"')

View file

@ -10,11 +10,17 @@ const now = new Date('2026-07-18T15:00:00.000Z')
const nucleus = ({
id,
coordinators = [],
cities = ['Salvador'],
regions = ['Metropolitano de Salvador'],
tseZones = [1],
confirmedVoteEstimate = null,
proposedVoteEstimate = null,
}: {
id: number
coordinators?: number[]
cities?: string[]
regions?: string[]
tseZones?: number[]
confirmedVoteEstimate?: number | null
proposedVoteEstimate?: number | null
}) => ({
@ -22,6 +28,9 @@ const nucleus = ({
slug: `nucleo-${id}`,
name: `Núcleo ${id}`,
coordinators,
cities,
regions,
tseZones,
confirmedVoteEstimate,
proposedVoteEstimate,
})
@ -127,4 +136,35 @@ describe('NucleusListOverview', () => {
expect(html).toContain('2 sugestões pendentes')
expect(html).toContain('Nenhuma atualização recente')
})
it('renders the Baseline 2022 card when aggregate data is present', () => {
const view = buildNucleusListOverviewViewModel({
role: 'geral',
nuclei: [nucleus({ id: 1, confirmedVoteEstimate: 1200 })],
recentUpdates: [],
upcomingActionPlans: [],
baseline2022: { gapTotal: 2140, above: 8, below: 6 },
})
const html = renderToStaticMarkup(createElement(NucleusListOverview, { view, now }))
expect(html).toContain('Baseline 2022')
expect(html).toContain('+2.140')
expect(html).toContain('8 acima')
expect(html).toContain('6 abaixo')
})
it('hides the Baseline 2022 card when aggregate is null', () => {
const view = buildNucleusListOverviewViewModel({
role: 'lideranca',
nuclei: [nucleus({ id: 1, cities: [], regions: [], tseZones: [] })],
recentUpdates: [],
upcomingActionPlans: [],
baseline2022: null,
})
const html = renderToStaticMarkup(createElement(NucleusListOverview, { view, now }))
expect(html).not.toContain('Baseline 2022')
})
})

View file

@ -0,0 +1,60 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { computeGapVs2022 } from '@/lib/electionInsights'
import type { NucleusElectoralBaselineViewModel } from '@/utilities/nucleusViewModels'
const baseline = (candidateVotes: number): NucleusElectoralBaselineViewModel => ({
candidate: { votes: candidateVotes, rank: candidateVotes > 0 ? 1 : null },
president: null,
governor: null,
electorate: { aptos: 0, validos: 0, brancos: 0, nulos: 0, abstencoes: 0 },
winnerFederal: null,
})
describe('computeGapVs2022', () => {
it('reports below when the estimate is under the 2022 patamar', () => {
expect(computeGapVs2022(baseline(850), 500)).toEqual({
gap: -350,
ratio: 500 / 850,
status: 'below',
message: 'Faltam 350 votos para o patamar de 2022',
})
})
it('reports above with the percent over 2022', () => {
expect(computeGapVs2022(baseline(850), 952)).toMatchObject({
gap: 102,
status: 'above',
message: 'Já superamos 2022 em 12%',
})
})
it('reports noCandidateVotes when the geography had zero candidate votes in 2022', () => {
expect(computeGapVs2022(baseline(0), 100)).toEqual({
gap: null,
ratio: null,
status: 'noCandidateVotes',
message: 'Jorge Solla não recebeu votos aqui em 2022 — território novo a abrir',
})
})
it('reports noEstimate when confirmedVoteEstimate is null', () => {
expect(computeGapVs2022(baseline(850), null)).toEqual({
gap: null,
ratio: null,
status: 'noEstimate',
message: 'Sem estimativa confirmada para comparar',
})
})
it('reports noBaseline when geography is missing', () => {
expect(computeGapVs2022(null, 100)).toEqual({
gap: null,
ratio: null,
status: 'noBaseline',
message: 'Sem baseline TSE (informe território/município)',
})
})
})

View file

@ -6,14 +6,12 @@ import { bahiaMunicipalities } from '@/lib/bahiaTerritories'
import { computeIdentityKey, normalizeIdentityPart } from '@/lib/electionCandidateIdentity'
import {
assertAllCanonicalMunicipalitiesResolvable,
BASELINE_TICKET_2022,
canonicalizeMunicipalityName,
computeWinnersByScope,
JERONIMO_CANDIDATE_NUMBER,
LULA_CANDIDATE_NUMBER,
mergeTallyWithWinners,
normalizeMunicipalityKey,
parseElectedStatus,
SOLLA_CANDIDATE_NUMBER,
UnknownMunicipalityError,
winnerKey,
} from '@/lib/electionResults'
@ -108,7 +106,7 @@ describe('electionResults fixture parse + winners', () => {
const sollaVotes = built.votes
.filter(
(row) =>
row.office === 'deputado_federal' && row.candidateNumber === SOLLA_CANDIDATE_NUMBER,
row.office === 'deputado_federal' && row.candidateNumber === BASELINE_TICKET_2022.candidate.candidateNumber,
)
.reduce((sum, row) => sum + row.votes, 0)
expect(sollaVotes).toBe(TSE_FIXTURE_EXPECTED.sollaVotesTotal)
@ -116,18 +114,18 @@ describe('electionResults fixture parse + winners', () => {
it('marks Solla, Lula and Jerônimo as elected', () => {
const solla = built.candidates.find(
(c) => c.office === 'deputado_federal' && c.candidateNumber === SOLLA_CANDIDATE_NUMBER,
(c) => c.office === 'deputado_federal' && c.candidateNumber === BASELINE_TICKET_2022.candidate.candidateNumber,
)
const lula = built.candidates.find(
(c) =>
c.office === 'presidente' &&
c.candidateNumber === LULA_CANDIDATE_NUMBER &&
c.candidateNumber === BASELINE_TICKET_2022.president.candidateNumber &&
c.turn === '2',
)
const jeronimo = built.candidates.find(
(c) =>
c.office === 'governador' &&
c.candidateNumber === JERONIMO_CANDIDATE_NUMBER &&
c.candidateNumber === BASELINE_TICKET_2022.governor.candidateNumber &&
c.turn === '2',
)

View file

@ -0,0 +1,214 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { citiesForTerritory } from '@/lib/bahiaTerritories'
import { tseZonesForCity } from '@/lib/bahiaTseZones'
import { BASELINE_TICKET_2022 } from '@/lib/electionResults'
import {
aggregateNucleusElectoralBaseline,
resolveNucleusElectionGeography,
type ElectionTallyAggregateRow,
type ElectionVoteAggregateRow,
} from '@/utilities/nucleusElectoralBaseline'
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?.cities).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?.cities).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'],
regions: [],
tseZones: [1, 2],
})!
const votes: ElectionVoteAggregateRow[] = [
{
office: 'deputado_federal',
turn: '1',
cityName: 'Salvador',
zoneNumber: 1,
candidateNumber: BASELINE_TICKET_2022.candidate.candidateNumber,
candidateName: 'JORGE SOLLA',
party: 'PT',
votes: 1200,
},
{
office: 'deputado_federal',
turn: '1',
cityName: 'Salvador',
zoneNumber: 2,
candidateNumber: BASELINE_TICKET_2022.candidate.candidateNumber,
candidateName: 'JORGE SOLLA',
party: 'PT',
votes: 900,
},
{
office: 'deputado_federal',
turn: '1',
cityName: 'Salvador',
zoneNumber: 1,
candidateNumber: 2222,
candidateName: 'RIVAL FEDERAL',
party: 'PL',
votes: 800,
},
{
office: 'deputado_federal',
turn: '1',
cityName: 'Salvador',
zoneNumber: 2,
candidateNumber: 2222,
candidateName: 'RIVAL FEDERAL',
party: 'PL',
votes: 1100,
},
{
office: 'presidente',
turn: '2',
cityName: 'Salvador',
zoneNumber: 1,
candidateNumber: BASELINE_TICKET_2022.president.candidateNumber,
candidateName: 'LULA',
party: 'PT',
votes: 8000,
},
{
office: 'governador',
turn: '2',
cityName: 'Salvador',
zoneNumber: 1,
candidateNumber: BASELINE_TICKET_2022.governor.candidateNumber,
candidateName: 'JERÔNIMO',
party: 'PT',
votes: 6000,
},
// Outside selected zones — must be ignored
{
office: 'deputado_federal',
turn: '1',
cityName: 'Feira de Santana',
zoneNumber: 10,
candidateNumber: BASELINE_TICKET_2022.candidate.candidateNumber,
candidateName: 'JORGE SOLLA',
party: 'PT',
votes: 500,
},
]
const tallies: ElectionTallyAggregateRow[] = [
{
office: 'deputado_federal',
turn: '1',
cityName: 'Salvador',
zoneNumber: 1,
aptos: 10000,
votosValidos: 7500,
votosBranco: 300,
votosNulo: 200,
abstencoes: 2000,
},
{
office: 'deputado_federal',
turn: '1',
cityName: 'Salvador',
zoneNumber: 2,
aptos: 9000,
votosValidos: 6800,
votosBranco: 250,
votosNulo: 150,
abstencoes: 1800,
},
]
it('sums the candidate, ticket leaders, electorate, winner, and rank for the geography', () => {
const baseline = aggregateNucleusElectoralBaseline(geography, votes, tallies)
expect(baseline.candidate).toEqual({ votes: 2100, rank: 1 })
expect(baseline.president).toEqual({ votes: 8000, turn: 2 })
expect(baseline.governor).toEqual({ votes: 6000, turn: 2 })
expect(baseline.electorate).toEqual({
aptos: 19000,
validos: 14300,
brancos: 550,
nulos: 350,
abstencoes: 3800,
})
// Candidate 2100 vs Rival 1900 → candidate wins and ranks 1
expect(baseline.winnerFederal).toEqual({
name: 'JORGE SOLLA',
votes: 2100,
party: 'PT',
})
})
it('ranks the candidate below the local federal winner when applicable', () => {
const zone1Only = resolveNucleusElectionGeography({
cities: ['Salvador'],
regions: [],
tseZones: [2],
})!
const baseline = aggregateNucleusElectoralBaseline(zone1Only, votes, tallies)
expect(baseline.candidate).toEqual({ votes: 900, rank: 2 })
expect(baseline.winnerFederal).toEqual({
name: 'RIVAL FEDERAL',
votes: 1100,
party: 'PL',
})
})
})

View file

@ -0,0 +1,66 @@
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { NucleusElectoralBaseline } from '@/components/campaign/NucleusElectoralBaseline'
import { NucleusInsights } from '@/components/campaign/NucleusInsights'
import type { NucleusElectoralBaselineViewModel } from '@/utilities/nucleusViewModels'
const baseline: NucleusElectoralBaselineViewModel = {
candidate: { votes: 850, rank: 2 },
president: { votes: 1480, turn: 2 },
governor: { votes: 1310, turn: 2 },
electorate: {
aptos: 4700,
validos: 2490,
brancos: 142,
nulos: 87,
abstencoes: 680,
},
winnerFederal: { name: 'Dep. Fulano de Tal', votes: 2100, party: 'PP' },
}
describe('NucleusElectoralBaseline', () => {
it('renders the empty geography state', () => {
const html = renderToStaticMarkup(createElement(NucleusElectoralBaseline, { baseline: null }))
expect(html).toContain('Baseline eleitoral 2022')
expect(html).toContain('Sem baseline TSE (informe território/município)')
})
it('renders the candidate, ticket, electorate, and local winner rows', () => {
const html = renderToStaticMarkup(createElement(NucleusElectoralBaseline, { baseline }))
expect(html).toContain('Jorge Solla')
expect(html).toContain('850')
expect(html).toContain('Lula')
expect(html).toContain('1.480')
expect(html).toContain('Jerônimo Rodrigues')
expect(html).toContain('Eleitorado 2022')
expect(html).toContain('2.490')
expect(html).toContain('Mais votado aqui em 2022')
expect(html).toContain('Dep. Fulano de Tal (PP)')
})
})
describe('NucleusInsights', () => {
it('renders the amber below alert for Gap vs 2022', () => {
const html = renderToStaticMarkup(
createElement(NucleusInsights, {
baseline,
confirmedVoteEstimate: 500,
}),
)
expect(html).toContain('data-insight="gap-vs-2022"')
expect(html).toContain('Faltam 350 votos para o patamar de 2022')
expect(html).toContain('Estimativa atual (500)')
})
it('renders the green above alert when the estimate clears 2022', () => {
const html = renderToStaticMarkup(
createElement(NucleusInsights, {
baseline,
confirmedVoteEstimate: 952,
}),
)
expect(html).toContain('Já superamos 2022 em 12%')
})
})