mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-15 10:43:07 +00:00
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:
parent
b12c4766a1
commit
1f8b0c8c72
25 changed files with 1771 additions and 67 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
103
docs/plans/escala-dry-pos-a4.md
Normal file
103
docs/plans/escala-dry-pos-a4.md
Normal 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
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
173
src/components/campaign/NucleusElectoralBaseline.tsx
Normal file
173
src/components/campaign/NucleusElectoralBaseline.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
43
src/components/campaign/NucleusInsights.tsx
Normal file
43
src/components/campaign/NucleusInsights.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
84
src/lib/electionInsights.ts
Normal file
84
src/lib/electionInsights.ts
Normal 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}%`,
|
||||
}
|
||||
}
|
||||
|
|
@ -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>> = {
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
528
src/utilities/nucleusElectoralBaseline.ts
Normal file
528
src/utilities/nucleusElectoralBaseline.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
219
tests/int/nucleusElectoralBaseline.int.spec.ts
Normal file
219
tests/int/nucleusElectoralBaseline.int.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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"')
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
60
tests/unit/electionInsights.unit.spec.ts
Normal file
60
tests/unit/electionInsights.unit.spec.ts
Normal 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)',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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',
|
||||
)
|
||||
|
||||
|
|
|
|||
214
tests/unit/nucleusElectoralBaseline.unit.spec.ts
Normal file
214
tests/unit/nucleusElectoralBaseline.unit.spec.ts
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
66
tests/unit/nucleusElectoralBaselineUi.unit.spec.ts
Normal file
66
tests/unit/nucleusElectoralBaselineUi.unit.spec.ts
Normal 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%')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue