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 ? (
+
+ 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