mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:53:05 +00:00
Deliver A5 mobilization insight and absorb E7 F2 int follow-up.
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.
This commit is contained in:
parent
0460b08429
commit
ea579d2173
11 changed files with 327 additions and 41 deletions
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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<br/>abstencoes + brancos + nulos + aptos"]
|
||||
Calc["computeMobilizationOpportunity(...)"]
|
||||
Detail["Detalhe: 'X votos não aproveitados<br/>(Y% do eleitorado apto)'"]
|
||||
Overview["Overview: Σ potencial sobre o filtro<br/>+ 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
|||
</Alert>
|
||||
) : null}
|
||||
|
||||
{showMobilization ? (
|
||||
<Alert data-insight="mobilization" variant="pending" className="rounded-xl px-3.5 py-3">
|
||||
<MegaphoneIcon aria-hidden="true" />
|
||||
<AlertTitle className="font-bold">{mobilization.message}</AlertTitle>
|
||||
<AlertDescription>{mobilization.supportLine}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{showLeverage ? (
|
||||
<Alert
|
||||
data-insight="ticket-leverage"
|
||||
|
|
|
|||
|
|
@ -139,6 +139,27 @@ export const NucleusListOverview = ({
|
|||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
{view.mobilization ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Mobilização:{' '}
|
||||
<span className="font-medium tabular-nums text-foreground">
|
||||
{formatElectionNumber(view.mobilization.absoluteTotal)} votos possíveis
|
||||
</span>
|
||||
{' · '}
|
||||
<span className="font-medium tabular-nums text-foreground">
|
||||
{view.mobilization.relativePercent}%
|
||||
</span>{' '}
|
||||
do eleitorado apto
|
||||
{' · '}
|
||||
<span className="font-medium text-estimate-pending-foreground">
|
||||
{formatElectionNumber(view.mobilization.brancosNulosTotal)} brancos/nulos
|
||||
</span>
|
||||
{' · '}
|
||||
<span className="font-medium text-foreground">
|
||||
{formatElectionNumber(view.mobilization.abstencoesTotal)} abstenções
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
{view.leverage ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Alavancagem da chapa:{' '}
|
||||
|
|
|
|||
|
|
@ -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' =>
|
||||
|
|
|
|||
|
|
@ -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<string, ElectionTallyAggregateRow>,
|
||||
geography: NucleusElectionGeography,
|
||||
): Pick<NucleusElectoralBaselineViewModel['electorate'], 'aptos' | 'abstencoes' | 'validos'> => {
|
||||
): 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<typeof sumFederalTallyForGeography>
|
||||
>()
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -134,6 +134,7 @@ describe('loadNucleusListElectionOverview (E7 F2)', () => {
|
|||
baseline2022: null,
|
||||
trend: null,
|
||||
conversion: null,
|
||||
mobilization: null,
|
||||
leverage: null,
|
||||
flipOpportunity: null,
|
||||
classification: null,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue