Add E2 TSE 2014/2018 vote series and historical trend insights.

Extends the electoral baseline with multi-year seeding, computeVoteTrend, list/detail UI, and registers post-simplify follow-ups as E7.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-19 05:09:43 -03:00
parent e5e051d175
commit b0f4ebc1ed
22 changed files with 895 additions and 205 deletions

View file

@ -16,7 +16,8 @@ alwaysApply: false
- **B2 Mapa Fase 1 (geometrias) entregue (2026-07-18):** TopoJSON estático municípios IBGE (`bahia-municipalities.topo.json`, ~132 KB, object `municipalities`) + TIs por dissolução (`bahia-identity-territories.topo.json`, ~15 KB, object `territories`); tabela `bahiaMunicipalityCodes` + `codeForMunicipality` / `municipalityForCode`; helpers `bahiaGeometries` (`getMunicipalityFeature` / `getTerritoryFeature`); script re-executável `pnpm build:geometries` (cache `data/geometries/`, sem DB); testes int de bijeção/cobertura/teto de bytes. Sem migration, sem UI (Leaflet = B3). Plano as-built: [`docs/plans/mapa-bahia-geometrias.md`](../../../docs/plans/mapa-bahia-geometrias.md).
- **B5 registrado (2026-07-18):** débitos de escala/DRY pós-B2 do `/simplify` que não entraram no cleanup (lazy load mun/TI + helper `ensureCachedDownload` para `build:geometries` / `db:seed:tse`) — plano [`docs/plans/escala-dry-pos-b2.md`](../../../docs/plans/escala-dry-pos-b2.md); F1 preferencialmente com B3; F2 cortável.
- **C7 registrado (2026-07-18) / entregue (2026-07-19):** débitos de escala/DRY pós-C3 do `/simplify` — plano [`docs/plans/escala-dry-pos-c3.md`](../../../docs/plans/escala-dry-pos-c3.md); composição territorial, shell assíncrono de contato, leituras por aba, short-circuit de hooks. Débitos pós-`/simplify` → **C11** ([`docs/plans/escala-dry-pos-c7.md`](../../../docs/plans/escala-dry-pos-c7.md)).
- **Trilha E registrada (2026-07-19) — mapa de projeção por município:** plano E1–E5 para equiparar e superar as planilhas "Mapa de projeção de votos Solla 2026" (`docs/sheets/`): E1 metas em cenários (`voteGoals` Bom/Regular/Mínimo) + `priority` no núcleo (migration compartilhada com E3), E2 série histórica TSE 2014/2018 + `computeVoteTrend` derivado (sem migration), E3 `dobradinhaNotes`/`nextSteps` staff-only na inteligência, E4 import único `pnpm db:seed:mapa` (sem criar `Contact`/`leadership`), E5 Salvador por bairro registrado como futuro. Somente documentação/plano — nada implementado. Plano: [`docs/plans/mapa-projecao-municipios.md`](../../../docs/plans/mapa-projecao-municipios.md).
- **Trilha E registrada (2026-07-19) — mapa de projeção por município:** plano E1–E5 para equiparar e superar as planilhas "Mapa de projeção de votos Solla 2026" (`docs/sheets/`): E1 metas em cenários (`voteGoals` Bom/Regular/Mínimo) + `priority` no núcleo (migration compartilhada com E3), E2 série histórica TSE 2014/2018 + `computeVoteTrend` derivado (sem migration), E3 `dobradinhaNotes`/`nextSteps` staff-only na inteligência, E4 import único `pnpm db:seed:mapa` (sem criar `Contact`/`leadership`), E5 Salvador por bairro registrado como futuro. Plano: [`docs/plans/mapa-projecao-municipios.md`](../../../docs/plans/mapa-projecao-municipios.md).
- **E2 entregue (2026-07-19):** seed TSE parametrizado (`pnpm db:seed:tse -- --year=2014|2018|2022`; anos históricos só dep. federal T1 BA), `computeVoteTrend` ±10% (`increase|stable|decline|noBaseline`), loader candidate-only multi-ano, série + badge no `NucleusElectoralBaseline`, Alert de tendência no `NucleusInsights`, card "Tendência histórica" no overview da lista. Sem migration.
- Fluxos operacionais cobertos: login isolado, CRUD/arquivo de núcleos por slug, designação de coordenador (`geral`), CRM de lideranças, estimativas sugerir/confirmar com versão UUID, feed de atualizações, convites WhatsApp (`wa.me` + hash de uso único), dashboard com agregados só de núcleos ativos.
- Pendência operacional (não de código): textos provisórios Onda 0 provisionados por migration ([`docs/plans/onda-0.md`](../../../docs/plans/onda-0.md)); substituição pela assessoria jurídica eleitoral + hold de PII real de lideranças/apoiadores até lá; smoke pós-deploy Vercel. Ver Onda 0 em [`docs/roadmap.md`](../../../docs/roadmap.md).
- Em 2026-07-17, a listagem de Núcleos passou a aplicar filtros automaticamente pelo estado da URL, sem botão de confirmação, com comboboxes territoriais estritos compartilhados com o formulário.

View file

@ -0,0 +1,105 @@
# Escala e DRY pós-E2 (série TSE 2014/2018 + tendência)
Status: registrado no roadmap (fases pendentes)
Atualizado em: 2026-07-19
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha E, item E7)
Responsável: —
## Contexto
O E2 ([mapa-projecao-municipios.md](mapa-projecao-municipios.md)) entrega seed `pnpm db:seed:tse -- --year=2014|2018|2022` (federal T1 BA nos anos históricos), `computeVoteTrend` ±10% (`increase|stable|decline|noBaseline`), série no card `NucleusElectoralBaseline`, alerta em `NucleusInsights` e card "Tendência histórica" no overview B1 — loader unificado `loadNucleusListElectionOverview` (gap + tendência numa query union) em `nucleusElectoralBaseline.ts`.
Duas passagens `/simplify` no mesmo branch já limparam o que cabia em cleanup: `HISTORICAL_PRIOR_SERIES_YEARS`, `HISTORICAL_SERIES_YEARS`, `buildUnionGeography`, `aggregateVoteTrend` / `comparableTrendCount`, `formatVoteTrendSeries` / `formatVoteTrendSeriesCompact`, `voteTrendAlertVariant`, merge de labels, `FEDERAL_DEPUTY_OFFICE` no build, remoção de `seriesFromYearTotals` e alias `NucleusListOverviewBaseline2022`.
Os revisores (performance / reuse / quality) marcaram como **importantes e maiores que simplify** os follow-ups abaixo. Débitos que **já têm dono em outro item** ficam fora deste plano (ver Não escopo).
1. **Tendência calculada e exibida duas vezes na aba Visão geral do núcleo.** `NucleusActiveTab.tsx` renderiza `NucleusElectoralBaseline` e `NucleusInsights` em sequência; cada um chama `computeVoteTrend(baseline.series)` e ambos mostram a série 2014→2018→2022 (badge + mensagem no card; alerta + mensagem + série no insights). Duplicação de trabalho no cliente e de informação para o usuário.
2. **Sem testes de integração do loader combinado.** `loadNucleusListElectionOverview` substitui loaders separados de gap e tendência; só há cobertura unitária de `computeVoteTrend` / `aggregateVoteTrend` e unit do overview VM — não há int que exercite a união geográfica, o caso "só tendência sem estimativas comparáveis" e o agregado gap+trend num único fetch.
3. **Tipos de distribuição duplicados.** `NucleusTrendOverviewAggregate` em `nucleusElectoralBaseline.ts` é `Record<VoteTrendStatus, number>` enquanto `electionInsights.ts` já expõe `VoteTrendDistribution` + `emptyVoteTrendDistribution` — mesmo shape, dois nomes.
4. **`VoteTrendResult.ratio` exposto na API pública** mas usado sobretudo em asserts de teste; o produto só consome `status` + `message` na UI.
**Explicitamente fora (revisores pediram skip no simplify ou já têm item):** ranking federal completo no detalhe e filtro por `cityCode` → **A7**; query duplicada lista+overview e re-resolve de geografia no baseline do overview → **E6**; DRY de `formatElectionNumber` nos cards não-tendência do overview → **E6 F3**; remover a query TSE do overview quando não há estimativas comparáveis (o card de tendência exige distribuição mesmo sem gap — comportamento intencional de produto).
## Objetivos
- Abrir a aba Visão geral de um núcleo calcula a tendência **uma vez** e não repete a série histórica em dois blocos adjacentes.
- `loadNucleusListElectionOverview` tem pelo menos um teste int com fixture TSE que cobre gap agregado + distribuição de tendência no mesmo path.
- Tipos de distribuição de tendência têm uma única fonte (`VoteTrendDistribution`); loaders e VMs importam de `electionInsights.ts`.
- Guardrails: sem migration, sem Consent (dado público TSE). Access continua `overrideAccess: false`. Identificadores em inglês; strings visíveis em pt-BR.
## Decisões travadas
- **Um item E7, três fases ordenadas.** Mesmo racional de A7/C8/E6: um ID no roadmap, PRs por fase. Ordem: UX do detalhe (duplicação visível) → testes int do loader → higiene de tipos/API.
- **Dependência dura de E2 mergeado.** Só faz sentido com seed multi-ano, `computeVoteTrend` e `loadNucleusListElectionOverview` já no código.
- **Fase 1 não remove o insight de tendência nem o badge no baseline** — apenas unifica o cálculo e evita repetir a série literal; o alerta pode mostrar só `trend.message` se o card acima já exibir `formatVoteTrendSeriesCompact`.
- **Cortável se poucos núcleos e time de campo não reclamar da duplicação visual** — Fase 2 (int) é a mais valiosa antes de E4 import em massa amplificar o conjunto filtrado.
- **i18n e naming** (AGENTS.md): `VoteTrendDistribution` canônico; `NucleusTrendOverviewAggregate` vira alias deprecado ou some na Fase 3.
## Questões em aberto
- **Fase 1: tendência só no Insights ou só no Baseline?** **Recomendação:** manter badge + série compacta no `NucleusElectoralBaseline` (paridade com design `Baseline-Eleitoral-2022`); `NucleusInsights` recebe `trend: VoteTrendResult` por prop e mostra só o alerta com `trend.message` (sem repetir a série). `computeVoteTrend` roda uma vez em `NucleusActiveTab`.
- **Fase 2: fixture int separada ou estender `tseFixtures.ts`?** **Recomendação:** estender `tests/helpers/tseFixtures.ts` com série 2014/2018/2022 mínima para dois núcleos sobrepostos na geografia union; reutilizar padrão de `electionResultsImport.int.spec.ts`.
- **Fase 3: remover `ratio` de `VoteTrendResult`?** **Recomendação:** sim, se nenhum caller de produção ler — ajustar testes para assertar só `status`/`message` ou testar `ratio` via função interna de par se ainda for útil para regressão.
## Abordagem proposta
```mermaid
flowchart TD
E2["E2 Série + tendência ✓"] --> F1
F1["Fase 1 — Trend VM único no detalhe<br/>(NucleusActiveTab → filhos)"]
F1 --> F2["Fase 2 — Int loadNucleusListElectionOverview"]
F1 --> F3["Fase 3 — VoteTrendDistribution único + API ratio"]
F2 -.confiança antes.-> E4["E4 Import planilha"]
A7["A7 Loader baseline"] -.mesmo módulo.-> F2
E6["E6 Lista/overview"] -.geo cache.-> F2
```
### Fase 1 — Tendência única na aba Visão geral
- `src/components/campaign/NucleusActiveTab.tsx`: `const trend = baseline ? computeVoteTrend(baseline.series) : null`; passar `trend` opcional para `NucleusElectoralBaseline` e `NucleusInsights`.
- `NucleusElectoralBaseline`: aceitar `trend?: VoteTrendResult | null` — se presente, não chamar `computeVoteTrend` internamente.
- `NucleusInsights`: aceitar `trend` por prop; remover segunda cópia da série no `AlertDescription` (manter só mensagem classificada).
- Testes: ajustar `nucleusElectoralBaselineUi.unit.spec.ts` se asserts dependerem do texto duplicado da série.
### Fase 2 — Testes int do loader combinado
- Novo ou estendido `tests/int/nucleusListElectionOverview.int.spec.ts` (ou seção em spec existente de election baseline).
- Cenários mínimos: (a) dois núcleos com geografia + estimativas → `gapTotal` e contagem `above`/`below`; (b) núcleos com geografia mas sem estimativa confirmada → `gapTotal: null` e `trend` populado; (c) núcleo sem território resolvível → `baseline2022: null`, `trend: null`.
- Usar `getPayload` + DB de teste (`teqo_test`); seed fixture TSE via helper, não Neon.
### Fase 3 — Tipos e API
- Substituir `NucleusTrendOverviewAggregate` por import de `VoteTrendDistribution` nos VMs (`nucleusListOverviewViewModels.ts`, `nucleusElectoralBaseline.ts` exports).
- Avaliar remoção de `VoteTrendResult.ratio` ou torná-lo `@internal` documentado; atualizar `electionInsights.unit.spec.ts`.
**Migration:** nenhuma nas três fases.
## Dependências
- **Dura:** E2 Série TSE 2014/2018 + tendência — implementado no branch (aguardando merge).
- **Suave:** E4 import planilha — amplifica valor da Fase 2; A7 F1 compartilha `nucleusElectoralBaseline.ts` (não misturar PRs sem necessidade); E6 F2 cache de geografia reduz custo do mesmo path do overview.
- Reusa: `electionInsights.ts`, `nucleusElectoralBaseline.ts`, `nucleusListOverviewPageData.ts`, `tests/helpers/tseFixtures.ts`, padrão int de `electionResultsImport.int.spec.ts`.
## Não escopo
- Agregar ranking federal no detalhe → [escala-dry-pos-a4.md](escala-dry-pos-a4.md) (A7 F1).
- Filtro geográfico por `cityCode` TSE → A7 F2.
- Query duplicada `/campanha/nucleos` lista + overview → [escala-dry-pos-e1.md](escala-dry-pos-e1.md) (E6 F1).
- Re-resolve de geografia no overview baseline → E6 F2.
- `formatElectionNumber` nos cards de estimativa/cobertura/metas do overview → E6 F3.
- Seed 2014 se formato TSE incompatível (spike já validou 2018; 2014 é risco separado no runbook do seed).
- Persistir tendência no banco (permanece derivada em leitura).
## Referências
- `docs/roadmap.md` (Trilha E, E7; E2 implementado)
- `docs/plans/mapa-projecao-municipios.md` — plano pai E2
- `docs/plans/escala-dry-pos-a4.md` / `escala-dry-pos-e1.md` — débitos adjacentes (A7 / E6)
- `src/components/campaign/NucleusActiveTab.tsx` — render duplo baseline + insights
- `src/components/campaign/NucleusElectoralBaseline.tsx` / `NucleusInsights.tsx`
- `src/lib/electionInsights.ts` — `computeVoteTrend`, `VoteTrendDistribution`, `aggregateVoteTrend`
- `src/utilities/nucleusElectoralBaseline.ts` — `loadNucleusListElectionOverview`, `getNucleusElectoralBaseline`
- `src/utilities/nucleusListOverviewPageData.ts`
- `tests/unit/electionInsights.unit.spec.ts` / `tests/unit/nucleusElectoralBaselineUi.unit.spec.ts`
- `tests/helpers/tseFixtures.ts` / `tests/int/electionResultsImport.int.spec.ts`
- AGENTS.md — naming, `overrideAccess: false`, seed guards

View file

@ -1,6 +1,6 @@
# Mapa de projeção de votos por município (equiparar e superar as planilhas 2026)
Status: rascunho
Status: rascunho (E2 implementado 2026-07-19)
Atualizado em: 2026-07-19 (revisão access E1/E3 — metas/prioridade/E3 sem `canSetDerivedNucleusField`; escrita via `canUpdateElectoralNucleus` + selects/VMs)
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha E — itens E1–E5, "Ciclo 3")
Responsável: —
@ -30,7 +30,7 @@ A vertical `/campanha` já cobre ou supera parte disso: baseline TSE 2022 por ge
## Questões em aberto
- **Banda de estabilidade da tendência?** Queda/Mantém/Aumento precisa de limiar. **Recomendação:** ±10% entre eleições consecutivas, constante versionada em `electionInsights.ts`; validar com produto.
- **Banda de estabilidade da tendência?** **Resolvido (2026-07-19):** ±10% (`VOTE_TREND_STABLE_BAND = 0.1` em `electionInsights.ts`); par preferido 2018→2022.
- **E4 cria núcleo para todo município com dado estratégico (≈193) ou só para os ~50 prioritários?** **Recomendação:** default = municípios com qualquer preenchimento estratégico (metas, dobradinha, encaminhamento), flag `--priority-only` para restringir. Definir com produto antes de rodar em produção.
- **Meta estadual (71.000) fica onde?** **Recomendação:** derivada — soma das metas dos núcleos no dashboard; sem campo global novo. Se produto quiser meta editável independente da soma, vira campo em global de campanha (fase posterior).
@ -62,10 +62,11 @@ flowchart LR
### E2 — Série histórica TSE 2014/2018 + tendência automática
- **Seed:** estender `scripts/seed-tse-results.mjs` para parametrizar o ano (2014/2018; URLs `*_YYYY.zip` das mesmas famílias TSE, SHA-256 no header). Escopo v1: **só `deputado_federal`, turno 1, BA** (limita volume; a tendência só precisa disso). Verificar na implementação se o TSE republicou 2014 no formato CSV com header — o parser (`src/lib/electionResultsParse.ts`) assume colunas nomeadas. Número do candidato configurável por ano (1313 nos três pleitos). Idempotência por `(year, office, turn)` e collections A3 com `year` na unique key já existem — **sem migration**.
- **Insight:** `computeVoteTrend(votes2014, votes2018, votes2022)` → `queda|mantem|aumento|semBaseline` em `src/lib/electionInsights.ts`, limiares como constantes versionadas.
- **UI:** série 2014→2018→2022 no card `NucleusElectoralBaseline` (`src/components/campaign/NucleusElectoralBaseline.tsx`); badge de tendência no detalhe + linha no `NucleusInsights` (mesma família de Alert do Gap vs 2022); no overview da lista, distribuição Queda/Mantém/Aumento do conjunto filtrado (espelha a aba RESUMO da planilha).
- **Custo de I/O:** estender `getNucleusElectoralBaseline` (`src/utilities/nucleusElectoralBaseline.ts`) para aceitar ano multiplica as leituras já sinalizadas em [A7](escala-dry-pos-a4.md) — preferir levar A7 F1 (agregar federal no detalhe) junto ou antes.
- **Seed:** `scripts/seed-tse-results.mjs` parametrizado por `--year=2014|2018|2022` (URLs `*_YYYY.zip` das mesmas famílias TSE; SHA-256 logado, pins opcionais em `EXPECTED_SHA256`). Escopo v1 histórico: **só `deputado_federal`, turno 1, BA**. Parser em `electionResultsCsv.ts` + build em `electionResultsBuild.ts` (`offices` opcional via `federalOnlyElectionOffices()`). Número do candidato: `CANDIDATE_NUMBER_BY_YEAR` (1313 nos três pleitos). Idempotência por `(year, office, turn)` — **sem migration**.
- **Insight:** `computeVoteTrend` + `VOTE_TREND_STABLE_BAND = 0.1` (±10%) em `src/lib/electionInsights.ts` → `queda|mantem|aumento|semBaseline`; par preferido 2018→2022.
- **Loader:** `loadCandidateSeriesByGeography` (query candidate-only multi-ano, espelha overview Gap) em `nucleusElectoralBaseline.ts`; `loadNucleusTrendOverview` para distribuição na lista.
- **UI:** série 2014→2018→2022 + badge no `NucleusElectoralBaseline`; Alert `vote-trend` no `NucleusInsights`; card "Tendência histórica" no `NucleusListOverview`.
- **Custo de I/O:** série usa query estreita (não multiplica o path caro do ranking federal — A7 F1 permanece backlog separado). Passagem `/simplify` no branch; débitos maiores → [escala-dry-pos-e2.md](escala-dry-pos-e2.md) (E7).
### E3 — Inteligência estratégica manual: dobradinhas + encaminhamentos
@ -107,8 +108,9 @@ flowchart LR
- `docs/sheets/Mapa_projecao_votos_Solla_2026.xlsx` e `docs/sheets/Mapa projeção de votos Solla 2026.xlsx` — planilhas-fonte (estrutura, abas RESUMO/PRIORITÁRIAS/SALVADOR)
- `src/collections/ElectoralNucleus.ts` — campos, hooks e access do núcleo (E1/E3)
- `src/lib/electionInsights.ts` + `src/utilities/nucleusElectoralBaseline.ts` — padrão de insight derivado e agregação do baseline (E2)
- `scripts/seed-tse-results.mjs` + `src/lib/electionResultsParse.ts` — seed TSE a parametrizar por ano (E2)
- `scripts/seed-tse-results.mjs` + `src/lib/electionResultsBuild.ts` + `src/lib/electionResultsCsv.ts` — seed TSE parametrizado por ano (E2)
- `src/components/campaign/NucleusElectoralBaseline.tsx`, `NucleusListOverview.tsx`, `NucleusIntelligenceDialog.tsx` — superfícies de UI a estender
- `src/lib/bahiaMunicipalityCodes.ts` (`canonicalizeMunicipalityName`) — reconciliação de nomes no import (E4)
- [escala-dry-pos-a4.md](escala-dry-pos-a4.md) — A7 F1, custo de I/O do loader do baseline
- [escala-dry-pos-e2.md](escala-dry-pos-e2.md) — E7, débitos pós-`/simplify` do E2
- AGENTS.md — Campaign auth, pessoa = `Contact` + junção, `overrideAccess: false`, migrations com `push: false`, naming

View file

@ -1,6 +1,6 @@
# Roadmap — Teqo
Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; A4 Baseline no produto + Gap vs 2022 implementado e mesclado em `main` (Fases 2–4 + simplify); 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 Escala e DRY pós-C3 implementado (Fases 1–5; feed O(n) condicional fora de escopo); C8 Escala e DRY pós-C6 implementado e mesclado em `main` (Fases 1–4 + duas passagens `/simplify`); C9 Escala e DRY pós-C8 implementado e mesclado em `main` (Fases 1–4 + simplify); C10 registrado — escala/DRY pós-C9 do `/simplify`; **C11 registrado** — escala/DRY pós-C7 do `/simplify` — [plano](plans/escala-dry-pos-c7.md); B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`; **Trilha E registrada — E1–E5 mapa de projeção por município**, equiparar/superar as planilhas "Mapa de projeção de votos 2026" — [plano](plans/mapa-projecao-municipios.md); **E6 registrado** — escala/DRY pós-E1+E3 do `/simplify` (aggregate lista/overview, geografia baseline, DRY formatação) — [plano](plans/escala-dry-pos-e1.md); **Onda 0 MVP** — Consent/privacidade provisórios auto-provisionados (migrations) + `/privacidade`; hold de PII real até jurídico — [plano](plans/onda-0.md); **O0+ registrado** — escala/DRY pós-Onda 0 do `/simplify` — [plano](plans/escala-dry-pos-onda0.md); **Visitados recentemente MVP** implementado (fill-in, client-side `localStorage`) — [plano](plans/visitados-recentemente.md); **VR+ registrado** — escala/DRY pós-visitados recentemente do `/simplify` — [plano](plans/escala-dry-pos-visitados-recentemente.md))
Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; A4 Baseline no produto + Gap vs 2022 implementado e mesclado em `main` (Fases 2–4 + simplify); 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 Escala e DRY pós-C3 implementado (Fases 1–5; feed O(n) condicional fora de escopo); C8 Escala e DRY pós-C6 implementado e mesclado em `main` (Fases 1–4 + duas passagens `/simplify`); C9 Escala e DRY pós-C8 implementado e mesclado em `main` (Fases 1–4 + simplify); C10 registrado — escala/DRY pós-C9 do `/simplify`; **C11 registrado** — escala/DRY pós-C7 do `/simplify` — [plano](plans/escala-dry-pos-c7.md); B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`; **Trilha E registrada — E1–E5 mapa de projeção por município**, equiparar/superar as planilhas "Mapa de projeção de votos 2026" — [plano](plans/mapa-projecao-municipios.md); **E2 série TSE 2014/2018 + tendência implementado 2026-07-19**; **E7 registrado** — escala/DRY pós-E2 do `/simplify` — [plano](plans/escala-dry-pos-e2.md); **E6 registrado** — escala/DRY pós-E1+E3 do `/simplify` (aggregate lista/overview, geografia baseline, DRY formatação) — [plano](plans/escala-dry-pos-e1.md); **Onda 0 MVP** — Consent/privacidade provisórios auto-provisionados (migrations) + `/privacidade`; hold de PII real até jurídico — [plano](plans/onda-0.md); **O0+ registrado** — escala/DRY pós-Onda 0 do `/simplify` — [plano](plans/escala-dry-pos-onda0.md); **Visitados recentemente MVP** implementado (fill-in, client-side `localStorage`) — [plano](plans/visitados-recentemente.md); **VR+ registrado** — escala/DRY pós-visitados recentemente do `/simplify` — [plano](plans/escala-dry-pos-visitados-recentemente.md))
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.
@ -89,7 +89,8 @@ Objetivo: equiparar e superar as planilhas "Mapa de projeção de votos Solla 20
- **E1 Metas em cenários + prioridade** — grupo `voteGoals` (`good`/`regular`/`minimum`) + `priority` (`alta|normal`) em `electoralNucleus` (migration compartilhada com E3); card "Metas 2026" no detalhe, filtro na lista, somas no overview B1 e no dashboard (meta estadual derivada). Passagem `/simplify` no branch; débitos maiores → E6.
- **E6 Escala e DRY pós-E1+E3 registrado (2026-07-19)** — débitos do `/simplify` que não entraram no cleanup: query duplicada lista+overview, re-resolve de geografia no baseline do overview, aggregate SQL de metas/prioridade (padrão C6), DRY `formatElectionNumber`/`campaignPercentage`/`voteGoalScenarioLabels`, tipos do dashboard por papel. [Plano](plans/escala-dry-pos-e1.md).
- **E2 Série histórica TSE 2014/2018 + tendência** — seed `db:seed:tse` parametrizado por ano (só dep. federal, turno 1, BA); `computeVoteTrend` → `queda|mantem|aumento|semBaseline` derivado em leitura (sem migration); série + badge no baseline card e distribuição no overview.
- **E2 Série histórica TSE 2014/2018 + tendência** — seed `pnpm db:seed:tse -- --year=2014|2018` (federal T1 BA only); `computeVoteTrend` ±10% (`increase|stable|decline|noBaseline`); série + badge no baseline card, insight e card de distribuição no overview; query candidate-only multi-ano (sem migration). **Implementado 2026-07-19.** Passagem `/simplify` no branch; débitos maiores → E7.
- **E7 Escala e DRY pós-E2 registrado (2026-07-19)** — débitos do `/simplify` que não entraram no cleanup: trend VM único no detalhe (sem série duplicada baseline+insights), testes int de `loadNucleusListElectionOverview`, tipo único `VoteTrendDistribution`. [Plano](plans/escala-dry-pos-e2.md).
- **E3 Dobradinhas + encaminhamentos manuais** — `dobradinhaNotes` e `nextSteps` staff-only na inteligência do núcleo (excluídos dos view models de `lideranca`); versão manual imediata que A6/C4 complementam depois.
- **E4 Import único da planilha** — `pnpm db:seed:mapa` idempotente por slug, município canônico via `canonicalizeMunicipalityName`; lideranças/assessores da planilha entram como **texto staff-only, nunca `Contact`/`leadership`** (sem telefone/consent).
- **E5 Salvador por bairro** — registrado como futuro (exigiria `votacao_secao` + DE-PARA local↔bairro versionado); a visão por zona já é coberta pelo baseline município×zona.
@ -119,6 +120,7 @@ Os designs gerados pelo UX Pilot para os próximos ciclos estão em [`docs/desig
| VR+ Escala e DRY pós-visitados | — (sem design novo; reusa dashboard + padrões de lista de núcleos) | [escala-dry-pos-visitados-recentemente.md](plans/escala-dry-pos-visitados-recentemente.md) |
| B5 Escala e DRY pós-B2 | — (sem design novo; reusa geometrias B2 + padrões de scripts CLI) | [escala-dry-pos-b2.md](plans/escala-dry-pos-b2.md) |
| E6 Escala e DRY pós-E1+E3 | — (sem design novo; reusa Metas 2026 + overview B1) | [escala-dry-pos-e1.md](plans/escala-dry-pos-e1.md) |
| E7 Escala e DRY pós-E2 | — (sem design novo; reusa Baseline + Insights + overview tendência) | [escala-dry-pos-e2.md](plans/escala-dry-pos-e2.md) |
| D2 Notificações (sino) | `Notificacoes-PWA` (central de notificações) | [notifications.md](plans/notifications.md) |
| D1 PWA `/campanha` | `Notificacoes-PWA` (banner "Instale o app da campanha") | [pwa-campanha.md](plans/pwa-campanha.md) |
| C5 Operação dia D / GOTV | `Dia-D-GOTV` | sem plano ainda — o design é a primeira materialização do item (check-off de apoiadores por seção/zona, progresso "62% da base", filtro "Ainda não confirmados", estado "Sincronizado" offline-tolerante) |
@ -176,7 +178,8 @@ flowchart TD
subgraph TrilhaE["Trilha E — mapa de projeção por município"]
E1["E1 Metas em cenários + prioridade<br/>(migration c/ E3)"]
E2["E2 Série TSE 2014/2018<br/>+ tendência"]
E2["E2 Série TSE 2014/2018<br/>+ tendência ✓"]
E7["E7 Escala e DRY pós-E2<br/>(trend VM / int loader / tipos)"]
E3["E3 Dobradinhas + encaminhamentos<br/>manuais (staff-only)"]
E4["E4 Import único da planilha<br/>(pnpm db:seed:mapa)"]
E5["E5 Salvador por bairro<br/>(futuro; validar valor)"]
@ -226,10 +229,14 @@ flowchart TD
E1 -.metas no coroplético.-> B3
E2 -.classe por cor.-> B3
E2 -.dataset votacao_secao.-> E5
E2 --> E7
E7 -.int loader antes.-> E4
A7 -.mesmo loader do baseline.-> E2
A7 -.mesmo módulo.-> E7
E6 -.geo cache.-> E7
```
Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3, **O0+** (após engenharia Onda 0 ✓), **VR+** (após Visitados recentemente ✓) e fill-ins (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, C7, C8, C9 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/E2), E2 (após A4 ✓), **E6 (após merge E1+E3 em `main`)**, 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), C10 (após C9 ✓; cortável se a base nominal permanecer pequena), C11 (após C7 ✓; cortável se a agenda permanecer pequena; Fase 1 feed condicional) e D2 push (D1 pronto; falta a chave jurídica). E4 destravará após E1+E3 (campos precisam existir antes do import; suave: E6 F1 reduz custo da lista antes do import em massa).
Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3, **O0+** (após engenharia Onda 0 ✓), **VR+** (após Visitados recentemente ✓) e fill-ins (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, C7, C8, C9 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/E2), E2 (após A4 ✓), **E7 (após E2 merge; Fase 2 int preferível antes de E4)**, **E6 (após merge E1+E3 em `main`)**, 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), C10 (após C9 ✓; cortável se a base nominal permanecer pequena), C11 (após C7 ✓; cortável se a agenda permanecer pequena; Fase 1 feed condicional) e D2 push (D1 pronto; falta a chave jurídica). E4 destravará após E1+E3 (campos precisam existir antes do import; suave: E6 F1 e E7 F2 reduzem custo/risco do overview antes do import em massa).
### Sequência de execução por janela do calendário
@ -259,7 +266,8 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3, **O0
| 15 | C11 Escala e DRY pós-C7 (feed O(n) condicional, loaders do detalhe, selects por aba, hot paths mutação/form, utilities de busca) _(cortável se a agenda permanecer pequena; Fase 1 migration só com volume medido)_ | [detalhes](plans/escala-dry-pos-c7.md) | C7 (merge) | A4, C3, C10, E1+E3, E2 |
| 16 | E1 Metas em cenários + prioridade + E3 dobradinhas/encaminhamentos manuais (migration compartilhada em `electoralNucleus`; `/simplify` → E6) | [detalhes](plans/mapa-projecao-municipios.md) | — | tudo da janela |
| 17 | E6 Escala e DRY pós-E1+E3 (aggregate SQL metas/prioridade, geografia baseline, DRY formatação) _(cortável se poucos núcleos ativos)_ | [detalhes](plans/escala-dry-pos-e1.md) | E1+E3 merge em `main` | E2, C10, A7 |
| 18 | E2 Série histórica TSE 2014/2018 + tendência automática (seed por ano; sem migration) | [detalhes](plans/mapa-projecao-municipios.md) | A4 ✓ (suave: A7 F1 — mesmo loader) | E1+E3, E6, C7, C8, C9, C10, C11 |
| 18 | E2 Série histórica TSE 2014/2018 + tendência automática (seed por ano; sem migration) **(implementado 2026-07-19; `/simplify` → E7)** | [detalhes](plans/mapa-projecao-municipios.md) | A4 ✓ (suave: A7 F1 — mesmo loader) | E1+E3, E6, E7, C7, C8, C9, C10, C11 |
| 19 | E7 Escala e DRY pós-E2 (trend VM único no detalhe, int `loadNucleusListElectionOverview`, tipo `VoteTrendDistribution`) _(cortável se poucos núcleos; Fase 2 int preferível antes de E4)_ | [detalhes](plans/escala-dry-pos-e2.md) | E2 (merge) | E6, A7, E4, C10 |
**Janela 3 — 16/08 → set (campanha de rua): inteligência ampliada, visualização e engajamento.**
@ -292,7 +300,7 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3, **O0
- **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): E5 Salvador por bairro (já registrado como futuro), B4 camada de zonas, B5 F2 (cache CLI — duplicação pequena), E4 import da planilha (os campos E1/E3 podem ser preenchidos à mão pela coordenação; o import só acelera), **E6 F2–F4** (geografia baseline / DRY UI / tipos dashboard — Fase 1 aggregate só se poucos núcleos; preferir E6 F1 antes de E4 ou B3 se a lista ficar lenta), A7 F2–F3 (`cityCode` / Alert DRY — só se a geografia real dos núcleos permanecer 1–2 municípios), E2 série 2014/2018 (reduzível a só 2018 se o formato TSE de 2014 atrasar; a tendência vira 2 pontos), 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), C8 (escala/DRY pós-C6 — só se a base nominal permanecer pequena; Fases 3–4 DRY podem entrar isoladas), C9 (escala/DRY pós-C8 — só se a base nominal permanecer pequena; Fases 3–4 DRY de forms podem entrar isoladas), C10 (escala/DRY pós-C9 — só se a base nominal permanecer pequena; Fase 3 `errorProps` pode entrar isolada; Fase 1 access dedup é a mais valiosa se coordenadores usarem `/apoiadores` com frequência), C11 (escala/DRY pós-C7 — só se a agenda permanecer pequena; Fase 1 migration `actionPlanUpdate` só com volume medido; Fases 2–3 loaders/selects são as mais valiosas se `/campanha/planos` tiver uso real), **O0+ Fase 4–5** (Lexical/layout shell e Footer condicional — Fases 1–3 preferíveis antes de tráfego real em `/privacidade`), **VR+ Fases 3–5** (row shell, layout wrapper, micro-perf — Fase 1 refresh bfcache é a mais valiosa se usuários reclamarem de painel stale), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2, C7 (entregue), C8 fases 1–2 (perf import/lista), C9 Fase 1 (filtro unificado — evita drift KPI/lista), C10 Fase 1 (access dedup na lista), C11 Fases 2–3 (loaders/selects por aba) se a agenda for usada antes de 16/08, **O0+ Fase 1** (revalidate globals pós-migration), **E6 Fase 1** (aggregate metas/prioridade — evita dupla query na lista antes de E4/B3) e **A7 F1** (agregar federal no detalhe) se A5/B3/E2 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, **E1+E3 metas/prioridade/estratégia manual** — são respectivamente o risco legal, a base de dados, a operação da propaganda, o instrumento de alocação de esforço e a paridade mínima com a planilha que a campanha já usa.
**Cortes seguros se o prazo apertar** (nesta ordem): E5 Salvador por bairro (já registrado como futuro), B4 camada de zonas, B5 F2 (cache CLI — duplicação pequena), E4 import da planilha (os campos E1/E3 podem ser preenchidos à mão pela coordenação; o import só acelera), **E7 F1 e F3** (dedup visual/API — Fase 2 int loader é a mais valiosa antes de E4), **E6 F2–F4** (geografia baseline / DRY UI / tipos dashboard — Fase 1 aggregate só se poucos núcleos; preferir E6 F1 antes de E4 ou B3 se a lista ficar lenta), A7 F2–F3 (`cityCode` / Alert DRY — só se a geografia real dos núcleos permanecer 1–2 municípios), E2 série 2014/2018 (reduzível a só 2018 se o formato TSE de 2014 atrasar; a tendência vira 2 pontos), 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), C8 (escala/DRY pós-C6 — só se a base nominal permanecer pequena; Fases 3–4 DRY podem entrar isoladas), C9 (escala/DRY pós-C8 — só se a base nominal permanecer pequena; Fases 3–4 DRY de forms podem entrar isoladas), C10 (escala/DRY pós-C9 — só se a base nominal permanecer pequena; Fase 3 `errorProps` pode entrar isolada; Fase 1 access dedup é a mais valiosa se coordenadores usarem `/apoiadores` com frequência), C11 (escala/DRY pós-C7 — só se a agenda permanecer pequena; Fase 1 migration `actionPlanUpdate` só com volume medido; Fases 2–3 loaders/selects são as mais valiosas se `/campanha/planos` tiver uso real), **O0+ Fase 4–5** (Lexical/layout shell e Footer condicional — Fases 1–3 preferíveis antes de tráfego real em `/privacidade`), **VR+ Fases 3–5** (row shell, layout wrapper, micro-perf — Fase 1 refresh bfcache é a mais valiosa se usuários reclamarem de painel stale), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2, C7 (entregue), C8 fases 1–2 (perf import/lista), C9 Fase 1 (filtro unificado — evita drift KPI/lista), C10 Fase 1 (access dedup na lista), C11 Fases 2–3 (loaders/selects por aba) se a agenda for usada antes de 16/08, **O0+ Fase 1** (revalidate globals pós-migration), **E6 Fase 1** (aggregate metas/prioridade — evita dupla query na lista antes de E4/B3), **E7 Fase 2** (int do loader combinado gap+trend — evita regressão silenciosa antes de E4) e **A7 F1** (agregar federal no detalhe) se A5/B3/E2 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, **E1+E3 metas/prioridade/estratégia manual** — são respectivamente o risco legal, a base de dados, a operação da propaganda, o instrumento de alocação de esforço e a paridade mínima com a planilha que a campanha já usa.
### Itens consolidados/removidos nesta revisão (2026-07-17)

View file

@ -1,27 +1,31 @@
/**
* Seeds electionTally / electionCandidateVote / electionCandidate from TSE
* open data for the 2022 general election (Bahia scope).
* open data (Bahia scope). Default year: 2022 (full ticket). Historical years
* 2014/2018 import only deputado_federal turno 1 (E2 series).
*
* Provenance (do not strip):
* - Portal: https://dadosabertos.tse.jus.br/dataset/resultados-2022
* - Portal: https://dadosabertos.tse.jus.br/dataset/candidatos-2022
* - votacao_candidato_munzona_2022.zip
* https://cdn.tse.jus.br/estatistica/sead/odsele/votacao_candidato_munzona/votacao_candidato_munzona_2022.zip
* - detalhe_votacao_munzona_2022.zip
* https://cdn.tse.jus.br/estatistica/sead/odsele/detalhe_votacao_munzona/detalhe_votacao_munzona_2022.zip
* - consulta_cand_2022.zip
* https://cdn.tse.jus.br/estatistica/sead/odsele/consulta_cand/consulta_cand_2022.zip
* - Portal 2022: https://dadosabertos.tse.jus.br/dataset/resultados-2022
* - Portal 2018: https://dadosabertos.tse.jus.br/dataset/resultados-2018
* - Portal 2014: https://dadosabertos.tse.jus.br/dataset/resultados-2014
* - Portal candidatos: https://dadosabertos.tse.jus.br/dataset/candidatos-{year}
* - CDN zips: https://cdn.tse.jus.br/estatistica/sead/odsele/{family}/{family}_{year}.zip
* - License: Creative Commons Atribuição (dados abertos TSE)
* - electionCandidateVote.votes ← QT_VOTOS_NOMINAIS (votos apurados; validade no tally)
* - v1: only voteType=nominal (no votacao_partido_munzona / legenda rows)
*
* Pinned SHA-256 (update after verifying a fresh download):
* - 2014/2018: log-only until first verified import in CI/local
* - 2022: log-only (existing deployments may have cached zips)
*
* Safety: refuses non-local DATABASE_URL unless ALLOW_REMOTE_DB=true.
* Idempotent: replace-by-scope (year, office, turn) via drizzle transaction.
*
* Usage:
* pnpm db:seed:tse
* pnpm db:seed:tse -- --year=2018
* pnpm db:seed:tse -- --year=2014
* ALLOW_REMOTE_DB=true pnpm db:seed:tse
* TSE_CACHE_DIR=./data/tse pnpm db:seed:tse # reuse downloaded zips
* TSE_CACHE_DIR=./data/tse pnpm db:seed:tse
*/
import { createHash } from 'node:crypto'
@ -37,27 +41,50 @@ loadEnv({ path: '.env' })
const config = (await import('../src/payload.config.ts')).default
const { parseTseCsvBuffer } = await import('../src/lib/electionResultsCsv.ts')
const { buildElectionResultsFromCsvRows } = await import('../src/lib/electionResultsBuild.ts')
const { buildElectionResultsFromCsvRows, FEDERAL_ONLY_OFFICES } = await import(
'../src/lib/electionResultsBuild.ts'
)
const { downloadToBuffer, readZipEntry } = await import('../src/lib/electionResultsZip.ts')
const { buildImportBundles, importElectionBundles } = await import(
'../src/utilities/electionResultsImport.ts'
)
const SOURCES = [
const SUPPORTED_YEARS = [2014, 2018, 2022]
/** Optional integrity pins — null = warn on mismatch only after download. */
const EXPECTED_SHA256 = {
2014: null,
2018: null,
2022: null,
}
const sourcesForYear = (year) => [
{
key: 'votacao_candidato_munzona_2022',
url: 'https://cdn.tse.jus.br/estatistica/sead/odsele/votacao_candidato_munzona/votacao_candidato_munzona_2022.zip',
key: `votacao_candidato_munzona_${year}`,
url: `https://cdn.tse.jus.br/estatistica/sead/odsele/votacao_candidato_munzona/votacao_candidato_munzona_${year}.zip`,
expectedSha256: EXPECTED_SHA256[year],
},
{
key: 'detalhe_votacao_munzona_2022',
url: 'https://cdn.tse.jus.br/estatistica/sead/odsele/detalhe_votacao_munzona/detalhe_votacao_munzona_2022.zip',
key: `detalhe_votacao_munzona_${year}`,
url: `https://cdn.tse.jus.br/estatistica/sead/odsele/detalhe_votacao_munzona/detalhe_votacao_munzona_${year}.zip`,
expectedSha256: EXPECTED_SHA256[year],
},
{
key: 'consulta_cand_2022',
url: 'https://cdn.tse.jus.br/estatistica/sead/odsele/consulta_cand/consulta_cand_2022.zip',
key: `consulta_cand_${year}`,
url: `https://cdn.tse.jus.br/estatistica/sead/odsele/consulta_cand/consulta_cand_${year}.zip`,
expectedSha256: EXPECTED_SHA256[year],
},
]
const csvNamesForYear = (year) => ({
voteBa: `votacao_candidato_munzona_${year}_BA.csv`,
voteBr: `votacao_candidato_munzona_${year}_BR.csv`,
detalheBa: `detalhe_votacao_munzona_${year}_BA.csv`,
detalheBr: `detalhe_votacao_munzona_${year}_BR.csv`,
candBa: `consulta_cand_${year}_BA.csv`,
candBr: `consulta_cand_${year}_BR.csv`,
})
const die = (message) => {
console.error(`\n[seed:tse] ${message}\n`)
process.exit(1)
@ -65,25 +92,41 @@ const die = (message) => {
const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex')
const parseYearArg = () => {
const explicit = process.argv.find((arg) => arg.startsWith('--year='))
if (!explicit) return 2022
const year = Number(explicit.slice('--year='.length))
if (!SUPPORTED_YEARS.includes(year)) {
die(`Unsupported --year=${year}. Supported: ${SUPPORTED_YEARS.join(', ')}`)
}
return year
}
const cacheDir = () => process.env.TSE_CACHE_DIR || join(process.cwd(), 'data', 'tse')
const ensureCachedZip = async ({ key, url }) => {
const ensureCachedZip = async ({ key, url, expectedSha256 }) => {
const dir = cacheDir()
await mkdir(dir, { recursive: true })
const path = join(dir, `${key}.zip`)
let buffer
try {
await access(path)
console.log(`[seed:tse] cache hit ${path}`)
const buffer = await readFile(path)
return { key, url, buffer, hash: sha256(buffer) }
buffer = await readFile(path)
} catch (error) {
if (error?.code !== 'ENOENT') throw error
console.log(`[seed:tse] downloading ${url}`)
buffer = await downloadToBuffer(url)
await writeFile(path, buffer)
console.log(`[seed:tse] saved ${path} (${buffer.length} bytes)`)
}
console.log(`[seed:tse] downloading ${url}`)
const buffer = await downloadToBuffer(url)
const hash = sha256(buffer)
await writeFile(path, buffer)
console.log(`[seed:tse] saved ${path} (${buffer.length} bytes, sha256=${hash})`)
if (expectedSha256 && hash !== expectedSha256) {
die(
`SHA-256 mismatch for ${key}\n expected=${expectedSha256}\n actual=${hash}\n Delete ${path} and retry.`,
)
}
return { key, url, buffer, hash }
}
@ -95,6 +138,9 @@ const loadCsvFromZip = async (zipBuffer, fileName) => {
}
const main = async () => {
const year = parseYearArg()
const historicalOnly = year === 2014 || year === 2018
assertLocalDatabase(
'seed:tse',
'This seed WRITES election reference data and is meant for the local Postgres.\n' +
@ -102,33 +148,33 @@ const main = async () => {
' 2. set DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo in .env.local',
)
const [votacaoZip, detalheZip, candZip] = await Promise.all(SOURCES.map(ensureCachedZip))
const sources = sourcesForYear(year)
const [votacaoZip, detalheZip, candZip] = await Promise.all(sources.map(ensureCachedZip))
console.log(`[seed:tse] year=${year} scope=${historicalOnly ? 'deputado_federal T1 BA' : 'full ticket BA'}`)
console.log('[seed:tse] provenance:')
for (const source of [votacaoZip, detalheZip, candZip]) {
console.log(` - ${source.url}`)
console.log(` sha256=${source.hash}`)
}
const csv = csvNamesForYear(year)
// Sequential: BA vote CSV alone is hundreds of MB; parallel parse spikes peak RAM.
const voteBa = await loadCsvFromZip(votacaoZip.buffer, 'votacao_candidato_munzona_2022_BA.csv')
const voteBr = await loadCsvFromZip(votacaoZip.buffer, 'votacao_candidato_munzona_2022_BR.csv')
const detalheBa = await loadCsvFromZip(
detalheZip.buffer,
'detalhe_votacao_munzona_2022_BA.csv',
)
const detalheBr = await loadCsvFromZip(
detalheZip.buffer,
'detalhe_votacao_munzona_2022_BR.csv',
)
const candBa = await loadCsvFromZip(candZip.buffer, 'consulta_cand_2022_BA.csv')
const candBr = await loadCsvFromZip(candZip.buffer, 'consulta_cand_2022_BR.csv')
const voteBa = await loadCsvFromZip(votacaoZip.buffer, csv.voteBa)
const voteBr = historicalOnly ? [] : await loadCsvFromZip(votacaoZip.buffer, csv.voteBr)
const detalheBa = await loadCsvFromZip(detalheZip.buffer, csv.detalheBa)
const detalheBr = historicalOnly ? [] : await loadCsvFromZip(detalheZip.buffer, csv.detalheBr)
const candBa = await loadCsvFromZip(candZip.buffer, csv.candBa)
const candBr = historicalOnly ? [] : await loadCsvFromZip(candZip.buffer, csv.candBr)
const built = buildElectionResultsFromCsvRows({
voteRows: [...voteBa, ...voteBr],
detalheRows: [...detalheBa, ...detalheBr],
tallyRows: [...detalheBa, ...detalheBr],
candBaRows: candBa,
candBrRows: candBr,
year,
...(historicalOnly ? { offices: FEDERAL_ONLY_OFFICES } : {}),
})
if (built.unknownMunicipalities.length > 0) {
@ -162,7 +208,7 @@ const main = async () => {
}
console.log(
`\n[seed:tse] done. inserted votes=${votes} tallies=${tallies} candidates=${candidates}`,
`\n[seed:tse] done (year=${year}). inserted votes=${votes} tallies=${tallies} candidates=${candidates}`,
)
process.exit(0)
}

View file

@ -1,7 +1,15 @@
import { ChartColumnIcon, TrophyIcon } from 'lucide-react'
import { ChartColumnIcon, TrendingDownIcon, TrendingUpIcon, TrophyIcon } from 'lucide-react'
import { Badge } from '@/components/ui/Badge'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { formatElectionNumber, NO_ELECTION_BASELINE_MESSAGE } from '@/lib/electionInsights'
import {
computeVoteTrend,
formatElectionNumber,
formatVoteTrendSeriesCompact,
NO_ELECTION_BASELINE_MESSAGE,
voteTrendBadgeVariant,
voteTrendStatusLabel,
} from '@/lib/electionInsights'
import { BASELINE_TICKET_2022 } from '@/lib/electionResults'
import type { NucleusElectoralBaselineViewModel } from '@/utilities/nucleusViewModels'
@ -41,6 +49,7 @@ export const NucleusElectoralBaseline = ({
1,
)
const candidateBar = barPercent(baseline.candidate.votes, scale)
const trend = computeVoteTrend(baseline.series)
return (
<Card className="overflow-hidden">
@ -50,7 +59,19 @@ export const NucleusElectoralBaseline = ({
<ChartColumnIcon aria-hidden="true" className="size-4 text-primary" />
Baseline eleitoral 2022
</CardTitle>
<CardDescription>Deputado Federal · Bahia</CardDescription>
<div className="flex flex-wrap items-center gap-2">
{trend.status !== 'noBaseline' ? (
<Badge variant={voteTrendBadgeVariant(trend.status)}>
{trend.status === 'increase' ? (
<TrendingUpIcon aria-hidden="true" className="size-3" />
) : trend.status === 'decline' ? (
<TrendingDownIcon aria-hidden="true" className="size-3" />
) : null}
{voteTrendStatusLabel(trend.status)}
</Badge>
) : null}
<CardDescription>Deputado Federal · Bahia</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
@ -85,6 +106,16 @@ export const NucleusElectoralBaseline = ({
</div>
</div>
<div className="px-4 py-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Série histórica
</p>
<p className="text-sm tabular-nums">{formatVoteTrendSeriesCompact(baseline.series)}</p>
{trend.status !== 'noBaseline' ? (
<p className="mt-1 text-xs text-muted-foreground">{trend.message}</p>
) : null}
</div>
{baseline.president ? (
<div className="flex items-center justify-between gap-3 px-4 py-3">
<div>

View file

@ -1,7 +1,19 @@
import { CheckCircle2Icon, TriangleAlertIcon } from 'lucide-react'
import {
CheckCircle2Icon,
MinusIcon,
TrendingDownIcon,
TrendingUpIcon,
TriangleAlertIcon,
} from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import { computeGapVs2022, formatElectionNumber } from '@/lib/electionInsights'
import {
computeGapVs2022,
computeVoteTrend,
formatElectionNumber,
formatVoteTrendSeries,
voteTrendAlertVariant,
} from '@/lib/electionInsights'
import { cn } from '@/lib/utils'
import type { NucleusElectoralBaselineViewModel } from '@/utilities/nucleusViewModels'
@ -13,7 +25,17 @@ export const NucleusInsights = ({
confirmedVoteEstimate: number | null
}) => {
const gap = computeGapVs2022(baseline, confirmedVoteEstimate)
const Icon = gap.status === 'above' ? CheckCircle2Icon : TriangleAlertIcon
const GapIcon = gap.status === 'above' ? CheckCircle2Icon : TriangleAlertIcon
const trend = baseline ? computeVoteTrend(baseline.series) : null
const TrendIcon =
trend?.status === 'increase'
? TrendingUpIcon
: trend?.status === 'decline'
? TrendingDownIcon
: trend?.status === 'stable'
? MinusIcon
: TriangleAlertIcon
let supportLine: string | null = null
if ((gap.status === 'above' || gap.status === 'below') && baseline && confirmedVoteEstimate !== null) {
@ -34,10 +56,26 @@ export const NucleusInsights = ({
'border-[color:var(--estimate-confirmed)] bg-[color:var(--estimate-confirmed)] text-[color:var(--estimate-confirmed-foreground)] *:data-[slot=alert-description]:text-[color:var(--estimate-confirmed-foreground)]',
)}
>
<Icon aria-hidden="true" />
<GapIcon aria-hidden="true" />
<AlertTitle className="font-bold">{gap.message}</AlertTitle>
{supportLine ? <AlertDescription>{supportLine}</AlertDescription> : null}
</Alert>
{baseline && trend && trend.status !== 'noBaseline' ? (
<Alert
data-insight="vote-trend"
variant={voteTrendAlertVariant(trend.status)}
className={cn(
'rounded-xl px-3.5 py-3',
trend.status === 'increase' &&
'border-[color:var(--estimate-confirmed)] bg-[color:var(--estimate-confirmed)] text-[color:var(--estimate-confirmed-foreground)] *:data-[slot=alert-description]:text-[color:var(--estimate-confirmed-foreground)]',
)}
>
<TrendIcon aria-hidden="true" />
<AlertTitle className="font-bold">Tendência: {trend.message}</AlertTitle>
<AlertDescription>Série {formatVoteTrendSeries(baseline.series)}</AlertDescription>
</Alert>
) : null}
</section>
)
}

View file

@ -3,6 +3,7 @@ import Link from 'next/link'
import { Badge } from '@/components/ui/Badge'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Progress } from '@/components/ui/Progress'
import { comparableTrendCount, formatElectionNumber } from '@/lib/electionInsights'
import { actionPlanKindLabels } from '@/lib/schemas/actionPlan'
import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime'
import type { NucleusListOverviewViewModel } from '@/utilities/nucleusListOverviewViewModels'
@ -139,6 +140,40 @@ export const NucleusListOverview = ({
</CardContent>
</Card>
) : null}
{view.trend ? (
<Card>
<CardHeader>
<CardDescription>Tendência histórica</CardDescription>
<CardTitle className="text-3xl tabular-nums">
{formatElectionNumber(comparableTrendCount(view.trend))}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
<p className="text-sm text-muted-foreground">
Núcleos com série comparável (2014/2018/2022)
</p>
<p className="text-sm">
<span className="font-semibold text-[color:var(--estimate-confirmed-foreground)]">
{formatElectionNumber(view.trend.increase)} aumento
</span>
{' · '}
<span className="font-semibold text-foreground">
{formatElectionNumber(view.trend.stable)} mantém
</span>
{' · '}
<span className="font-semibold text-[color:var(--estimate-pending-foreground)]">
{formatElectionNumber(view.trend.decline)} queda
</span>
</p>
{view.trend.noBaseline > 0 ? (
<p className="text-xs text-muted-foreground">
{formatElectionNumber(view.trend.noBaseline)} sem série suficiente
</p>
) : null}
</CardContent>
</Card>
) : null}
</div>
<Card>

View file

@ -3,6 +3,23 @@ import { BASELINE_TICKET_2022 } from '@/lib/electionResults'
export const NO_ELECTION_BASELINE_MESSAGE =
'Sem baseline TSE (informe território/município)' as const
/** ±10% band between consecutive elections → stable (product default, E2). */
export const VOTE_TREND_STABLE_BAND = 0.1
export type VoteTrendStatus = 'decline' | 'stable' | 'increase' | 'noBaseline'
export type VoteTrendSeries = {
y2014: number
y2018: number
y2022: number
}
export type VoteTrendResult = {
status: VoteTrendStatus
message: string
ratio: number | null
}
export type GapVs2022Status =
| 'above'
| 'below'
@ -26,6 +43,113 @@ const numberFormatter = new Intl.NumberFormat('pt-BR')
export const formatElectionNumber = (value: number): string => numberFormatter.format(value)
export const formatVoteTrendSeries = (series: VoteTrendSeries): string =>
`${formatElectionNumber(series.y2014)} (2014) → ${formatElectionNumber(series.y2018)} (2018) → ${formatElectionNumber(series.y2022)} (2022)`
export const formatVoteTrendSeriesCompact = (series: VoteTrendSeries): string =>
`2014: ${formatElectionNumber(series.y2014)} → 2018: ${formatElectionNumber(series.y2018)} → 2022: ${formatElectionNumber(series.y2022)}`
export type VoteTrendDistribution = Record<VoteTrendStatus, number>
export const emptyVoteTrendDistribution = (): VoteTrendDistribution => ({
decline: 0,
stable: 0,
increase: 0,
noBaseline: 0,
})
export const comparableTrendCount = (trend: VoteTrendDistribution): number =>
trend.increase + trend.stable + trend.decline
type TrendPair = { fromVotes: number; toVotes: number; fromYear: number; toYear: number }
const trendPairsByPriority = (series: VoteTrendSeries): TrendPair[] => [
{ fromVotes: series.y2018, toVotes: series.y2022, fromYear: 2018, toYear: 2022 },
{ fromVotes: series.y2014, toVotes: series.y2018, fromYear: 2014, toYear: 2018 },
{ fromVotes: series.y2014, toVotes: series.y2022, fromYear: 2014, toYear: 2022 },
]
/**
* Classify vote trend from the federal candidate series (2014/2018/2022).
* Uses the most recent consecutive pair with votes in both years; ±10% → stable.
*/
export const computeVoteTrend = (series: VoteTrendSeries): VoteTrendResult => {
for (const pair of trendPairsByPriority(series)) {
if (pair.fromVotes <= 0 || pair.toVotes <= 0) continue
const ratio = pair.toVotes / pair.fromVotes
const change = ratio - 1
const percent = Math.round(Math.abs(change) * 100)
if (Math.abs(change) <= VOTE_TREND_STABLE_BAND) {
return {
status: 'stable',
ratio,
message: `Mantém (${pair.fromYear}→${pair.toYear}, variação de ${percent}%)`,
}
}
if (change > VOTE_TREND_STABLE_BAND) {
return {
status: 'increase',
ratio,
message: `Aumento (${pair.fromYear}→${pair.toYear}, +${percent}%)`,
}
}
return {
status: 'decline',
ratio,
message: `Queda (${pair.fromYear}→${pair.toYear}, −${percent}%)`,
}
}
return {
status: 'noBaseline',
ratio: null,
message: 'Sem série histórica suficiente para tendência',
}
}
export const aggregateVoteTrend = (seriesList: readonly VoteTrendSeries[]): VoteTrendDistribution => {
const distribution = emptyVoteTrendDistribution()
for (const series of seriesList) {
distribution[computeVoteTrend(series).status] += 1
}
return distribution
}
export const voteTrendStatusLabel = (status: VoteTrendStatus): string => {
switch (status) {
case 'decline':
return 'Queda'
case 'stable':
return 'Mantém'
case 'increase':
return 'Aumento'
case 'noBaseline':
return 'Sem baseline'
}
}
export const voteTrendAlertVariant = (
status: Exclude<VoteTrendStatus, 'noBaseline'>,
): 'default' | 'pending' => (status === 'decline' ? 'pending' : 'default')
export const voteTrendBadgeVariant = (
status: VoteTrendStatus,
): 'estimate-confirmed' | 'estimate-pending' | 'secondary' => {
switch (status) {
case 'increase':
return 'estimate-confirmed'
case 'decline':
return 'estimate-pending'
case 'stable':
case 'noBaseline':
return 'secondary'
}
}
/**
* Compare a confirmed vote estimate against the campaign candidate's 2022
* votes in the same geography.

View file

@ -1,7 +1,21 @@
import { bahiaMunicipalities } from '@/lib/bahiaTerritories'
export const ELECTION_YEAR_2014 = 2014 as const
export const ELECTION_YEAR_2018 = 2018 as const
export const ELECTION_YEAR_2022 = 2022 as const
/** Years loaded for prior-cycle comparison before 2022 (detail baseline query). */
export const HISTORICAL_PRIOR_SERIES_YEARS = [
ELECTION_YEAR_2014,
ELECTION_YEAR_2018,
] as const
/** Years shown in the federal candidate vote series (E2). */
export const HISTORICAL_SERIES_YEARS = [
...HISTORICAL_PRIOR_SERIES_YEARS,
ELECTION_YEAR_2022,
] as const
export const ELECTION_OFFICE_OPTIONS = [
{ label: 'Presidente', value: 'presidente' },
{ label: 'Governador', value: 'governador' },
@ -12,6 +26,8 @@ export const ELECTION_OFFICE_OPTIONS = [
export const ELECTION_OFFICES = ELECTION_OFFICE_OPTIONS.map((o) => o.value)
export type ElectionOffice = (typeof ELECTION_OFFICES)[number]
export const FEDERAL_DEPUTY_OFFICE = 'deputado_federal' as const satisfies ElectionOffice
export const ELECTION_TURN_OPTIONS = [
{ label: '1º turno', value: '1' },
{ label: '2º turno', value: '2' },

View file

@ -4,7 +4,12 @@ import type {
ElectionOffice,
TseDetalheApuracaoRow,
} from '@/lib/electionResults'
import { ELECTION_OFFICES, ELECTION_YEAR_2022, UnknownMunicipalityError } from '@/lib/electionResults'
import {
ELECTION_OFFICES,
ELECTION_YEAR_2022,
FEDERAL_DEPUTY_OFFICE,
UnknownMunicipalityError,
} from '@/lib/electionResults'
import {
applyStateVoteTotals,
dedupeCandidates,
@ -15,6 +20,7 @@ import {
} from '@/lib/electionResultsParse'
const ALL_OFFICES = new Set<ElectionOffice>(ELECTION_OFFICES)
export const FEDERAL_ONLY_OFFICES = new Set<ElectionOffice>([FEDERAL_DEPUTY_OFFICE])
const STATE_OFFICES = new Set<ElectionOffice>([
'governador',
'deputado_federal',
@ -57,36 +63,49 @@ const collectParsed = <T extends { year: number }>(
*/
export const buildElectionResultsFromCsvRows = (args: {
voteRows: readonly TseCsvRow[]
detalheRows: readonly TseCsvRow[]
tallyRows: readonly TseCsvRow[]
candBaRows: readonly TseCsvRow[]
candBrRows: readonly TseCsvRow[]
year?: number
offices?: ReadonlySet<ElectionOffice>
}): BuiltElectionResults => {
const year = args.year ?? ELECTION_YEAR_2022
const offices = args.offices ?? ALL_OFFICES
const includePresident = offices.has('presidente')
const includeStateTicket =
offices.has('governador') ||
offices.has('deputado_federal') ||
offices.has('deputado_estadual')
const unknownMunicipalities = new Set<string>()
const votes = collectParsed(args.voteRows, year, unknownMunicipalities, (row) =>
parseCandidateVoteRow(row, { stateFilter: 'BA', offices: ALL_OFFICES }),
parseCandidateVoteRow(row, { stateFilter: 'BA', offices }),
)
const tallies = collectParsed(args.detalheRows, year, unknownMunicipalities, (row) =>
parseDetalheApuracaoRow(row, { stateFilter: 'BA', offices: ALL_OFFICES }),
const tallies = collectParsed(args.tallyRows, year, unknownMunicipalities, (row) =>
parseDetalheApuracaoRow(row, { stateFilter: 'BA', offices }),
)
const candidatesRaw: ElectionCandidateRow[] = []
for (const row of args.candBaRows) {
const parsed = parseConsultaCandRow(row, {
stateFilter: 'BA',
offices: STATE_OFFICES,
})
if (parsed && parsed.year === year) candidatesRaw.push(parsed)
if (includeStateTicket) {
for (const row of args.candBaRows) {
const parsed = parseConsultaCandRow(row, {
stateFilter: 'BA',
offices: STATE_OFFICES,
})
if (parsed && parsed.year === year && offices.has(parsed.office)) {
candidatesRaw.push(parsed)
}
}
}
for (const row of args.candBrRows) {
const parsed = parseConsultaCandRow(row, {
stateFilter: 'BR',
offices: PRESIDENT_OFFICE,
forceState: 'BA',
})
if (parsed && parsed.year === year) candidatesRaw.push(parsed)
if (includePresident) {
for (const row of args.candBrRows) {
const parsed = parseConsultaCandRow(row, {
stateFilter: 'BR',
offices: PRESIDENT_OFFICE,
forceState: 'BA',
})
if (parsed && parsed.year === year) candidatesRaw.push(parsed)
}
}
return {

View file

@ -4,10 +4,19 @@ import { citiesForTerritory, isBahiaIdentityTerritory } from '@/lib/bahiaTerrito
import { tseZonesForCity } from '@/lib/bahiaTseZones'
import {
BASELINE_TICKET_2022,
ELECTION_YEAR_2014,
ELECTION_YEAR_2018,
ELECTION_YEAR_2022,
HISTORICAL_PRIOR_SERIES_YEARS,
HISTORICAL_SERIES_YEARS,
type ElectionOffice,
type ElectionTurn,
} from '@/lib/electionResults'
import {
aggregateVoteTrend,
type VoteTrendSeries,
type VoteTrendStatus,
} from '@/lib/electionInsights'
import type { CampaignUser, User } from '@/payload-types'
import { normalizeTerritoryTextArray } from '@/utilities/campaignTerritoryValidation'
import type { NucleusElectoralBaselineViewModel } from '@/utilities/nucleusViewModels'
@ -220,6 +229,7 @@ export const aggregateNucleusElectoralBaseline = (
geography: NucleusElectionGeography,
votes: readonly ElectionVoteAggregateRow[],
tallies: readonly ElectionTallyAggregateRow[],
historicalVotes: Pick<VoteTrendSeries, 'y2014' | 'y2018'>,
): NucleusElectoralBaselineViewModel => {
const federalTotals = aggregateFederalCandidateTotals(votes, geography)
const candidateIndex = federalTotals.findIndex(
@ -227,10 +237,11 @@ export const aggregateNucleusElectoralBaseline = (
)
const candidateRow = federalTotals[candidateIndex]
const winner = federalTotals[0] ?? null
const candidateVotes = candidateRow?.votes ?? 0
return {
candidate: {
votes: candidateRow?.votes ?? 0,
votes: candidateVotes,
rank: candidateIndex >= 0 ? candidateIndex + 1 : null,
},
president: decisiveTicketVotes(
@ -249,6 +260,11 @@ export const aggregateNucleusElectoralBaseline = (
winnerFederal: winner
? { name: winner.name, votes: winner.votes, party: winner.party }
: null,
series: {
y2014: historicalVotes.y2014,
y2018: historicalVotes.y2018,
y2022: candidateVotes,
},
}
}
@ -375,106 +391,15 @@ const loadElectionTallies = async (
}))
}
/** Overview Gap: campaign candidate 1º-turno votes indexed by city×zone. */
const loadCandidateVotesByCityZone = async (
payload: Pick<Payload, 'find'>,
user: ElectionReader,
geography: NucleusElectionGeography,
): Promise<Map<string, number>> => {
const result = await payload.find({
collection: 'electionCandidateVote',
where: {
and: [
...ba2022Scope(),
{ voteType: { equals: 'nominal' } },
{ office: { equals: CANDIDATE_OFFICE } },
{ turn: { equals: '1' } },
{ candidateNumber: { equals: BASELINE_TICKET_2022.candidate.candidateNumber } },
geographyWhere(geography),
],
},
depth: 0,
pagination: false,
select: {
cityName: true,
zoneNumber: true,
votes: true,
},
user,
overrideAccess: false,
})
const byCityZone = new Map<string, number>()
for (const doc of result.docs) {
const key = cityZoneKey(doc.cityName, doc.zoneNumber)
byCityZone.set(key, (byCityZone.get(key) ?? 0) + doc.votes)
}
return byCityZone
}
const sumCandidateVotesForGeography = (
geography: NucleusElectionGeography,
candidateVotesByCityZone: ReadonlyMap<string, number>,
): number =>
geography.cityZonePairs.reduce(
(sum, { cityName, zoneNumber }) =>
sum + (candidateVotesByCityZone.get(cityZoneKey(cityName, zoneNumber)) ?? 0),
0,
)
export const getNucleusElectoralBaseline = async (
payload: Pick<Payload, 'find'>,
user: ElectionReader,
nucleus: NucleusElectionGeographyInput,
): Promise<NucleusElectoralBaselineViewModel | null> => {
const geography = resolveNucleusElectionGeography(nucleus)
if (!geography) return null
const [votes, tallies] = await Promise.all([
loadElectionVotes(payload, user, geography),
loadElectionTallies(payload, user, geography),
])
return aggregateNucleusElectoralBaseline(geography, votes, tallies)
}
export type NucleusBaseline2022OverviewInput = NucleusElectionGeographyInput & {
confirmedVoteEstimate: number | null
}
export type NucleusBaseline2022OverviewAggregate = {
gapTotal: number | null
above: number
below: number
}
/**
* Aggregate Gap vs 2022 over a filtered nucleus set.
* One candidate-only vote query for the union geography, then O(pairs) sums per nucleus.
*/
export const loadNucleusBaseline2022Overview = async (
payload: Pick<Payload, 'find'>,
user: ElectionReader,
nuclei: readonly NucleusBaseline2022OverviewInput[],
): Promise<NucleusBaseline2022OverviewAggregate | null> => {
const geographies = nuclei.map((nucleus) => resolveNucleusElectionGeography(nucleus))
const comparableIndexes: number[] = []
for (let index = 0; index < nuclei.length; index += 1) {
if (!geographies[index]) continue
if (nuclei[index]?.confirmedVoteEstimate == null) continue
comparableIndexes.push(index)
}
// Geography exists but nothing to compare — keep the "—" card without a TSE query.
if (comparableIndexes.length === 0) {
return geographies.some(Boolean) ? { gapTotal: null, above: 0, below: 0 } : null
}
/** Union city×zone pairs across multiple nucleus geographies (list overview). */
const buildUnionGeography = (
geographies: ReadonlyArray<NucleusElectionGeography | null>,
indexes: readonly number[],
): NucleusElectionGeography | null => {
const unionPairs = new Map<string, { cityName: string; zoneNumber: number }>()
const zonesByCity = new Map<string, number[]>()
for (const index of comparableIndexes) {
for (const index of indexes) {
const geography = geographies[index]
if (!geography) continue
for (const pair of geography.cityZonePairs) {
@ -487,17 +412,175 @@ export const loadNucleusBaseline2022Overview = async (
}
}
if (unionPairs.size === 0) return null
for (const [cityName, zones] of zonesByCity) {
zonesByCity.set(cityName, sortedUniqueZoneNumbers(zones))
}
const unionGeography: NucleusElectionGeography = {
return {
cities: uniqueSortedCities(zonesByCity.keys()),
zonesByCity,
cityZonePairs: [...unionPairs.values()],
}
}
const candidateVotesByCityZone = await loadCandidateVotesByCityZone(payload, user, unionGeography)
const sumCandidateVotesForGeography = (
geography: NucleusElectionGeography,
candidateVotesByCityZone: ReadonlyMap<string, number>,
): number =>
geography.cityZonePairs.reduce(
(sum, { cityName, zoneNumber }) =>
sum + (candidateVotesByCityZone.get(cityZoneKey(cityName, zoneNumber)) ?? 0),
0,
)
/** Candidate-only votes indexed by year then city×zone (E2 lean loader). */
const loadCandidateSeriesByGeography = async (
payload: Pick<Payload, 'find'>,
user: ElectionReader,
geography: NucleusElectionGeography,
years: readonly number[] = [...HISTORICAL_SERIES_YEARS],
): Promise<Map<number, Map<string, number>>> => {
const result = await payload.find({
collection: 'electionCandidateVote',
where: {
and: [
{ year: { in: [...years] } },
{ state: { equals: 'BA' } },
{ voteType: { equals: 'nominal' } },
{ office: { equals: CANDIDATE_OFFICE } },
{ turn: { equals: '1' } },
{ candidateNumber: { equals: BASELINE_TICKET_2022.candidate.candidateNumber } },
geographyWhere(geography),
],
},
depth: 0,
pagination: false,
select: {
year: true,
cityName: true,
zoneNumber: true,
votes: true,
},
user,
overrideAccess: false,
})
const byYear = new Map<number, Map<string, number>>()
for (const doc of result.docs) {
const year = Number(doc.year)
const key = cityZoneKey(doc.cityName, doc.zoneNumber)
const yearMap = byYear.get(year) ?? new Map<string, number>()
yearMap.set(key, (yearMap.get(key) ?? 0) + doc.votes)
byYear.set(year, yearMap)
}
return byYear
}
const sumCandidateSeriesForGeography = (
geography: NucleusElectionGeography,
seriesByYear: ReadonlyMap<number, ReadonlyMap<string, number>>,
years: readonly number[] = [...HISTORICAL_SERIES_YEARS],
): VoteTrendSeries => {
const totalsByYear = new Map<number, number>()
for (const year of years) {
const cityZoneVotes = seriesByYear.get(year)
totalsByYear.set(
year,
cityZoneVotes ? sumCandidateVotesForGeography(geography, cityZoneVotes) : 0,
)
}
return {
y2014: totalsByYear.get(ELECTION_YEAR_2014) ?? 0,
y2018: totalsByYear.get(ELECTION_YEAR_2018) ?? 0,
y2022: totalsByYear.get(ELECTION_YEAR_2022) ?? 0,
}
}
export const getNucleusElectoralBaseline = async (
payload: Pick<Payload, 'find'>,
user: ElectionReader,
nucleus: NucleusElectionGeographyInput,
): Promise<NucleusElectoralBaselineViewModel | null> => {
const geography = resolveNucleusElectionGeography(nucleus)
if (!geography) return null
const [votes, tallies, seriesByYear] = await Promise.all([
loadElectionVotes(payload, user, geography),
loadElectionTallies(payload, user, geography),
loadCandidateSeriesByGeography(payload, user, geography, HISTORICAL_PRIOR_SERIES_YEARS),
])
const historicalVotes = sumCandidateSeriesForGeography(
geography,
seriesByYear,
HISTORICAL_PRIOR_SERIES_YEARS,
)
return aggregateNucleusElectoralBaseline(geography, votes, tallies, historicalVotes)
}
export type NucleusBaseline2022OverviewInput = NucleusElectionGeographyInput & {
confirmedVoteEstimate: number | null
}
export type NucleusBaseline2022OverviewAggregate = {
gapTotal: number | null
above: number
below: number
}
export type NucleusTrendOverviewAggregate = Record<VoteTrendStatus, number>
export type NucleusListElectionOverview = {
baseline2022: NucleusBaseline2022OverviewAggregate | null
trend: NucleusTrendOverviewAggregate | null
}
/**
* Gap vs 2022 and vote-trend distribution over a filtered nucleus set (one union query).
*/
export const loadNucleusListElectionOverview = async (
payload: Pick<Payload, 'find'>,
user: ElectionReader,
nuclei: readonly NucleusBaseline2022OverviewInput[],
): Promise<NucleusListElectionOverview> => {
const geographies = nuclei.map((nucleus) => resolveNucleusElectionGeography(nucleus))
const withGeographyIndexes = geographies.flatMap((geography, index) =>
geography ? [index] : [],
)
if (withGeographyIndexes.length === 0) {
return { baseline2022: null, trend: null }
}
const comparableIndexes: number[] = []
for (const index of withGeographyIndexes) {
if (nuclei[index]?.confirmedVoteEstimate == null) continue
comparableIndexes.push(index)
}
const unionGeography = buildUnionGeography(geographies, withGeographyIndexes)
if (!unionGeography) {
return { baseline2022: null, trend: null }
}
const seriesByYear = await loadCandidateSeriesByGeography(payload, user, unionGeography)
const votes2022 = seriesByYear.get(ELECTION_YEAR_2022) ?? new Map<string, number>()
const trend = aggregateVoteTrend(
withGeographyIndexes.flatMap((index) => {
const geography = geographies[index]
return geography ? [sumCandidateSeriesForGeography(geography, seriesByYear)] : []
}),
)
if (comparableIndexes.length === 0) {
return {
baseline2022: { gapTotal: null, above: 0, below: 0 },
trend,
}
}
let estimateSum = 0
let candidateVotesSum = 0
@ -510,7 +593,7 @@ export const loadNucleusBaseline2022Overview = async (
const estimate = nuclei[index]?.confirmedVoteEstimate
if (!geography || estimate == null) continue
const candidateVotes = sumCandidateVotesForGeography(geography, candidateVotesByCityZone)
const candidateVotes = sumCandidateVotesForGeography(geography, votes2022)
if (candidateVotes <= 0) continue
if (estimate >= candidateVotes) above += 1
@ -521,8 +604,11 @@ export const loadNucleusBaseline2022Overview = async (
}
return {
gapTotal: comparableCount > 0 ? estimateSum - candidateVotesSum : null,
above,
below,
baseline2022: {
gapTotal: comparableCount > 0 ? estimateSum - candidateVotesSum : null,
above,
below,
},
trend,
}
}

View file

@ -3,7 +3,7 @@ import type { Payload } from 'payload'
import type { CampaignUser, NucleusUpdate } from '@/payload-types'
import { loadUpcomingActionPlansPreview } from '@/utilities/actionPlanUpcomingPreview'
import {
loadNucleusBaseline2022Overview,
loadNucleusListElectionOverview,
toNucleusElectionGeographyInput,
} from '@/utilities/nucleusElectoralBaseline'
import {
@ -113,7 +113,7 @@ export const loadNucleusListOverviewData = async (
})
const nucleiById = new Map(nuclei.map((nucleus) => [nucleus.id, nucleus]))
const [updateResult, baseline2022] = await Promise.all([
const [updateResult, listElectionOverview] = await Promise.all([
payload.find({
collection: 'nucleusUpdate',
where: { nucleus: { in: nuclei.map(({ id }) => id) } },
@ -125,7 +125,7 @@ export const loadNucleusListOverviewData = async (
user,
overrideAccess: false,
}),
loadNucleusBaseline2022Overview(payload, user, nuclei),
loadNucleusListElectionOverview(payload, user, nuclei),
])
const updates = updateResult.docs as unknown as RawOverviewUpdate[]
@ -171,6 +171,7 @@ export const loadNucleusListOverviewData = async (
recentUpdates,
role: user.role,
upcomingActionPlans,
baseline2022,
baseline2022: listElectionOverview.baseline2022,
trend: listElectionOverview.trend,
})
}

View file

@ -1,7 +1,7 @@
import type { NucleusPriority } from '@/lib/schemas/nucleus'
import type { CampaignUser, NucleusUpdate } from '@/payload-types'
import type { ActionPlanUpcomingPreviewRecord } from '@/utilities/actionPlanUpcomingPreview'
import type { NucleusBaseline2022OverviewAggregate } from '@/utilities/nucleusElectoralBaseline'
import type { NucleusBaseline2022OverviewAggregate, NucleusTrendOverviewAggregate } from '@/utilities/nucleusElectoralBaseline'
import {
aggregateVoteGoals,
type VoteGoalsSumViewModel,
@ -24,8 +24,6 @@ export type NucleusListOverviewNucleusRecord = {
priority: NucleusPriority
}
export type NucleusListOverviewBaseline2022 = NucleusBaseline2022OverviewAggregate
export type NucleusListOverviewUpdateRecord = {
id: number
nucleusSlug: string
@ -49,9 +47,11 @@ export type NucleusListOverviewViewModel = {
percent: number
}
/** Null when no nucleus in the filtered set has resolvable TSE geography. */
baseline2022: NucleusListOverviewBaseline2022 | null
baseline2022: NucleusBaseline2022OverviewAggregate | null
voteGoals: VoteGoalsSumViewModel
highPriorityCount: number
/** Null when no nucleus in the filtered set has resolvable TSE geography. */
trend: NucleusTrendOverviewAggregate | null
recentUpdates: NucleusListOverviewUpdateRecord[]
upcomingActionPlans: ActionPlanUpcomingPreviewRecord[]
}
@ -65,12 +65,14 @@ export const buildNucleusListOverviewViewModel = ({
role,
upcomingActionPlans,
baseline2022 = null,
trend = null,
}: {
nuclei: NucleusListOverviewNucleusRecord[]
recentUpdates: NucleusListOverviewUpdateRecord[]
role: CampaignUser['role']
upcomingActionPlans: ActionPlanUpcomingPreviewRecord[]
baseline2022?: NucleusListOverviewBaseline2022 | null
baseline2022?: NucleusBaseline2022OverviewAggregate | null
trend?: NucleusTrendOverviewAggregate | null
}): NucleusListOverviewViewModel => {
const totalFiltered = nuclei.length
const confirmedCount = nuclei.filter(
@ -104,6 +106,7 @@ export const buildNucleusListOverviewViewModel = ({
baseline2022,
voteGoals,
highPriorityCount,
trend,
recentUpdates,
upcomingActionPlans,
}

View file

@ -1,5 +1,6 @@
import type { NucleusPriority } from '@/lib/schemas/nucleus'
import type { CampaignUser, ElectoralNucleus } from '@/payload-types'
import type { VoteTrendSeries } from '@/lib/electionInsights'
import { isPopulatedRelationship, relationshipId } from '@/utilities/relationship'
import { toVoteGoalsViewModel, type VoteGoalsViewModel } from '@/utilities/voteGoals'
@ -237,6 +238,7 @@ export type NucleusElectoralBaselineViewModel = {
abstencoes: number
}
winnerFederal: { name: string; votes: number; party: string } | null
series: VoteTrendSeries
}
const relationshipName = (relationship: number | CampaignUser | null | undefined): string | null =>

View file

@ -2,24 +2,49 @@ import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { parseTseCsvString } from '@/lib/electionResultsCsv'
import { buildElectionResultsFromCsvRows } from '@/lib/electionResultsBuild'
import { buildElectionResultsFromCsvRows, FEDERAL_ONLY_OFFICES } from '@/lib/electionResultsBuild'
import type { BuiltElectionResults } from '@/lib/electionResultsBuild'
import type { TseCsvRow } from '@/lib/electionResultsParse'
import type { ElectionOffice } from '@/lib/electionResults'
const fixtureDir = join(process.cwd(), 'tests/fixtures/tse')
const readFixture = (name: string): string => readFileSync(join(fixtureDir, name), 'utf8')
export const loadTseFixtureResults = (): BuiltElectionResults => {
const voteRows = parseTseCsvString(readFixture('votacao_candidato_munzona_fixture.csv'))
const detalheRows = parseTseCsvString(readFixture('detalhe_votacao_munzona_fixture.csv'))
const candBaRows = parseTseCsvString(readFixture('consulta_cand_ba_fixture.csv'))
const candBrRows = parseTseCsvString(readFixture('consulta_cand_br_fixture.csv'))
const remapFixtureYear = (rows: readonly TseCsvRow[], year: number): TseCsvRow[] =>
rows.map((row) => ({ ...row, ANO_ELEICAO: String(year) }))
export const loadTseFixtureResults = (): BuiltElectionResults =>
loadTseFixtureResultsForYear(2022)
export const loadTseFixtureResultsForYear = (
year: number,
offices?: ReadonlySet<ElectionOffice>,
): BuiltElectionResults => {
const voteRows = remapFixtureYear(
parseTseCsvString(readFixture('votacao_candidato_munzona_fixture.csv')),
year,
)
const tallyRows = remapFixtureYear(
parseTseCsvString(readFixture('detalhe_votacao_munzona_fixture.csv')),
year,
)
const candBaRows = remapFixtureYear(
parseTseCsvString(readFixture('consulta_cand_ba_fixture.csv')),
year,
)
const candBrRows = remapFixtureYear(
parseTseCsvString(readFixture('consulta_cand_br_fixture.csv')),
year,
)
return buildElectionResultsFromCsvRows({
voteRows,
detalheRows,
tallyRows,
candBaRows,
candBrRows,
year,
...(offices ? { offices } : {}),
})
}

View file

@ -9,7 +9,8 @@ import {
buildImportBundles,
importElectionBundles,
} from '@/utilities/electionResultsImport'
import { loadTseFixtureResults, TSE_FIXTURE_EXPECTED } from '../helpers/tseFixtures'
import { loadTseFixtureResults, loadTseFixtureResultsForYear, TSE_FIXTURE_EXPECTED } from '../helpers/tseFixtures'
import { FEDERAL_ONLY_OFFICES } from '@/lib/electionResultsBuild'
let payload: Payload
@ -126,6 +127,44 @@ describe('election results import', () => {
expect(lula?.identityKey).toBeTruthy()
})
it('imports a historical year without removing the 2022 scope', async () => {
const built2018 = loadTseFixtureResultsForYear(2018, FEDERAL_ONLY_OFFICES)
await importElectionBundles(payload, buildImportBundles(built2018))
const votes2022 = await payload.find({
collection: 'electionCandidateVote',
where: { year: { equals: 2022 } },
depth: 0,
limit: 0,
pagination: false,
overrideAccess: true,
})
const votes2018 = await payload.find({
collection: 'electionCandidateVote',
where: {
and: [{ year: { equals: 2018 } }, { office: { equals: 'deputado_federal' } }],
},
depth: 0,
limit: 0,
pagination: false,
overrideAccess: true,
})
const president2018 = await payload.find({
collection: 'electionCandidateVote',
where: {
and: [{ year: { equals: 2018 } }, { office: { equals: 'presidente' } }],
},
depth: 0,
limit: 0,
pagination: false,
overrideAccess: true,
})
expect(votes2022.totalDocs).toBe(TSE_FIXTURE_EXPECTED.voteRowCount)
expect(votes2018.totalDocs).toBeGreaterThan(0)
expect(president2018.totalDocs).toBe(0)
})
it('allows campaignUser read and denies campaignUser mutation', async () => {
// Avoid campaignFixtures here: teqo_test may already include territory migrations
// from other worktrees that diverge from this branch's electoralNucleus schema.

View file

@ -199,4 +199,21 @@ describe('NucleusListOverview', () => {
expect(html).not.toContain('Baseline 2022')
})
it('renders the trend distribution card when aggregate data is present', () => {
const view = buildNucleusListOverviewViewModel({
role: 'geral',
nuclei: [nucleus({ id: 1 })],
recentUpdates: [],
upcomingActionPlans: [],
trend: { decline: 3, stable: 5, increase: 2, noBaseline: 1 },
})
const html = renderToStaticMarkup(createElement(NucleusListOverview, { view, now }))
expect(html).toContain('Tendência histórica')
expect(html).toContain('2 aumento')
expect(html).toContain('5 mantém')
expect(html).toContain('3 queda')
})
})

View file

@ -2,7 +2,7 @@
import { describe, expect, it } from 'vitest'
import { computeGapVs2022 } from '@/lib/electionInsights'
import { aggregateVoteTrend, comparableTrendCount, computeGapVs2022, computeVoteTrend } from '@/lib/electionInsights'
import type { NucleusElectoralBaselineViewModel } from '@/utilities/nucleusViewModels'
const baseline = (candidateVotes: number): NucleusElectoralBaselineViewModel => ({
@ -11,6 +11,7 @@ const baseline = (candidateVotes: number): NucleusElectoralBaselineViewModel =>
governor: null,
electorate: { aptos: 0, validos: 0, brancos: 0, nulos: 0, abstencoes: 0 },
winnerFederal: null,
series: { y2014: 0, y2018: 0, y2022: candidateVotes },
})
describe('computeGapVs2022', () => {
@ -58,3 +59,61 @@ describe('computeGapVs2022', () => {
})
})
})
describe('computeVoteTrend', () => {
it('classifies increase when the latest step exceeds 10%', () => {
expect(computeVoteTrend({ y2014: 1000, y2018: 1200, y2022: 1400 })).toMatchObject({
status: 'increase',
ratio: 1400 / 1200,
})
})
it('classifies decline when the latest step drops more than 10%', () => {
expect(computeVoteTrend({ y2014: 1000, y2018: 1200, y2022: 1000 })).toMatchObject({
status: 'decline',
ratio: 1000 / 1200,
})
})
it('classifies stable within the ±10% band on the preferred 2018→2022 pair', () => {
expect(computeVoteTrend({ y2014: 500, y2018: 1000, y2022: 1050 })).toMatchObject({
status: 'stable',
ratio: 1.05,
})
})
it('falls back to 2014→2018 when 2022 is missing', () => {
expect(computeVoteTrend({ y2014: 1000, y2018: 1500, y2022: 0 })).toMatchObject({
status: 'increase',
})
})
it('reports noBaseline with fewer than two non-zero years', () => {
expect(computeVoteTrend({ y2014: 0, y2018: 0, y2022: 500 }).status).toBe('noBaseline')
})
})
describe('aggregateVoteTrend', () => {
it('counts trend statuses across nucleus series', () => {
expect(
aggregateVoteTrend([
{ y2014: 1000, y2018: 1200, y2022: 1400 },
{ y2014: 1000, y2018: 1200, y2022: 1000 },
{ y2014: 0, y2018: 0, y2022: 500 },
]),
).toEqual({
increase: 1,
decline: 1,
stable: 0,
noBaseline: 1,
})
})
})
describe('comparableTrendCount', () => {
it('sums increase, stable, and decline only', () => {
expect(
comparableTrendCount({ increase: 2, stable: 1, decline: 3, noBaseline: 4 }),
).toBe(6)
})
})

View file

@ -15,7 +15,8 @@ import {
UnknownMunicipalityError,
winnerKey,
} from '@/lib/electionResults'
import { loadTseFixtureResults, TSE_FIXTURE_EXPECTED } from '../helpers/tseFixtures'
import { loadTseFixtureResults, loadTseFixtureResultsForYear, TSE_FIXTURE_EXPECTED } from '../helpers/tseFixtures'
import { FEDERAL_ONLY_OFFICES } from '@/lib/electionResultsBuild'
describe('electionResults municipality mapping', () => {
it('resolves every canonical Bahia municipality through itself', () => {
@ -156,4 +157,13 @@ describe('electionResults fixture parse + winners', () => {
)
expect(federalZ1?.winnerCandidateName).toBe('JORGE SOLLA')
})
it('filters to federal deputy only when offices are restricted (E2 historical seed)', () => {
const built = loadTseFixtureResultsForYear(2018, FEDERAL_ONLY_OFFICES)
expect(built.votes.every((row) => row.office === 'deputado_federal')).toBe(true)
expect(built.tallies.every((row) => row.office === 'deputado_federal')).toBe(true)
expect(built.candidates.every((row) => row.office === 'deputado_federal')).toBe(true)
expect(built.votes.every((row) => row.year === 2018)).toBe(true)
})
})

View file

@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest'
import { citiesForTerritory } from '@/lib/bahiaTerritories'
import { tseZonesForCity } from '@/lib/bahiaTseZones'
import { BASELINE_TICKET_2022 } from '@/lib/electionResults'
import { computeVoteTrend } from '@/lib/electionInsights'
import {
aggregateNucleusElectoralBaseline,
resolveNucleusElectionGeography,
@ -175,8 +176,11 @@ describe('aggregateNucleusElectoralBaseline', () => {
},
]
it('sums the candidate, ticket leaders, electorate, winner, and rank for the geography', () => {
const baseline = aggregateNucleusElectoralBaseline(geography, votes, tallies)
it('sums the candidate, ticket leaders, electorate, winner, rank, and series for the geography', () => {
const baseline = aggregateNucleusElectoralBaseline(geography, votes, tallies, {
y2014: 1000,
y2018: 1500,
})
expect(baseline.candidate).toEqual({ votes: 2100, rank: 1 })
expect(baseline.president).toEqual({ votes: 8000, turn: 2 })
@ -194,6 +198,8 @@ describe('aggregateNucleusElectoralBaseline', () => {
votes: 2100,
party: 'PT',
})
expect(baseline.series).toEqual({ y2014: 1000, y2018: 1500, y2022: 2100 })
expect(computeVoteTrend(baseline.series).status).toBe('increase')
})
it('ranks the candidate below the local federal winner when applicable', () => {
@ -202,7 +208,10 @@ describe('aggregateNucleusElectoralBaseline', () => {
regions: [],
tseZones: [2],
})!
const baseline = aggregateNucleusElectoralBaseline(zone1Only, votes, tallies)
const baseline = aggregateNucleusElectoralBaseline(zone1Only, votes, tallies, {
y2014: 0,
y2018: 0,
})
expect(baseline.candidate).toEqual({ votes: 900, rank: 2 })
expect(baseline.winnerFederal).toEqual({

View file

@ -18,6 +18,7 @@ const baseline: NucleusElectoralBaselineViewModel = {
abstencoes: 680,
},
winnerFederal: { name: 'Dep. Fulano de Tal', votes: 2100, party: 'PP' },
series: { y2014: 700, y2018: 800, y2022: 850 },
}
describe('NucleusElectoralBaseline', () => {
@ -38,6 +39,8 @@ describe('NucleusElectoralBaseline', () => {
expect(html).toContain('2.490')
expect(html).toContain('Mais votado aqui em 2022')
expect(html).toContain('Dep. Fulano de Tal (PP)')
expect(html).toContain('Série histórica')
expect(html).toContain('2014: 700')
})
})
@ -63,4 +66,15 @@ describe('NucleusInsights', () => {
)
expect(html).toContain('Já superamos 2022 em 12%')
})
it('renders the vote-trend insight when the series is comparable', () => {
const html = renderToStaticMarkup(
createElement(NucleusInsights, {
baseline,
confirmedVoteEstimate: 500,
}),
)
expect(html).toContain('data-insight="vote-trend"')
expect(html).toContain('Tendência:')
})
})