mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:25:20 +00:00
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:
parent
e5e051d175
commit
b0f4ebc1ed
22 changed files with 895 additions and 205 deletions
|
|
@ -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.
|
||||
|
|
|
|||
105
docs/plans/escala-dry-pos-e2.md
Normal file
105
docs/plans/escala-dry-pos-e2.md
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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:')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue