Deliver A5-2 territorial classification insight and absorb review debts.
Classify nuclei as defesa/ataque/indecisa/perdida from 2022 baseline share, surface Alert + overview distribution, and register post-simplify follow-ups in A7/E7 plans.
This commit is contained in:
parent
909778eaea
commit
81b01b9907
14 changed files with 474 additions and 59 deletions
|
|
@ -1,7 +1,7 @@
|
|||
# Escala e DRY pós-A4 (baseline TSE no produto)
|
||||
|
||||
Status: Fase 1 implementada (Fases 2–5 pendentes; merge pendente)
|
||||
Atualizado em: 2026-07-19 (Fase 5 lista flip/leverage + extensão F3 gap registradas via `capture-review-debts` pós-A5 `/simplify`)
|
||||
Atualizado em: 2026-07-19 (Fase 5 lista flip/leverage + extensão F3 gap registradas via `capture-review-debts` pós-A5 `/simplify`; **pós-A5-2 classificação territorial** — absorções no mesmo plano via `capture-review-debts`)
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha A, item A7)
|
||||
Responsável: —
|
||||
|
||||
|
|
@ -22,7 +22,9 @@ Os revisores (performance / reuse / quality) marcaram como **importantes e maior
|
|||
|
||||
**Já resolvido no simplify pós-A5 alavancagem (não reabrir):** `ticketFlip` pré-computado no VM (`aggregateNucleusElectoralBaseline`) e consumido em `NucleusInsights` (paridade detail/overview em empates majoritários); `resolveMajoritarianWinners` compartilhado; winners/federalRace reutilizados sem segunda passagem no detalhe; `Promise.all` série+ticket+majoritarian no overview (remove waterfall); loop flip sem `null` filter; guard redundante `leverage.ticketVotes`; testes gap com `GapVs2022Baseline`; assert `ticketFlip` no unit de baseline.
|
||||
|
||||
**Explicitamente fora (revisores pediram skip no simplify ou descartados no triage):** map JSX de linhas Lula/Jerônimo, dropar `candidate.rank` / `electorate.aptos` / `ratio` do tipo público só por higiene (só fariam sentido se a F1 deixar de calcular rank), cast `as unknown as RawOverviewNucleus` da B1 (não é débito novo do A4), parity test drizzle vs `aggregateFederalCandidateTotals` (int + unit cobrem comportamento), typed drizzle rows/zod no `drizzleResultRows` (padrão plataforma C6/C8), `requirePostgresDrizzle` em `supporterListOverviewAggregate.ts` (fora do escopo A7 — DRY de ~6 linhas; ver C10 quando o plano existir), micro-opt `aggregateElectorate`/`matchesGeography` com `Set` (impacto negligível), `BahiaMap` `setStyle` incremental → **B6** ([escala-dry-pos-b3.md](escala-dry-pos-b3.md)), factory geometrias mun/TI → **B5 F3** ([escala-dry-pos-b2.md](escala-dry-pos-b2.md)); **pós-A5:** `buildUnionGeography` 2× (micro-opt), mover testes leverage/flip para `electionInsights.unit.spec.ts`, unions discriminadas `TicketLeverage`/`TicketFlip`, estreitar campos VM/result flip, `ticketFlipAlertVariant`/`formatPercent` export, `MajoritarianTallyRow` tipo separado, maps de ícones em `NucleusInsights`, wrapper `NucleusInsightAlert` além da F3 (E7 adia DRY do stack até mais insights).
|
||||
**Já resolvido no simplify pós-A5-2 classificação territorial (não reabrir):** `sumFederalTallyForGeography` único (substitui `sumElectorateForGeography` + helper paralelo); reuso de `classificationTalliesByCityZone` no loop de conversão (elimina 2º `loadElectionTallies` em `conversionUnion`); loop de classificação in-memory antes do `await` do flip N+1; push só de bandas comparáveis (`isComparableTerritorialClass`) — evita linha overview `0 defesa · …`; `TERRITORIAL_CLASS_UI` canônico + accessors finos; `TerritorialClassIcon` no detalhe.
|
||||
|
||||
**Explicitamente fora (revisores pediram skip no simplify ou descartados no triage):** map JSX de linhas Lula/Jerônimo, dropar `candidate.rank` / `electorate.aptos` / `ratio` do tipo público só por higiene (só fariam sentido se a F1 deixar de calcular rank), cast `as unknown as RawOverviewNucleus` da B1 (não é débito novo do A4), parity test drizzle vs `aggregateFederalCandidateTotals` (int + unit cobrem comportamento), typed drizzle rows/zod no `drizzleResultRows` (padrão plataforma C6/C8), `requirePostgresDrizzle` em `supporterListOverviewAggregate.ts` (fora do escopo A7 — DRY de ~6 linhas; ver C10 quando o plano existir), micro-opt `aggregateElectorate`/`matchesGeography` com `Set` (impacto negligível), `BahiaMap` `setStyle` incremental → **B6** ([escala-dry-pos-b3.md](escala-dry-pos-b3.md)), factory geometrias mun/TI → **B5 F3** ([escala-dry-pos-b2.md](escala-dry-pos-b2.md)); **pós-A5:** `buildUnionGeography` 2× (micro-opt), mover testes leverage/flip para `electionInsights.unit.spec.ts`, unions discriminadas `TicketLeverage`/`TicketFlip`, estreitar campos VM/result flip, `ticketFlipAlertVariant`/`formatPercent` export, `MajoritarianTallyRow` tipo separado, maps de ícones em `NucleusInsights`, wrapper `NucleusInsightAlert` além da F3 (E7 adia DRY do stack até mais insights); **pós-A5-2:** agregador genérico conversion+territorial, achatar `NucleusClassificationOverviewAggregate`, `percentValid` só em testes, merge CPU dos loops `sumCandidateVotesForGeography` trend+classificação, 4ª query `loadElectionTallies` no union (custo intencional A5-2), `territorialClassLabel` na linha do overview (conversão também hardcoded), componente compartilhado de linha de distribuição → **E6 F3**.
|
||||
|
||||
## Objetivos
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Escala e DRY pós-E2 (série TSE 2014/2018 + tendência)
|
||||
|
||||
Status: Fase 2 entregue (2026-07-19); extensão F2 pós-A5 pendente; Fases 1, 3 e 4 pendentes
|
||||
Atualizado em: 2026-07-19 (extensão int leverage/flip registrada via `capture-review-debts` pós-A5 `/simplify`)
|
||||
Atualizado em: 2026-07-19 (extensão int leverage/flip/**classification** registrada via `capture-review-debts` pós-A5 `/simplify` + pós-A5-2)
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha E, item E7)
|
||||
Responsável: —
|
||||
|
||||
|
|
@ -25,12 +25,14 @@ Os revisores (performance / reuse / quality) marcaram como **importantes e maior
|
|||
|
||||
**Pendente pós-A5 (`capture-review-debts`):** int de `leverage`/`flipOpportunity` no loader combinado; spy estrutural de que `loadFederalCandidateTotalsAggregated` não é chamado N vezes na lista (documenta débito até A7 F5 fechar o batch).
|
||||
|
||||
**Pendente pós-A5-2 (`capture-review-debts`):** int de `classification.distribution` no mesmo `loadNucleusListElectionOverview` (fixture com `validos` > 0 e bandas comparáveis; assert `classification: null` quando só `semBaseline`).
|
||||
|
||||
**Explicitamente fora (revisores pediram skip no simplify, capture-review-debts 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); chip de faixa no Alert de conversão (produto adia); DRY do stack de 3 `Alert`s em `NucleusInsights` até mais insights A5; **imports sequenciais 2014/2018/2022** em `seedMultiYearFederalCandidateFixture` (fixture pequena; paralelizar só se CI atrasar); **documentação de isolamento** entre suites int que apagam `election*` na mesma DB (mitigado por `teqo_test` por worker).
|
||||
|
||||
## 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 + agregado de conversão (`weightedRate`, `distribution` reduto/consolidado/oportunidade) no mesmo path; **extensão pós-A5:** agregados `leverage` / `flipOpportunity` + spy de query count federal (guardrail contra regressão N+1 até A7 F5).
|
||||
- `loadNucleusListElectionOverview` tem pelo menos um teste int com fixture TSE que cobre gap agregado + distribuição de tendência + agregado de conversão (`weightedRate`, `distribution` reduto/consolidado/oportunidade) no mesmo path; **extensão pós-A5:** agregados `leverage` / `flipOpportunity` + spy de query count federal (guardrail contra regressão N+1 até A7 F5); **extensão pós-A5-2:** agregado `classification.distribution` (defesa/indecisa/ataque/perdida) + caso `classification: null` sem válidos.
|
||||
- Tipos de distribuição de tendência têm uma única fonte (`VoteTrendDistribution`); loaders e VMs importam de `electionInsights.ts`.
|
||||
- Helpers compartilhados para wipe/seed de dados eleitorais nos int tests (sem triplicar `deleteAllElectionData`).
|
||||
- Guardrails: sem migration, sem Consent (dado público TSE). Access continua `overrideAccess: false`. Identificadores em inglês; strings visíveis em pt-BR.
|
||||
|
|
@ -76,11 +78,12 @@ flowchart TD
|
|||
- `tests/helpers/tseFixtures.ts` — `seedMultiYearFederalCandidateFixture`, `TSE_FIXTURE_ZONE_EXPECTED`.
|
||||
- `tests/int/nucleusListElectionOverview.int.spec.ts` — gap+trend+conversão, tendência sem estimate, geografia vazia, conversão ponderada e aptos sem votos 2022 do candidato.
|
||||
|
||||
#### Extensão Fase 2 (pós-A5 — pendente)
|
||||
#### Extensão Fase 2 (pós-A5 / pós-A5-2 — pendente)
|
||||
|
||||
- Estender fixture TSE com votos de chapa majoritária + federal proporcional por partido suficientes para `leverage` comparável e `flipOpportunity` com `status: 'opportunity'`.
|
||||
- Asserts em `loadNucleusListElectionOverview`: `leverage.weightedPercent` / `unconvertedCount` e `flipOpportunity.count` / `bothAlignedCount`.
|
||||
- Spy estrutural: com N núcleos no filtro, `loadFederalCandidateTotalsAggregated` (ou execute drizzle federal) **não** deve ser invocado N vezes — falha documenta regressão até A7 F5; após F5, o spy valida uma única batch query.
|
||||
- **Pós-A5-2:** asserts de `classification.distribution` sobre o conjunto filtrado com geografia; caso negativo com tallies sem `votosValidos` → `classification: null` e overview sem linha `Classificação:`.
|
||||
|
||||
### Fase 3 — Tipos e API
|
||||
|
||||
|
|
|
|||
|
|
@ -1,71 +1,78 @@
|
|||
# Insight: classificação territorial (defesa/ataque/indecisa/perdida)
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-17
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Campanha → Próximos ciclos")
|
||||
Status: entregue (slice A5-2, 2026-07-19)
|
||||
Atualizado em: 2026-07-19
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Janela 3, A5 — segundo dos cinco insights)
|
||||
Responsável: —
|
||||
|
||||
**Revisão 2026-07-19:** as-built espelha A5-1 conversão — Alert no stack `NucleusInsights.tsx` (sem `NucleusTerritorialClassification.tsx`); overview via `loadNucleusListElectionOverview` + linha em `NucleusListOverview`; limiares 35%/20%/10% versionados em `electionInsights.ts` _(assumido — validar com produto)_; classificação sobre todos os núcleos filtrados com geografia + válidos > 0 (não exige estimativa confirmada).
|
||||
|
||||
## Referência visual (UX Pilot)
|
||||
|
||||
Design: [`Baseline-Eleitoral-2022.png`](../design-refs/latest/Baseline-Eleitoral-2022.png) — card "Insights do território", linha "Território de defesa · Base sólida — prioridade: manter engajamento" com chip de classificação à direita (`Defesa` em verde). Os quatro estados usam pares de badge do tema `campaign`: defesa = verde, ataque = contorno vermelho, indecisa = âmbar, perdida = cinza. Implementar como um card do stack `NucleusInsights.tsx` ([baseline-eleitoral-tse.md](baseline-eleitoral-tse.md)), ajustando a paleta antiga do HTML/PNG para os tokens claros.
|
||||
Design: [`Baseline-Eleitoral-2022.png`](../design-refs/latest/Baseline-Eleitoral-2022.png) — card "Insights do território", linha "Território de defesa · Base sólida — prioridade: manter engajamento" com chip de classificação à direita (`Defesa` em verde). Os quatro estados usam pares de badge do tema `campaign`: defesa = verde, ataque = contorno vermelho, indecisa = âmbar, perdida = cinza. Implementar como um card do stack `NucleusInsights.tsx` ([baseline-eleitoral-tse.md](baseline-eleitoral-tse.md)), com os tokens claros do tema `campaign` em vez da paleta antiga do HTML/PNG.
|
||||
|
||||
## Contexto
|
||||
|
||||
A literatura de territorialização eleitoral (Politipédia AVM, OPUS, Seja Eleito) classifica cada território em quatro zonas operacionais — **defesa** (voto histórico favorável, meta: manter), **ataque** (desfavorável mas relevante, meta: virar/reduzir margem), **indecisa** (pulverizada, baixa rejeição, meta: consolidar) e **perdida** (desfavorável e elástica, meta: minimizar perda) — para alocar esforço de forma proporcional ao retorno. Hoje o `/campanha` não classifica núcleos; com o baseline TSE 2022 (ver [baseline-eleitoral-tse.md](baseline-eleitoral-tse.md)) podemos derivar uma primeira classificação a partir do histórico.
|
||||
A literatura de territorialização eleitoral (Politipédia AVM, OPUS, Seja Eleito) classifica cada território em quatro zonas operacionais — **defesa** (voto histórico favorável, meta: manter), **ataque** (desfavorável mas relevante, meta: virar/reduzir margem), **indecisa** (pulverizada, meta: consolidar) e **perdida** (baixa base histórica, meta: minimizar perda) — para alocar esforço de forma proporcional ao retorno. Com o baseline TSE 2022 (ver [baseline-eleitoral-tse.md](baseline-eleitoral-tse.md)) derivamos a primeira classificação a partir de `sollaVotes2022 / votosValidosFederal2022`.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Computar `sollaPercentValid = sollaVotes2022 / votosValidosFederal2022` por núcleo (soma sobre cidades∩zonas).
|
||||
- Classificar o núcleo em defesa/ataque/indecisa/perdida por `sollaPercentValid` vs. limiares configuráveis.
|
||||
- Exibir badge no detalhe do núcleo e distribuição por classe no overview da lista (sobre o conjunto filtrado).
|
||||
- (Opcional, quando o domínio existir) refinar a classe cruzando com pesquisa de intenção de voto atual.
|
||||
- Computar `percentValid = sollaVotes2022 / votosValidosFederal2022` por núcleo (soma sobre cidades∩zonas).
|
||||
- Classificar o núcleo em defesa/ataque/indecisa/perdida por `percentValid` vs. limiares configuráveis.
|
||||
- Exibir Alert com badge no detalhe do núcleo e distribuição por classe no overview da lista (sobre o conjunto filtrado com geografia resolvível).
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Leitura derivada** — sem escrita, sem `Consent`, sem migration.
|
||||
- **Limiares versionados** em `src/lib/electionInsights.ts` (constantes); decisão de produto por limiar. A classe é **sugestão automática**, não rótulo definitivo — coordenador pode discordar.
|
||||
- **Reusa** `getNucleusElectoralBaseline` (Solla + válidos federal) do plano baseline.
|
||||
- **Leitura derivada** — sem escrita, sem `Consent`, sem migration. Reusa `getNucleusElectoralBaseline` / `loadNucleusListElectionOverview`.
|
||||
- **Limiares versionados** em `src/lib/electionInsights.ts`: `TERRITORIAL_DEFESA_MIN = 0.35`, `TERRITORIAL_INDECISA_MIN = 0.20`, `TERRITORIAL_ATAQUE_MIN = 0.10` _(assumido — validar com produto)_.
|
||||
- A classe é **sugestão automática**, não rótulo definitivo — coordenador pode discordar.
|
||||
- **Sem rejeição** por geografia (domínio inexistente); refinamento futuro com pesquisas.
|
||||
- **`lideranca` vê** o insight (mesmo stack da Visão geral).
|
||||
- **UI:** `Alert` em `NucleusInsights.tsx` (`data-insight="territorial-class"`), não componente separado; chip de classe no Alert (design-ref).
|
||||
- **Overview:** distribuição sobre todos os núcleos filtrados com geografia + `validos > 0` (não exige estimativa confirmada).
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- Limiares exatos por classe — definir com produto (referência: alta % válidos → defesa; baixa + relevante → ataque; pulverizada → indecisa; baixa + alta rejeição → perdida).
|
||||
- Como incorporar rejeição (não temos dado de rejeição por geografia hoje) — adiar até domínio de pesquisas.
|
||||
- `lideranca` vê a classe do próprio núcleo? Recomendação: sim.
|
||||
- Limiares exatos — **assumidos 35%/20%/10%** até validação de produto.
|
||||
- Refinar com pesquisa de intenção/rejeição — adiado.
|
||||
|
||||
## Abordagem proposta
|
||||
## Abordagem (as-built)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Base["getNucleusElectoralBaseline<br/>sollaVotes + votosValidosFederal"]
|
||||
Calc["computeTerritorialClass(sollaPercentValid)<br/>classe + confiança"]
|
||||
Detail["Detalhe: badge da classe<br/>+ % válidos de Solla"]
|
||||
Overview["Overview: distribuição<br/>defesa/ataque/indecisa/perdida<br/>sobre o filtro"]
|
||||
Base["getNucleusElectoralBaseline<br/>candidate.votes + electorate.validos"]
|
||||
Calc["computeTerritorialClass(...)"]
|
||||
Detail["NucleusInsights Alert + Badge"]
|
||||
Overview["loadNucleusListElectionOverview<br/>+ NucleusListOverview"]
|
||||
Base --> Calc
|
||||
Calc --> Detail
|
||||
Calc --> Overview
|
||||
```
|
||||
|
||||
- **Helper** `src/lib/electionInsights.ts`: `computeTerritorialClass(sollaVotes, votosValidosFederal)` → `{ percentValid, class: 'defesa'|'ataque'|'indecisa'|'perdida'|'semBaseline' }`.
|
||||
- **Componente** `src/components/campaign/NucleusTerritorialClassification.tsx` (server). Overview: bloco com contagem por classe.
|
||||
- **Teste int** cenários limítrofes e `votosValidosFederal=0`.
|
||||
- **Helper** [`src/lib/electionInsights.ts`](../../src/lib/electionInsights.ts): `computeTerritorialClass`, `aggregateTerritorialClass`, `territorialClassBadgeVariant`, `territorialClassLabel`.
|
||||
- **Detalhe** [`src/components/campaign/NucleusInsights.tsx`](../../src/components/campaign/NucleusInsights.tsx).
|
||||
- **Overview** [`src/utilities/nucleusElectoralBaseline.ts`](../../src/utilities/nucleusElectoralBaseline.ts) (`classification.distribution`) + [`NucleusListOverview.tsx`](../../src/components/campaign/NucleusListOverview.tsx).
|
||||
- **Testes** unitários em `tests/unit/electionInsights.unit.spec.ts`; UI em `tests/unit/nucleusElectoralBaselineUi.unit.spec.ts` e `tests/unit/campaignNucleusListOverview.unit.spec.ts`.
|
||||
|
||||
## Arquivos a criar/alterar
|
||||
**Já resolvido no simplify / capture-review-debts (não reabrir):** `TERRITORIAL_CLASS_UI` + accessors; `sumFederalTallyForGeography` único; reuso de tallies union na conversão (sem 2º fetch); filtro `isComparableTerritorialClass` no loader; `TerritorialClassIcon`; branches `semBaseline` unificados em `computeTerritorialClass`.
|
||||
|
||||
- Criar: `src/components/campaign/NucleusTerritorialClassification.tsx`.
|
||||
- Alterar: `src/lib/electionInsights.ts`, `nucleos/[slug]/page.tsx` + `nucleusDetailPageData.ts`, overview da lista.
|
||||
**Débitos maiores (planos existentes):** batch federal flip/leverage na lista → [escala-dry-pos-a4.md](escala-dry-pos-a4.md) **A7 F5**; int `classification` + leverage/flip no loader → [escala-dry-pos-e2.md](escala-dry-pos-e2.md) **E7 F2 ext.**; fetch único 2022 lista+coroplético → **A7 F4**; DRY linha de distribuição no overview → [escala-dry-pos-e1.md](escala-dry-pos-e1.md) **E6 F3**; shell `NucleusInsightAlert` → **E7 F1** (adiado até estabilizar stack A5).
|
||||
|
||||
## Dependências
|
||||
|
||||
- [baseline-eleitoral-tse.md](baseline-eleitoral-tse.md) — **única dependência dura**.
|
||||
- [zonas-por-municipio.md](zonas-por-municipio.md) — dependência suave herdada do baseline (melhora a qualidade de `tseZones`; ver revisão 2026-07-17 no plano baseline).
|
||||
- [zonas-por-municipio.md](zonas-por-municipio.md) — dependência suave herdada do baseline.
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Mapa/PostGIS com a classe pintada por território (roadmap linha 52).
|
||||
- Pesquisa de intenção/rejeição (domínio inexistente; refinamento futuro).
|
||||
- Mapa/PostGIS com a classe pintada por território (B3).
|
||||
- Pesquisa de intenção/rejeição; override manual da classe; demais insights A5.
|
||||
|
||||
## Referências
|
||||
|
||||
- Politipédia AVM — "Territorialização eleitoral em campanha"
|
||||
- [baseline-eleitoral-tse.md](baseline-eleitoral-tse.md)
|
||||
- [insight-taxa-conversao.md](insight-taxa-conversao.md) — padrão as-built A5-1
|
||||
- [escala-dry-pos-a4.md](escala-dry-pos-a4.md) — A7 F5 (batch flip/leverage)
|
||||
- [escala-dry-pos-e2.md](escala-dry-pos-e2.md) — E7 F2 ext. (int loader)
|
||||
- AGENTS.md
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ MVP de território + reporte implementado e enviado (ondas 1–8 + refactors de
|
|||
### Ciclo 2+ — A4 implementado e mesclado em `main` (2026-07-18)
|
||||
|
||||
- **A4 Baseline no produto + Gap vs 2022** — agregação `getNucleusElectoralBaseline` / `loadNucleusBaseline2022Overview` + `computeGapVs2022`; bloco "Baseline eleitoral 2022" e insight Gap na aba Visão geral do núcleo; card "Baseline 2022" no overview da lista filtrada. Chapa tipada via `BASELINE_TICKET_2022` (papéis `candidate` / `president` / `governor`). Sem migration, sem Consent (dado público TSE). Três passagens `/simplify` no PR; débitos maiores → A7. [Plano](plans/baseline-eleitoral-tse.md).
|
||||
- **A7 Escala e DRY pós-A4 — Fase 1 entregue (2026-07-19; merge pendente)** — agregação drizzle do ranking federal no detalhe (`loadFederalCandidateTotalsAggregated`); Fases 2–5 pendentes (`cityCode` TSE, Alert `confirmed` + helpers gap + `Progress`, fetch único 2022 lista+coroplético B3, batch federal flip/leverage na lista pós-A5). Passagens `/simplify` + `capture-review-debts` pós-B3 e pós-A5. [Plano](plans/escala-dry-pos-a4.md).
|
||||
- **A7 Escala e DRY pós-A4 — Fase 1 entregue (2026-07-19; merge pendente)** — agregação drizzle do ranking federal no detalhe (`loadFederalCandidateTotalsAggregated`); Fases 2–5 pendentes (`cityCode` TSE, Alert `confirmed` + helpers gap + `Progress`, fetch único 2022 lista+coroplético B3, batch federal flip/leverage na lista pós-A5). Passagens `/simplify` + `capture-review-debts` pós-B3, pós-A5 e **pós-A5-2 classificação territorial** (tally único, reuso tallies conversão, ordem classificação antes do flip N+1). [Plano](plans/escala-dry-pos-a4.md).
|
||||
- **A8 Perfis do eleitorado IBGE registrado (2026-07-19)** — perfil médio/comum do território derivado de indicadores municipais do Censo IBGE (artefato estático + ponderação por `cities[]`), exibido na aba Eleitorado junto com `voterProfiles` manuais já existentes; sem migration, sem Consent. [Plano](plans/perfil-eleitorado-ibge.md).
|
||||
|
||||
### Ciclo 2+ — B2 fundação de geometrias do mapa (2026-07-18)
|
||||
|
|
@ -92,7 +92,7 @@ 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). **Implementado e mesclado em `main` 2026-07-19** (migration `20260719_054522_add_nucleus_goals_strategy`; `/simplify` → 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 `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` (gap + tendência **+ conversão A5-1**; **extensão pós-A5:** leverage/flip + spy N+1 federal), tipo único `VoteTrendDistribution`, helpers compartilhados de wipe eleitoral nos int tests. **Fase 2 int loader entregue (2026-07-19)** — `tests/int/nucleusListElectionOverview.int.spec.ts` + `seedMultiYearFederalCandidateFixture`; extensão F2 pós-A5 pendente; Fases 1, 3 e 4 pendentes. [Plano](plans/escala-dry-pos-e2.md).
|
||||
- **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` (gap + tendência **+ conversão A5-1**; **extensão pós-A5:** leverage/flip + spy N+1 federal; **extensão pós-A5-2:** `classification.distribution`), tipo único `VoteTrendDistribution`, helpers compartilhados de wipe eleitoral nos int tests. **Fase 2 int loader entregue (2026-07-19)** — `tests/int/nucleusListElectionOverview.int.spec.ts` + `seedMultiYearFederalCandidateFixture`; extensões F2 pós-A5 e pós-A5-2 pendentes; Fases 1, 3 e 4 pendentes. [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. **Implementado e mesclado em `main` 2026-07-19** (mesma migration E1+E3; UI em `NucleusIntelligenceDialog` + cards staff na Visão geral).
|
||||
- **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.
|
||||
|
|
|
|||
|
|
@ -1,27 +1,36 @@
|
|||
import type { LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
CheckCircle2Icon,
|
||||
Link2Icon,
|
||||
MegaphoneIcon,
|
||||
MinusIcon,
|
||||
PercentIcon,
|
||||
ShieldIcon,
|
||||
TrendingDownIcon,
|
||||
TrendingUpIcon,
|
||||
TriangleAlertIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import {
|
||||
computeConversionRate,
|
||||
computeGapVs2022,
|
||||
computeTerritorialClass,
|
||||
computeTicketLeverage,
|
||||
computeVoteTrend,
|
||||
conversionRateAlertVariant,
|
||||
formatElectionNumber,
|
||||
formatVoteTrendSeries,
|
||||
isComparableConversionBand,
|
||||
isComparableTerritorialClass,
|
||||
isComparableTicketLeverage,
|
||||
territorialClassAlertVariant,
|
||||
territorialClassBadgeVariant,
|
||||
territorialClassLabel,
|
||||
ticketLeverageAlertVariant,
|
||||
voteTrendAlertVariant,
|
||||
type ComparableTerritorialClass,
|
||||
} from '@/lib/electionInsights'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { NucleusElectoralBaselineViewModel } from '@/utilities/nucleusViewModels'
|
||||
|
|
@ -29,6 +38,18 @@ import type { NucleusElectoralBaselineViewModel } from '@/utilities/nucleusViewM
|
|||
const confirmedInsightAlertClass =
|
||||
'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)]'
|
||||
|
||||
const TERRITORIAL_CLASS_ICON: Record<ComparableTerritorialClass, LucideIcon> = {
|
||||
defesa: ShieldIcon,
|
||||
ataque: TriangleAlertIcon,
|
||||
indecisa: MinusIcon,
|
||||
perdida: TrendingDownIcon,
|
||||
}
|
||||
|
||||
const TerritorialClassIcon = ({ band }: { band: ComparableTerritorialClass }) => {
|
||||
const Icon = TERRITORIAL_CLASS_ICON[band]
|
||||
return <Icon aria-hidden="true" />
|
||||
}
|
||||
|
||||
export const NucleusInsights = ({
|
||||
baseline,
|
||||
confirmedVoteEstimate,
|
||||
|
|
@ -73,6 +94,11 @@ export const NucleusInsights = ({
|
|||
|
||||
const flip = baseline?.ticketFlip ?? null
|
||||
|
||||
const territorial = computeTerritorialClass({
|
||||
sollaVotes: baseline?.candidate.votes ?? 0,
|
||||
federalValidVotes: baseline?.electorate.validos ?? null,
|
||||
})
|
||||
|
||||
let supportLine: string | null = null
|
||||
if ((gap.status === 'above' || gap.status === 'below') && baseline && confirmedVoteEstimate !== null) {
|
||||
supportLine = `Estimativa atual (${formatElectionNumber(confirmedVoteEstimate)}) · resultado 2022 (${formatElectionNumber(baseline.candidate.votes)})`
|
||||
|
|
@ -141,6 +167,32 @@ export const NucleusInsights = ({
|
|||
</Alert>
|
||||
) : null}
|
||||
|
||||
{isComparableTerritorialClass(territorial.band) ? (
|
||||
<Alert
|
||||
data-insight="territorial-class"
|
||||
variant={territorialClassAlertVariant(territorial.band)}
|
||||
className={cn(
|
||||
'rounded-xl px-3.5 py-3',
|
||||
territorial.band === 'defesa' && confirmedInsightAlertClass,
|
||||
)}
|
||||
>
|
||||
<TerritorialClassIcon band={territorial.band} />
|
||||
<AlertTitle className="flex items-start justify-between gap-2 font-bold">
|
||||
<span>{territorial.title}</span>
|
||||
<Badge
|
||||
className="shrink-0"
|
||||
variant={territorialClassBadgeVariant(territorial.band)}
|
||||
>
|
||||
{territorialClassLabel(territorial.band)}
|
||||
</Badge>
|
||||
</AlertTitle>
|
||||
<AlertDescription>{territorial.priorityLine}</AlertDescription>
|
||||
{territorial.supportLine ? (
|
||||
<AlertDescription>{territorial.supportLine}</AlertDescription>
|
||||
) : null}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{baseline && trend && trend.status !== 'noBaseline' ? (
|
||||
<Alert
|
||||
data-insight="vote-trend"
|
||||
|
|
|
|||
|
|
@ -174,6 +174,26 @@ export const NucleusListOverview = ({
|
|||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
{view.classification ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Classificação:{' '}
|
||||
<span className="font-medium text-estimate-confirmed-foreground">
|
||||
{formatElectionNumber(view.classification.distribution.defesa)} defesa
|
||||
</span>
|
||||
{' · '}
|
||||
<span className="font-medium text-estimate-pending-foreground">
|
||||
{formatElectionNumber(view.classification.distribution.indecisa)} indecisa
|
||||
</span>
|
||||
{' · '}
|
||||
<span className="font-medium text-destructive">
|
||||
{formatElectionNumber(view.classification.distribution.ataque)} ataque
|
||||
</span>
|
||||
{' · '}
|
||||
<span className="font-medium text-muted-foreground">
|
||||
{formatElectionNumber(view.classification.distribution.perdida)} perdida
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,15 @@ export const CONVERSION_REDUTO_MIN = 0.4
|
|||
/** Share of federal nominal votes outside the chapa field → flip opportunity (A5 Fase 2). */
|
||||
export const RIGHT_SHARE_THRESHOLD = 0.25
|
||||
|
||||
/** At or above this share of valid federal votes → defesa (literature reference, A5-2). */
|
||||
export const TERRITORIAL_DEFESA_MIN = 0.35
|
||||
|
||||
/** At or above this share → indecisa (between defesa and ataque). */
|
||||
export const TERRITORIAL_INDECISA_MIN = 0.2
|
||||
|
||||
/** At or above this share → ataque; below → perdida. */
|
||||
export const TERRITORIAL_ATAQUE_MIN = 0.1
|
||||
|
||||
export type VoteTrendStatus = 'decline' | 'stable' | 'increase' | 'noBaseline'
|
||||
|
||||
export type VoteTrendSeries = {
|
||||
|
|
@ -72,6 +81,35 @@ export type ConversionBandDistribution = {
|
|||
oportunidade: number
|
||||
}
|
||||
|
||||
export type TerritorialClassificationBand =
|
||||
| 'defesa'
|
||||
| 'ataque'
|
||||
| 'indecisa'
|
||||
| 'perdida'
|
||||
| 'semBaseline'
|
||||
|
||||
export type ComparableTerritorialClass = Exclude<TerritorialClassificationBand, 'semBaseline'>
|
||||
|
||||
export type TerritorialClassificationInput = {
|
||||
sollaVotes: number
|
||||
federalValidVotes: number | null
|
||||
}
|
||||
|
||||
export type TerritorialClassificationResult = {
|
||||
percentValid: number | null
|
||||
band: TerritorialClassificationBand
|
||||
title: string
|
||||
priorityLine: string
|
||||
supportLine: string | null
|
||||
}
|
||||
|
||||
export type TerritorialClassificationDistribution = {
|
||||
defesa: number
|
||||
ataque: number
|
||||
indecisa: number
|
||||
perdida: number
|
||||
}
|
||||
|
||||
/** Minimal baseline shape for Gap vs 2022 (full detail VM is assignable). */
|
||||
export type GapVs2022Baseline = {
|
||||
candidate: { votes: number }
|
||||
|
|
@ -122,6 +160,84 @@ export const isComparableConversionBand = (
|
|||
): band is 'reduto' | 'consolidado' | 'oportunidade' =>
|
||||
band === 'reduto' || band === 'consolidado' || band === 'oportunidade'
|
||||
|
||||
const emptyTerritorialClassificationDistribution =
|
||||
(): TerritorialClassificationDistribution => ({
|
||||
defesa: 0,
|
||||
ataque: 0,
|
||||
indecisa: 0,
|
||||
perdida: 0,
|
||||
})
|
||||
|
||||
export const aggregateTerritorialClass = (
|
||||
bands: readonly TerritorialClassificationBand[],
|
||||
): TerritorialClassificationDistribution => {
|
||||
const distribution = emptyTerritorialClassificationDistribution()
|
||||
for (const band of bands) {
|
||||
if (isComparableTerritorialClass(band)) {
|
||||
distribution[band] += 1
|
||||
}
|
||||
}
|
||||
return distribution
|
||||
}
|
||||
|
||||
export const isComparableTerritorialClass = (
|
||||
band: TerritorialClassificationBand,
|
||||
): band is ComparableTerritorialClass =>
|
||||
band === 'defesa' || band === 'ataque' || band === 'indecisa' || band === 'perdida'
|
||||
|
||||
const TERRITORIAL_CLASS_UI = {
|
||||
defesa: {
|
||||
label: 'Defesa',
|
||||
badgeVariant: 'estimate-confirmed',
|
||||
alertVariant: 'default',
|
||||
title: 'Território de defesa',
|
||||
priorityLine: 'Base sólida — prioridade: manter engajamento',
|
||||
},
|
||||
indecisa: {
|
||||
label: 'Indecisa',
|
||||
badgeVariant: 'estimate-pending',
|
||||
alertVariant: 'pending',
|
||||
title: 'Território indeciso',
|
||||
priorityLine: 'Pulverizado — prioridade: consolidar',
|
||||
},
|
||||
ataque: {
|
||||
label: 'Ataque',
|
||||
badgeVariant: 'destructive',
|
||||
alertVariant: 'destructive',
|
||||
title: 'Território de ataque',
|
||||
priorityLine: 'Desfavorável relevante — prioridade: virar/reduzir margem',
|
||||
},
|
||||
perdida: {
|
||||
label: 'Perdida',
|
||||
badgeVariant: 'secondary',
|
||||
alertVariant: 'default',
|
||||
title: 'Território perdido',
|
||||
priorityLine: 'Baixa base histórica — prioridade: minimizar perda',
|
||||
},
|
||||
} as const satisfies Record<
|
||||
ComparableTerritorialClass,
|
||||
{
|
||||
label: string
|
||||
badgeVariant: 'estimate-confirmed' | 'estimate-pending' | 'destructive' | 'secondary'
|
||||
alertVariant: 'default' | 'pending' | 'destructive'
|
||||
title: string
|
||||
priorityLine: string
|
||||
}
|
||||
>
|
||||
|
||||
export const territorialClassLabel = (band: TerritorialClassificationBand): string =>
|
||||
isComparableTerritorialClass(band) ? TERRITORIAL_CLASS_UI[band].label : 'Sem baseline'
|
||||
|
||||
export const territorialClassBadgeVariant = (
|
||||
band: TerritorialClassificationBand,
|
||||
): (typeof TERRITORIAL_CLASS_UI)[ComparableTerritorialClass]['badgeVariant'] =>
|
||||
isComparableTerritorialClass(band) ? TERRITORIAL_CLASS_UI[band].badgeVariant : 'secondary'
|
||||
|
||||
export const territorialClassAlertVariant = (
|
||||
band: TerritorialClassificationBand,
|
||||
): (typeof TERRITORIAL_CLASS_UI)[ComparableTerritorialClass]['alertVariant'] =>
|
||||
isComparableTerritorialClass(band) ? TERRITORIAL_CLASS_UI[band].alertVariant : 'default'
|
||||
|
||||
export const conversionRateAlertVariant = (band: ConversionRateBand): 'default' | 'pending' =>
|
||||
band === 'oportunidade' ? 'pending' : 'default'
|
||||
|
||||
|
|
@ -666,3 +782,45 @@ export const ticketLeverageAlertVariant = (
|
|||
isComparableTicketLeverage(leverage.status) && (leverage.headlinePercent ?? 0) < 100
|
||||
? 'pending'
|
||||
: 'default'
|
||||
|
||||
export const computeTerritorialClass = ({
|
||||
sollaVotes,
|
||||
federalValidVotes,
|
||||
}: TerritorialClassificationInput): TerritorialClassificationResult => {
|
||||
if (federalValidVotes === null || federalValidVotes <= 0) {
|
||||
return {
|
||||
percentValid: null,
|
||||
band: 'semBaseline',
|
||||
title: 'Classificação territorial',
|
||||
priorityLine:
|
||||
federalValidVotes === null
|
||||
? NO_ELECTION_BASELINE_MESSAGE
|
||||
: 'Sem votos válidos no baseline para esta geografia',
|
||||
supportLine: null,
|
||||
}
|
||||
}
|
||||
|
||||
const percentValid = sollaVotes / federalValidVotes
|
||||
const percent = Math.round(percentValid * 100)
|
||||
|
||||
let band: ComparableTerritorialClass
|
||||
if (percentValid >= TERRITORIAL_DEFESA_MIN) {
|
||||
band = 'defesa'
|
||||
} else if (percentValid >= TERRITORIAL_INDECISA_MIN) {
|
||||
band = 'indecisa'
|
||||
} else if (percentValid >= TERRITORIAL_ATAQUE_MIN) {
|
||||
band = 'ataque'
|
||||
} else {
|
||||
band = 'perdida'
|
||||
}
|
||||
|
||||
const { title, priorityLine } = TERRITORIAL_CLASS_UI[band]
|
||||
|
||||
return {
|
||||
percentValid,
|
||||
band,
|
||||
title,
|
||||
priorityLine,
|
||||
supportLine: `${percent}% dos votos válidos federais em 2022 (${formatElectionNumber(sollaVotes)} / ${formatElectionNumber(federalValidVotes)})`,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,20 +14,24 @@ import {
|
|||
} from '@/lib/electionResults'
|
||||
import {
|
||||
aggregateConversionBand,
|
||||
aggregateTerritorialClass,
|
||||
aggregateTicketFlipOverview,
|
||||
aggregateTicketLeverageOverview,
|
||||
aggregateVoteTrend,
|
||||
computeConversionRate,
|
||||
computeGapVs2022,
|
||||
computeTerritorialClass,
|
||||
computeTicketFlipOpportunity,
|
||||
computeTicketLeverage,
|
||||
isComparableTicketLeverage,
|
||||
isComparableConversionBand,
|
||||
isComparableTerritorialClass,
|
||||
type ConversionBandDistribution,
|
||||
type ConversionRateBand,
|
||||
isComparableConversionBand,
|
||||
type TerritorialClassificationBand,
|
||||
type TerritorialClassificationDistribution,
|
||||
type TicketFlipOpportunityResult,
|
||||
type TicketLeverageOverviewAggregate,
|
||||
type TicketFlipOverviewAggregate,
|
||||
type TicketLeverageOverviewAggregate,
|
||||
type VoteTrendSeries,
|
||||
type VoteTrendStatus,
|
||||
} from '@/lib/electionInsights'
|
||||
|
|
@ -382,21 +386,23 @@ const aggregateElectorate = (
|
|||
return { aptos, validos, brancos, nulos, abstencoes }
|
||||
}
|
||||
|
||||
const sumElectorateForGeography = (
|
||||
const sumFederalTallyForGeography = (
|
||||
talliesByCityZone: ReadonlyMap<string, ElectionTallyAggregateRow>,
|
||||
geography: NucleusElectionGeography,
|
||||
): Pick<NucleusElectoralBaselineViewModel['electorate'], 'aptos' | 'abstencoes'> => {
|
||||
): Pick<NucleusElectoralBaselineViewModel['electorate'], 'aptos' | 'abstencoes' | 'validos'> => {
|
||||
let aptos = 0
|
||||
let abstencoes = 0
|
||||
let validos = 0
|
||||
|
||||
for (const { cityName, zoneNumber } of geography.cityZonePairs) {
|
||||
const row = talliesByCityZone.get(cityZoneKey(cityName, zoneNumber))
|
||||
if (!row) continue
|
||||
aptos += row.aptos
|
||||
abstencoes += row.abstencoes
|
||||
validos += row.votosValidos
|
||||
}
|
||||
|
||||
return { aptos, abstencoes }
|
||||
return { aptos, abstencoes, validos }
|
||||
}
|
||||
|
||||
export const aggregateNucleusElectoralBaseline = (
|
||||
|
|
@ -786,12 +792,17 @@ export type NucleusConversionOverviewAggregate = {
|
|||
distribution: ConversionBandDistribution
|
||||
}
|
||||
|
||||
export type NucleusClassificationOverviewAggregate = {
|
||||
distribution: TerritorialClassificationDistribution
|
||||
}
|
||||
|
||||
export type NucleusListElectionOverview = {
|
||||
baseline2022: NucleusBaseline2022OverviewAggregate | null
|
||||
trend: NucleusTrendOverviewAggregate | null
|
||||
conversion: NucleusConversionOverviewAggregate | null
|
||||
leverage: TicketLeverageOverviewAggregate | null
|
||||
flipOpportunity: TicketFlipOverviewAggregate | null
|
||||
classification: NucleusClassificationOverviewAggregate | null
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -808,7 +819,14 @@ export const loadNucleusListElectionOverview = async (
|
|||
)
|
||||
|
||||
if (withGeographyIndexes.length === 0) {
|
||||
return { baseline2022: null, trend: null, conversion: null, leverage: null, flipOpportunity: null }
|
||||
return {
|
||||
baseline2022: null,
|
||||
trend: null,
|
||||
conversion: null,
|
||||
leverage: null,
|
||||
flipOpportunity: null,
|
||||
classification: null,
|
||||
}
|
||||
}
|
||||
|
||||
const comparableIndexes: number[] = []
|
||||
|
|
@ -819,15 +837,26 @@ export const loadNucleusListElectionOverview = async (
|
|||
|
||||
const unionGeography = buildUnionGeography(geographies, withGeographyIndexes)
|
||||
if (!unionGeography) {
|
||||
return { baseline2022: null, trend: null, conversion: null, leverage: null, flipOpportunity: null }
|
||||
return {
|
||||
baseline2022: null,
|
||||
trend: null,
|
||||
conversion: null,
|
||||
leverage: null,
|
||||
flipOpportunity: null,
|
||||
classification: null,
|
||||
}
|
||||
}
|
||||
|
||||
const [seriesByYear, ticketVotes, majoritarianTallies] = await Promise.all([
|
||||
const [seriesByYear, ticketVotes, majoritarianTallies, classificationTallies] = await Promise.all([
|
||||
loadCandidateSeriesByGeography(payload, user, unionGeography),
|
||||
loadTicketOfficeVotes(payload, user, unionGeography),
|
||||
loadMajoritarianElectionTallies(payload, user, unionGeography),
|
||||
loadElectionTallies(payload, user, unionGeography),
|
||||
])
|
||||
const votes2022 = seriesByYear.get(ELECTION_YEAR_2022) ?? new Map<string, number>()
|
||||
const classificationTalliesByCityZone = new Map(
|
||||
classificationTallies.map((row) => [cityZoneKey(row.cityName, row.zoneNumber), row]),
|
||||
)
|
||||
|
||||
const trend = aggregateVoteTrend(
|
||||
withGeographyIndexes.flatMap((index) => {
|
||||
|
|
@ -836,6 +865,24 @@ export const loadNucleusListElectionOverview = async (
|
|||
}),
|
||||
)
|
||||
|
||||
const classificationBands: TerritorialClassificationBand[] = []
|
||||
for (const index of withGeographyIndexes) {
|
||||
const geography = geographies[index]!
|
||||
const { validos } = sumFederalTallyForGeography(classificationTalliesByCityZone, geography)
|
||||
const territorial = computeTerritorialClass({
|
||||
sollaVotes: sumCandidateVotesForGeography(geography, votes2022),
|
||||
federalValidVotes: validos,
|
||||
})
|
||||
if (isComparableTerritorialClass(territorial.band)) {
|
||||
classificationBands.push(territorial.band)
|
||||
}
|
||||
}
|
||||
|
||||
const classification: NucleusClassificationOverviewAggregate | null =
|
||||
classificationBands.length > 0
|
||||
? { distribution: aggregateTerritorialClass(classificationBands) }
|
||||
: null
|
||||
|
||||
const flipResults = await Promise.all(
|
||||
withGeographyIndexes.map(async (index) => {
|
||||
const geography = geographies[index]!
|
||||
|
|
@ -852,17 +899,10 @@ export const loadNucleusListElectionOverview = async (
|
|||
conversion: null,
|
||||
leverage: null,
|
||||
flipOpportunity,
|
||||
classification,
|
||||
}
|
||||
}
|
||||
|
||||
const conversionUnion = buildUnionGeography(geographies, comparableIndexes)
|
||||
const tallies = conversionUnion
|
||||
? await loadElectionTallies(payload, user, conversionUnion)
|
||||
: []
|
||||
const talliesByCityZone = new Map(
|
||||
tallies.map((row) => [cityZoneKey(row.cityName, row.zoneNumber), row]),
|
||||
)
|
||||
|
||||
let estimateSum = 0
|
||||
let candidateVotesSum = 0
|
||||
let comparableCount = 0
|
||||
|
|
@ -888,15 +928,18 @@ export const loadNucleusListElectionOverview = async (
|
|||
comparableCount += 1
|
||||
}
|
||||
|
||||
const electorate = sumElectorateForGeography(talliesByCityZone, geography)
|
||||
const { aptos, abstencoes } = sumFederalTallyForGeography(
|
||||
classificationTalliesByCityZone,
|
||||
geography,
|
||||
)
|
||||
const conversion = computeConversionRate({
|
||||
aptos: electorate.aptos,
|
||||
abstencoes: electorate.abstencoes,
|
||||
aptos,
|
||||
abstencoes,
|
||||
confirmedVoteEstimate: estimate,
|
||||
})
|
||||
if (isComparableConversionBand(conversion.band)) {
|
||||
conversionEstimateSum += estimate
|
||||
conversionAptosSum += electorate.aptos
|
||||
conversionAptosSum += aptos
|
||||
conversionBands.push(conversion.band)
|
||||
}
|
||||
|
||||
|
|
@ -929,6 +972,7 @@ export const loadNucleusListElectionOverview = async (
|
|||
conversion,
|
||||
leverage: aggregateTicketLeverageOverview(leverageRows),
|
||||
flipOpportunity,
|
||||
classification,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -178,6 +178,7 @@ export const loadNucleusListOverviewData = async (
|
|||
conversion: listElectionOverview.conversion,
|
||||
leverage: listElectionOverview.leverage,
|
||||
flipOpportunity: listElectionOverview.flipOpportunity,
|
||||
classification: listElectionOverview.classification,
|
||||
choropleth,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@ import type { NucleusPriority } from '@/lib/schemas/nucleus'
|
|||
import type { TicketFlipOverviewAggregate, TicketLeverageOverviewAggregate } from '@/lib/electionInsights'
|
||||
import type { CampaignUser, NucleusUpdate } from '@/payload-types'
|
||||
import type { ActionPlanUpcomingPreviewRecord } from '@/utilities/actionPlanUpcomingPreview'
|
||||
import type { NucleusBaseline2022OverviewAggregate, NucleusConversionOverviewAggregate, NucleusTrendOverviewAggregate } from '@/utilities/nucleusElectoralBaseline'
|
||||
import type {
|
||||
NucleusBaseline2022OverviewAggregate,
|
||||
NucleusClassificationOverviewAggregate,
|
||||
NucleusConversionOverviewAggregate,
|
||||
NucleusTrendOverviewAggregate,
|
||||
} from '@/utilities/nucleusElectoralBaseline'
|
||||
import type { NucleusChoroplethBundle } from '@/utilities/nucleusChoroplethTypes'
|
||||
import {
|
||||
aggregateVoteGoals,
|
||||
|
|
@ -60,6 +65,8 @@ export type NucleusListOverviewViewModel = {
|
|||
leverage: TicketLeverageOverviewAggregate | null
|
||||
/** Null when no nucleus in the filtered set has resolvable TSE geography. */
|
||||
flipOpportunity: TicketFlipOverviewAggregate | null
|
||||
/** Null when no nucleus in the filtered set has resolvable geography with valid votes. */
|
||||
classification: NucleusClassificationOverviewAggregate | null
|
||||
recentUpdates: NucleusListOverviewUpdateRecord[]
|
||||
upcomingActionPlans: ActionPlanUpcomingPreviewRecord[]
|
||||
choropleth: NucleusChoroplethBundle
|
||||
|
|
@ -78,6 +85,7 @@ export const buildNucleusListOverviewViewModel = ({
|
|||
conversion = null,
|
||||
leverage = null,
|
||||
flipOpportunity = null,
|
||||
classification = null,
|
||||
choropleth,
|
||||
}: {
|
||||
nuclei: NucleusListOverviewNucleusRecord[]
|
||||
|
|
@ -89,6 +97,7 @@ export const buildNucleusListOverviewViewModel = ({
|
|||
conversion?: NucleusConversionOverviewAggregate | null
|
||||
leverage?: TicketLeverageOverviewAggregate | null
|
||||
flipOpportunity?: TicketFlipOverviewAggregate | null
|
||||
classification?: NucleusClassificationOverviewAggregate | null
|
||||
choropleth: NucleusChoroplethBundle
|
||||
}): NucleusListOverviewViewModel => {
|
||||
const totalFiltered = nuclei.length
|
||||
|
|
@ -127,6 +136,7 @@ export const buildNucleusListOverviewViewModel = ({
|
|||
conversion,
|
||||
leverage,
|
||||
flipOpportunity,
|
||||
classification,
|
||||
recentUpdates,
|
||||
upcomingActionPlans,
|
||||
choropleth,
|
||||
|
|
|
|||
|
|
@ -134,6 +134,9 @@ describe('loadNucleusListElectionOverview (E7 F2)', () => {
|
|||
baseline2022: null,
|
||||
trend: null,
|
||||
conversion: null,
|
||||
leverage: null,
|
||||
flipOpportunity: null,
|
||||
classification: null,
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -231,4 +231,23 @@ describe('NucleusListOverview', () => {
|
|||
expect(html).toContain('3 queda')
|
||||
expect(html).not.toContain('Com série histórica')
|
||||
})
|
||||
|
||||
it('renders the territorial classification distribution when aggregate data is present', () => {
|
||||
const view = buildNucleusListOverviewViewModel({
|
||||
role: 'geral',
|
||||
nuclei: [nucleus({ id: 1 })],
|
||||
recentUpdates: [],
|
||||
upcomingActionPlans: [],
|
||||
classification: { distribution: { defesa: 4, indecisa: 2, ataque: 1, perdida: 3 } },
|
||||
choropleth: emptyChoropleth,
|
||||
})
|
||||
|
||||
const html = renderToStaticMarkup(createElement(NucleusListOverview, { view, now }))
|
||||
|
||||
expect(html).toContain('Classificação:')
|
||||
expect(html).toContain('4 defesa')
|
||||
expect(html).toContain('2 indecisa')
|
||||
expect(html).toContain('1 ataque')
|
||||
expect(html).toContain('3 perdida')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,15 +4,20 @@ import { describe, expect, it } from 'vitest'
|
|||
|
||||
import {
|
||||
aggregateConversionBand,
|
||||
aggregateTerritorialClass,
|
||||
aggregateVoteTrend,
|
||||
comparableTrendCount,
|
||||
computeConversionRate,
|
||||
computeGapVs2022,
|
||||
computeTerritorialClass,
|
||||
computeVoteTrend,
|
||||
CONVERSION_OPPORTUNITY_MAX,
|
||||
CONVERSION_REDUTO_MIN,
|
||||
isComparableConversionBand,
|
||||
type GapVs2022Baseline,
|
||||
TERRITORIAL_ATAQUE_MIN,
|
||||
TERRITORIAL_DEFESA_MIN,
|
||||
TERRITORIAL_INDECISA_MIN,
|
||||
} from '@/lib/electionInsights'
|
||||
|
||||
const baseline = (candidateVotes: number): GapVs2022Baseline => ({
|
||||
|
|
@ -188,3 +193,89 @@ describe('isComparableConversionBand', () => {
|
|||
expect(isComparableConversionBand('semEstimativa')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeTerritorialClass', () => {
|
||||
it('classifies defesa at 35% of valid votes', () => {
|
||||
const validos = 2490
|
||||
expect(
|
||||
computeTerritorialClass({
|
||||
sollaVotes: Math.ceil(validos * TERRITORIAL_DEFESA_MIN),
|
||||
federalValidVotes: validos,
|
||||
}),
|
||||
).toMatchObject({
|
||||
band: 'defesa',
|
||||
title: 'Território de defesa',
|
||||
})
|
||||
})
|
||||
|
||||
it('classifies indecisa at 20% of valid votes', () => {
|
||||
expect(
|
||||
computeTerritorialClass({
|
||||
sollaVotes: Math.floor(2490 * TERRITORIAL_INDECISA_MIN),
|
||||
federalValidVotes: 2490,
|
||||
}),
|
||||
).toMatchObject({
|
||||
band: 'indecisa',
|
||||
title: 'Território indeciso',
|
||||
})
|
||||
})
|
||||
|
||||
it('classifies ataque at 10% of valid votes', () => {
|
||||
expect(
|
||||
computeTerritorialClass({
|
||||
sollaVotes: Math.floor(2490 * TERRITORIAL_ATAQUE_MIN),
|
||||
federalValidVotes: 2490,
|
||||
}),
|
||||
).toMatchObject({
|
||||
band: 'ataque',
|
||||
title: 'Território de ataque',
|
||||
})
|
||||
})
|
||||
|
||||
it('classifies perdida below 10%', () => {
|
||||
expect(
|
||||
computeTerritorialClass({
|
||||
sollaVotes: Math.floor(2490 * (TERRITORIAL_ATAQUE_MIN - 0.001)),
|
||||
federalValidVotes: 2490,
|
||||
}),
|
||||
).toMatchObject({
|
||||
band: 'perdida',
|
||||
title: 'Território perdido',
|
||||
})
|
||||
})
|
||||
|
||||
it('reports semBaseline when validos is zero', () => {
|
||||
expect(computeTerritorialClass({ sollaVotes: 100, federalValidVotes: 0 })).toMatchObject({
|
||||
band: 'semBaseline',
|
||||
percentValid: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('reports semBaseline when validos is null', () => {
|
||||
expect(computeTerritorialClass({ sollaVotes: 100, federalValidVotes: null })).toMatchObject({
|
||||
band: 'semBaseline',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('aggregateTerritorialClass', () => {
|
||||
it('counts comparable territorial bands only', () => {
|
||||
expect(
|
||||
aggregateTerritorialClass(['defesa', 'ataque', 'indecisa', 'perdida', 'semBaseline']),
|
||||
).toEqual({
|
||||
defesa: 1,
|
||||
ataque: 1,
|
||||
indecisa: 1,
|
||||
perdida: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns all zeros when every band is semBaseline', () => {
|
||||
expect(aggregateTerritorialClass(['semBaseline', 'semBaseline'])).toEqual({
|
||||
defesa: 0,
|
||||
ataque: 0,
|
||||
indecisa: 0,
|
||||
perdida: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ describe('NucleusInsights', () => {
|
|||
expect(html).toContain('Tendência:')
|
||||
})
|
||||
|
||||
it('renders ticket leverage and flip opportunity insights', () => {
|
||||
it('renders ticket leverage, flip opportunity, and territorial classification insights', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(NucleusInsights, {
|
||||
baseline,
|
||||
|
|
@ -100,5 +100,10 @@ describe('NucleusInsights', () => {
|
|||
expect(html).toContain('data-insight="ticket-flip"')
|
||||
expect(html).toContain('completar a chapa')
|
||||
expect(html).not.toMatch(/direita/i)
|
||||
expect(html).toContain('data-insight="territorial-class"')
|
||||
expect(html).toContain('Território indeciso')
|
||||
expect(html).toContain('Pulverizado — prioridade: consolidar')
|
||||
expect(html).toContain('Indecisa')
|
||||
expect(html).toContain('34% dos votos válidos federais em 2022')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue