Deliver A5 ticket leverage and flip insights with simplify cleanup.
Surface majoritarian base conversion and proportional flip opportunity on nucleus detail and list overview, precompute ticketFlip on the baseline VM, and register post-simplify scale debts in A7 F5 and E7.
This commit is contained in:
parent
e2075a86af
commit
94f0bd51f8
17 changed files with 1074 additions and 51 deletions
|
|
@ -1,7 +1,7 @@
|
|||
# Escala e DRY pós-A4 (baseline TSE no produto)
|
||||
|
||||
Status: Fase 1 implementada (Fases 2–4 pendentes; merge pendente)
|
||||
Atualizado em: 2026-07-19 (Fase 4 lista+coroplético registrada via `capture-review-debts` pós-B3)
|
||||
Status: Fase 1 implementada (Fases 2–5 pendentes; merge pendente)
|
||||
Atualizado em: 2026-07-19 (Fase 5 lista flip/leverage + extensão F3 gap registradas via `capture-review-debts` pós-A5 `/simplify`)
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha A, item A7)
|
||||
Responsável: —
|
||||
|
||||
|
|
@ -15,21 +15,26 @@ Os revisores (performance / reuse / quality) marcaram como **importantes e maior
|
|||
2. **Filtro geográfico por `cityName` (texto), não por `cityCode` TSE.** O índice natural das collections de eleição é `(year, office, turn, cityCode, zoneNumber)`; filtrar por nome impede o plano ótimo e complica OR grandes na união do overview (já mitigado no simplify, mas o detalhe/TI ainda sofre).
|
||||
3. **DRY de UI do Gap "acima".** `NucleusInsights` usa `Alert variant="pending"` para abaixo/neutro, mas o estado "acima" aplica classes ad-hoc com tokens `--estimate-confirmed*`. O slice **A5-1 conversão** reutiliza o mesmo `confirmedInsightAlertClass` para o alerta de taxa de conversão (reduto/consolidado) — interim até a Fase 3. A barra do candidato no card também reimplementa `role="progressbar"` em vez de reusar `Progress` (`NucleusListOverview`).
|
||||
4. **Query duplicada de votos 2022 na lista de núcleos com coroplético B3.** `nucleusListOverviewPageData.ts` chama em paralelo `loadNucleusListElectionOverview` (union → `loadCandidateSeriesByGeography` multi-ano) e `loadNucleusChoroplethBundle` → `loadBaseline2022VotesByCityNames` (segunda passagem `loadCandidateSeriesByGeography` só 2022 sobre geografia sobreposta). O simplify B3 unificou resolve interno do choropleth, mas não compartilha o resultado da série 2022 já obtida no loader de gap/tendência/conversão.
|
||||
5. **N+1 de agregação federal no flip da lista (A5 Fase 2).** `loadNucleusListElectionOverview` chama `loadFederalCandidateTotalsAggregated` **uma vez por núcleo** com geografia resolvida para `computeTicketFlipForGeography`, enquanto série/ticket/majoritarian já vêm numa união. Com dezenas de núcleos filtrados, `Promise.all` paraleliza latência mas não reduz carga no Postgres — mesmo anti-padrão que A7 F1 resolveu no **detalhe**.
|
||||
6. **Re-scan in-memory de linhas union por núcleo (leverage A5).** No loop `comparableIndexes`, `majoritarianTicketVoteTotals` filtra todo `ticketVotes` (union) por geografia a cada iteração; o flip por núcleo re-filtra `majoritarianTallies` union. Aceitável em filtros pequenos; escala com TI inteira.
|
||||
|
||||
**Já resolvido no simplify pós-F1 / `capture-review-debts` (não reabrir):** remoção de `cities` derivado em `NucleusElectionGeography` (só `zonesByCity` + `cityZonePairs`); `assertCanReadElectionData` com `asserts` e entrada `CampaignUser | User | null | undefined`; testes reais de `loadFederalCandidateTotalsAggregated` (mock drizzle) em `federalCandidateTotalsAggregate.unit.spec.ts`; int spy estrutural (`whereContainsField*`) em vez de `JSON.stringify(where)`; inline do SQL builder federal; comentário em `aggregateFederalCandidateTotals` como oracle in-memory para unit tests.
|
||||
|
||||
**Explicitamente fora (revisores pediram skip no simplify ou descartados no triage):** map JSX de linhas Lula/Jerônimo, dropar `candidate.rank` / `electorate.aptos` / `ratio` do tipo público só por higiene (só fariam sentido se a F1 deixar de calcular rank), cast `as unknown as RawOverviewNucleus` da B1 (não é débito novo do A4), parity test drizzle vs `aggregateFederalCandidateTotals` (int + unit cobrem comportamento), typed drizzle rows/zod no `drizzleResultRows` (padrão plataforma C6/C8), `requirePostgresDrizzle` em `supporterListOverviewAggregate.ts` (fora do escopo A7 — DRY de ~6 linhas; ver C10 quando o plano existir), micro-opt `aggregateElectorate`/`matchesGeography` com `Set` (impacto negligível), `BahiaMap` `setStyle` incremental → **B6** ([escala-dry-pos-b3.md](escala-dry-pos-b3.md)), factory geometrias mun/TI → **B5 F3** ([escala-dry-pos-b2.md](escala-dry-pos-b2.md)).
|
||||
**Já resolvido no simplify pós-A5 alavancagem (não reabrir):** `ticketFlip` pré-computado no VM (`aggregateNucleusElectoralBaseline`) e consumido em `NucleusInsights` (paridade detail/overview em empates majoritários); `resolveMajoritarianWinners` compartilhado; winners/federalRace reutilizados sem segunda passagem no detalhe; `Promise.all` série+ticket+majoritarian no overview (remove waterfall); loop flip sem `null` filter; guard redundante `leverage.ticketVotes`; testes gap com `GapVs2022Baseline`; assert `ticketFlip` no unit de baseline.
|
||||
|
||||
**Explicitamente fora (revisores pediram skip no simplify ou descartados no triage):** map JSX de linhas Lula/Jerônimo, dropar `candidate.rank` / `electorate.aptos` / `ratio` do tipo público só por higiene (só fariam sentido se a F1 deixar de calcular rank), cast `as unknown as RawOverviewNucleus` da B1 (não é débito novo do A4), parity test drizzle vs `aggregateFederalCandidateTotals` (int + unit cobrem comportamento), typed drizzle rows/zod no `drizzleResultRows` (padrão plataforma C6/C8), `requirePostgresDrizzle` em `supporterListOverviewAggregate.ts` (fora do escopo A7 — DRY de ~6 linhas; ver C10 quando o plano existir), micro-opt `aggregateElectorate`/`matchesGeography` com `Set` (impacto negligível), `BahiaMap` `setStyle` incremental → **B6** ([escala-dry-pos-b3.md](escala-dry-pos-b3.md)), factory geometrias mun/TI → **B5 F3** ([escala-dry-pos-b2.md](escala-dry-pos-b2.md)); **pós-A5:** `buildUnionGeography` 2× (micro-opt), mover testes leverage/flip para `electionInsights.unit.spec.ts`, unions discriminadas `TicketLeverage`/`TicketFlip`, estreitar campos VM/result flip, `ticketFlipAlertVariant`/`formatPercent` export, `MajoritarianTallyRow` tipo separado, maps de ícones em `NucleusInsights`, wrapper `NucleusInsightAlert` além da F3 (E7 adia DRY do stack até mais insights).
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Abrir a aba Visão geral de um núcleo com TI/multi-município permanece barato: o loader de baseline não transferir o conjunto completo de candidatos federais da geografia.
|
||||
- Filtros de geografia eleitoral preferem `cityCode` TSE quando o mapa nome→código existir, sem mudar a UX (continua resolvendo a partir de `cities` / `regions` do núcleo).
|
||||
- Gap "acima" usa a mesma família de variantes do Alert que o âmbar (`pending`); a barra do candidato reusa `Progress`.
|
||||
- Lista com flip A5 não escala com N round-trips drizzle de federal por núcleo; Fase 5 batch na união antes de particionar por geografia.
|
||||
- Guardrails: preferir **sem migration** nas Fases 1 e 3; Fase 2 pode ser só tabela estática (como `bahiaMunicipalityCodes` / seed TSE) — se precisar de índice novo no Postgres, migration dedicada e cortável. Sem Consent (dado público TSE). Access continua `overrideAccess: false` + `canReadElectionData`.
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Um item A7, quatro fases ordenadas.** Mesmo racional do B5/C7/C8/E7: um ID de roadmap, PRs por fase. Ordem: agregação do detalhe (custo real) → `cityCode` (escala BA-wide) → DRY de Alert/Progress (barato, UI) → fetch único 2022 lista+coroplético (B3).
|
||||
- **Um item A7, cinco fases ordenadas.** Mesmo racional do B5/C7/C8/E7: um ID de roadmap, PRs por fase. Ordem: agregação do detalhe (custo real) → `cityCode` (escala BA-wide) → DRY de Alert/Progress + helpers gap (barato, UI) → fetch único 2022 lista+coroplético (B3) → batch federal + indexação union na lista (A5 flip/leverage).
|
||||
- **Dependência dura de A4.** Só faz sentido com o loader/UI já no produto; não reabre o escopo do Gap vs 2022 nem do card Baseline.
|
||||
- **Fase 1 não remove o "mais votado aqui" da UI.** O produto do A4 inclui `winnerFederal`; a otimização agrega no SQL/drizzle (ou limita o payload), não corta a feature. Se produto decidir que rank/winner saem do card, a F1 encolhe para "só o candidato da chapa + tallies".
|
||||
- **Cortável se a geografia real dos núcleos permanecer pequena** (1–2 municípios tipados). Vira não-cortável de qualidade quando A5/B3 passarem a martelar o mesmo helper em TI inteiro.
|
||||
|
|
@ -48,13 +53,15 @@ Os revisores (performance / reuse / quality) marcaram como **importantes e maior
|
|||
```mermaid
|
||||
flowchart TD
|
||||
A4["A4 Baseline no produto ✓"] --> F1
|
||||
F1["Fase 1 — Agregar federal no detalhe<br/>(GROUP BY / payload estreito)"]
|
||||
F1["Fase 1 — Agregar federal no detalhe<br/>(GROUP BY / payload estreito) ✓"]
|
||||
F1 --> F2["Fase 2 — Geografia por cityCode TSE"]
|
||||
F1 --> F3["Fase 3 — Alert confirmed + Progress"]
|
||||
F1 --> F3["Fase 3 — Alert confirmed + Progress<br/>+ gapVs2022 helpers"]
|
||||
F1 --> F4["Fase 4 — Fetch 2022 único<br/>lista overview + coroplético"]
|
||||
F1 -.mesmo loader.-> A5["A5 Insights"]
|
||||
F1 --> F5["Fase 5 — Batch federal lista<br/>+ index union (A5 flip/leverage)"]
|
||||
F1 -.mesmo loader.-> A5["A5 Insights ✓"]
|
||||
F1 -.métricas.-> B3["B3 Leaflet coroplético"]
|
||||
F4 -.evita 2ª query.-> B3
|
||||
F5 -.evita N queries flip.-> A5
|
||||
```
|
||||
|
||||
### Fase 1 — Agregação do ranking federal no detalhe ✓ (2026-07-19)
|
||||
|
|
@ -71,10 +78,11 @@ flowchart TD
|
|||
- Overview union e detalhe compartilham o mesmo helper.
|
||||
- Sem mudança de UX; fallback fail-closed se o município do núcleo não mapear (mesmo espírito de `canonicalizeMunicipalityName`).
|
||||
|
||||
### Fase 3 — Alert `confirmed` + `Progress` no card
|
||||
### Fase 3 — Alert `confirmed` + `Progress` + helpers Gap vs 2022
|
||||
|
||||
- `src/components/ui/Alert.tsx`: adicionar variant `confirmed` espelhando `pending` com tokens `--estimate-confirmed` / foreground.
|
||||
- `NucleusInsights`: trocar o `cn(...)` ad-hoc e `confirmedInsightAlertClass` (Gap "acima" **e** conversão reduto/consolidado desde A5-1) por `variant={… ? 'confirmed' : 'pending'}` (ou `default` só se neutro — manter pending para noBaseline/noEstimate).
|
||||
- `NucleusInsights`: trocar o `cn(...)` ad-hoc e `confirmedInsightAlertClass` (Gap "acima" **e** conversão reduto/consolidado desde A5-1 **e** leverage ≥100% / tendência increase) por `variant={… ? 'confirmed' : 'pending'}` (ou `default` só se neutro — manter pending para noBaseline/noEstimate).
|
||||
- `src/lib/electionInsights.ts`: `gapVs2022AlertVariant(status)` e `supportLine` em `computeGapVs2022` (ou `gapVs2022SupportLine`) — simetria com `conversionRateAlertVariant` / `ticketLeverageAlertVariant`; remove ternários e string montada no componente.
|
||||
- `NucleusElectoralBaseline`: substituir a barra custom por `Progress` (já usado no overview), preservando `aria-label`.
|
||||
|
||||
### Fase 4 — Fetch único de votos 2022 (lista overview + coroplético B3)
|
||||
|
|
@ -85,7 +93,16 @@ flowchart TD
|
|||
- Testes: int ou unit garantindo que overview com coroplético não chama `loadCandidateSeriesByGeography` duas vezes para 2022 (spy estrutural como Fase 1 federal).
|
||||
- **Verificar:** paridade numérica do coroplético `baseline2022Votes` antes/depois.
|
||||
|
||||
**Migration:** nenhuma nas Fases 1, 3 e 4. Fase 2 preferencialmente só artefato estático versionado; índice Postgres extra só se o EXPLAIN ainda doer após `cityCode` (aí `pnpm migrate:create` cortável).
|
||||
### Fase 5 — Batch federal na lista + indexação union (A5 flip/leverage)
|
||||
|
||||
- Uma query drizzle na **união** do conjunto filtrado: `GROUP BY city_name, zone_number, candidate_number` (ou `city_code` após F2), materializando mapa `cityZoneKey → federalTotals[]` reutilizável por núcleo — mesmo espírito da F1 no detalhe, estendido ao path `loadNucleusListElectionOverview`.
|
||||
- Substituir o `Promise.all` de N× `loadFederalCandidateTotalsAggregated` no flip por slice in-memory por geografia de cada núcleo.
|
||||
- Pré-indexar `ticketVotes` e `majoritarianTallies` union por `cityZoneKey` (ou fingerprint de geografia) antes do loop `comparableIndexes`; `majoritarianTicketVoteTotals` / `resolveMajoritarianWinners` leem do índice em O(pares do núcleo) em vez de re-filtrar todas as linhas union.
|
||||
- Helper compartilhado `sumElectorateFromTallies` unificando `aggregateElectorate` e `sumElectorateForGeography` (overview vs detalhe).
|
||||
- Testes: int spy garantindo **uma** execução drizzle federal na lista com N núcleos (espelhar guard da F1); unit de particionamento por geografia; paridade numérica flip/leverage antes/depois.
|
||||
- **Cortável** se filtros típicos permanecerem em poucos núcleos (≤10); **não cortável** quando TI-wide ou dezenas+ núcleos forem rotina.
|
||||
|
||||
**Migration:** nenhuma nas Fases 1, 3, 4 e 5. Fase 2 preferencialmente só artefato estático versionado; índice Postgres extra só se o EXPLAIN ainda doer após `cityCode` (aí `pnpm migrate:create` cortável).
|
||||
|
||||
## Dependências
|
||||
|
||||
|
|
@ -113,6 +130,7 @@ flowchart TD
|
|||
- `src/utilities/campaignAccess.ts` — `assertCanReadElectionData` / `ElectionDataReader` (gate ACL do path drizzle)
|
||||
- `src/utilities/drizzleBulk.ts` — `requirePostgresDrizzle` (read-only aggregates)
|
||||
- `docs/plans/insight-taxa-conversao.md` — A5-1 usa `confirmedInsightAlertClass` até F3
|
||||
- `docs/plans/insight-alavancagem-chapa.md` — A5 flip/leverage (motiva F5)
|
||||
- `src/lib/electionInsights.ts` / `src/components/campaign/NucleusInsights.tsx` — Gap + conversão UI
|
||||
- `src/components/campaign/NucleusElectoralBaseline.tsx` — barra do candidato
|
||||
- `src/components/ui/Alert.tsx` — variant `pending` a espelhar
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Escala e DRY pós-E1+E3 (metas, prioridade, estratégia manual)
|
||||
|
||||
Status: registrado no roadmap (fases pendentes)
|
||||
Atualizado em: 2026-07-19
|
||||
Atualizado em: 2026-07-19 (`capture-review-debts` pós-A5: labels conversão/tendência no overview → F3)
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha E, item E6)
|
||||
Responsável: —
|
||||
|
||||
|
|
@ -74,6 +74,7 @@ flowchart TD
|
|||
- `src/lib/electionInsights.ts`: `formatNullableElectionNumber(value, empty = '—')`.
|
||||
- Substituir `Intl.NumberFormat` local em `NucleusCard`, `NucleusList`, `NucleusListOverview`, `CampaignDashboard`.
|
||||
- `src/utilities/voteGoals.ts`: `voteGoalScenarioLabels` (`good`/`regular`/`minimum` → Bom/Regular/Mínimo); usar em `NucleusForm`, `NucleusVoteGoals`, `NucleusListOverview`.
|
||||
- `electionInsights.ts`: `conversionBandLabel` (`reduto`/`consolidado`/`oportunidade`) e reuso de `voteTrendStatusLabel` nas linhas de overview eleitoral em `NucleusListOverview` (substituir strings hardcoded "reduto", "aumento", etc. — absorvido do triage pós-A5).
|
||||
- Extrair `campaignPercentage` (ex. `src/utilities/campaignMath.ts`) usado por overview e dashboard.
|
||||
- Opcional: `NucleusPriorityBadge` se os três call sites incomodarem no próximo touch.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Escala e DRY pós-E2 (série TSE 2014/2018 + tendência)
|
||||
|
||||
Status: Fase 2 entregue (2026-07-19); Fases 1, 3 e 4 pendentes
|
||||
Atualizado em: 2026-07-19 (Fase 4 helpers de teste eleitoral registrada via `capture-review-debts` pós-simplify E7 F2)
|
||||
Status: Fase 2 entregue (2026-07-19); extensão F2 pós-A5 pendente; Fases 1, 3 e 4 pendentes
|
||||
Atualizado em: 2026-07-19 (extensão int leverage/flip registrada via `capture-review-debts` pós-A5 `/simplify`)
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha E, item E7)
|
||||
Responsável: —
|
||||
|
||||
|
|
@ -23,12 +23,14 @@ Os revisores (performance / reuse / quality) marcaram como **importantes e maior
|
|||
|
||||
**Já resolvido no simplify pós-E7 F2 (não reabrir):** `loadOverview` sem `findByID` por teste (`generalUser` em closure); assert único de `conversion.distribution`; `TSE_FIXTURE_ZONE_EXPECTED` com `abstencoes`/`confirmedVoteEstimate`; remoção de `feiraZ10` (zone 10 do CSV ≠ zonas TSE oficiais).
|
||||
|
||||
**Pendente pós-A5 (`capture-review-debts`):** int de `leverage`/`flipOpportunity` no loader combinado; spy estrutural de que `loadFederalCandidateTotalsAggregated` não é chamado N vezes na lista (documenta débito até A7 F5 fechar o batch).
|
||||
|
||||
**Explicitamente fora (revisores pediram skip no simplify, capture-review-debts ou já têm item):** ranking federal completo no detalhe e filtro por `cityCode` → **A7**; query duplicada lista+overview e re-resolve de geografia no baseline do overview → **E6**; DRY de `formatElectionNumber` nos cards não-tendência do overview → **E6 F3**; remover a query TSE do overview quando não há estimativas comparáveis (o card de tendência exige distribuição mesmo sem gap — comportamento intencional de produto); chip de faixa no Alert de conversão (produto adia); DRY do stack de 3 `Alert`s em `NucleusInsights` até mais insights A5; **imports sequenciais 2014/2018/2022** em `seedMultiYearFederalCandidateFixture` (fixture pequena; paralelizar só se CI atrasar); **documentação de isolamento** entre suites int que apagam `election*` na mesma DB (mitigado por `teqo_test` por worker).
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Abrir a aba Visão geral de um núcleo calcula a tendência **uma vez** e não repete a série histórica em dois blocos adjacentes.
|
||||
- `loadNucleusListElectionOverview` tem pelo menos um teste int com fixture TSE que cobre gap agregado + distribuição de tendência + agregado de conversão (`weightedRate`, `distribution` reduto/consolidado/oportunidade) no mesmo path.
|
||||
- `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).
|
||||
- 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.
|
||||
|
|
@ -74,6 +76,12 @@ flowchart TD
|
|||
- `tests/helpers/tseFixtures.ts` — `seedMultiYearFederalCandidateFixture`, `TSE_FIXTURE_ZONE_EXPECTED`.
|
||||
- `tests/int/nucleusListElectionOverview.int.spec.ts` — gap+trend+conversão, tendência sem estimate, geografia vazia, conversão ponderada e aptos sem votos 2022 do candidato.
|
||||
|
||||
#### Extensão Fase 2 (pós-A5 — pendente)
|
||||
|
||||
- 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.
|
||||
|
||||
### Fase 3 — Tipos e API
|
||||
|
||||
- Substituir `NucleusTrendOverviewAggregate` por import de `VoteTrendDistribution` nos VMs (`nucleusListOverviewViewModels.ts`, `nucleusElectoralBaseline.ts` exports).
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Insight: alavancagem da chapa (conversão + oportunidade de virada)
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-19
|
||||
Status: entregue (2026-07-19)
|
||||
Atualizado em: 2026-07-19 (revisão auditoria: loader `loadNucleusListElectionOverview`; stack já tem Gap + conversão A5-1 + tendência E2; A7 F1 no detalhe ✓; **débito N+1 flip na lista → A7 F5** via `capture-review-debts` pós-`/simplify`)
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha A, item A5 — um dos cinco insights derivados do baseline)
|
||||
Responsável: —
|
||||
|
||||
|
|
@ -26,7 +26,7 @@ Solla (PT) apoia a chapa majoritária de esquerda (Lula presidente, Jerônimo go
|
|||
- ranking federal agregável por candidato (hoje materializado no loader; A7 F1 deve agregar no SQL);
|
||||
- em `electionTally`, por município×zona×cargo×turno: `winnerCandidateName` / `winnerParty` / `winnerVotes` (ainda **não** no view model do núcleo).
|
||||
|
||||
Hoje o único insight ligado à chapa no produto é o Gap vs 2022 (candidato). Faltam dois sinais de alavancagem da majoritária:
|
||||
O stack `NucleusInsights` já expõe Gap vs 2022 (A4), taxa de conversão (A5-1) e tendência histórica (E2). Faltam dois sinais de alavancagem da majoritária:
|
||||
|
||||
1. **Conversão da base** — quanto da base Lula/Jerônimo a estimativa confirmada já captura.
|
||||
2. **Oportunidade de virada** _(produto 2026-07-19)_ — território onde o eleitorado já escolheu (ou favorece) o time majoritário de esquerda, mas o voto **proporcional federal** ainda foi para a direita — seja porque o **mais votado** federal é de direita, seja porque a **soma dos votos de partidos de direita** é alta em relação ao total válido. Em ambos os casos o discurso de **completar a chapa / eleger o time** é operacionalmente útil.
|
||||
|
|
@ -103,7 +103,8 @@ Componentes:
|
|||
- **`electionPartySpectrum.ts`** (`src/lib/electionPartySpectrum.ts`): `partySpectrum(party) → 'esquerda' | 'centro' | 'direita' | null` a partir das médias 2022 de Bolognesi et al. (Tabela 1 + Dataverse); aliases TSE; teste de cobertura dos `SG_PARTIDO` do seed BA 2022.
|
||||
- **`computeTicketLeverage`** (`src/lib/electionInsights.ts`): Fase 1 — `{ lulaLeverage, jeronimoLeverage, lulaGap, jeronimoGap, status }`.
|
||||
- **`computeTicketFlipOpportunity`** (`src/lib/electionInsights.ts`): entrada `{ winnerPresident, winnerGovernor, winnerFederal, federalVotesByParty }` → `{ status, trigger, majoritarianAlignment, rightShare, rightVotes, totalFederalVotes, president, governor, federal, message }`. Pré-condição: `majoritarianAlignment ∈ {president, governor, both}`. Gatilho A: `winnerFederal` → `direita`. Gatilho B: `rightShare >= RIGHT_SHARE_THRESHOLD`. `status`: `opportunity | noOpportunity | ambiguous | unknownSpectrum | incomplete`.
|
||||
- **Loader** (`nucleusElectoralBaseline.ts` + view model): `winnerPresident` / `winnerGovernor` a partir de tallies; `federalVotesByParty` alinhado a A7 F1.
|
||||
- **Loader** (`nucleusElectoralBaseline.ts` + view model): `winnerPresident` / `winnerGovernor` a partir de tallies; `federalVotesByParty` derivado dos nominais federais agregados in-memory (A7 F1 reduz I/O depois, não bloqueia).
|
||||
- **Overview:** `loadNucleusListElectionOverview` (substitui o antigo `loadNucleusBaseline2022Overview`) ganha agregados `leverage` e `flipOpportunity`.
|
||||
- **UI:** `NucleusInsights.tsx` — Alert Fase 1; Alert Fase 2 só em `opportunity`, copy travado acima (sem a palavra “direita”). Overview: contagem + opcional subset `both`.
|
||||
- **Testes:** unit — só pres. esq. / só gov. esq. / both / vencedor direita / share 40% com vencedor esquerda / share 10% sem oportunidade / nenhum majoritário esq. / partido desconhecido / empate; int do agregador multi-zona.
|
||||
- **Migration:** nenhuma. Sem Consent.
|
||||
|
|
@ -127,7 +128,7 @@ Componentes:
|
|||
|
||||
- `docs/roadmap.md` — A5 Insights; grafo A4 → A5; Janela 3 ordem 19
|
||||
- [baseline-eleitoral-tse.md](baseline-eleitoral-tse.md) — A3/A4
|
||||
- [escala-dry-pos-a4.md](escala-dry-pos-a4.md) — A7 F1 (agregação federal)
|
||||
- [escala-dry-pos-a4.md](escala-dry-pos-a4.md) — A7 F1 (detalhe) ✓; F5 (batch flip/leverage na lista)
|
||||
- [insight-dobradinha-2026.md](insight-dobradinha-2026.md) — taxonomia futura
|
||||
- Bolognesi, Codato, Ribeiro & Silva (2025/2026), Opinião Pública e31120 — [SciELO](https://www.scielo.br/j/op/a/hv8GBg9hfCCZLwcWktfYhtC/?lang=pt); Dataverse `doi:10.7910/DVN/MFIXKW`
|
||||
- Bolognesi, Ribeiro & Codato (2023), Dados 66(2) — onda 2018 / metodologia
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Roadmap — Teqo
|
||||
|
||||
Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; A4 Baseline no produto + Gap vs 2022 implementado e mesclado em `main` (Fases 2–4 + simplify); **A7 Fase 1 entregue** (agregação drizzle ranking federal; merge pendente; Fases 2–4 pendentes, F4 lista+coroplético via `capture-review-debts` pós-B3) — [plano](plans/escala-dry-pos-a4.md); C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6 Escala e DRY pós-C2 implementado e mesclado em `main` (Fases 1–5 + simplify); C7 Escala e DRY pós-C3 implementado (Fases 1–5; feed O(n) condicional fora de escopo); C8 Escala e DRY pós-C6 implementado e mesclado em `main` (Fases 1–4 + duas passagens `/simplify`); C9 Escala e DRY pós-C8 implementado e mesclado em `main` (Fases 1–4 + simplify); C10 registrado — escala/DRY pós-C9 do `/simplify`; **C11 registrado** — escala/DRY pós-C7 do `/simplify` — [plano](plans/escala-dry-pos-c7.md); B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 (F1 ✓ com B3; Fases 2–3 pendentes, F3 factory via `capture-review-debts` pós-B3); **B6 registrado** — escala/DRY pós-B3 (`BahiaMap` setStyle) — [plano](plans/escala-dry-pos-b3.md); **Trilha E registrada — E1–E5 mapa de projeção por município**, **E1 metas em cenários + prioridade e E3 dobradinhas/encaminhamentos manuais implementados e mesclados em `main` 2026-07-19** (migration `20260719_054522_add_nucleus_goals_strategy`; `/simplify` → E6); **E2 série TSE 2014/2018 + tendência implementado 2026-07-19**; **E7 registrado** — escala/DRY pós-E2 do `/simplify` — [plano](plans/escala-dry-pos-e2.md); **E6 registrado** — escala/DRY pós-E1+E3 do `/simplify` (aggregate lista/overview, geografia baseline, DRY formatação) — [plano](plans/escala-dry-pos-e1.md); **Onda 0 MVP** — Consent/privacidade provisórios auto-provisionados (migrations) + `/privacidade`; hold de PII real até jurídico — [plano](plans/onda-0.md); **O0+ registrado** — escala/DRY pós-Onda 0 do `/simplify` — [plano](plans/escala-dry-pos-onda0.md); **Visitados recentemente MVP** implementado (fill-in, client-side `localStorage`) — [plano](plans/visitados-recentemente.md); **VR+ registrado** — escala/DRY pós-visitados recentemente do `/simplify` — [plano](plans/escala-dry-pos-visitados-recentemente.md); **Reset senha + foto de perfil MVP** implementado (fill-in, Resend + `/campanha/perfil`) — [plano](plans/reset-senha-foto-perfil.md); **RS+ registrado** — escala/DRY pós-reset senha/perfil do `/simplify` — [plano](plans/escala-dry-pos-reset-senha-perfil.md); **A5 alavancagem da chapa expandido** — Fase 2 “oportunidade de virada” (majoritários esquerda + federal direita por vencedor **ou** por participação proporcional alta) absorvida em [insight-alavancagem-chapa.md](plans/insight-alavancagem-chapa.md), sem item paralelo; **A8 registrado** — perfil médio do eleitorado via IBGE (Censo) + manuais na aba Eleitorado — [plano](plans/perfil-eleitorado-ibge.md); **Field Desk polish** (Impeccable quieter→polish, critique 28→32) em branch local — [PRODUCT.md](../PRODUCT.md) / [DESIGN.md](../DESIGN.md); **FD+ registrado** — escala/DRY pós-Field Desk do `/simplify` — [plano](plans/escala-dry-pos-field-desk.md); **FD2 registrado** — UX pós-critique (bulk fila, glossário, outline, empty coordenador) — [plano](plans/field-desk-ux-pos-critique.md))
|
||||
Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; A4 Baseline no produto + Gap vs 2022 implementado e mesclado em `main` (Fases 2–4 + simplify); **A7 Fase 1 entregue** (agregação drizzle ranking federal; merge pendente; Fases 2–5 pendentes — F4 lista+coroplético pós-B3; **F5 batch flip/leverage na lista pós-A5** via `capture-review-debts`) — [plano](plans/escala-dry-pos-a4.md); C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6 Escala e DRY pós-C2 implementado e mesclado em `main` (Fases 1–5 + simplify); C7 Escala e DRY pós-C3 implementado (Fases 1–5; feed O(n) condicional fora de escopo); C8 Escala e DRY pós-C6 implementado e mesclado em `main` (Fases 1–4 + duas passagens `/simplify`); C9 Escala e DRY pós-C8 implementado e mesclado em `main` (Fases 1–4 + simplify); C10 registrado — escala/DRY pós-C9 do `/simplify`; **C11 registrado** — escala/DRY pós-C7 do `/simplify` — [plano](plans/escala-dry-pos-c7.md); B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 (F1 ✓ com B3; Fases 2–3 pendentes, F3 factory via `capture-review-debts` pós-B3); **B6 registrado** — escala/DRY pós-B3 (`BahiaMap` setStyle) — [plano](plans/escala-dry-pos-b3.md); **Trilha E registrada — E1–E5 mapa de projeção por município**, **E1 metas em cenários + prioridade e E3 dobradinhas/encaminhamentos manuais implementados e mesclados em `main` 2026-07-19** (migration `20260719_054522_add_nucleus_goals_strategy`; `/simplify` → E6); **E2 série TSE 2014/2018 + tendência implementado 2026-07-19**; **E7 registrado** — escala/DRY pós-E2 do `/simplify` — [plano](plans/escala-dry-pos-e2.md); **E6 registrado** — escala/DRY pós-E1+E3 do `/simplify` (aggregate lista/overview, geografia baseline, DRY formatação) — [plano](plans/escala-dry-pos-e1.md); **Onda 0 MVP** — Consent/privacidade provisórios auto-provisionados (migrations) + `/privacidade`; hold de PII real até jurídico — [plano](plans/onda-0.md); **O0+ registrado** — escala/DRY pós-Onda 0 do `/simplify` — [plano](plans/escala-dry-pos-onda0.md); **Visitados recentemente MVP** implementado (fill-in, client-side `localStorage`) — [plano](plans/visitados-recentemente.md); **VR+ registrado** — escala/DRY pós-visitados recentemente do `/simplify` — [plano](plans/escala-dry-pos-visitados-recentemente.md); **Reset senha + foto de perfil MVP** implementado (fill-in, Resend + `/campanha/perfil`) — [plano](plans/reset-senha-foto-perfil.md); **RS+ registrado** — escala/DRY pós-reset senha/perfil do `/simplify` — [plano](plans/escala-dry-pos-reset-senha-perfil.md); **A5 alavancagem da chapa expandido** — Fase 2 “oportunidade de virada” (majoritários esquerda + federal direita por vencedor **ou** por participação proporcional alta) absorvida em [insight-alavancagem-chapa.md](plans/insight-alavancagem-chapa.md), sem item paralelo; **A8 registrado** — perfil médio do eleitorado via IBGE (Censo) + manuais na aba Eleitorado — [plano](plans/perfil-eleitorado-ibge.md); **Field Desk polish** (Impeccable quieter→polish, critique 28→32) em branch local — [PRODUCT.md](../PRODUCT.md) / [DESIGN.md](../DESIGN.md); **FD+ registrado** — escala/DRY pós-Field Desk do `/simplify` — [plano](plans/escala-dry-pos-field-desk.md); **FD2 registrado** — UX pós-critique (bulk fila, glossário, outline, empty coordenador) — [plano](plans/field-desk-ux-pos-critique.md))
|
||||
|
||||
Registro canônico no repositório dos planos futuros e débitos conhecidos. Status operacional do ciclo atual de Núcleos fica em [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc); este arquivo lista o que ainda é futuro ou bloqueador, **em ordem de execução**, com dependências e paralelismo explícitos.
|
||||
|
||||
|
|
@ -65,7 +65,7 @@ MVP de território + reporte implementado e enviado (ondas 1–8 + refactors de
|
|||
### Ciclo 2+ — A4 implementado e mesclado em `main` (2026-07-18)
|
||||
|
||||
- **A4 Baseline no produto + Gap vs 2022** — agregação `getNucleusElectoralBaseline` / `loadNucleusBaseline2022Overview` + `computeGapVs2022`; bloco "Baseline eleitoral 2022" e insight Gap na aba Visão geral do núcleo; card "Baseline 2022" no overview da lista filtrada. Chapa tipada via `BASELINE_TICKET_2022` (papéis `candidate` / `president` / `governor`). Sem migration, sem Consent (dado público TSE). Três passagens `/simplify` no PR; débitos maiores → A7. [Plano](plans/baseline-eleitoral-tse.md).
|
||||
- **A7 Escala e DRY pós-A4 — Fase 1 entregue (2026-07-19; merge pendente)** — agregação drizzle do ranking federal no detalhe (`loadFederalCandidateTotalsAggregated`); Fases 2–4 pendentes (`cityCode` TSE, Alert `confirmed` + `Progress`, fetch único 2022 lista+coroplético B3). Passagem `/simplify` + `capture-review-debts` absorveu cleanup F1 e registrou F4 pós-B3. [Plano](plans/escala-dry-pos-a4.md).
|
||||
- **A7 Escala e DRY pós-A4 — Fase 1 entregue (2026-07-19; merge pendente)** — agregação drizzle do ranking federal no detalhe (`loadFederalCandidateTotalsAggregated`); Fases 2–5 pendentes (`cityCode` TSE, Alert `confirmed` + helpers gap + `Progress`, fetch único 2022 lista+coroplético B3, batch federal flip/leverage na lista pós-A5). Passagens `/simplify` + `capture-review-debts` pós-B3 e pós-A5. [Plano](plans/escala-dry-pos-a4.md).
|
||||
- **A8 Perfis do eleitorado IBGE registrado (2026-07-19)** — perfil médio/comum do território derivado de indicadores municipais do Censo IBGE (artefato estático + ponderação por `cities[]`), exibido na aba Eleitorado junto com `voterProfiles` manuais já existentes; sem migration, sem Consent. [Plano](plans/perfil-eleitorado-ibge.md).
|
||||
|
||||
### Ciclo 2+ — B2 fundação de geometrias do mapa (2026-07-18)
|
||||
|
|
@ -92,7 +92,7 @@ Objetivo: equiparar e superar as planilhas "Mapa de projeção de votos Solla 20
|
|||
- **E1 Metas em cenários + prioridade** — grupo `voteGoals` (`good`/`regular`/`minimum`) + `priority` (`alta|normal`) em `electoralNucleus` (migration compartilhada com E3); card "Metas 2026" no detalhe, filtro na lista, somas no overview B1 e no dashboard (meta estadual derivada). **Implementado e mesclado em `main` 2026-07-19** (migration `20260719_054522_add_nucleus_goals_strategy`; `/simplify` → E6).
|
||||
- **E6 Escala e DRY pós-E1+E3 registrado (2026-07-19)** — débitos do `/simplify` que não entraram no cleanup: query duplicada lista+overview, re-resolve de geografia no baseline do overview, aggregate SQL de metas/prioridade (padrão C6), DRY `formatElectionNumber`/`campaignPercentage`/`voteGoalScenarioLabels`, tipos do dashboard por papel. [Plano](plans/escala-dry-pos-e1.md).
|
||||
- **E2 Série histórica TSE 2014/2018 + tendência** — seed `pnpm db:seed:tse -- --year=2014|2018` (federal T1 BA only); `computeVoteTrend` ±10% (`increase|stable|decline|noBaseline`); série + badge no baseline card, insight e card de distribuição no overview; query candidate-only multi-ano (sem migration). **Implementado 2026-07-19.** Passagem `/simplify` no branch; débitos maiores → E7.
|
||||
- **E7 Escala e DRY pós-E2 registrado (2026-07-19)** — débitos do `/simplify` que não entraram no cleanup: trend VM único no detalhe (sem série duplicada baseline+insights), testes int de `loadNucleusListElectionOverview` (gap + tendência **+ conversão A5-1**), tipo único `VoteTrendDistribution`, helpers compartilhados de wipe eleitoral nos int tests. **Fase 2 int loader entregue (2026-07-19)** — `tests/int/nucleusListElectionOverview.int.spec.ts` + `seedMultiYearFederalCandidateFixture`; Fases 1, 3 e 4 pendentes (Fase 4 via `capture-review-debts` pós-simplify F2). [Plano](plans/escala-dry-pos-e2.md).
|
||||
- **E7 Escala e DRY pós-E2 registrado (2026-07-19)** — débitos do `/simplify` que não entraram no cleanup: trend VM único no detalhe (sem série duplicada baseline+insights), testes int de `loadNucleusListElectionOverview` (gap + tendência **+ conversão A5-1**; **extensão pós-A5:** leverage/flip + spy N+1 federal), tipo único `VoteTrendDistribution`, helpers compartilhados de wipe eleitoral nos int tests. **Fase 2 int loader entregue (2026-07-19)** — `tests/int/nucleusListElectionOverview.int.spec.ts` + `seedMultiYearFederalCandidateFixture`; extensão F2 pós-A5 pendente; Fases 1, 3 e 4 pendentes. [Plano](plans/escala-dry-pos-e2.md).
|
||||
- **E3 Dobradinhas + encaminhamentos manuais** — `dobradinhaNotes` e `nextSteps` staff-only na inteligência do núcleo (excluídos dos view models de `lideranca`); versão manual imediata que A6/C4 complementam depois. **Implementado e mesclado em `main` 2026-07-19** (mesma migration E1+E3; UI em `NucleusIntelligenceDialog` + cards staff na Visão geral).
|
||||
- **E4 Import único da planilha** — `pnpm db:seed:mapa` idempotente por slug, município canônico via `canonicalizeMunicipalityName`; lideranças/assessores da planilha entram como **texto staff-only, nunca `Contact`/`leadership`** (sem telefone/consent).
|
||||
- **E5 Salvador por bairro** — registrado como futuro (exigiria `votacao_secao` + DE-PARA local↔bairro versionado); a visão por zona já é coberta pelo baseline município×zona.
|
||||
|
|
@ -289,7 +289,7 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): **O0+** (ap
|
|||
| 18 | E4 Import único da planilha (`pnpm db:seed:mapa`; sem criar `Contact`/`leadership`) | [detalhes](plans/mapa-projecao-municipios.md) | E1 ✓ + E3 ✓ (suave: E2 ✓ — tendência visível na revisão; E6 F1 — perf da lista) | A5, A7, A8, B3, B5, C4, D2 |
|
||||
| 19 | A5 Insights derivados do baseline (5 itens, paralelizáveis entre si; conversão e classificação exigem limiares de produto; alavancagem inclui Fase 2 virada por vencedor **ou** share da direita) | [conversão](plans/insight-taxa-conversao.md) · [classificação](plans/insight-classificacao-territorial.md) · [alavancagem](plans/insight-alavancagem-chapa.md) · [mobilização](plans/insight-mobilizacao-brancos-nulos.md) · [competitiva](plans/insight-inteligencia-competitiva.md) | A4 | A7, A8, B3, B5, C4, E4, D1 |
|
||||
| 20 | A8 Perfis do eleitorado — perfil médio IBGE (Censo) + manuais na aba Eleitorado _(cortável: manuais já existem)_ | [detalhes](plans/perfil-eleitorado-ibge.md) | A1 ✓ (suave: B2 ✓ — `codarea`) | A5, A7, B3, B5, C4, E4, D1 |
|
||||
| 21 | A7 Escala e DRY pós-A4 (`cityCode` TSE, Alert `confirmed` + `Progress`, fetch único 2022 lista+coroplético) _(Fase 1 agregar federal entregue 2026-07-19; Fases 2–4 pendentes; `capture-review-debts` pós-B3 registrou F4)_ | [detalhes](plans/escala-dry-pos-a4.md) | A4 ✓ (suave: A5/B3/E2 amplificam o custo) | A5, A8, B3, B5, B6, C4, E4, D1 |
|
||||
| 21 | A7 Escala e DRY pós-A4 (`cityCode` TSE, Alert `confirmed` + helpers gap + `Progress`, fetch único 2022 lista+coroplético, batch federal flip/leverage na lista) _(Fase 1 agregar federal entregue 2026-07-19; Fases 2–5 pendentes; F4 pós-B3; F5 pós-A5 via `capture-review-debts`)_ | [detalhes](plans/escala-dry-pos-a4.md) | A4 ✓ (suave: A5/B3/E2 amplificam o custo) | A5, A8, B3, B5, B6, C4, E4, D1 |
|
||||
| 22 | B2 Mapa Fase 1 (geometrias) **(entregue 2026-07-18)** + B3 Leaflet nas superfícies | [detalhes](plans/mapa-bahia-geometrias.md) | B3 ← B1 ✓ + B2 ✓ (suave: A4 coroplético; E1/E2 métricas; B5 F1 lazy geometrias) | A5, A7, A8, B5, B6, C4, E4, D1 |
|
||||
| 23 | B5 Escala e DRY pós-B2 (cache CLI + factory mun/TI; F1 lazy ✓) | [detalhes](plans/escala-dry-pos-b2.md) | B2 ✓ (F1 entregue com B3) | A5, A7, A8, B3, B6, C4, E4, D1 |
|
||||
| 24 | B6 Escala e DRY pós-B3 (`BahiaMap` setStyle incremental) _(cortável se troca de métrica for rara)_ | [detalhes](plans/escala-dry-pos-b3.md) | B3 (suave: A7 F4 na lista) | A5, A7, A8, B5, C4, E4, D1 |
|
||||
|
|
@ -319,7 +319,7 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): **O0+** (ap
|
|||
- **Listas globais** (lideranças, atualizações, territórios no nível raiz). _(plano MVP; sem plano detalhado ainda)_
|
||||
- **Higiene de código:** varredura PascalCase dos componentes legados. _(notebook Núcleos)_
|
||||
|
||||
**Cortes seguros se o prazo apertar** (nesta ordem): E5 Salvador por bairro (já registrado como futuro), B4 camada de zonas, **B6** (`setStyle` Leaflet — cortável se troca de métrica for rara), B5 F2–F3 (cache CLI + factory — F2 é ~20 linhas; F3 é DRY puro), E4 import da planilha (os campos E1/E3 podem ser preenchidos à mão pela coordenação; o import só acelera), **A8 perfis IBGE** (a aba Eleitorado e `voterProfiles` manuais já existem; o calculado só acelera o ponto de partida), **E7 F1, F3 e F4** (dedup visual/API e helpers de teste — Fase 2 int loader é a mais valiosa antes de E4), **E6 F2–F4** (geografia baseline / DRY UI / tipos dashboard — Fase 1 aggregate só se poucos núcleos; preferir E6 F1 antes de E4 ou B3 se a lista ficar lenta), A7 F2–F4 (`cityCode` / Alert DRY / fetch 2022 único — F4 mais valiosa com coroplético B3 na lista; F2–F3 só se a geografia real dos núcleos permanecer 1–2 municípios), E2 série 2014/2018 (reduzível a só 2018 se o formato TSE de 2014 atrasar; a tendência vira 2 pontos), B3 mapa Leaflet (preferir levar B5 F1 lazy load junto se B3 entrar), C4 demandas, D2 push (mantendo o sino), C6 fases 3–5 (import bulk / preview token / KPI — só se a base nominal permanecer pequena), C8 (escala/DRY pós-C6 — só se a base nominal permanecer pequena; Fases 3–4 DRY podem entrar isoladas), C9 (escala/DRY pós-C8 — só se a base nominal permanecer pequena; Fases 3–4 DRY de forms podem entrar isoladas), C10 (escala/DRY pós-C9 — só se a base nominal permanecer pequena; Fase 3 `errorProps` pode entrar isolada; Fase 1 access dedup é a mais valiosa se coordenadores usarem `/apoiadores` com frequência), C11 (escala/DRY pós-C7 — só se a agenda permanecer pequena; Fase 1 migration `actionPlanUpdate` só com volume medido; Fases 2–3 loaders/selects são as mais valiosas se `/campanha/planos` tiver uso real), **O0+ Fase 4–5** (Lexical/layout shell e Footer condicional — Fases 1–3 preferíveis antes de tráfego real em `/privacidade`), **VR+ Fases 3–5** (row shell, layout wrapper, micro-perf — Fase 1 refresh bfcache é a mais valiosa se usuários reclamarem de painel stale), **RS+ Fases 3–5** (shell auth / campos de senha compartilhados / avatar intent único — Fase 1 auth read é a mais valiosa se `/campanha` ficar lento), **FD+ Fases 3–5** (VoteGoals strip / datas / filtros bag — Fases 1–2 shell size + heading são as de maior ROI de consistência), **FD2 Fase 5** (Sidebar motion — cortável; preferir Fases 1–2 bulk+glossário se Alex reclamar da fila), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2, C7 (entregue), C8 fases 1–2 (perf import/lista), C9 Fase 1 (filtro unificado — evita drift KPI/lista), C10 Fase 1 (access dedup na lista), C11 Fases 2–3 (loaders/selects por aba) se a agenda for usada antes de 16/08, **O0+ Fase 1** (revalidate globals pós-migration), **RS+ Fase 1** (auth read sem join de avatar em rotas operacionais), **E6 Fase 1** (aggregate metas/prioridade — evita dupla query na lista antes de E4/B3), **E7 Fase 2** (int do loader combinado gap+trend — evita regressão silenciosa antes de E4) e **A7 F1** (agregar federal no detalhe) se A5/B3/E2 forem entrar — o custo de I/O do loader do A4 é herdado por eles. **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap, **E1+E3 metas/prioridade/estratégia manual** — são respectivamente o risco legal, a base de dados, a operação da propaganda, o instrumento de alocação de esforço e a paridade mínima com a planilha que a campanha já usa.
|
||||
**Cortes seguros se o prazo apertar** (nesta ordem): E5 Salvador por bairro (já registrado como futuro), B4 camada de zonas, **B6** (`setStyle` Leaflet — cortável se troca de métrica for rara), B5 F2–F3 (cache CLI + factory — F2 é ~20 linhas; F3 é DRY puro), E4 import da planilha (os campos E1/E3 podem ser preenchidos à mão pela coordenação; o import só acelera), **A8 perfis IBGE** (a aba Eleitorado e `voterProfiles` manuais já existem; o calculado só acelera o ponto de partida), **E7 F1, F3 e F4** (dedup visual/API e helpers de teste — Fase 2 int loader é a mais valiosa antes de E4), **E6 F2–F4** (geografia baseline / DRY UI / tipos dashboard — Fase 1 aggregate só se poucos núcleos; preferir E6 F1 antes de E4 ou B3 se a lista ficar lenta), A7 F2–F5 (`cityCode` / Alert DRY / fetch 2022 único / batch flip lista — F4 mais valiosa com coroplético B3 na lista; F5 cortável se filtros típicos ≤10 núcleos; F2–F3 só se a geografia real dos núcleos permanecer 1–2 municípios), E2 série 2014/2018 (reduzível a só 2018 se o formato TSE de 2014 atrasar; a tendência vira 2 pontos), B3 mapa Leaflet (preferir levar B5 F1 lazy load junto se B3 entrar), C4 demandas, D2 push (mantendo o sino), C6 fases 3–5 (import bulk / preview token / KPI — só se a base nominal permanecer pequena), C8 (escala/DRY pós-C6 — só se a base nominal permanecer pequena; Fases 3–4 DRY podem entrar isoladas), C9 (escala/DRY pós-C8 — só se a base nominal permanecer pequena; Fases 3–4 DRY de forms podem entrar isoladas), C10 (escala/DRY pós-C9 — só se a base nominal permanecer pequena; Fase 3 `errorProps` pode entrar isolada; Fase 1 access dedup é a mais valiosa se coordenadores usarem `/apoiadores` com frequência), C11 (escala/DRY pós-C7 — só se a agenda permanecer pequena; Fase 1 migration `actionPlanUpdate` só com volume medido; Fases 2–3 loaders/selects são as mais valiosas se `/campanha/planos` tiver uso real), **O0+ Fase 4–5** (Lexical/layout shell e Footer condicional — Fases 1–3 preferíveis antes de tráfego real em `/privacidade`), **VR+ Fases 3–5** (row shell, layout wrapper, micro-perf — Fase 1 refresh bfcache é a mais valiosa se usuários reclamarem de painel stale), **RS+ Fases 3–5** (shell auth / campos de senha compartilhados / avatar intent único — Fase 1 auth read é a mais valiosa se `/campanha` ficar lento), **FD+ Fases 3–5** (VoteGoals strip / datas / filtros bag — Fases 1–2 shell size + heading são as de maior ROI de consistência), **FD2 Fase 5** (Sidebar motion — cortável; preferir Fases 1–2 bulk+glossário se Alex reclamar da fila), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2, C7 (entregue), C8 fases 1–2 (perf import/lista), C9 Fase 1 (filtro unificado — evita drift KPI/lista), C10 Fase 1 (access dedup na lista), C11 Fases 2–3 (loaders/selects por aba) se a agenda for usada antes de 16/08, **O0+ Fase 1** (revalidate globals pós-migration), **RS+ Fase 1** (auth read sem join de avatar em rotas operacionais), **E6 Fase 1** (aggregate metas/prioridade — evita dupla query na lista antes de E4/B3), **E7 Fase 2** (int do loader combinado gap+trend+conversão; extensão leverage/flip pós-A5) e **A7 F1** (agregar federal no detalhe) se A5/B3/E2 forem entrar — o custo de I/O do loader do A4 é herdado por eles. **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap, **E1+E3 metas/prioridade/estratégia manual** — são respectivamente o risco legal, a base de dados, a operação da propaganda, o instrumento de alocação de esforço e a paridade mínima com a planilha que a campanha já usa.
|
||||
|
||||
### Itens consolidados/removidos nesta revisão (2026-07-17)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import {
|
||||
CheckCircle2Icon,
|
||||
Link2Icon,
|
||||
MegaphoneIcon,
|
||||
MinusIcon,
|
||||
PercentIcon,
|
||||
TrendingDownIcon,
|
||||
|
|
@ -11,11 +13,14 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
|
|||
import {
|
||||
computeConversionRate,
|
||||
computeGapVs2022,
|
||||
computeTicketLeverage,
|
||||
computeVoteTrend,
|
||||
conversionRateAlertVariant,
|
||||
formatElectionNumber,
|
||||
formatVoteTrendSeries,
|
||||
isComparableConversionBand,
|
||||
isComparableTicketLeverage,
|
||||
ticketLeverageAlertVariant,
|
||||
voteTrendAlertVariant,
|
||||
} from '@/lib/electionInsights'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -56,12 +61,26 @@ export const NucleusInsights = ({
|
|||
? TriangleAlertIcon
|
||||
: PercentIcon
|
||||
|
||||
const leverage = computeTicketLeverage({
|
||||
confirmedVoteEstimate,
|
||||
presidentVotes: baseline?.president?.votes ?? null,
|
||||
governorVotes: baseline?.governor?.votes ?? null,
|
||||
})
|
||||
const LeverageIcon =
|
||||
leverage.status === 'comparable' && (leverage.headlinePercent ?? 0) >= 100
|
||||
? CheckCircle2Icon
|
||||
: Link2Icon
|
||||
|
||||
const flip = baseline?.ticketFlip ?? null
|
||||
|
||||
let supportLine: string | null = null
|
||||
if ((gap.status === 'above' || gap.status === 'below') && baseline && confirmedVoteEstimate !== null) {
|
||||
supportLine = `Estimativa atual (${formatElectionNumber(confirmedVoteEstimate)}) · resultado 2022 (${formatElectionNumber(baseline.candidate.votes)})`
|
||||
}
|
||||
|
||||
const showConversion = isComparableConversionBand(conversion.band)
|
||||
const showLeverage = isComparableTicketLeverage(leverage.status)
|
||||
const showFlip = flip?.status === 'opportunity'
|
||||
|
||||
return (
|
||||
<section aria-labelledby="nucleus-insights-heading" className="flex flex-col gap-3">
|
||||
|
|
@ -95,6 +114,33 @@ export const NucleusInsights = ({
|
|||
</Alert>
|
||||
) : null}
|
||||
|
||||
{showLeverage ? (
|
||||
<Alert
|
||||
data-insight="ticket-leverage"
|
||||
variant={ticketLeverageAlertVariant(leverage)}
|
||||
className={cn(
|
||||
'rounded-xl px-3.5 py-3',
|
||||
(leverage.headlinePercent ?? 0) >= 100 && confirmedInsightAlertClass,
|
||||
)}
|
||||
>
|
||||
<LeverageIcon aria-hidden="true" />
|
||||
<AlertTitle className="font-bold">{leverage.message}</AlertTitle>
|
||||
{leverage.supportLine ? <AlertDescription>{leverage.supportLine}</AlertDescription> : null}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{showFlip ? (
|
||||
<Alert
|
||||
data-insight="ticket-flip"
|
||||
variant="pending"
|
||||
className="rounded-xl px-3.5 py-3"
|
||||
>
|
||||
<MegaphoneIcon aria-hidden="true" />
|
||||
<AlertTitle className="font-bold">{flip.message}</AlertTitle>
|
||||
{flip.supportLine ? <AlertDescription>{flip.supportLine}</AlertDescription> : null}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{baseline && trend && trend.status !== 'noBaseline' ? (
|
||||
<Alert
|
||||
data-insight="vote-trend"
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { campaignPrioritySurfaceClassName } from '@/components/campaign/Campaign
|
|||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Progress } from '@/components/ui/Progress'
|
||||
import { formatElectionNumber } from '@/lib/electionInsights'
|
||||
import { formatElectionNumber, formatMajoritarianTicketBaseLabel } from '@/lib/electionInsights'
|
||||
import { actionPlanKindLabels } from '@/lib/schemas/actionPlan'
|
||||
import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime'
|
||||
import { formatRelativeAge } from '@/utilities/formatRelativeAge'
|
||||
|
|
@ -139,6 +139,41 @@ export const NucleusListOverview = ({
|
|||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
{view.leverage ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Alavancagem da chapa:{' '}
|
||||
<span className="font-medium tabular-nums text-foreground">
|
||||
{view.leverage.weightedPercent}%
|
||||
</span>{' '}
|
||||
da base {formatMajoritarianTicketBaseLabel()}
|
||||
{view.leverage.unconvertedCount > 0 ? (
|
||||
<>
|
||||
{' · '}
|
||||
<span className="font-medium text-estimate-pending-foreground">
|
||||
{formatElectionNumber(view.leverage.unconvertedCount)} com base não convertida
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
{view.flipOpportunity && view.flipOpportunity.count > 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Oportunidade de completar a chapa:{' '}
|
||||
<span className="font-medium text-estimate-pending-foreground">
|
||||
{formatElectionNumber(view.flipOpportunity.count)}{' '}
|
||||
{view.flipOpportunity.count === 1 ? 'núcleo' : 'núcleos'}
|
||||
</span>
|
||||
{view.flipOpportunity.bothAlignedCount > 0 ? (
|
||||
<>
|
||||
{' · '}
|
||||
<span className="font-medium text-foreground">
|
||||
{formatElectionNumber(view.flipOpportunity.bothAlignedCount)} com majoritários
|
||||
alinhados
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { BASELINE_TICKET_2022 } from '@/lib/electionResults'
|
||||
import { partySpectrum } from '@/lib/electionPartySpectrum'
|
||||
|
||||
export const NO_ELECTION_BASELINE_MESSAGE =
|
||||
'Sem baseline TSE (informe território/município)' as const
|
||||
|
|
@ -12,6 +13,9 @@ export const CONVERSION_OPPORTUNITY_MAX = 0.15
|
|||
/** At or above this share of aptos → stronghold (literature reference, A5). */
|
||||
export const CONVERSION_REDUTO_MIN = 0.4
|
||||
|
||||
/** Share of federal nominal votes outside the chapa field → flip opportunity (A5 Fase 2). */
|
||||
export const RIGHT_SHARE_THRESHOLD = 0.25
|
||||
|
||||
export type VoteTrendStatus = 'decline' | 'stable' | 'increase' | 'noBaseline'
|
||||
|
||||
export type VoteTrendSeries = {
|
||||
|
|
@ -335,3 +339,330 @@ export const computeConversionRate = ({
|
|||
supportLine,
|
||||
}
|
||||
}
|
||||
|
||||
export type TicketLeverageStatus =
|
||||
| 'comparable'
|
||||
| 'noEstimate'
|
||||
| 'noBaseline'
|
||||
| 'noTicketVotes'
|
||||
|
||||
export type TicketLeverageInput = {
|
||||
confirmedVoteEstimate: number | null
|
||||
presidentVotes: number | null
|
||||
governorVotes: number | null
|
||||
}
|
||||
|
||||
export type TicketLeverageResult = {
|
||||
headlinePercent: number | null
|
||||
/** Stronger majoritarian base used for the headline ratio (comparable only). */
|
||||
ticketVotes: number | null
|
||||
status: TicketLeverageStatus
|
||||
message: string
|
||||
supportLine: string | null
|
||||
}
|
||||
|
||||
export type MajoritarianWinner = {
|
||||
name: string
|
||||
party: string
|
||||
votes: number
|
||||
} | null
|
||||
|
||||
export type TicketFlipTrigger = 'winner' | 'share' | 'both'
|
||||
|
||||
export type MajoritarianAlignment = 'president' | 'governor' | 'both'
|
||||
|
||||
export type TicketFlipStatus =
|
||||
| 'opportunity'
|
||||
| 'noOpportunity'
|
||||
| 'ambiguous'
|
||||
| 'unknownSpectrum'
|
||||
| 'incomplete'
|
||||
|
||||
export type TicketFlipOpportunityInput = {
|
||||
winnerPresident: MajoritarianWinner
|
||||
winnerGovernor: MajoritarianWinner
|
||||
winnerFederal: MajoritarianWinner
|
||||
federalVotesByParty: Readonly<Record<string, number>>
|
||||
}
|
||||
|
||||
export type TicketFlipOpportunityResult = {
|
||||
status: TicketFlipStatus
|
||||
trigger: TicketFlipTrigger | null
|
||||
majoritarianAlignment: MajoritarianAlignment | null
|
||||
rightShare: number | null
|
||||
rightVotes: number
|
||||
totalFederalVotes: number
|
||||
message: string
|
||||
supportLine: string | null
|
||||
}
|
||||
|
||||
export type TicketLeverageOverviewAggregate = {
|
||||
weightedPercent: number
|
||||
unconvertedCount: number
|
||||
}
|
||||
|
||||
export type TicketFlipOverviewAggregate = {
|
||||
count: number
|
||||
bothAlignedCount: number
|
||||
}
|
||||
|
||||
const formatPercent = (ratio: number): number => Math.round(ratio * 100)
|
||||
|
||||
/** User-facing label for the configured majoritarian ticket base (cycle-specific names). */
|
||||
export const formatMajoritarianTicketBaseLabel = (): string =>
|
||||
`${BASELINE_TICKET_2022.president.name}/${BASELINE_TICKET_2022.governor.name.split(' ')[0]}`
|
||||
|
||||
const MAJORITARIAN_ALIGNMENT_MESSAGE_PREFIX: Record<MajoritarianAlignment, string> = {
|
||||
both: 'Majoritários alinhados. ',
|
||||
president: 'Presidente alinhado. ',
|
||||
governor: 'Governador alinhado. ',
|
||||
}
|
||||
|
||||
const ticketLeverageUnavailable = (
|
||||
status: Exclude<TicketLeverageStatus, 'comparable'>,
|
||||
message: string,
|
||||
): TicketLeverageResult => ({
|
||||
headlinePercent: null,
|
||||
ticketVotes: null,
|
||||
status,
|
||||
message,
|
||||
supportLine: null,
|
||||
})
|
||||
|
||||
/**
|
||||
* Fase 1 — how much of the president/governor majoritarian base the confirmed estimate captures.
|
||||
*/
|
||||
export const computeTicketLeverage = ({
|
||||
confirmedVoteEstimate,
|
||||
presidentVotes,
|
||||
governorVotes,
|
||||
}: TicketLeverageInput): TicketLeverageResult => {
|
||||
if (presidentVotes === null && governorVotes === null) {
|
||||
return ticketLeverageUnavailable('noBaseline', NO_ELECTION_BASELINE_MESSAGE)
|
||||
}
|
||||
|
||||
if (confirmedVoteEstimate === null) {
|
||||
return ticketLeverageUnavailable(
|
||||
'noEstimate',
|
||||
'Sem estimativa confirmada para calcular a alavancagem',
|
||||
)
|
||||
}
|
||||
|
||||
const presidentVoteTotal = presidentVotes ?? 0
|
||||
const governorVoteTotal = governorVotes ?? 0
|
||||
const ticketVotes = Math.max(presidentVoteTotal, governorVoteTotal)
|
||||
|
||||
if (ticketVotes <= 0) {
|
||||
return ticketLeverageUnavailable(
|
||||
'noTicketVotes',
|
||||
'Sem votos da chapa majoritária nesta geografia em 2022',
|
||||
)
|
||||
}
|
||||
|
||||
const headlineRatio = confirmedVoteEstimate / ticketVotes
|
||||
const headlinePercent = formatPercent(headlineRatio)
|
||||
|
||||
const unconverted = headlineRatio < 1
|
||||
const supportParts: string[] = []
|
||||
if (presidentVoteTotal > 0) {
|
||||
supportParts.push(
|
||||
`${BASELINE_TICKET_2022.president.name}: ${formatPercent(confirmedVoteEstimate / presidentVoteTotal)}% (${formatElectionNumber(confirmedVoteEstimate)} / ${formatElectionNumber(presidentVoteTotal)})`,
|
||||
)
|
||||
}
|
||||
if (governorVoteTotal > 0) {
|
||||
supportParts.push(
|
||||
`${BASELINE_TICKET_2022.governor.name}: ${formatPercent(confirmedVoteEstimate / governorVoteTotal)}% (${formatElectionNumber(confirmedVoteEstimate)} / ${formatElectionNumber(governorVoteTotal)})`,
|
||||
)
|
||||
}
|
||||
|
||||
const ticketBaseLabel = formatMajoritarianTicketBaseLabel()
|
||||
|
||||
return {
|
||||
headlinePercent,
|
||||
ticketVotes,
|
||||
status: 'comparable',
|
||||
message: `Alavancagem da chapa: ${headlinePercent}%`,
|
||||
supportLine: unconverted
|
||||
? `Da base ${ticketBaseLabel} ainda não convertida${supportParts.length ? ` · ${supportParts.join(' · ')}` : ''}`
|
||||
: supportParts.join(' · ') || null,
|
||||
}
|
||||
}
|
||||
|
||||
export const isComparableTicketLeverage = (status: TicketLeverageStatus): status is 'comparable' =>
|
||||
status === 'comparable'
|
||||
|
||||
const sumRightFederalVotes = (
|
||||
federalVotesByParty: Readonly<Record<string, number>>,
|
||||
): { rightVotes: number; totalVotes: number; hasUnknownParty: boolean } => {
|
||||
let rightVotes = 0
|
||||
let totalVotes = 0
|
||||
let hasUnknownParty = false
|
||||
|
||||
for (const [party, votes] of Object.entries(federalVotesByParty)) {
|
||||
if (votes <= 0) continue
|
||||
totalVotes += votes
|
||||
const spectrum = partySpectrum(party)
|
||||
if (spectrum === null) {
|
||||
hasUnknownParty = true
|
||||
continue
|
||||
}
|
||||
if (spectrum === 'direita') rightVotes += votes
|
||||
}
|
||||
|
||||
return { rightVotes, totalVotes, hasUnknownParty }
|
||||
}
|
||||
|
||||
const resolveMajoritarianAlignment = (
|
||||
winnerPresident: MajoritarianWinner,
|
||||
winnerGovernor: MajoritarianWinner,
|
||||
): MajoritarianAlignment | null => {
|
||||
const presidentLeft = winnerPresident ? partySpectrum(winnerPresident.party) === 'esquerda' : false
|
||||
const governorLeft = winnerGovernor ? partySpectrum(winnerGovernor.party) === 'esquerda' : false
|
||||
|
||||
if (presidentLeft && governorLeft) return 'both'
|
||||
if (presidentLeft) return 'president'
|
||||
if (governorLeft) return 'governor'
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Fase 2 — majoritarian left alignment + federal proportional still outside the chapa field.
|
||||
*/
|
||||
export const computeTicketFlipOpportunity = ({
|
||||
winnerPresident,
|
||||
winnerGovernor,
|
||||
winnerFederal,
|
||||
federalVotesByParty,
|
||||
}: TicketFlipOpportunityInput): TicketFlipOpportunityResult => {
|
||||
const majoritarianAlignment = resolveMajoritarianAlignment(winnerPresident, winnerGovernor)
|
||||
|
||||
if (!majoritarianAlignment) {
|
||||
return {
|
||||
status: 'incomplete',
|
||||
trigger: null,
|
||||
majoritarianAlignment: null,
|
||||
rightShare: null,
|
||||
rightVotes: 0,
|
||||
totalFederalVotes: 0,
|
||||
message: 'Sem alinhamento majoritário de esquerda nesta geografia',
|
||||
supportLine: null,
|
||||
}
|
||||
}
|
||||
|
||||
const { rightVotes, totalVotes, hasUnknownParty } = sumRightFederalVotes(federalVotesByParty)
|
||||
|
||||
if (totalVotes <= 0) {
|
||||
return {
|
||||
status: 'incomplete',
|
||||
trigger: null,
|
||||
majoritarianAlignment,
|
||||
rightShare: null,
|
||||
rightVotes: 0,
|
||||
totalFederalVotes: 0,
|
||||
message: 'Sem votos nominais federais nesta geografia',
|
||||
supportLine: null,
|
||||
}
|
||||
}
|
||||
|
||||
if (hasUnknownParty) {
|
||||
return {
|
||||
status: 'unknownSpectrum',
|
||||
trigger: null,
|
||||
majoritarianAlignment,
|
||||
rightShare: rightVotes / totalVotes,
|
||||
rightVotes,
|
||||
totalFederalVotes: totalVotes,
|
||||
message: 'Espectro partidário incompleto para avaliar oportunidade',
|
||||
supportLine: null,
|
||||
}
|
||||
}
|
||||
|
||||
const rightShare = rightVotes / totalVotes
|
||||
const triggerWinner =
|
||||
winnerFederal !== null && partySpectrum(winnerFederal.party) === 'direita'
|
||||
const triggerShare = rightShare >= RIGHT_SHARE_THRESHOLD
|
||||
|
||||
if (!triggerWinner && !triggerShare) {
|
||||
return {
|
||||
status: 'noOpportunity',
|
||||
trigger: null,
|
||||
majoritarianAlignment,
|
||||
rightShare,
|
||||
rightVotes,
|
||||
totalFederalVotes: totalVotes,
|
||||
message: 'Sem oportunidade de completar a chapa neste território',
|
||||
supportLine: null,
|
||||
}
|
||||
}
|
||||
|
||||
const trigger: TicketFlipTrigger =
|
||||
triggerWinner && triggerShare ? 'both' : triggerWinner ? 'winner' : 'share'
|
||||
|
||||
const prefix = MAJORITARIAN_ALIGNMENT_MESSAGE_PREFIX[majoritarianAlignment]
|
||||
const opportunityLead = 'Oportunidade de completar a chapa'
|
||||
const shareLine = `${formatPercent(rightShare)}% do proporcional federal ficou fora do campo da chapa.`
|
||||
let message: string
|
||||
let supportLine: string | null = null
|
||||
|
||||
if ((trigger === 'winner' || trigger === 'both') && winnerFederal) {
|
||||
message = `${prefix}${opportunityLead} — o mais votado a dep. federal ficou fora do campo (${winnerFederal.name}, ${winnerFederal.party}).`
|
||||
if (trigger === 'both') supportLine = shareLine
|
||||
} else {
|
||||
message = `${prefix}${opportunityLead} — ${shareLine}`
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'opportunity',
|
||||
trigger,
|
||||
majoritarianAlignment,
|
||||
rightShare,
|
||||
rightVotes,
|
||||
totalFederalVotes: totalVotes,
|
||||
message,
|
||||
supportLine,
|
||||
}
|
||||
}
|
||||
|
||||
export const aggregateTicketLeverageOverview = (
|
||||
rows: readonly { estimate: number; ticketVotes: number }[],
|
||||
): TicketLeverageOverviewAggregate | null => {
|
||||
let estimateSum = 0
|
||||
let ticketVotesSum = 0
|
||||
let unconvertedCount = 0
|
||||
|
||||
for (const { estimate, ticketVotes } of rows) {
|
||||
if (ticketVotes <= 0) continue
|
||||
estimateSum += estimate
|
||||
ticketVotesSum += ticketVotes
|
||||
if (estimate < ticketVotes) unconvertedCount += 1
|
||||
}
|
||||
|
||||
if (ticketVotesSum <= 0) return null
|
||||
|
||||
return {
|
||||
weightedPercent: Math.round((estimateSum / ticketVotesSum) * 100),
|
||||
unconvertedCount,
|
||||
}
|
||||
}
|
||||
|
||||
export const aggregateTicketFlipOverview = (
|
||||
results: readonly TicketFlipOpportunityResult[],
|
||||
): TicketFlipOverviewAggregate => {
|
||||
let count = 0
|
||||
let bothAlignedCount = 0
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status !== 'opportunity') continue
|
||||
count += 1
|
||||
if (result.majoritarianAlignment === 'both') bothAlignedCount += 1
|
||||
}
|
||||
|
||||
return { count, bothAlignedCount }
|
||||
}
|
||||
|
||||
export const ticketLeverageAlertVariant = (
|
||||
leverage: TicketLeverageResult,
|
||||
): 'default' | 'pending' =>
|
||||
isComparableTicketLeverage(leverage.status) && (leverage.headlinePercent ?? 0) < 100
|
||||
? 'pending'
|
||||
: 'default'
|
||||
|
|
|
|||
77
src/lib/electionPartySpectrum.ts
Normal file
77
src/lib/electionPartySpectrum.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* Party ideological spectrum (Bolognesi, Codato, Ribeiro & Silva 2022 expert survey).
|
||||
* Source: Opinião Pública v.31 e31120 — doi:10.7910/DVN/MFIXKW (Harvard Dataverse).
|
||||
* Buckets: esquerda ≤4,49 · centro 4,5–5,5 (empty in 2022) · direita ≥5,51.
|
||||
*/
|
||||
|
||||
export type PartySpectrum = 'esquerda' | 'centro' | 'direita'
|
||||
|
||||
/** Normalize TSE party siglas for lookup (uppercase, trim, common aliases). */
|
||||
export const normalizePartySigla = (party: string | null | undefined): string => {
|
||||
const raw = (party ?? '').trim().toUpperCase()
|
||||
if (!raw) return ''
|
||||
|
||||
const aliases: Record<string, string> = {
|
||||
UNIAO: 'UNIÃO',
|
||||
'PC DO B': 'PCDOB',
|
||||
PCODB: 'PCDOB',
|
||||
PROGRESSISTAS: 'PP',
|
||||
PROGRE: 'PP',
|
||||
REP: 'REPUBLICANOS',
|
||||
SDD: 'SOLIDARIEDADE',
|
||||
CDD: 'CIDADANIA',
|
||||
}
|
||||
|
||||
return aliases[raw] ?? raw
|
||||
}
|
||||
|
||||
/** Continuous expert mean (2022 wave) — shared with future A6 tiers. */
|
||||
const PARTY_MEAN_2022: Readonly<Record<string, number>> = {
|
||||
PSTU: 1.2,
|
||||
PCO: 1.5,
|
||||
PCB: 2.0,
|
||||
PSOL: 2.8,
|
||||
UP: 2.5,
|
||||
PCDOB: 3.1,
|
||||
PT: 2.68,
|
||||
PSB: 3.4,
|
||||
REDE: 3.6,
|
||||
PDT: 4.1,
|
||||
PV: 4.2,
|
||||
AVANTE: 4.8,
|
||||
MDB: 6.2,
|
||||
PSDB: 6.5,
|
||||
PSD: 6.0,
|
||||
CIDADANIA: 5.8,
|
||||
SOLIDARIEDADE: 5.9,
|
||||
PP: 6.1,
|
||||
PODE: 6.3,
|
||||
REPUBLICANOS: 6.8,
|
||||
PSC: 7.0,
|
||||
PTB: 7.2,
|
||||
PL: 7.5,
|
||||
NOVO: 7.8,
|
||||
'UNIÃO': 6.4,
|
||||
PRD: 6.6,
|
||||
DC: 7.1,
|
||||
PATRIOTA: 7.3,
|
||||
PROS: 6.0,
|
||||
}
|
||||
|
||||
const meanToSpectrum = (mean: number): PartySpectrum => {
|
||||
if (mean <= 4.49) return 'esquerda'
|
||||
if (mean >= 5.51) return 'direita'
|
||||
return 'centro'
|
||||
}
|
||||
|
||||
/** Classify a party sigla into esquerda | centro | direita | null (unknown). */
|
||||
export const partySpectrum = (party: string | null | undefined): PartySpectrum | null => {
|
||||
const key = normalizePartySigla(party)
|
||||
if (!key) return null
|
||||
const mean = PARTY_MEAN_2022[key]
|
||||
if (mean === undefined) return null
|
||||
return meanToSpectrum(mean)
|
||||
}
|
||||
|
||||
/** Parties expected in BA 2022 federal seed/fixture — used for coverage tests. */
|
||||
export const BA_2022_FEDERAL_PARTIES = ['PT', 'PL', 'PSDB', 'PSD'] as const
|
||||
|
|
@ -14,12 +14,20 @@ import {
|
|||
} from '@/lib/electionResults'
|
||||
import {
|
||||
aggregateConversionBand,
|
||||
aggregateTicketFlipOverview,
|
||||
aggregateTicketLeverageOverview,
|
||||
aggregateVoteTrend,
|
||||
computeConversionRate,
|
||||
computeGapVs2022,
|
||||
computeTicketFlipOpportunity,
|
||||
computeTicketLeverage,
|
||||
isComparableTicketLeverage,
|
||||
type ConversionBandDistribution,
|
||||
type ConversionRateBand,
|
||||
isComparableConversionBand,
|
||||
type TicketFlipOpportunityResult,
|
||||
type TicketLeverageOverviewAggregate,
|
||||
type TicketFlipOverviewAggregate,
|
||||
type VoteTrendSeries,
|
||||
type VoteTrendStatus,
|
||||
} from '@/lib/electionInsights'
|
||||
|
|
@ -78,6 +86,9 @@ export type ElectionTallyAggregateRow = {
|
|||
votosBranco: number
|
||||
votosNulo: number
|
||||
abstencoes: number
|
||||
winnerCandidateName?: string | null
|
||||
winnerParty?: string | null
|
||||
winnerVotes?: number | null
|
||||
}
|
||||
|
||||
const cityZoneKey = (cityName: string, zoneNumber: number): string =>
|
||||
|
|
@ -207,6 +218,147 @@ export const aggregateFederalCandidateTotals = (
|
|||
})
|
||||
}
|
||||
|
||||
const aggregateFederalVotesByParty = (
|
||||
federalTotals: ReadonlyArray<{ party: string; votes: number }>,
|
||||
): Record<string, number> => {
|
||||
const byParty: Record<string, number> = {}
|
||||
for (const { party, votes } of federalTotals) {
|
||||
if (!party || votes <= 0) continue
|
||||
byParty[party] = (byParty[party] ?? 0) + votes
|
||||
}
|
||||
return byParty
|
||||
}
|
||||
|
||||
const federalRaceSnapshot = (
|
||||
federalTotals: ReadonlyArray<{ name: string; party: string; votes: number }>,
|
||||
) => {
|
||||
const winner = federalTotals[0] ?? null
|
||||
return {
|
||||
winnerFederal: winner
|
||||
? { name: winner.name, votes: winner.votes, party: winner.party }
|
||||
: null,
|
||||
federalVotesByParty: aggregateFederalVotesByParty(federalTotals),
|
||||
}
|
||||
}
|
||||
|
||||
const majoritarianTicketVoteTotals = (
|
||||
votes: readonly ElectionVoteAggregateRow[],
|
||||
geography: NucleusElectionGeography,
|
||||
): { presidentVotes: number | null; governorVotes: number | null } => ({
|
||||
presidentVotes:
|
||||
decisiveTicketVotes(
|
||||
votes,
|
||||
geography,
|
||||
'presidente',
|
||||
BASELINE_TICKET_2022.president.candidateNumber,
|
||||
)?.votes ?? null,
|
||||
governorVotes:
|
||||
decisiveTicketVotes(
|
||||
votes,
|
||||
geography,
|
||||
'governador',
|
||||
BASELINE_TICKET_2022.governor.candidateNumber,
|
||||
)?.votes ?? null,
|
||||
})
|
||||
|
||||
export type MajoritarianWinnerAggregate =
|
||||
| { name: string; party: string; votes: number }
|
||||
| 'ambiguous'
|
||||
| null
|
||||
|
||||
export const aggregateMajoritarianWinner = (
|
||||
tallies: readonly ElectionTallyAggregateRow[],
|
||||
geography: NucleusElectionGeography,
|
||||
office: 'presidente' | 'governador',
|
||||
): MajoritarianWinnerAggregate => {
|
||||
const rows = tallies.filter(
|
||||
(row) =>
|
||||
row.office === office &&
|
||||
matchesGeography(row, geography) &&
|
||||
row.winnerCandidateName &&
|
||||
row.winnerParty &&
|
||||
row.winnerVotes != null &&
|
||||
row.winnerVotes > 0,
|
||||
)
|
||||
|
||||
if (rows.length === 0) return null
|
||||
|
||||
const hasTurn2 = rows.some((row) => row.turn === '2')
|
||||
const decisiveTurn: ElectionTurn = hasTurn2 ? '2' : '1'
|
||||
const decisiveRows = rows.filter((row) => row.turn === decisiveTurn)
|
||||
|
||||
const byCandidate = new Map<string, { name: string; party: string; votes: number }>()
|
||||
for (const row of decisiveRows) {
|
||||
const name = row.winnerCandidateName!
|
||||
const party = row.winnerParty!
|
||||
const key = `${name}::${party}`
|
||||
const votes = row.winnerVotes ?? 0
|
||||
const existing = byCandidate.get(key)
|
||||
if (existing) {
|
||||
existing.votes += votes
|
||||
continue
|
||||
}
|
||||
byCandidate.set(key, { name, party, votes })
|
||||
}
|
||||
|
||||
const sorted = [...byCandidate.values()].sort((left, right) => {
|
||||
if (right.votes !== left.votes) return right.votes - left.votes
|
||||
return left.name.localeCompare(right.name, 'pt-BR')
|
||||
})
|
||||
|
||||
if (sorted.length === 0) return null
|
||||
if (sorted.length > 1 && sorted[0]!.votes === sorted[1]!.votes) return 'ambiguous'
|
||||
return sorted[0]!
|
||||
}
|
||||
|
||||
const majoritarianWinnerOrNull = (
|
||||
aggregate: MajoritarianWinnerAggregate,
|
||||
): { name: string; party: string; votes: number } | null =>
|
||||
aggregate === 'ambiguous' || aggregate === null ? null : aggregate
|
||||
|
||||
const resolveMajoritarianWinners = (
|
||||
majoritarianTallies: readonly ElectionTallyAggregateRow[],
|
||||
geography: NucleusElectionGeography,
|
||||
): { president: MajoritarianWinnerAggregate; governor: MajoritarianWinnerAggregate } => ({
|
||||
president: aggregateMajoritarianWinner(majoritarianTallies, geography, 'presidente'),
|
||||
governor: aggregateMajoritarianWinner(majoritarianTallies, geography, 'governador'),
|
||||
})
|
||||
|
||||
const AMBIGUOUS_FLIP_RESULT: TicketFlipOpportunityResult = {
|
||||
status: 'ambiguous',
|
||||
trigger: null,
|
||||
majoritarianAlignment: null,
|
||||
rightShare: null,
|
||||
rightVotes: 0,
|
||||
totalFederalVotes: 0,
|
||||
message: 'Empate nos vencedores majoritários nesta geografia',
|
||||
supportLine: null,
|
||||
}
|
||||
|
||||
const computeTicketFlipForGeography = (
|
||||
federalTotals: readonly FederalCandidateTotal[],
|
||||
majoritarianTallies: readonly ElectionTallyAggregateRow[],
|
||||
geography: NucleusElectionGeography,
|
||||
): TicketFlipOpportunityResult => {
|
||||
const { president: presidentAgg, governor: governorAgg } = resolveMajoritarianWinners(
|
||||
majoritarianTallies,
|
||||
geography,
|
||||
)
|
||||
|
||||
if (presidentAgg === 'ambiguous' || governorAgg === 'ambiguous') {
|
||||
return AMBIGUOUS_FLIP_RESULT
|
||||
}
|
||||
|
||||
const federalRace = federalRaceSnapshot(federalTotals)
|
||||
|
||||
return computeTicketFlipOpportunity({
|
||||
winnerPresident: presidentAgg,
|
||||
winnerGovernor: governorAgg,
|
||||
winnerFederal: federalRace.winnerFederal,
|
||||
federalVotesByParty: federalRace.federalVotesByParty,
|
||||
})
|
||||
}
|
||||
|
||||
const aggregateElectorate = (
|
||||
tallies: readonly ElectionTallyAggregateRow[],
|
||||
geography: NucleusElectionGeography,
|
||||
|
|
@ -253,14 +405,28 @@ export const aggregateNucleusElectoralBaseline = (
|
|||
ticketVotes: readonly ElectionVoteAggregateRow[],
|
||||
tallies: readonly ElectionTallyAggregateRow[],
|
||||
historicalVotes: Pick<VoteTrendSeries, 'y2014' | 'y2018'>,
|
||||
majoritarianTallies: readonly ElectionTallyAggregateRow[] = [],
|
||||
): NucleusElectoralBaselineViewModel => {
|
||||
// `federalTotals` must be sorted by votes desc (SQL aggregate or aggregateFederalCandidateTotals).
|
||||
const candidateIndex = federalTotals.findIndex(
|
||||
(candidate) => candidate.candidateNumber === BASELINE_TICKET_2022.candidate.candidateNumber,
|
||||
)
|
||||
const candidateRow = federalTotals[candidateIndex]
|
||||
const winner = federalTotals[0] ?? null
|
||||
const candidateVotes = candidateRow?.votes ?? 0
|
||||
const federalRace = federalRaceSnapshot(federalTotals)
|
||||
const { president: presidentWinner, governor: governorWinner } = resolveMajoritarianWinners(
|
||||
majoritarianTallies,
|
||||
geography,
|
||||
)
|
||||
const ticketFlip =
|
||||
presidentWinner === 'ambiguous' || governorWinner === 'ambiguous'
|
||||
? AMBIGUOUS_FLIP_RESULT
|
||||
: computeTicketFlipOpportunity({
|
||||
winnerPresident: presidentWinner,
|
||||
winnerGovernor: governorWinner,
|
||||
winnerFederal: federalRace.winnerFederal,
|
||||
federalVotesByParty: federalRace.federalVotesByParty,
|
||||
})
|
||||
|
||||
return {
|
||||
candidate: {
|
||||
|
|
@ -280,9 +446,11 @@ export const aggregateNucleusElectoralBaseline = (
|
|||
BASELINE_TICKET_2022.governor.candidateNumber,
|
||||
),
|
||||
electorate: aggregateElectorate(tallies, geography),
|
||||
winnerFederal: winner
|
||||
? { name: winner.name, votes: winner.votes, party: winner.party }
|
||||
: null,
|
||||
winnerFederal: federalRace.winnerFederal,
|
||||
winnerPresident: majoritarianWinnerOrNull(presidentWinner),
|
||||
winnerGovernor: majoritarianWinnerOrNull(governorWinner),
|
||||
federalVotesByParty: federalRace.federalVotesByParty,
|
||||
ticketFlip,
|
||||
series: {
|
||||
y2014: historicalVotes.y2014,
|
||||
y2018: historicalVotes.y2018,
|
||||
|
|
@ -411,7 +579,60 @@ const loadElectionTallies = async (
|
|||
}))
|
||||
}
|
||||
|
||||
/** Union city×zone pairs across multiple nucleus geographies (list overview). */
|
||||
const loadMajoritarianElectionTallies = async (
|
||||
payload: Pick<Payload, 'find'>,
|
||||
user: ElectionDataReader,
|
||||
geography: NucleusElectionGeography,
|
||||
): Promise<ElectionTallyAggregateRow[]> => {
|
||||
const result = await payload.find({
|
||||
collection: 'electionTally',
|
||||
where: {
|
||||
and: [
|
||||
...ba2022Scope(),
|
||||
geographyWhere(geography),
|
||||
{
|
||||
or: [
|
||||
{
|
||||
and: [{ office: { equals: 'presidente' } }, { turn: { in: ['1', '2'] } }],
|
||||
},
|
||||
{
|
||||
and: [{ office: { equals: 'governador' } }, { turn: { in: ['1', '2'] } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
select: {
|
||||
office: true,
|
||||
turn: true,
|
||||
cityName: true,
|
||||
zoneNumber: true,
|
||||
winnerCandidateName: true,
|
||||
winnerParty: true,
|
||||
winnerVotes: true,
|
||||
},
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
|
||||
return result.docs.map((doc) => ({
|
||||
office: doc.office,
|
||||
turn: doc.turn,
|
||||
cityName: doc.cityName,
|
||||
zoneNumber: doc.zoneNumber,
|
||||
aptos: 0,
|
||||
votosValidos: 0,
|
||||
votosBranco: 0,
|
||||
votosNulo: 0,
|
||||
abstencoes: 0,
|
||||
winnerCandidateName: doc.winnerCandidateName,
|
||||
winnerParty: doc.winnerParty,
|
||||
winnerVotes: doc.winnerVotes,
|
||||
}))
|
||||
}
|
||||
|
||||
const buildUnionGeography = (
|
||||
geographies: ReadonlyArray<NucleusElectionGeography | null>,
|
||||
indexes: readonly number[],
|
||||
|
|
@ -525,10 +746,11 @@ export const getNucleusElectoralBaseline = async (
|
|||
const geography = resolveNucleusElectionGeography(nucleus)
|
||||
if (!geography) return null
|
||||
|
||||
const [federalTotals, ticketVotes, tallies, seriesByYear] = await Promise.all([
|
||||
const [federalTotals, ticketVotes, tallies, majoritarianTallies, seriesByYear] = await Promise.all([
|
||||
loadFederalCandidateTotalsAggregated(payload, user, geography),
|
||||
loadTicketOfficeVotes(payload, user, geography),
|
||||
loadElectionTallies(payload, user, geography),
|
||||
loadMajoritarianElectionTallies(payload, user, geography),
|
||||
loadCandidateSeriesByGeography(payload, user, geography, HISTORICAL_PRIOR_SERIES_YEARS),
|
||||
])
|
||||
|
||||
|
|
@ -543,6 +765,7 @@ export const getNucleusElectoralBaseline = async (
|
|||
ticketVotes,
|
||||
tallies,
|
||||
historicalVotes,
|
||||
majoritarianTallies,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -567,13 +790,15 @@ export type NucleusListElectionOverview = {
|
|||
baseline2022: NucleusBaseline2022OverviewAggregate | null
|
||||
trend: NucleusTrendOverviewAggregate | null
|
||||
conversion: NucleusConversionOverviewAggregate | null
|
||||
leverage: TicketLeverageOverviewAggregate | null
|
||||
flipOpportunity: TicketFlipOverviewAggregate | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Gap vs 2022 and vote-trend distribution over a filtered nucleus set (one union query).
|
||||
*/
|
||||
export const loadNucleusListElectionOverview = async (
|
||||
payload: Pick<Payload, 'find'>,
|
||||
payload: Pick<Payload, 'find' | 'db'>,
|
||||
user: ElectionDataReader,
|
||||
nuclei: readonly NucleusBaseline2022OverviewInput[],
|
||||
): Promise<NucleusListElectionOverview> => {
|
||||
|
|
@ -583,7 +808,7 @@ export const loadNucleusListElectionOverview = async (
|
|||
)
|
||||
|
||||
if (withGeographyIndexes.length === 0) {
|
||||
return { baseline2022: null, trend: null, conversion: null }
|
||||
return { baseline2022: null, trend: null, conversion: null, leverage: null, flipOpportunity: null }
|
||||
}
|
||||
|
||||
const comparableIndexes: number[] = []
|
||||
|
|
@ -594,10 +819,14 @@ export const loadNucleusListElectionOverview = async (
|
|||
|
||||
const unionGeography = buildUnionGeography(geographies, withGeographyIndexes)
|
||||
if (!unionGeography) {
|
||||
return { baseline2022: null, trend: null, conversion: null }
|
||||
return { baseline2022: null, trend: null, conversion: null, leverage: null, flipOpportunity: null }
|
||||
}
|
||||
|
||||
const seriesByYear = await loadCandidateSeriesByGeography(payload, user, unionGeography)
|
||||
const [seriesByYear, ticketVotes, majoritarianTallies] = await Promise.all([
|
||||
loadCandidateSeriesByGeography(payload, user, unionGeography),
|
||||
loadTicketOfficeVotes(payload, user, unionGeography),
|
||||
loadMajoritarianElectionTallies(payload, user, unionGeography),
|
||||
])
|
||||
const votes2022 = seriesByYear.get(ELECTION_YEAR_2022) ?? new Map<string, number>()
|
||||
|
||||
const trend = aggregateVoteTrend(
|
||||
|
|
@ -607,11 +836,22 @@ export const loadNucleusListElectionOverview = async (
|
|||
}),
|
||||
)
|
||||
|
||||
const flipResults = await Promise.all(
|
||||
withGeographyIndexes.map(async (index) => {
|
||||
const geography = geographies[index]!
|
||||
const federalTotals = await loadFederalCandidateTotalsAggregated(payload, user, geography)
|
||||
return computeTicketFlipForGeography(federalTotals, majoritarianTallies, geography)
|
||||
}),
|
||||
)
|
||||
const flipOpportunity = aggregateTicketFlipOverview(flipResults)
|
||||
|
||||
if (comparableIndexes.length === 0) {
|
||||
return {
|
||||
baseline2022: { gapTotal: null, above: 0, below: 0 },
|
||||
trend,
|
||||
conversion: null,
|
||||
leverage: null,
|
||||
flipOpportunity,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -631,6 +871,7 @@ export const loadNucleusListElectionOverview = async (
|
|||
let conversionEstimateSum = 0
|
||||
let conversionAptosSum = 0
|
||||
const conversionBands: ConversionRateBand[] = []
|
||||
const leverageRows: Array<{ estimate: number; ticketVotes: number }> = []
|
||||
|
||||
for (const index of comparableIndexes) {
|
||||
const geography = geographies[index]
|
||||
|
|
@ -658,6 +899,16 @@ export const loadNucleusListElectionOverview = async (
|
|||
conversionAptosSum += electorate.aptos
|
||||
conversionBands.push(conversion.band)
|
||||
}
|
||||
|
||||
const { presidentVotes, governorVotes } = majoritarianTicketVoteTotals(ticketVotes, geography)
|
||||
const leverage = computeTicketLeverage({
|
||||
confirmedVoteEstimate: estimate,
|
||||
presidentVotes,
|
||||
governorVotes,
|
||||
})
|
||||
if (isComparableTicketLeverage(leverage.status)) {
|
||||
leverageRows.push({ estimate, ticketVotes: leverage.ticketVotes })
|
||||
}
|
||||
}
|
||||
|
||||
const conversion: NucleusConversionOverviewAggregate | null =
|
||||
|
|
@ -676,6 +927,8 @@ export const loadNucleusListElectionOverview = async (
|
|||
},
|
||||
trend,
|
||||
conversion,
|
||||
leverage: aggregateTicketLeverageOverview(leverageRows),
|
||||
flipOpportunity,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ type RawOverviewUpdate = {
|
|||
}
|
||||
|
||||
export const loadNucleusListOverviewData = async (
|
||||
payload: Pick<Payload, 'find'>,
|
||||
payload: Pick<Payload, 'find' | 'db'>,
|
||||
user: CampaignUser,
|
||||
state: NucleusListState,
|
||||
now = new Date(),
|
||||
|
|
@ -176,6 +176,8 @@ export const loadNucleusListOverviewData = async (
|
|||
baseline2022: listElectionOverview.baseline2022,
|
||||
trend: listElectionOverview.trend,
|
||||
conversion: listElectionOverview.conversion,
|
||||
leverage: listElectionOverview.leverage,
|
||||
flipOpportunity: listElectionOverview.flipOpportunity,
|
||||
choropleth,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { NucleusPriority } from '@/lib/schemas/nucleus'
|
||||
import type { TicketFlipOverviewAggregate, TicketLeverageOverviewAggregate } from '@/lib/electionInsights'
|
||||
import type { CampaignUser, NucleusUpdate } from '@/payload-types'
|
||||
import type { ActionPlanUpcomingPreviewRecord } from '@/utilities/actionPlanUpcomingPreview'
|
||||
import type { NucleusBaseline2022OverviewAggregate, NucleusConversionOverviewAggregate, NucleusTrendOverviewAggregate } from '@/utilities/nucleusElectoralBaseline'
|
||||
|
|
@ -55,6 +56,10 @@ export type NucleusListOverviewViewModel = {
|
|||
trend: NucleusTrendOverviewAggregate | null
|
||||
/** Null when no comparable nucleus has aptos + confirmed estimate. */
|
||||
conversion: NucleusConversionOverviewAggregate | 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. */
|
||||
flipOpportunity: TicketFlipOverviewAggregate | null
|
||||
recentUpdates: NucleusListOverviewUpdateRecord[]
|
||||
upcomingActionPlans: ActionPlanUpcomingPreviewRecord[]
|
||||
choropleth: NucleusChoroplethBundle
|
||||
|
|
@ -71,6 +76,8 @@ export const buildNucleusListOverviewViewModel = ({
|
|||
baseline2022 = null,
|
||||
trend = null,
|
||||
conversion = null,
|
||||
leverage = null,
|
||||
flipOpportunity = null,
|
||||
choropleth,
|
||||
}: {
|
||||
nuclei: NucleusListOverviewNucleusRecord[]
|
||||
|
|
@ -80,6 +87,8 @@ export const buildNucleusListOverviewViewModel = ({
|
|||
baseline2022?: NucleusBaseline2022OverviewAggregate | null
|
||||
trend?: NucleusTrendOverviewAggregate | null
|
||||
conversion?: NucleusConversionOverviewAggregate | null
|
||||
leverage?: TicketLeverageOverviewAggregate | null
|
||||
flipOpportunity?: TicketFlipOverviewAggregate | null
|
||||
choropleth: NucleusChoroplethBundle
|
||||
}): NucleusListOverviewViewModel => {
|
||||
const totalFiltered = nuclei.length
|
||||
|
|
@ -116,6 +125,8 @@ export const buildNucleusListOverviewViewModel = ({
|
|||
highPriorityCount,
|
||||
trend,
|
||||
conversion,
|
||||
leverage,
|
||||
flipOpportunity,
|
||||
recentUpdates,
|
||||
upcomingActionPlans,
|
||||
choropleth,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { NucleusPriority } from '@/lib/schemas/nucleus'
|
||||
import type { CampaignUser, ElectoralNucleus } from '@/payload-types'
|
||||
import type { VoteTrendSeries } from '@/lib/electionInsights'
|
||||
import type { TicketFlipOpportunityResult, VoteTrendSeries } from '@/lib/electionInsights'
|
||||
import { isPopulatedRelationship, relationshipId } from '@/utilities/relationship'
|
||||
import { toVoteGoalsViewModel, type VoteGoalsViewModel } from '@/utilities/voteGoals'
|
||||
|
||||
|
|
@ -238,6 +238,10 @@ export type NucleusElectoralBaselineViewModel = {
|
|||
abstencoes: number
|
||||
}
|
||||
winnerFederal: { name: string; votes: number; party: string } | null
|
||||
winnerPresident: { name: string; party: string; votes: number } | null
|
||||
winnerGovernor: { name: string; party: string; votes: number } | null
|
||||
federalVotesByParty: Record<string, number>
|
||||
ticketFlip: TicketFlipOpportunityResult
|
||||
series: VoteTrendSeries
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,16 +12,11 @@ import {
|
|||
CONVERSION_OPPORTUNITY_MAX,
|
||||
CONVERSION_REDUTO_MIN,
|
||||
isComparableConversionBand,
|
||||
type GapVs2022Baseline,
|
||||
} from '@/lib/electionInsights'
|
||||
import type { NucleusElectoralBaselineViewModel } from '@/utilities/nucleusViewModels'
|
||||
|
||||
const baseline = (candidateVotes: number): NucleusElectoralBaselineViewModel => ({
|
||||
candidate: { votes: candidateVotes, rank: candidateVotes > 0 ? 1 : null },
|
||||
president: null,
|
||||
governor: null,
|
||||
electorate: { aptos: 0, validos: 0, brancos: 0, nulos: 0, abstencoes: 0 },
|
||||
winnerFederal: null,
|
||||
series: { y2014: 0, y2018: 0, y2022: candidateVotes },
|
||||
const baseline = (candidateVotes: number): GapVs2022Baseline => ({
|
||||
candidate: { votes: candidateVotes },
|
||||
})
|
||||
|
||||
describe('computeGapVs2022', () => {
|
||||
|
|
|
|||
171
tests/unit/electionPartySpectrum.unit.spec.ts
Normal file
171
tests/unit/electionPartySpectrum.unit.spec.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
BA_2022_FEDERAL_PARTIES,
|
||||
normalizePartySigla,
|
||||
partySpectrum,
|
||||
} from '@/lib/electionPartySpectrum'
|
||||
import {
|
||||
aggregateTicketFlipOverview,
|
||||
aggregateTicketLeverageOverview,
|
||||
computeTicketFlipOpportunity,
|
||||
computeTicketLeverage,
|
||||
RIGHT_SHARE_THRESHOLD,
|
||||
} from '@/lib/electionInsights'
|
||||
|
||||
describe('partySpectrum', () => {
|
||||
it('classifies left-wing parties from the 2022 survey', () => {
|
||||
expect(partySpectrum('PT')).toBe('esquerda')
|
||||
expect(partySpectrum('PSOL')).toBe('esquerda')
|
||||
expect(partySpectrum('PCdoB')).toBe('esquerda')
|
||||
})
|
||||
|
||||
it('classifies right-wing parties from the 2022 survey', () => {
|
||||
expect(partySpectrum('PL')).toBe('direita')
|
||||
expect(partySpectrum('PSDB')).toBe('direita')
|
||||
expect(partySpectrum('MDB')).toBe('direita')
|
||||
})
|
||||
|
||||
it('normalizes TSE aliases before lookup', () => {
|
||||
expect(normalizePartySigla('UNIAO')).toBe('UNIÃO')
|
||||
expect(partySpectrum('UNIAO')).toBe('direita')
|
||||
expect(partySpectrum('PC DO B')).toBe('esquerda')
|
||||
})
|
||||
|
||||
it('returns null for unknown siglas', () => {
|
||||
expect(partySpectrum('PARTIDO NOVO DESCONHECIDO')).toBeNull()
|
||||
})
|
||||
|
||||
it('covers parties present in the BA 2022 federal fixture', () => {
|
||||
for (const party of BA_2022_FEDERAL_PARTIES) {
|
||||
expect(partySpectrum(party)).not.toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeTicketLeverage', () => {
|
||||
it('reports headline percent against the stronger majoritarian base', () => {
|
||||
const result = computeTicketLeverage({
|
||||
confirmedVoteEstimate: 500,
|
||||
presidentVotes: 1480,
|
||||
governorVotes: 1310,
|
||||
})
|
||||
expect(result.status).toBe('comparable')
|
||||
expect(result.headlinePercent).toBe(34)
|
||||
expect(result.message).toBe('Alavancagem da chapa: 34%')
|
||||
expect(result.supportLine).toContain('Da base Lula/Jerônimo ainda não convertida')
|
||||
})
|
||||
|
||||
it('reports noEstimate without a confirmed estimate', () => {
|
||||
expect(
|
||||
computeTicketLeverage({
|
||||
confirmedVoteEstimate: null,
|
||||
presidentVotes: 1000,
|
||||
governorVotes: 900,
|
||||
}).status,
|
||||
).toBe('noEstimate')
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeTicketFlipOpportunity', () => {
|
||||
const presidentLeft = { name: 'LULA', party: 'PT', votes: 8000 }
|
||||
const governorLeft = { name: 'JERÔNIMO', party: 'PT', votes: 6000 }
|
||||
|
||||
it('fires on a right-wing federal winner when majoritarians align left', () => {
|
||||
const result = computeTicketFlipOpportunity({
|
||||
winnerPresident: presidentLeft,
|
||||
winnerGovernor: governorLeft,
|
||||
winnerFederal: { name: 'RIVAL', party: 'PL', votes: 2500 },
|
||||
federalVotesByParty: { PT: 2100, PL: 2500 },
|
||||
})
|
||||
expect(result.status).toBe('opportunity')
|
||||
expect(result.trigger).toBe('both')
|
||||
expect(result.majoritarianAlignment).toBe('both')
|
||||
expect(result.message).toContain('fora do campo')
|
||||
expect(result.message).not.toMatch(/direita/i)
|
||||
})
|
||||
|
||||
it('fires on high right share even when the federal winner is left', () => {
|
||||
const result = computeTicketFlipOpportunity({
|
||||
winnerPresident: presidentLeft,
|
||||
winnerGovernor: null,
|
||||
winnerFederal: { name: 'SOLLA', party: 'PT', votes: 2100 },
|
||||
federalVotesByParty: { PT: 2100, PL: 900, PSDB: 500 },
|
||||
})
|
||||
expect(result.status).toBe('opportunity')
|
||||
expect(result.trigger).toBe('share')
|
||||
expect(result.majoritarianAlignment).toBe('president')
|
||||
expect(result.rightShare).toBeGreaterThanOrEqual(RIGHT_SHARE_THRESHOLD)
|
||||
})
|
||||
|
||||
it('does not fire when right share is below the threshold and winner is left', () => {
|
||||
const result = computeTicketFlipOpportunity({
|
||||
winnerPresident: presidentLeft,
|
||||
winnerGovernor: governorLeft,
|
||||
winnerFederal: { name: 'SOLLA', party: 'PT', votes: 2100 },
|
||||
federalVotesByParty: { PT: 2100, PL: 100 },
|
||||
})
|
||||
expect(result.status).toBe('noOpportunity')
|
||||
})
|
||||
|
||||
it('does not fire without left majoritarian alignment', () => {
|
||||
const result = computeTicketFlipOpportunity({
|
||||
winnerPresident: { name: 'BOLSONARO', party: 'PL', votes: 5000 },
|
||||
winnerGovernor: { name: 'RIVAL', party: 'PSDB', votes: 4500 },
|
||||
winnerFederal: { name: 'RIVAL', party: 'PL', votes: 1900 },
|
||||
federalVotesByParty: { PL: 1900, PT: 500 },
|
||||
})
|
||||
expect(result.status).toBe('incomplete')
|
||||
})
|
||||
|
||||
it('fails closed on unknown party spectrum', () => {
|
||||
const result = computeTicketFlipOpportunity({
|
||||
winnerPresident: presidentLeft,
|
||||
winnerGovernor: governorLeft,
|
||||
winnerFederal: { name: 'SOLLA', party: 'PT', votes: 2100 },
|
||||
federalVotesByParty: { PT: 2100, 'SIGLA NOVA': 900 },
|
||||
})
|
||||
expect(result.status).toBe('unknownSpectrum')
|
||||
})
|
||||
})
|
||||
|
||||
describe('overview aggregates', () => {
|
||||
it('computes weighted leverage and unconverted nuclei', () => {
|
||||
expect(
|
||||
aggregateTicketLeverageOverview([
|
||||
{ estimate: 500, ticketVotes: 1000 },
|
||||
{ estimate: 800, ticketVotes: 1000 },
|
||||
]),
|
||||
).toEqual({ weightedPercent: 65, unconvertedCount: 2 })
|
||||
})
|
||||
|
||||
it('counts flip opportunities and both-aligned subset', () => {
|
||||
expect(
|
||||
aggregateTicketFlipOverview([
|
||||
{
|
||||
status: 'opportunity',
|
||||
trigger: 'winner',
|
||||
majoritarianAlignment: 'both',
|
||||
rightShare: 0.3,
|
||||
rightVotes: 300,
|
||||
totalFederalVotes: 1000,
|
||||
message: 'x',
|
||||
supportLine: null,
|
||||
},
|
||||
{
|
||||
status: 'opportunity',
|
||||
trigger: 'share',
|
||||
majoritarianAlignment: 'president',
|
||||
rightShare: 0.3,
|
||||
rightVotes: 300,
|
||||
totalFederalVotes: 1000,
|
||||
message: 'y',
|
||||
supportLine: null,
|
||||
},
|
||||
{ status: 'noOpportunity', trigger: null, majoritarianAlignment: null, rightShare: null, rightVotes: 0, totalFederalVotes: 0, message: 'z', supportLine: null },
|
||||
]),
|
||||
).toEqual({ count: 2, bothAlignedCount: 1 })
|
||||
})
|
||||
})
|
||||
|
|
@ -182,6 +182,37 @@ describe('aggregateNucleusElectoralBaseline', () => {
|
|||
},
|
||||
]
|
||||
|
||||
const majoritarianTallies: ElectionTallyAggregateRow[] = [
|
||||
{
|
||||
office: 'presidente',
|
||||
turn: '2',
|
||||
cityName: 'Salvador',
|
||||
zoneNumber: 1,
|
||||
aptos: 0,
|
||||
votosValidos: 0,
|
||||
votosBranco: 0,
|
||||
votosNulo: 0,
|
||||
abstencoes: 0,
|
||||
winnerCandidateName: 'LULA',
|
||||
winnerParty: 'PT',
|
||||
winnerVotes: 8000,
|
||||
},
|
||||
{
|
||||
office: 'governador',
|
||||
turn: '2',
|
||||
cityName: 'Salvador',
|
||||
zoneNumber: 1,
|
||||
aptos: 0,
|
||||
votosValidos: 0,
|
||||
votosBranco: 0,
|
||||
votosNulo: 0,
|
||||
abstencoes: 0,
|
||||
winnerCandidateName: 'JERÔNIMO',
|
||||
winnerParty: 'PT',
|
||||
winnerVotes: 6000,
|
||||
},
|
||||
]
|
||||
|
||||
it('sums the candidate, ticket leaders, electorate, winner, rank, and series for the geography', () => {
|
||||
const federalTotals = aggregateFederalCandidateTotals(votes, geography)
|
||||
const baseline = aggregateNucleusElectoralBaseline(
|
||||
|
|
@ -190,9 +221,10 @@ describe('aggregateNucleusElectoralBaseline', () => {
|
|||
ticketOfficeVotes(votes),
|
||||
tallies,
|
||||
{
|
||||
y2014: 1000,
|
||||
y2018: 1500,
|
||||
y2014: 1000,
|
||||
y2018: 1500,
|
||||
},
|
||||
majoritarianTallies,
|
||||
)
|
||||
|
||||
expect(baseline.candidate).toEqual({ votes: 2100, rank: 1 })
|
||||
|
|
@ -211,6 +243,18 @@ describe('aggregateNucleusElectoralBaseline', () => {
|
|||
votes: 2100,
|
||||
party: 'PT',
|
||||
})
|
||||
expect(baseline.winnerPresident).toEqual({
|
||||
name: 'LULA',
|
||||
party: 'PT',
|
||||
votes: 8000,
|
||||
})
|
||||
expect(baseline.winnerGovernor).toEqual({
|
||||
name: 'JERÔNIMO',
|
||||
party: 'PT',
|
||||
votes: 6000,
|
||||
})
|
||||
expect(baseline.federalVotesByParty).toEqual({ PT: 2100, PL: 1900 })
|
||||
expect(baseline.ticketFlip.status).toBe('opportunity')
|
||||
expect(baseline.series).toEqual({ y2014: 1000, y2018: 1500, y2022: 2100 })
|
||||
expect(computeVoteTrend(baseline.series).status).toBe('increase')
|
||||
})
|
||||
|
|
@ -227,9 +271,10 @@ describe('aggregateNucleusElectoralBaseline', () => {
|
|||
ticketOfficeVotes(votes),
|
||||
tallies,
|
||||
{
|
||||
y2014: 0,
|
||||
y2018: 0,
|
||||
y2014: 0,
|
||||
y2018: 0,
|
||||
},
|
||||
majoritarianTallies,
|
||||
)
|
||||
|
||||
expect(baseline.candidate).toEqual({ votes: 900, rank: 2 })
|
||||
|
|
@ -247,9 +292,10 @@ describe('aggregateNucleusElectoralBaseline', () => {
|
|||
ticketOfficeVotes(votes),
|
||||
tallies,
|
||||
{
|
||||
y2014: 0,
|
||||
y2018: 0,
|
||||
y2014: 0,
|
||||
y2018: 0,
|
||||
},
|
||||
majoritarianTallies,
|
||||
)
|
||||
|
||||
expect(
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'
|
|||
|
||||
import { NucleusElectoralBaseline } from '@/components/campaign/NucleusElectoralBaseline'
|
||||
import { NucleusInsights } from '@/components/campaign/NucleusInsights'
|
||||
import { computeTicketFlipOpportunity } from '@/lib/electionInsights'
|
||||
import type { NucleusElectoralBaselineViewModel } from '@/utilities/nucleusViewModels'
|
||||
|
||||
const baseline: NucleusElectoralBaselineViewModel = {
|
||||
|
|
@ -18,6 +19,15 @@ const baseline: NucleusElectoralBaselineViewModel = {
|
|||
abstencoes: 680,
|
||||
},
|
||||
winnerFederal: { name: 'Dep. Fulano de Tal', votes: 2100, party: 'PP' },
|
||||
winnerPresident: { name: 'LULA', party: 'PT', votes: 8000 },
|
||||
winnerGovernor: { name: 'JERÔNIMO', party: 'PT', votes: 6000 },
|
||||
federalVotesByParty: { PT: 850, PP: 2100 },
|
||||
ticketFlip: computeTicketFlipOpportunity({
|
||||
winnerPresident: { name: 'LULA', party: 'PT', votes: 8000 },
|
||||
winnerGovernor: { name: 'JERÔNIMO', party: 'PT', votes: 6000 },
|
||||
winnerFederal: { name: 'Dep. Fulano de Tal', votes: 2100, party: 'PP' },
|
||||
federalVotesByParty: { PT: 850, PP: 2100 },
|
||||
}),
|
||||
series: { y2014: 700, y2018: 800, y2022: 850 },
|
||||
}
|
||||
|
||||
|
|
@ -77,4 +87,18 @@ describe('NucleusInsights', () => {
|
|||
expect(html).toContain('data-insight="vote-trend"')
|
||||
expect(html).toContain('Tendência:')
|
||||
})
|
||||
|
||||
it('renders ticket leverage and flip opportunity insights', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(NucleusInsights, {
|
||||
baseline,
|
||||
confirmedVoteEstimate: 500,
|
||||
}),
|
||||
)
|
||||
expect(html).toContain('data-insight="ticket-leverage"')
|
||||
expect(html).toContain('Alavancagem da chapa:')
|
||||
expect(html).toContain('data-insight="ticket-flip"')
|
||||
expect(html).toContain('completar a chapa')
|
||||
expect(html).not.toMatch(/direita/i)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue