From ea579d2173e59d7bd8c7c6616b69a4b1d24b693a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?=
Date: Sun, 19 Jul 2026 22:34:35 -0300
Subject: [PATCH] Deliver A5 mobilization insight and absorb E7 F2 int
follow-up.
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Surface brancos/nulos/abstenções as mobilization opportunity on nucleus detail and list overview, with loader aggregate and unit tests; register pending int coverage in escala-dry-pos-e2.
---
docs/plans/escala-dry-pos-e2.md | 22 ++--
.../insight-mobilizacao-brancos-nulos.md | 40 ++++---
docs/roadmap.md | 4 +-
src/components/campaign/NucleusInsights.tsx | 26 +++-
.../campaign/NucleusListOverview.tsx | 21 ++++
src/lib/electionInsights.ts | 111 +++++++++++++++++-
src/utilities/nucleusElectoralBaseline.ts | 50 ++++++--
src/utilities/nucleusListOverviewPageData.ts | 1 +
.../nucleusListOverviewViewModels.ts | 7 +-
.../nucleusListElectionOverview.int.spec.ts | 1 +
tests/unit/electionInsights.unit.spec.ts | 85 ++++++++++++++
11 files changed, 327 insertions(+), 41 deletions(-)
diff --git a/docs/plans/escala-dry-pos-e2.md b/docs/plans/escala-dry-pos-e2.md
index cc694517..386fbe9d 100644
--- a/docs/plans/escala-dry-pos-e2.md
+++ b/docs/plans/escala-dry-pos-e2.md
@@ -1,13 +1,13 @@
# 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/**classification** registrada via `capture-review-debts` pós-A5 `/simplify` + pós-A5-2; **E4 import planilha cortado**)
+Status: Fase 2 entregue (2026-07-19); extensão F2 pós-A5 / pós-A5-2 / **pós-A5-mobilização** pendente; Fases 1, 3 e 4 pendentes
+Atualizado em: 2026-07-19 (extensão int **`mobilization`** absorvida via `capture-review-debts` pós-A5 mobilização; leverage/flip/classification já registrados; **E4 import planilha cortado**)
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`. O slice **A5-1 taxa de conversão** ([insight-taxa-conversao.md](insight-taxa-conversao.md), 2026-07-19) estende o mesmo loader com agregado `conversion` (`weightedRate` + `distribution` por faixa) e fetch condicional de `electionTally` — ver Fase 2.
+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`. O slice **A5-1 taxa de conversão** ([insight-taxa-conversao.md](insight-taxa-conversao.md), 2026-07-19) estende o mesmo loader com agregado `conversion` (`weightedRate` + `distribution` por faixa) e fetch condicional de `electionTally` — ver Fase 2. O slice **A5 mobilização** ([insight-mobilizacao-brancos-nulos.md](insight-mobilizacao-brancos-nulos.md), 2026-07-19) acrescenta `mobilization` (`absoluteTotal`, `relativePercent`, subtotais) via `aggregateMobilizationOverview` — int pendente nesta extensão F2.
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`.
@@ -27,12 +27,16 @@ Os revisores (performance / reuse / quality) marcaram como **importantes e maior
**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).
+**Pendente pós-A5-mobilização (`capture-review-debts`, ~0,25 dia eng):** int de `mobilization` no mesmo loader — asserts de `absoluteTotal`, `relativePercent`, `brancosNulosTotal`, `abstençõesTotal` sobre o conjunto filtrado (fixture Salvador Z1+Z2 já traz `QT_ABSTENCOES` / brancos / nulos no `detalhe_votacao_munzona_fixture.csv`); caso negativo `mobilization: null` quando geografia vazia (já assertado no int existente) ou potencial zero/`semAptos`.
+
+**Já resolvido no simplify pós-A5 mobilização (não reabrir):** loops de classificação + mobilização unificados; `mobilizationInput` acumulador único; `federalTallyByIndex` elimina segunda `sumFederalTallyForGeography` no loop de conversão; `aggregateMobilizationOverview` + unit tests; `formatPercent` hoisted; remoção de `mobilizationAlertVariant` (variant `pending` inline).
+
+**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; **ranking/sort da lista por mobilização** → gatilho em [insight-mobilizacao-brancos-nulos.md](insight-mobilizacao-brancos-nulos.md) (produto pedir sort); helpers cosméticos `mobilizationUnavailable` / guard `supportLine` / remover `isComparableMobilization` (paridade com conversão/leverage); **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); **extensão pós-A5-2:** agregado `classification.distribution` (defesa/indecisa/ataque/perdida) + caso `classification: null` sem válidos.
+- `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; **extensão pós-A5-mobilização:** agregado `mobilization` (Σ brancos/nulos/abstenções + `%` ponderado) espelhando `aggregateMobilizationOverview`.
- 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.
@@ -78,12 +82,15 @@ 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 / pós-A5-2 — pendente)
+#### Extensão Fase 2 (pós-A5 / pós-A5-2 / pós-A5-mobilização — pendente)
+
+**Appetite do lote F2 ext.:** ~0,75–1 dia eng (leverage/flip spy + classification + mobilization int; sem UI).
- 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:`.
+- **Pós-A5-mobilização:** em `tests/int/nucleusListElectionOverview.int.spec.ts`, assert `result.mobilization` para `[salvadorZ1, salvadorZ2]` com totais derivados de `TSE_FIXTURE_ZONE_EXPECTED` + brancos/nulos do fixture (`detalhe_votacao_munzona_fixture.csv` cargo federal T1); espelhar `aggregateMobilizationOverview` (não duplicar fórmula inline). Caso `aptos: 0` ou geografia vazia → `mobilization: null` (vazio já coberto).
### Fase 3 — Tipos e API
@@ -123,7 +130,8 @@ flowchart TD
- `src/components/campaign/NucleusActiveTab.tsx` — render duplo baseline + insights
- `src/components/campaign/NucleusElectoralBaseline.tsx` / `NucleusInsights.tsx`
- `docs/plans/insight-taxa-conversao.md` — slice A5-1 (conversão no loader/overview)
-- `src/lib/electionInsights.ts` — `computeVoteTrend`, `computeConversionRate`, `aggregateConversionBand`, `VoteTrendDistribution`, `aggregateVoteTrend`
+- `docs/plans/insight-mobilizacao-brancos-nulos.md` — slice A5 mobilização (loader/overview; ranking adiado)
+- `src/lib/electionInsights.ts` — `computeVoteTrend`, `computeConversionRate`, `aggregateConversionBand`, `VoteTrendDistribution`, `aggregateVoteTrend`, `computeMobilizationOpportunity`, `aggregateMobilizationOverview`
- `src/utilities/nucleusElectoralBaseline.ts` — `loadNucleusListElectionOverview`, `getNucleusElectoralBaseline`
- `src/utilities/nucleusListOverviewPageData.ts`
- `tests/unit/electionInsights.unit.spec.ts` / `tests/unit/nucleusElectoralBaselineUi.unit.spec.ts`
diff --git a/docs/plans/insight-mobilizacao-brancos-nulos.md b/docs/plans/insight-mobilizacao-brancos-nulos.md
index f6d9cf12..15f6e420 100644
--- a/docs/plans/insight-mobilizacao-brancos-nulos.md
+++ b/docs/plans/insight-mobilizacao-brancos-nulos.md
@@ -1,10 +1,12 @@
# Insight: oportunidade de mobilização (brancos/nulos/abstenções)
-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 mobilização, 2026-07-19)
+Atualizado em: 2026-07-19
+Item do roadmap: [docs/roadmap.md](../roadmap.md) (Janela 2, A5 restante)
Responsável: —
+**Revisão 2026-07-19:** implementado como Alert no stack `NucleusInsights.tsx` (sem componente separado); overview via `loadNucleusListElectionOverview` + linha em `NucleusListOverview`. Ranking por núcleo adiado (rabbit hole — gatilho: produto pedir sort na lista).
+
## Referência visual (UX Pilot)
Design: [`Baseline-Eleitoral-2022.png`](../design-refs/latest/Baseline-Eleitoral-2022.png) — card "Insights do território", linha "Oportunidade de mobilização: 142 brancos/nulos + 680 abstenções = 822 votos possíveis". 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.
@@ -17,49 +19,49 @@ Em 2022, parte significativa dos eleitores aptos de cada geografia não gerou vo
- Computar `mobilizationPotential = abstencoes + votosBranco + votosNulo` por geografia do núcleo.
- Expressar como % de `aptos` (potencial relativo) e como absoluto (votos "deixados na mesa").
-- Exibir no detalhe do núcleo: "X votos em branco/nulo + Y abstenções (Z% do eleitorado apto) — potencial de mobilização".
-- No overview da lista: soma do potencial sobre o conjunto filtrado, ordenado para priorizar onde mobilizar.
+- Exibir no detalhe do núcleo: subtotais de brancos/nulos e abstenções com total e % do eleitorado apto.
+- No overview da lista: soma do potencial sobre o conjunto filtrado (Σ absolute / Σ aptos).
## Decisões travadas
- **Leitura derivada** — sem escrita, sem `Consent`, sem migration.
- **Reusa** `getNucleusElectoralBaseline` (já expõe `abstencoes`, `brancos`, `nulos`, `aptos`).
- **Não somar anulados** (votos anulados por motivo de urna, não são mobilizáveis da mesma forma) — só brancos + nulos + abstenções.
+- **UI:** `Alert` em `NucleusInsights.tsx` (`data-insight="mobilization"`), não componente separado.
+- **Dois subtotais** na linha de apoio (`X brancos/nulos + Y abstenções = Z votos possíveis · W% do eleitorado apto`).
+- **`lideranca` vê** (mesmo stack da Visão geral).
+- **Overview:** Σ absolute + % ponderado; sem ranking por núcleo neste slice.
## Questões em aberto
-- Separar abstenção de brancos/nulos na UI (são naturezas diferentes: abstenção = não foi; branco/nulo = foi mas não votou válido)? Recomendação: sim, com dois subtotais.
-- `lideranca` vê? Recomendação: sim.
+- _(fechadas 2026-07-19 — ver Decisões travadas)_
-## Abordagem proposta
+## Abordagem (as-built)
```mermaid
flowchart LR
Base["getNucleusElectoralBaseline
abstencoes + brancos + nulos + aptos"]
Calc["computeMobilizationOpportunity(...)"]
- Detail["Detalhe: 'X votos não aproveitados
(Y% do eleitorado apto)'"]
- Overview["Overview: Σ potencial sobre o filtro
+ ranking de onde mobilizar"]
+ Detail["NucleusInsights Alert"]
+ Overview["loadNucleusListElectionOverview Σ"]
Base --> Calc
Calc --> Detail
Calc --> Overview
```
-- **Helper** `src/lib/electionInsights.ts`: `computeMobilizationOpportunity(abstencoes, brancos, nulos, aptos)` → `{ absolute, relative, abstencoes, brancosNulos, status }`.
-- **Componente** `src/components/campaign/NucleusMobilizationOpportunity.tsx` (server). Overview: bloco agregado + ranking.
-- **Teste int** cenários: `aptos=0`, tudo zero.
-
-## Arquivos a criar/alterar
-
-- Criar: `src/components/campaign/NucleusMobilizationOpportunity.tsx`.
-- Alterar: `src/lib/electionInsights.ts`, `nucleos/[slug]/page.tsx` + `nucleusDetailPageData.ts`, overview da lista.
+- **Helper** [`src/lib/electionInsights.ts`](../../src/lib/electionInsights.ts): `computeMobilizationOpportunity`, `isComparableMobilization`, `aggregateMobilizationOverview`.
+- **Detalhe** [`src/components/campaign/NucleusInsights.tsx`](../../src/components/campaign/NucleusInsights.tsx).
+- **Overview** [`src/utilities/nucleusElectoralBaseline.ts`](../../src/utilities/nucleusElectoralBaseline.ts) (`MobilizationOverviewAggregate`) + [`NucleusListOverview.tsx`](../../src/components/campaign/NucleusListOverview.tsx).
+- **Testes** unitários em `tests/unit/electionInsights.unit.spec.ts`.
## Dependências
-- [baseline-eleitoral-tse.md](baseline-eleitoral-tse.md) (apuração por município+zona).
+- [baseline-eleitoral-tse.md](baseline-eleitoral-tse.md) (apuração por município+zona) — **única dependência dura**.
## Não escopo
- Disparar campanhas de mobilização efetivas (WhatsApp, eventos) — só o insight, não a ação.
+- Ranking/sort da lista por potencial de mobilização — adiar até pedido de produto.
## Referências
diff --git a/docs/roadmap.md b/docs/roadmap.md
index ed2bf89e..07154fb3 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,6 +1,6 @@
# Roadmap — Teqo
-Atualizado em: 2026-07-19 (E4 import planilha cortado — preenchimento manual via E1+E3; janela 1 vigente; foco: Onda 0 + ativação com dados reais)
+Atualizado em: 2026-07-19 (E7 F2 ext. absorve int `mobilization` pós-A5 mobilização via capture-review-debts; E4 import planilha cortado)
Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc).
@@ -57,7 +57,7 @@ Engenharia de núcleos / C2 / C3 / baseline / PWA / Trilha E parcial já em `mai
- **C** — C11 feed/loaders de planos sem O(n) no detalhe · gatilho: agenda com uso real / volume medido · [plano](plans/escala-dry-pos-c7.md)
- **D** — D2 push + sino in-app · soft: chave `campanha-notificacoes-push` (Onda 0) · [plano](plans/notifications.md)
- **E** — E6 aggregate metas/prioridade + DRY geo/UI na lista · gatilho: muitos núcleos / lista lenta · [plano](plans/escala-dry-pos-e1.md)
-- **E** — E7 F1/F3/F4 + extensões F2 pós-A5 (trend VM único, tipos, helpers de teste) · [plano](plans/escala-dry-pos-e2.md)
+- **E** — E7 F1/F3/F4 + extensões F2 pós-A5 (leverage/flip/classification/**mobilization** int, trend VM único, tipos, helpers) · [plano](plans/escala-dry-pos-e2.md)
- **Débitos / fill-ins** — O0+, VR+, RS+, FD+, FD2, listas globais, higiene PascalCase (ver abaixo)
### A validar (assumptions)
diff --git a/src/components/campaign/NucleusInsights.tsx b/src/components/campaign/NucleusInsights.tsx
index 6ef004de..50d88398 100644
--- a/src/components/campaign/NucleusInsights.tsx
+++ b/src/components/campaign/NucleusInsights.tsx
@@ -16,6 +16,7 @@ import { Badge } from '@/components/ui/Badge'
import {
computeConversionRate,
computeGapVs2022,
+ computeMobilizationOpportunity,
computeTerritorialClass,
computeTicketLeverage,
computeVoteTrend,
@@ -23,6 +24,7 @@ import {
formatElectionNumber,
formatVoteTrendSeries,
isComparableConversionBand,
+ isComparableMobilization,
isComparableTerritorialClass,
isComparableTicketLeverage,
territorialClassAlertVariant,
@@ -70,9 +72,11 @@ export const NucleusInsights = ({
? MinusIcon
: TriangleAlertIcon
+ const electorate = baseline?.electorate
+
const conversion = computeConversionRate({
- aptos: baseline?.electorate.aptos ?? null,
- abstencoes: baseline?.electorate.abstencoes ?? null,
+ aptos: electorate?.aptos ?? null,
+ abstencoes: electorate?.abstencoes ?? null,
confirmedVoteEstimate,
})
const ConversionIcon =
@@ -82,6 +86,13 @@ export const NucleusInsights = ({
? TriangleAlertIcon
: PercentIcon
+ const mobilization = computeMobilizationOpportunity({
+ abstencoes: electorate?.abstencoes ?? null,
+ brancos: electorate?.brancos ?? null,
+ nulos: electorate?.nulos ?? null,
+ aptos: electorate?.aptos ?? null,
+ })
+
const leverage = computeTicketLeverage({
confirmedVoteEstimate,
presidentVotes: baseline?.president?.votes ?? null,
@@ -96,7 +107,7 @@ export const NucleusInsights = ({
const territorial = computeTerritorialClass({
sollaVotes: baseline?.candidate.votes ?? 0,
- federalValidVotes: baseline?.electorate.validos ?? null,
+ federalValidVotes: electorate?.validos ?? null,
})
let supportLine: string | null = null
@@ -105,6 +116,7 @@ export const NucleusInsights = ({
}
const showConversion = isComparableConversionBand(conversion.band)
+ const showMobilization = isComparableMobilization(mobilization.status)
const showLeverage = isComparableTicketLeverage(leverage.status)
const showFlip = flip?.status === 'opportunity'
@@ -140,6 +152,14 @@ export const NucleusInsights = ({
) : null}
+ {showMobilization ? (
+
+
+ {mobilization.message}
+ {mobilization.supportLine}
+
+ ) : null}
+
{showLeverage ? (
) : null}
+ {view.mobilization ? (
+
+ Mobilização:{' '}
+
+ {formatElectionNumber(view.mobilization.absoluteTotal)} votos possíveis
+
+ {' · '}
+
+ {view.mobilization.relativePercent}%
+ {' '}
+ do eleitorado apto
+ {' · '}
+
+ {formatElectionNumber(view.mobilization.brancosNulosTotal)} brancos/nulos
+
+ {' · '}
+
+ {formatElectionNumber(view.mobilization.abstencoesTotal)} abstenções
+
+
+ ) : null}
{view.leverage ? (
Alavancagem da chapa:{' '}
diff --git a/src/lib/electionInsights.ts b/src/lib/electionInsights.ts
index 9636f545..4fa379b7 100644
--- a/src/lib/electionInsights.ts
+++ b/src/lib/electionInsights.ts
@@ -81,6 +81,29 @@ export type ConversionBandDistribution = {
oportunidade: number
}
+export type MobilizationOpportunityStatus =
+ | 'opportunity'
+ | 'zero'
+ | 'semBaseline'
+ | 'semAptos'
+
+export type MobilizationOpportunityInput = {
+ abstencoes: number | null
+ brancos: number | null
+ nulos: number | null
+ aptos: number | null
+}
+
+export type MobilizationOpportunityResult = {
+ absolute: number
+ relative: number | null
+ abstencoes: number
+ brancosNulos: number
+ status: MobilizationOpportunityStatus
+ message: string
+ supportLine: string | null
+}
+
export type TerritorialClassificationBand =
| 'defesa'
| 'ataque'
@@ -119,6 +142,8 @@ const numberFormatter = new Intl.NumberFormat('pt-BR')
export const formatElectionNumber = (value: number): string => numberFormatter.format(value)
+const formatPercent = (ratio: number): number => Math.round(ratio * 100)
+
export const formatVoteTrendSeries = (series: VoteTrendSeries): string =>
`${formatElectionNumber(series.y2014)} (2014) → ${formatElectionNumber(series.y2018)} (2018) → ${formatElectionNumber(series.y2022)} (2022)`
@@ -241,6 +266,10 @@ export const territorialClassAlertVariant = (
export const conversionRateAlertVariant = (band: ConversionRateBand): 'default' | 'pending' =>
band === 'oportunidade' ? 'pending' : 'default'
+export const isComparableMobilization = (
+ status: MobilizationOpportunityStatus,
+): status is 'opportunity' => status === 'opportunity'
+
type TrendPair = { fromVotes: number; toVotes: number; fromYear: number; toYear: number }
const trendPairsByPriority = (series: VoteTrendSeries): TrendPair[] => [
@@ -456,6 +485,67 @@ export const computeConversionRate = ({
}
}
+/**
+ * Sum abstentions + blank + null votes as mobilization potential (excludes anulados).
+ */
+export const computeMobilizationOpportunity = ({
+ abstencoes,
+ brancos,
+ nulos,
+ aptos,
+}: MobilizationOpportunityInput): MobilizationOpportunityResult => {
+ if (aptos === null) {
+ return {
+ absolute: 0,
+ relative: null,
+ abstencoes: 0,
+ brancosNulos: 0,
+ status: 'semBaseline',
+ message: NO_ELECTION_BASELINE_MESSAGE,
+ supportLine: null,
+ }
+ }
+
+ if (aptos <= 0) {
+ return {
+ absolute: 0,
+ relative: null,
+ abstencoes: 0,
+ brancosNulos: 0,
+ status: 'semAptos',
+ message: 'Sem eleitores aptos no baseline para esta geografia',
+ supportLine: null,
+ }
+ }
+
+ const abstencoesTotal = abstencoes ?? 0
+ const brancosNulos = (brancos ?? 0) + (nulos ?? 0)
+ const absolute = abstencoesTotal + brancosNulos
+ const relative = absolute / aptos
+
+ if (absolute <= 0) {
+ return {
+ absolute: 0,
+ relative: 0,
+ abstencoes: abstencoesTotal,
+ brancosNulos,
+ status: 'zero',
+ message: 'Sem potencial de mobilização no baseline 2022',
+ supportLine: 'Sem brancos, nulos ou abstenções no baseline 2022',
+ }
+ }
+
+ return {
+ absolute,
+ relative,
+ abstencoes: abstencoesTotal,
+ brancosNulos,
+ status: 'opportunity',
+ message: 'Oportunidade de mobilização',
+ supportLine: `${formatElectionNumber(brancosNulos)} brancos/nulos + ${formatElectionNumber(abstencoesTotal)} abstenções = ${formatElectionNumber(absolute)} votos possíveis · ${formatPercent(relative)}% do eleitorado apto`,
+ }
+}
+
export type TicketLeverageStatus =
| 'comparable'
| 'noEstimate'
@@ -522,7 +612,12 @@ export type TicketFlipOverviewAggregate = {
bothAlignedCount: number
}
-const formatPercent = (ratio: number): number => Math.round(ratio * 100)
+export type MobilizationOverviewAggregate = {
+ absoluteTotal: number
+ relativePercent: number
+ brancosNulosTotal: number
+ abstencoesTotal: number
+}
/** User-facing label for the configured majoritarian ticket base (cycle-specific names). */
export const formatMajoritarianTicketBaseLabel = (): string =>
@@ -776,6 +871,20 @@ export const aggregateTicketFlipOverview = (
return { count, bothAlignedCount }
}
+export const aggregateMobilizationOverview = (
+ input: MobilizationOpportunityInput,
+): MobilizationOverviewAggregate | null => {
+ const result = computeMobilizationOpportunity(input)
+ if (!isComparableMobilization(result.status)) return null
+
+ return {
+ absoluteTotal: result.absolute,
+ relativePercent: formatPercent(result.relative ?? 0),
+ brancosNulosTotal: result.brancosNulos,
+ abstencoesTotal: result.abstencoes,
+ }
+}
+
export const ticketLeverageAlertVariant = (
leverage: TicketLeverageResult,
): 'default' | 'pending' =>
diff --git a/src/utilities/nucleusElectoralBaseline.ts b/src/utilities/nucleusElectoralBaseline.ts
index 65e9d330..fa25e9c3 100644
--- a/src/utilities/nucleusElectoralBaseline.ts
+++ b/src/utilities/nucleusElectoralBaseline.ts
@@ -18,6 +18,7 @@ import {
aggregateTicketFlipOverview,
aggregateTicketLeverageOverview,
aggregateVoteTrend,
+ aggregateMobilizationOverview,
computeConversionRate,
computeGapVs2022,
computeTerritorialClass,
@@ -25,6 +26,8 @@ import {
computeTicketLeverage,
isComparableConversionBand,
isComparableTerritorialClass,
+ type MobilizationOverviewAggregate,
+ type MobilizationOpportunityInput,
type ConversionBandDistribution,
type ConversionRateBand,
type TerritorialClassificationBand,
@@ -389,10 +392,15 @@ const aggregateElectorate = (
const sumFederalTallyForGeography = (
talliesByCityZone: ReadonlyMap,
geography: NucleusElectionGeography,
-): Pick => {
+): Pick<
+ NucleusElectoralBaselineViewModel['electorate'],
+ 'aptos' | 'abstencoes' | 'validos' | 'brancos' | 'nulos'
+> => {
let aptos = 0
let abstencoes = 0
let validos = 0
+ let brancos = 0
+ let nulos = 0
for (const { cityName, zoneNumber } of geography.cityZonePairs) {
const row = talliesByCityZone.get(cityZoneKey(cityName, zoneNumber))
@@ -400,9 +408,11 @@ const sumFederalTallyForGeography = (
aptos += row.aptos
abstencoes += row.abstencoes
validos += row.votosValidos
+ brancos += row.votosBranco
+ nulos += row.votosNulo
}
- return { aptos, abstencoes, validos }
+ return { aptos, abstencoes, validos, brancos, nulos }
}
export const aggregateNucleusElectoralBaseline = (
@@ -800,6 +810,7 @@ export type NucleusListElectionOverview = {
baseline2022: NucleusBaseline2022OverviewAggregate | null
trend: NucleusTrendOverviewAggregate | null
conversion: NucleusConversionOverviewAggregate | null
+ mobilization: MobilizationOverviewAggregate | null
leverage: TicketLeverageOverviewAggregate | null
flipOpportunity: TicketFlipOverviewAggregate | null
classification: NucleusClassificationOverviewAggregate | null
@@ -823,6 +834,7 @@ export const loadNucleusListElectionOverview = async (
baseline2022: null,
trend: null,
conversion: null,
+ mobilization: null,
leverage: null,
flipOpportunity: null,
classification: null,
@@ -841,6 +853,7 @@ export const loadNucleusListElectionOverview = async (
baseline2022: null,
trend: null,
conversion: null,
+ mobilization: null,
leverage: null,
flipOpportunity: null,
classification: null,
@@ -866,12 +879,29 @@ export const loadNucleusListElectionOverview = async (
)
const classificationBands: TerritorialClassificationBand[] = []
+ const federalTallyByIndex = new Map<
+ number,
+ ReturnType
+ >()
+ const mobilizationInput: MobilizationOpportunityInput = {
+ aptos: 0,
+ abstencoes: 0,
+ brancos: 0,
+ nulos: 0,
+ }
+
for (const index of withGeographyIndexes) {
const geography = geographies[index]!
- const { validos } = sumFederalTallyForGeography(classificationTalliesByCityZone, geography)
+ const tallies = sumFederalTallyForGeography(classificationTalliesByCityZone, geography)
+ federalTallyByIndex.set(index, tallies)
+ mobilizationInput.aptos = (mobilizationInput.aptos ?? 0) + tallies.aptos
+ mobilizationInput.abstencoes = (mobilizationInput.abstencoes ?? 0) + tallies.abstencoes
+ mobilizationInput.brancos = (mobilizationInput.brancos ?? 0) + tallies.brancos
+ mobilizationInput.nulos = (mobilizationInput.nulos ?? 0) + tallies.nulos
+
const territorial = computeTerritorialClass({
sollaVotes: sumCandidateVotesForGeography(geography, votes2022),
- federalValidVotes: validos,
+ federalValidVotes: tallies.validos,
})
if (isComparableTerritorialClass(territorial.band)) {
classificationBands.push(territorial.band)
@@ -883,6 +913,8 @@ export const loadNucleusListElectionOverview = async (
? { distribution: aggregateTerritorialClass(classificationBands) }
: null
+ const mobilization = aggregateMobilizationOverview(mobilizationInput)
+
const flipResults = await Promise.all(
withGeographyIndexes.map(async (index) => {
const geography = geographies[index]!
@@ -897,6 +929,7 @@ export const loadNucleusListElectionOverview = async (
baseline2022: { gapTotal: null, above: 0, below: 0 },
trend,
conversion: null,
+ mobilization,
leverage: null,
flipOpportunity,
classification,
@@ -928,10 +961,10 @@ export const loadNucleusListElectionOverview = async (
comparableCount += 1
}
- const { aptos, abstencoes } = sumFederalTallyForGeography(
- classificationTalliesByCityZone,
- geography,
- )
+ const tallies = federalTallyByIndex.get(index)
+ if (!tallies) continue
+
+ const { aptos, abstencoes } = tallies
const conversion = computeConversionRate({
aptos,
abstencoes,
@@ -970,6 +1003,7 @@ export const loadNucleusListElectionOverview = async (
},
trend,
conversion,
+ mobilization,
leverage: aggregateTicketLeverageOverview(leverageRows),
flipOpportunity,
classification,
diff --git a/src/utilities/nucleusListOverviewPageData.ts b/src/utilities/nucleusListOverviewPageData.ts
index e8099ce5..a93af921 100644
--- a/src/utilities/nucleusListOverviewPageData.ts
+++ b/src/utilities/nucleusListOverviewPageData.ts
@@ -176,6 +176,7 @@ export const loadNucleusListOverviewData = async (
baseline2022: listElectionOverview.baseline2022,
trend: listElectionOverview.trend,
conversion: listElectionOverview.conversion,
+ mobilization: listElectionOverview.mobilization,
leverage: listElectionOverview.leverage,
flipOpportunity: listElectionOverview.flipOpportunity,
classification: listElectionOverview.classification,
diff --git a/src/utilities/nucleusListOverviewViewModels.ts b/src/utilities/nucleusListOverviewViewModels.ts
index 639b621b..a8cf814d 100644
--- a/src/utilities/nucleusListOverviewViewModels.ts
+++ b/src/utilities/nucleusListOverviewViewModels.ts
@@ -1,5 +1,5 @@
import type { NucleusPriority } from '@/lib/schemas/nucleus'
-import type { TicketFlipOverviewAggregate, TicketLeverageOverviewAggregate } from '@/lib/electionInsights'
+import type { TicketFlipOverviewAggregate, TicketLeverageOverviewAggregate, MobilizationOverviewAggregate } from '@/lib/electionInsights'
import type { CampaignUser, NucleusUpdate } from '@/payload-types'
import type { ActionPlanUpcomingPreviewRecord } from '@/utilities/actionPlanUpcomingPreview'
import type {
@@ -61,6 +61,8 @@ export type NucleusListOverviewViewModel = {
trend: NucleusTrendOverviewAggregate | null
/** Null when no comparable nucleus has aptos + confirmed estimate. */
conversion: NucleusConversionOverviewAggregate | null
+ /** Null when the filtered set has no mobilization potential in baseline tallies. */
+ mobilization: MobilizationOverviewAggregate | null
/** Null when no comparable nucleus has ticket votes + confirmed estimate. */
leverage: TicketLeverageOverviewAggregate | null
/** Null when no nucleus in the filtered set has resolvable TSE geography. */
@@ -83,6 +85,7 @@ export const buildNucleusListOverviewViewModel = ({
baseline2022 = null,
trend = null,
conversion = null,
+ mobilization = null,
leverage = null,
flipOpportunity = null,
classification = null,
@@ -95,6 +98,7 @@ export const buildNucleusListOverviewViewModel = ({
baseline2022?: NucleusBaseline2022OverviewAggregate | null
trend?: NucleusTrendOverviewAggregate | null
conversion?: NucleusConversionOverviewAggregate | null
+ mobilization?: MobilizationOverviewAggregate | null
leverage?: TicketLeverageOverviewAggregate | null
flipOpportunity?: TicketFlipOverviewAggregate | null
classification?: NucleusClassificationOverviewAggregate | null
@@ -134,6 +138,7 @@ export const buildNucleusListOverviewViewModel = ({
highPriorityCount,
trend,
conversion,
+ mobilization,
leverage,
flipOpportunity,
classification,
diff --git a/tests/int/nucleusListElectionOverview.int.spec.ts b/tests/int/nucleusListElectionOverview.int.spec.ts
index c8c666de..3cb0f5de 100644
--- a/tests/int/nucleusListElectionOverview.int.spec.ts
+++ b/tests/int/nucleusListElectionOverview.int.spec.ts
@@ -134,6 +134,7 @@ describe('loadNucleusListElectionOverview (E7 F2)', () => {
baseline2022: null,
trend: null,
conversion: null,
+ mobilization: null,
leverage: null,
flipOpportunity: null,
classification: null,
diff --git a/tests/unit/electionInsights.unit.spec.ts b/tests/unit/electionInsights.unit.spec.ts
index ea632665..641b1ebd 100644
--- a/tests/unit/electionInsights.unit.spec.ts
+++ b/tests/unit/electionInsights.unit.spec.ts
@@ -4,16 +4,19 @@ import { describe, expect, it } from 'vitest'
import {
aggregateConversionBand,
+ aggregateMobilizationOverview,
aggregateTerritorialClass,
aggregateVoteTrend,
comparableTrendCount,
computeConversionRate,
computeGapVs2022,
+ computeMobilizationOpportunity,
computeTerritorialClass,
computeVoteTrend,
CONVERSION_OPPORTUNITY_MAX,
CONVERSION_REDUTO_MIN,
isComparableConversionBand,
+ isComparableMobilization,
type GapVs2022Baseline,
TERRITORIAL_ATAQUE_MIN,
TERRITORIAL_DEFESA_MIN,
@@ -194,6 +197,88 @@ describe('isComparableConversionBand', () => {
})
})
+describe('computeMobilizationOpportunity', () => {
+ it('matches the design-ref subtotals and total', () => {
+ expect(
+ computeMobilizationOpportunity({
+ abstencoes: 680,
+ brancos: 100,
+ nulos: 42,
+ aptos: 4700,
+ }),
+ ).toMatchObject({
+ absolute: 822,
+ brancosNulos: 142,
+ abstencoes: 680,
+ status: 'opportunity',
+ message: 'Oportunidade de mobilização',
+ supportLine:
+ '142 brancos/nulos + 680 abstenções = 822 votos possíveis · 17% do eleitorado apto',
+ })
+ })
+
+ it('reports semAptos when aptos is zero', () => {
+ expect(
+ computeMobilizationOpportunity({ abstencoes: 10, brancos: 5, nulos: 2, aptos: 0 }),
+ ).toMatchObject({
+ status: 'semAptos',
+ absolute: 0,
+ })
+ })
+
+ it('reports zero when all components are absent', () => {
+ expect(
+ computeMobilizationOpportunity({ abstencoes: 0, brancos: 0, nulos: 0, aptos: 1000 }),
+ ).toMatchObject({
+ status: 'zero',
+ absolute: 0,
+ })
+ })
+
+ it('reports semBaseline when aptos is null', () => {
+ expect(
+ computeMobilizationOpportunity({ abstencoes: 10, brancos: 5, nulos: 2, aptos: null }),
+ ).toMatchObject({
+ status: 'semBaseline',
+ absolute: 0,
+ })
+ })
+})
+
+describe('isComparableMobilization', () => {
+ it('accepts only opportunity', () => {
+ expect(isComparableMobilization('opportunity')).toBe(true)
+ expect(isComparableMobilization('zero')).toBe(false)
+ })
+})
+
+describe('aggregateMobilizationOverview', () => {
+ it('maps comparable opportunity to overview fields', () => {
+ expect(
+ aggregateMobilizationOverview({
+ abstencoes: 680,
+ brancos: 100,
+ nulos: 42,
+ aptos: 4700,
+ }),
+ ).toEqual({
+ absoluteTotal: 822,
+ relativePercent: 17,
+ brancosNulosTotal: 142,
+ abstencoesTotal: 680,
+ })
+ })
+
+ it('returns null when there is no mobilization potential', () => {
+ expect(
+ aggregateMobilizationOverview({ abstencoes: 0, brancos: 0, nulos: 0, aptos: 1000 }),
+ ).toBeNull()
+ expect(
+ aggregateMobilizationOverview({ abstencoes: 10, brancos: 5, nulos: 2, aptos: null }),
+ ).toBeNull()
+ })
+})
+
describe('computeTerritorialClass', () => {
it('classifies defesa at 35% of valid votes', () => {
const validos = 2490