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

{NO_ELECTION_BASELINE_MESSAGE}

+
+
+ ) + } + + const scale = Math.max( + baseline.electorate.validos, + baseline.candidate.votes, + baseline.winnerFederal?.votes ?? 0, + 1, + ) + const candidateBar = barPercent(baseline.candidate.votes, scale) + + return ( + + +
+ + + Deputado Federal · Bahia +
+
+ +
+
+
+
+

{ticket.candidate.name}

+

+ {ticket.candidate.party} · {ticket.candidate.officeLabel} +

+
+
+

+ {formatElectionNumber(baseline.candidate.votes)} +

+

votos · 2022

+
+
+
+
+
+
+ + {baseline.president ? ( +
+
+

{ticket.president.name}

+

+ {ticket.president.party} · Presidente ( + {baseline.president.turn === 2 ? '2º turno' : '1º turno'}) +

+
+

+ {formatElectionNumber(baseline.president.votes)} +

+
+ ) : null} + + {baseline.governor ? ( +
+
+

{ticket.governor.name}

+

+ {ticket.governor.party} · Governador ( + {baseline.governor.turn === 2 ? '2º turno' : '1º turno'}) +

+
+

+ {formatElectionNumber(baseline.governor.votes)} +

+
+ ) : null} + +
+

+ Eleitorado 2022 +

+
+
+ Votos válidos + + {formatElectionNumber(baseline.electorate.validos)} + +
+
+ Brancos + + {formatElectionNumber(baseline.electorate.brancos)} + +
+
+ Nulos + + {formatElectionNumber(baseline.electorate.nulos)} + +
+
+ Abstenções + + {formatElectionNumber(baseline.electorate.abstencoes)} + +
+
+
+ + {baseline.winnerFederal ? ( +
+
+
+

+ {formatElectionNumber(baseline.winnerFederal.votes)} +

+
+ ) : null} +
+ + + ) +} diff --git a/src/components/campaign/NucleusInsights.tsx b/src/components/campaign/NucleusInsights.tsx new file mode 100644 index 00000000..b5ac073f --- /dev/null +++ b/src/components/campaign/NucleusInsights.tsx @@ -0,0 +1,43 @@ +import { CheckCircle2Icon, TriangleAlertIcon } from 'lucide-react' + +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert' +import { computeGapVs2022, formatElectionNumber } from '@/lib/electionInsights' +import { cn } from '@/lib/utils' +import type { NucleusElectoralBaselineViewModel } from '@/utilities/nucleusViewModels' + +export const NucleusInsights = ({ + baseline, + confirmedVoteEstimate, +}: { + baseline: NucleusElectoralBaselineViewModel | null + confirmedVoteEstimate: number | null +}) => { + const gap = computeGapVs2022(baseline, confirmedVoteEstimate) + const Icon = gap.status === 'above' ? CheckCircle2Icon : TriangleAlertIcon + + let supportLine: string | null = null + if ((gap.status === 'above' || gap.status === 'below') && baseline && confirmedVoteEstimate !== null) { + supportLine = `Estimativa atual (${formatElectionNumber(confirmedVoteEstimate)}) · resultado 2022 (${formatElectionNumber(baseline.candidate.votes)})` + } + + return ( +
+

+ Insights do território +

+ + +
+ ) +} diff --git a/src/components/campaign/NucleusListOverview.tsx b/src/components/campaign/NucleusListOverview.tsx index ba236b0d..1594566f 100644 --- a/src/components/campaign/NucleusListOverview.tsx +++ b/src/components/campaign/NucleusListOverview.tsx @@ -86,6 +86,37 @@ export const NucleusListOverview = ({

+ + {view.baseline2022 ? ( + + + Baseline 2022 + + {view.baseline2022.gapTotal === null + ? '—' + : `${view.baseline2022.gapTotal >= 0 ? '+' : ''}${numberFormatter.format(view.baseline2022.gapTotal)}`} + + + +

+ {view.baseline2022.gapTotal === null + ? 'Sem estimativas comparáveis ao patamar de 2022' + : view.baseline2022.gapTotal >= 0 + ? 'votos acima do patamar' + : 'votos abaixo do patamar'} +

+

+ + {numberFormatter.format(view.baseline2022.above)} acima + + {' · '} + + {numberFormatter.format(view.baseline2022.below)} abaixo + +

+
+
+ ) : null}
@@ -157,7 +188,5 @@ export const NucleusListOverview = ({ )}
- - {/* Extension points for future roadmap domains (Demandas): plug new cards here when those collections exist. */} ) diff --git a/src/lib/electionInsights.ts b/src/lib/electionInsights.ts new file mode 100644 index 00000000..c286e3a4 --- /dev/null +++ b/src/lib/electionInsights.ts @@ -0,0 +1,84 @@ +import { BASELINE_TICKET_2022 } from '@/lib/electionResults' + +export const NO_ELECTION_BASELINE_MESSAGE = + 'Sem baseline TSE (informe território/município)' as const + +export type GapVs2022Status = + | 'above' + | 'below' + | 'noBaseline' + | 'noEstimate' + | 'noCandidateVotes' + +export type GapVs2022Result = { + gap: number | null + ratio: number | null + status: GapVs2022Status + message: string +} + +/** Minimal baseline shape for Gap vs 2022 (full detail VM is assignable). */ +export type GapVs2022Baseline = { + candidate: { votes: number } +} | null + +const numberFormatter = new Intl.NumberFormat('pt-BR') + +export const formatElectionNumber = (value: number): string => numberFormatter.format(value) + +/** + * Compare a confirmed vote estimate against the campaign candidate's 2022 + * votes in the same geography. + */ +export const computeGapVs2022 = ( + baseline: GapVs2022Baseline, + confirmedVoteEstimate: number | null, +): GapVs2022Result => { + if (!baseline) { + return { + gap: null, + ratio: null, + status: 'noBaseline', + message: NO_ELECTION_BASELINE_MESSAGE, + } + } + + if (confirmedVoteEstimate === null) { + return { + gap: null, + ratio: null, + status: 'noEstimate', + message: 'Sem estimativa confirmada para comparar', + } + } + + const candidateVotes2022 = baseline.candidate.votes + if (candidateVotes2022 <= 0) { + return { + gap: null, + ratio: null, + status: 'noCandidateVotes', + message: `${BASELINE_TICKET_2022.candidate.name} não recebeu votos aqui em 2022 — território novo a abrir`, + } + } + + const gap = confirmedVoteEstimate - candidateVotes2022 + const ratio = confirmedVoteEstimate / candidateVotes2022 + + if (gap < 0) { + return { + gap, + ratio, + status: 'below', + message: `Faltam ${formatElectionNumber(Math.abs(gap))} votos para o patamar de 2022`, + } + } + + const percentAbove = Math.round((ratio - 1) * 100) + return { + gap, + ratio, + status: 'above', + message: `Já superamos 2022 em ${percentAbove}%`, + } +} diff --git a/src/lib/electionResults.ts b/src/lib/electionResults.ts index a6e9b87e..cc73afb4 100644 --- a/src/lib/electionResults.ts +++ b/src/lib/electionResults.ts @@ -46,10 +46,22 @@ export const ELECTION_RUNNING_AGAIN_OPTIONS = [ export const ELECTION_RUNNING_AGAIN = ELECTION_RUNNING_AGAIN_OPTIONS.map((o) => o.value) export type ElectionRunningAgain = (typeof ELECTION_RUNNING_AGAIN)[number] -/** Solla — dep. federal BA, PT, nº 1313 (eleito em 2022). */ -export const SOLLA_CANDIDATE_NUMBER = 1313 -export const LULA_CANDIDATE_NUMBER = 13 -export const JERONIMO_CANDIDATE_NUMBER = 13 +/** + * Campaign ticket for the electoral baseline, keyed by role. + * Swap this block (and the election year) for a future cycle — aggregation/UI stay put. + * 2022: Jorge Solla (dep. federal BA nº 1313) / Lula (pres. nº 13) / Jerônimo (gov. nº 13). + */ +export const BASELINE_TICKET_2022 = { + candidate: { + candidateNumber: 1313, + office: 'deputado_federal', + name: 'Jorge Solla', + party: 'PT', + officeLabel: 'Dep. Federal', + }, + president: { candidateNumber: 13, name: 'Lula', party: 'PT' }, + governor: { candidateNumber: 13, name: 'Jerônimo Rodrigues', party: 'PT' }, +} as const /** TSE CD_CARGO → office enum used in collections. */ export const TSE_CARGO_TO_OFFICE: Readonly> = { diff --git a/src/utilities/nucleusDetailPageData.ts b/src/utilities/nucleusDetailPageData.ts index 169b6c80..855d5e10 100644 --- a/src/utilities/nucleusDetailPageData.ts +++ b/src/utilities/nucleusDetailPageData.ts @@ -8,6 +8,10 @@ import { } from '@/utilities/leadershipPageData' import { parseLeadershipFilterState, parseLeadershipPanelState } from '@/utilities/leadershipUi' import { getNucleusCoordinatorAssignmentPageData } from '@/utilities/nucleusCoordinatorAssignmentPageData' +import { + getNucleusElectoralBaseline, + toNucleusElectionGeographyInput, +} from '@/utilities/nucleusElectoralBaseline' import { getNucleusDetailPageData, resolveAccessibleNucleusContext, @@ -49,11 +53,12 @@ export const loadNucleusActiveTabPageData = async ( searchParams: NucleusDetailSearchParams, ) => { if (activeTab === 'overview') { - const [primaryContactPageData, updatePreview] = await Promise.all([ + const [primaryContactPageData, updatePreview, baseline] = await Promise.all([ getNucleusPrimaryContactPageData(payload, user, context), getNucleusUpdatesPreviewData(payload, user, context), + getNucleusElectoralBaseline(payload, user, toNucleusElectionGeographyInput(context.document)), ]) - return { tab: activeTab, primaryContactPageData, updatePreview } as const + return { tab: activeTab, primaryContactPageData, updatePreview, baseline } as const } if (activeTab === 'leaderships') { diff --git a/src/utilities/nucleusElectoralBaseline.ts b/src/utilities/nucleusElectoralBaseline.ts new file mode 100644 index 00000000..b647ccf0 --- /dev/null +++ b/src/utilities/nucleusElectoralBaseline.ts @@ -0,0 +1,528 @@ +import type { Payload, Where } from 'payload' + +import { citiesForTerritory, isBahiaIdentityTerritory } from '@/lib/bahiaTerritories' +import { tseZonesForCity } from '@/lib/bahiaTseZones' +import { + BASELINE_TICKET_2022, + ELECTION_YEAR_2022, + type ElectionOffice, + type ElectionTurn, +} from '@/lib/electionResults' +import type { CampaignUser, User } from '@/payload-types' +import { normalizeTerritoryTextArray } from '@/utilities/campaignTerritoryValidation' +import type { NucleusElectoralBaselineViewModel } from '@/utilities/nucleusViewModels' +import { sortedUniqueZoneNumbers } from '@/utilities/tseZone' + +const CANDIDATE_OFFICE = BASELINE_TICKET_2022.candidate.office + +export type NucleusElectionGeographyInput = { + cities: string[] + regions: string[] + tseZones: number[] +} + +export const toNucleusElectionGeographyInput = (nucleus: { + cities?: string[] | null + regions?: string[] | null + tseZones?: Array | null +}): NucleusElectionGeographyInput => ({ + cities: normalizeTerritoryTextArray(nucleus.cities), + regions: normalizeTerritoryTextArray(nucleus.regions), + tseZones: (nucleus.tseZones ?? []).map((zone) => + typeof zone === 'number' ? zone : zone.zoneNumber, + ), +}) + +export type NucleusElectionGeography = { + cities: string[] + zonesByCity: Map + cityZonePairs: Array<{ cityName: string; zoneNumber: number }> +} + +export type ElectionVoteAggregateRow = { + office: ElectionOffice + turn: ElectionTurn + cityName: string + zoneNumber: number + candidateNumber: number + candidateName: string + party: string + votes: number +} + +export type ElectionTallyAggregateRow = { + office: ElectionOffice + turn: ElectionTurn + cityName: string + zoneNumber: number + aptos: number + votosValidos: number + votosBranco: number + votosNulo: number + abstencoes: number +} + +type ElectionReader = CampaignUser | User + +const cityZoneKey = (cityName: string, zoneNumber: number): string => + `${cityName}::${zoneNumber}` + +const uniqueSortedCities = (values: Iterable): string[] => + [...new Set(values)].sort((left, right) => left.localeCompare(right, 'pt-BR')) + +/** + * Resolve the effective city×zone geography for a nucleus baseline query. + * Returns null when the nucleus has no usable territory (no cities and no regions). + * + * Zone rules per city: + * - nucleus.tseZones empty → all official zones of the city (`tseZonesForCity`) + * - nucleus.tseZones non-empty → intersection with the city's official zones; + * if the intersection is empty (typed zones outside the city), fall back to all city zones + */ +export const resolveNucleusElectionGeography = ( + nucleus: NucleusElectionGeographyInput, +): NucleusElectionGeography | null => { + const citiesFromNucleus = uniqueSortedCities(nucleus.cities.filter((city) => city.length > 0)) + const cities = + citiesFromNucleus.length > 0 + ? citiesFromNucleus + : uniqueSortedCities( + nucleus.regions.flatMap((region) => + isBahiaIdentityTerritory(region) ? citiesForTerritory(region) : [], + ), + ) + + if (cities.length === 0) return null + + const requestedZones = sortedUniqueZoneNumbers( + nucleus.tseZones.filter((zone) => Number.isInteger(zone) && zone >= 1 && zone <= 999), + ) + + const zonesByCity = new Map() + const cityZonePairs: Array<{ cityName: string; zoneNumber: number }> = [] + + for (const city of cities) { + const cityZones = [...tseZonesForCity(city)] + let effectiveZones = cityZones + if (requestedZones.length > 0) { + const cityZoneSet = new Set(cityZones) + const intersection = requestedZones.filter((zone) => cityZoneSet.has(zone)) + if (intersection.length > 0) effectiveZones = intersection + } + + if (effectiveZones.length === 0) continue + + zonesByCity.set(city, effectiveZones) + for (const zoneNumber of effectiveZones) { + cityZonePairs.push({ cityName: city, zoneNumber }) + } + } + + if (cityZonePairs.length === 0) return null + + return { cities: [...zonesByCity.keys()], zonesByCity, cityZonePairs } +} + +const matchesGeography = ( + row: { cityName: string; zoneNumber: number }, + geography: NucleusElectionGeography, +): boolean => { + const zones = geography.zonesByCity.get(row.cityName) + return Boolean(zones?.includes(row.zoneNumber)) +} + +const sumCandidateVotes = ( + votes: readonly ElectionVoteAggregateRow[], + geography: NucleusElectionGeography, + office: ElectionOffice, + turn: ElectionTurn, + candidateNumber: number, +): number => + votes + .filter( + (row) => + row.office === office && + row.turn === turn && + row.candidateNumber === candidateNumber && + matchesGeography(row, geography), + ) + .reduce((sum, row) => sum + row.votes, 0) + +const decisiveTicketVotes = ( + votes: readonly ElectionVoteAggregateRow[], + geography: NucleusElectionGeography, + office: 'presidente' | 'governador', + candidateNumber: number, +): { votes: number; turn: 1 | 2 } | null => { + const turn2 = sumCandidateVotes(votes, geography, office, '2', candidateNumber) + if (turn2 > 0) return { votes: turn2, turn: 2 } + const turn1 = sumCandidateVotes(votes, geography, office, '1', candidateNumber) + if (turn1 > 0) return { votes: turn1, turn: 1 } + return null +} + +const aggregateFederalCandidateTotals = ( + votes: readonly ElectionVoteAggregateRow[], + geography: NucleusElectionGeography, +): Array<{ candidateNumber: number; name: string; party: string; votes: number }> => { + const byCandidate = new Map< + number, + { candidateNumber: number; name: string; party: string; votes: number } + >() + + for (const row of votes) { + if (row.office !== CANDIDATE_OFFICE || row.turn !== '1') continue + if (!matchesGeography(row, geography)) continue + + const existing = byCandidate.get(row.candidateNumber) + if (existing) { + existing.votes += row.votes + continue + } + byCandidate.set(row.candidateNumber, { + candidateNumber: row.candidateNumber, + name: row.candidateName, + party: row.party, + votes: row.votes, + }) + } + + return [...byCandidate.values()].sort((left, right) => { + if (right.votes !== left.votes) return right.votes - left.votes + return left.candidateNumber - right.candidateNumber + }) +} + +const aggregateElectorate = ( + tallies: readonly ElectionTallyAggregateRow[], + geography: NucleusElectionGeography, +): NucleusElectoralBaselineViewModel['electorate'] => { + let aptos = 0 + let validos = 0 + let brancos = 0 + let nulos = 0 + let abstencoes = 0 + + for (const row of tallies) { + if (row.office !== CANDIDATE_OFFICE || row.turn !== '1') continue + if (!matchesGeography(row, geography)) continue + aptos += row.aptos + validos += row.votosValidos + brancos += row.votosBranco + nulos += row.votosNulo + abstencoes += row.abstencoes + } + + return { aptos, validos, brancos, nulos, abstencoes } +} + +export const aggregateNucleusElectoralBaseline = ( + geography: NucleusElectionGeography, + votes: readonly ElectionVoteAggregateRow[], + tallies: readonly ElectionTallyAggregateRow[], +): NucleusElectoralBaselineViewModel => { + const federalTotals = aggregateFederalCandidateTotals(votes, geography) + const candidateIndex = federalTotals.findIndex( + (candidate) => candidate.candidateNumber === BASELINE_TICKET_2022.candidate.candidateNumber, + ) + const candidateRow = federalTotals[candidateIndex] + const winner = federalTotals[0] ?? null + + return { + candidate: { + votes: candidateRow?.votes ?? 0, + rank: candidateIndex >= 0 ? candidateIndex + 1 : null, + }, + president: decisiveTicketVotes( + votes, + geography, + 'presidente', + BASELINE_TICKET_2022.president.candidateNumber, + ), + governor: decisiveTicketVotes( + votes, + geography, + 'governador', + BASELINE_TICKET_2022.governor.candidateNumber, + ), + electorate: aggregateElectorate(tallies, geography), + winnerFederal: winner + ? { name: winner.name, votes: winner.votes, party: winner.party } + : null, + } +} + +const ba2022Scope = (): Where[] => [ + { year: { equals: ELECTION_YEAR_2022 } }, + { state: { equals: 'BA' } }, +] + +/** One clause per city with `zoneNumber in […]` — avoids an OR per city×zone pair. */ +const geographyWhere = (geography: NucleusElectionGeography): Where => ({ + or: [...geography.zonesByCity.entries()].map( + ([cityName, zones]): Where => ({ + and: [{ cityName: { equals: cityName } }, { zoneNumber: { in: zones } }], + }), + ), +}) + +/** Votes for the detail baseline card (federal ranking + president/governor tickets). */ +const detailVoteWhere = (geography: NucleusElectionGeography): Where => ({ + and: [ + ...ba2022Scope(), + { voteType: { equals: 'nominal' } }, + geographyWhere(geography), + { + or: [ + { + and: [{ office: { equals: CANDIDATE_OFFICE } }, { turn: { equals: '1' } }], + }, + { + and: [ + { office: { equals: 'presidente' } }, + { candidateNumber: { equals: BASELINE_TICKET_2022.president.candidateNumber } }, + ], + }, + { + and: [ + { office: { equals: 'governador' } }, + { candidateNumber: { equals: BASELINE_TICKET_2022.governor.candidateNumber } }, + ], + }, + ], + }, + ], +}) + +const loadElectionVotes = async ( + payload: Pick, + user: ElectionReader, + geography: NucleusElectionGeography, +): Promise => { + const result = await payload.find({ + collection: 'electionCandidateVote', + where: detailVoteWhere(geography), + depth: 0, + pagination: false, + select: { + office: true, + turn: true, + cityName: true, + zoneNumber: true, + candidateNumber: true, + candidateName: true, + party: true, + votes: true, + }, + user, + overrideAccess: false, + }) + + return result.docs.map((doc) => ({ + office: doc.office, + turn: doc.turn, + cityName: doc.cityName, + zoneNumber: doc.zoneNumber, + candidateNumber: doc.candidateNumber, + candidateName: doc.candidateName, + party: doc.party ?? '', + votes: doc.votes, + })) +} + +const loadElectionTallies = async ( + payload: Pick, + user: ElectionReader, + geography: NucleusElectionGeography, +): Promise => { + const result = await payload.find({ + collection: 'electionTally', + where: { + and: [ + ...ba2022Scope(), + { office: { equals: CANDIDATE_OFFICE } }, + { turn: { equals: '1' } }, + geographyWhere(geography), + ], + }, + depth: 0, + pagination: false, + select: { + office: true, + turn: true, + cityName: true, + zoneNumber: true, + aptos: true, + votosValidos: true, + votosBranco: true, + votosNulo: true, + abstencoes: true, + }, + user, + overrideAccess: false, + }) + + return result.docs.map((doc) => ({ + office: doc.office, + turn: doc.turn, + cityName: doc.cityName, + zoneNumber: doc.zoneNumber, + aptos: doc.aptos, + votosValidos: doc.votosValidos, + votosBranco: doc.votosBranco, + votosNulo: doc.votosNulo, + abstencoes: doc.abstencoes, + })) +} + +/** Overview Gap: campaign candidate 1º-turno votes indexed by city×zone. */ +const loadCandidateVotesByCityZone = async ( + payload: Pick, + user: ElectionReader, + geography: NucleusElectionGeography, +): Promise> => { + 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() + 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, +): number => + geography.cityZonePairs.reduce( + (sum, { cityName, zoneNumber }) => + sum + (candidateVotesByCityZone.get(cityZoneKey(cityName, zoneNumber)) ?? 0), + 0, + ) + +export const getNucleusElectoralBaseline = async ( + payload: Pick, + user: ElectionReader, + nucleus: NucleusElectionGeographyInput, +): Promise => { + 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, + user: ElectionReader, + nuclei: readonly NucleusBaseline2022OverviewInput[], +): Promise => { + 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() + const zonesByCity = new Map() + + 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, + } +} diff --git a/src/utilities/nucleusListOverviewPageData.ts b/src/utilities/nucleusListOverviewPageData.ts index f4e01be3..51b24bf4 100644 --- a/src/utilities/nucleusListOverviewPageData.ts +++ b/src/utilities/nucleusListOverviewPageData.ts @@ -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 + 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, }) } diff --git a/src/utilities/nucleusListOverviewViewModels.ts b/src/utilities/nucleusListOverviewViewModels.ts index 3a88b7ff..db54b6c4 100644 --- a/src/utilities/nucleusListOverviewViewModels.ts +++ b/src/utilities/nucleusListOverviewViewModels.ts @@ -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 + 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, } diff --git a/src/utilities/nucleusViewModels.ts b/src/utilities/nucleusViewModels.ts index 5d65f0d3..51621e09 100644 --- a/src/utilities/nucleusViewModels.ts +++ b/src/utilities/nucleusViewModels.ts @@ -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(relationship) ? relationship.name : null diff --git a/tests/e2e/campaign-nuclei.e2e.spec.ts b/tests/e2e/campaign-nuclei.e2e.spec.ts index d58d2d29..193b4ae4 100644 --- a/tests/e2e/campaign-nuclei.e2e.spec.ts +++ b/tests/e2e/campaign-nuclei.e2e.spec.ts @@ -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 }) + }) }) diff --git a/tests/int/electionResultsImport.int.spec.ts b/tests/int/electionResultsImport.int.spec.ts index f42d19f0..9a71031c 100644 --- a/tests/int/electionResultsImport.int.spec.ts +++ b/tests/int/electionResultsImport.int.spec.ts @@ -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) diff --git a/tests/int/nucleusElectoralBaseline.int.spec.ts b/tests/int/nucleusElectoralBaseline.int.spec.ts new file mode 100644 index 00000000..704dbe6c --- /dev/null +++ b/tests/int/nucleusElectoralBaseline.int.spec.ts @@ -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) + }) +}) diff --git a/tests/unit/campaignNucleusDetailTabs.unit.spec.ts b/tests/unit/campaignNucleusDetailTabs.unit.spec.ts index 8287c247..f8e770fc 100644 --- a/tests/unit/campaignNucleusDetailTabs.unit.spec.ts +++ b/tests/unit/campaignNucleusDetailTabs.unit.spec.ts @@ -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"') diff --git a/tests/unit/campaignNucleusListOverview.unit.spec.ts b/tests/unit/campaignNucleusListOverview.unit.spec.ts index 23220049..38544484 100644 --- a/tests/unit/campaignNucleusListOverview.unit.spec.ts +++ b/tests/unit/campaignNucleusListOverview.unit.spec.ts @@ -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') + }) }) diff --git a/tests/unit/electionInsights.unit.spec.ts b/tests/unit/electionInsights.unit.spec.ts new file mode 100644 index 00000000..1e9eb23c --- /dev/null +++ b/tests/unit/electionInsights.unit.spec.ts @@ -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)', + }) + }) +}) diff --git a/tests/unit/electionResults.unit.spec.ts b/tests/unit/electionResults.unit.spec.ts index 9803304b..0f6ff9a9 100644 --- a/tests/unit/electionResults.unit.spec.ts +++ b/tests/unit/electionResults.unit.spec.ts @@ -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', ) diff --git a/tests/unit/nucleusElectoralBaseline.unit.spec.ts b/tests/unit/nucleusElectoralBaseline.unit.spec.ts new file mode 100644 index 00000000..99e969ba --- /dev/null +++ b/tests/unit/nucleusElectoralBaseline.unit.spec.ts @@ -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', + }) + }) +}) diff --git a/tests/unit/nucleusElectoralBaselineUi.unit.spec.ts b/tests/unit/nucleusElectoralBaselineUi.unit.spec.ts new file mode 100644 index 00000000..96709a17 --- /dev/null +++ b/tests/unit/nucleusElectoralBaselineUi.unit.spec.ts @@ -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%') + }) +})