diff --git a/docs/plans/escala-dry-pos-a4.md b/docs/plans/escala-dry-pos-a4.md index af8c4ae6..deaaf4ba 100644 --- a/docs/plans/escala-dry-pos-a4.md +++ b/docs/plans/escala-dry-pos-a4.md @@ -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
(GROUP BY / payload estreito)"] + F1["Fase 1 — Agregar federal no detalhe
(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
+ gapVs2022 helpers"] F1 --> F4["Fase 4 — Fetch 2022 único
lista overview + coroplético"] - F1 -.mesmo loader.-> A5["A5 Insights"] + F1 --> F5["Fase 5 — Batch federal lista
+ 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 diff --git a/docs/plans/escala-dry-pos-e1.md b/docs/plans/escala-dry-pos-e1.md index bdbaa2cb..29d5151e 100644 --- a/docs/plans/escala-dry-pos-e1.md +++ b/docs/plans/escala-dry-pos-e1.md @@ -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. diff --git a/docs/plans/escala-dry-pos-e2.md b/docs/plans/escala-dry-pos-e2.md index 87be6a46..c36bdb17 100644 --- a/docs/plans/escala-dry-pos-e2.md +++ b/docs/plans/escala-dry-pos-e2.md @@ -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). diff --git a/docs/plans/insight-alavancagem-chapa.md b/docs/plans/insight-alavancagem-chapa.md index d8c2bdfc..abf5df37 100644 --- a/docs/plans/insight-alavancagem-chapa.md +++ b/docs/plans/insight-alavancagem-chapa.md @@ -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 diff --git a/docs/roadmap.md b/docs/roadmap.md index 7ff97aa5..bbd8e7a6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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) diff --git a/src/components/campaign/NucleusInsights.tsx b/src/components/campaign/NucleusInsights.tsx index 04a49529..7c804f0a 100644 --- a/src/components/campaign/NucleusInsights.tsx +++ b/src/components/campaign/NucleusInsights.tsx @@ -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 (
@@ -95,6 +114,33 @@ export const NucleusInsights = ({ ) : null} + {showLeverage ? ( + = 100 && confirmedInsightAlertClass, + )} + > + + ) : null} + + {showFlip ? ( + + + ) : null} + {baseline && trend && trend.status !== 'noBaseline' ? (

) : null} + {view.leverage ? ( +

+ Alavancagem da chapa:{' '} + + {view.leverage.weightedPercent}% + {' '} + da base {formatMajoritarianTicketBaseLabel()} + {view.leverage.unconvertedCount > 0 ? ( + <> + {' · '} + + {formatElectionNumber(view.leverage.unconvertedCount)} com base não convertida + + + ) : null} +

+ ) : null} + {view.flipOpportunity && view.flipOpportunity.count > 0 ? ( +

+ Oportunidade de completar a chapa:{' '} + + {formatElectionNumber(view.flipOpportunity.count)}{' '} + {view.flipOpportunity.count === 1 ? 'núcleo' : 'núcleos'} + + {view.flipOpportunity.bothAlignedCount > 0 ? ( + <> + {' · '} + + {formatElectionNumber(view.flipOpportunity.bothAlignedCount)} com majoritários + alinhados + + + ) : null} +

+ ) : null} diff --git a/src/lib/electionInsights.ts b/src/lib/electionInsights.ts index 8fc70e42..204853a3 100644 --- a/src/lib/electionInsights.ts +++ b/src/lib/electionInsights.ts @@ -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> +} + +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 = { + both: 'Majoritários alinhados. ', + president: 'Presidente alinhado. ', + governor: 'Governador alinhado. ', +} + +const ticketLeverageUnavailable = ( + status: Exclude, + 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>, +): { 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' diff --git a/src/lib/electionPartySpectrum.ts b/src/lib/electionPartySpectrum.ts new file mode 100644 index 00000000..beb367ea --- /dev/null +++ b/src/lib/electionPartySpectrum.ts @@ -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 = { + 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> = { + 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 diff --git a/src/utilities/nucleusElectoralBaseline.ts b/src/utilities/nucleusElectoralBaseline.ts index bdfe714a..052777af 100644 --- a/src/utilities/nucleusElectoralBaseline.ts +++ b/src/utilities/nucleusElectoralBaseline.ts @@ -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 => { + const byParty: Record = {} + 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() + 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, + 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, + user: ElectionDataReader, + geography: NucleusElectionGeography, +): Promise => { + 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, 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: Pick, user: ElectionDataReader, nuclei: readonly NucleusBaseline2022OverviewInput[], ): Promise => { @@ -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() 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, } } diff --git a/src/utilities/nucleusListOverviewPageData.ts b/src/utilities/nucleusListOverviewPageData.ts index b0856621..4a5054e5 100644 --- a/src/utilities/nucleusListOverviewPageData.ts +++ b/src/utilities/nucleusListOverviewPageData.ts @@ -71,7 +71,7 @@ type RawOverviewUpdate = { } export const loadNucleusListOverviewData = async ( - payload: Pick, + payload: Pick, 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, }) } diff --git a/src/utilities/nucleusListOverviewViewModels.ts b/src/utilities/nucleusListOverviewViewModels.ts index d5601cc2..af7cc790 100644 --- a/src/utilities/nucleusListOverviewViewModels.ts +++ b/src/utilities/nucleusListOverviewViewModels.ts @@ -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, diff --git a/src/utilities/nucleusViewModels.ts b/src/utilities/nucleusViewModels.ts index be8a2762..6b5bb896 100644 --- a/src/utilities/nucleusViewModels.ts +++ b/src/utilities/nucleusViewModels.ts @@ -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 + ticketFlip: TicketFlipOpportunityResult series: VoteTrendSeries } diff --git a/tests/unit/electionInsights.unit.spec.ts b/tests/unit/electionInsights.unit.spec.ts index 9f709209..f87ab0c2 100644 --- a/tests/unit/electionInsights.unit.spec.ts +++ b/tests/unit/electionInsights.unit.spec.ts @@ -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', () => { diff --git a/tests/unit/electionPartySpectrum.unit.spec.ts b/tests/unit/electionPartySpectrum.unit.spec.ts new file mode 100644 index 00000000..b955dac2 --- /dev/null +++ b/tests/unit/electionPartySpectrum.unit.spec.ts @@ -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 }) + }) +}) diff --git a/tests/unit/nucleusElectoralBaseline.unit.spec.ts b/tests/unit/nucleusElectoralBaseline.unit.spec.ts index b2482009..286f75bf 100644 --- a/tests/unit/nucleusElectoralBaseline.unit.spec.ts +++ b/tests/unit/nucleusElectoralBaseline.unit.spec.ts @@ -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( diff --git a/tests/unit/nucleusElectoralBaselineUi.unit.spec.ts b/tests/unit/nucleusElectoralBaselineUi.unit.spec.ts index 08e69238..09adab71 100644 --- a/tests/unit/nucleusElectoralBaselineUi.unit.spec.ts +++ b/tests/unit/nucleusElectoralBaselineUi.unit.spec.ts @@ -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) + }) })