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