Deliver A5 slice 1 conversion-rate insight and absorb review debts.

Surface taxa de conversão on nucleus detail and list overview, with loader
optimizations and unit tests; update plan/roadmap follow-ups for E7, A7, and RS+.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-19 14:05:11 -03:00
parent 1b6de775a0
commit 429b31048f
14 changed files with 444 additions and 71 deletions

View file

@ -0,0 +1,17 @@
# Critique — A5 conversão (NucleusInsights + overview)
**Target:** `src/components/campaign/NucleusInsights.tsx`, `src/components/campaign/NucleusListOverview.tsx`
**Score:** 34/40 (compact craft pass)
**Detector:** clean
## Strengths
- Reuses Alert stack and tokens `estimate-confirmed` / `pending` consistent with Gap and Tendência.
- One-line veredito + support numbers match design-ref Baseline-Eleitoral-2022.
- Overview aggregation mirrors existing gap/trend lines.
## Issues addressed in polish
- P1: Duplicated confirmed-alert class strings → extracted `confirmedInsightAlertClass`.
## Minor (deferred)
- Band label (reduto/consolidado/oportunidade) not shown in detail Alert title — product may want chip later.
- Overview distribution counts may be zero-heavy on small filters — acceptable for v1.

View file

@ -1,7 +1,7 @@
# Escala e DRY pós-A4 (baseline TSE no produto)
Status: registrado no roadmap (fases pendentes)
Atualizado em: 2026-07-19
Atualizado em: 2026-07-19 (nota A5-1 em Fase 3 / `capture-review-debts`)
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha A, item A7)
Responsável: —
@ -13,7 +13,7 @@ Os revisores (performance / reuse / quality) marcaram como **importantes e maior
1. **Detalhe puxa todas as linhas nominais de dep. federal (1º turno) na geografia.** `detailVoteWhere` + `loadElectionVotes` em `src/utilities/nucleusElectoralBaseline.ts` buscam o ranking local inteiro para `winnerFederal` e `candidate.rank`. Em núcleo multi-município / TI inteiro isso pode ser milhares de rows por abertura da aba Visão geral (`pagination: false`).
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 `Alert` já tem o precedente `pending` espelhando o Badge; falta a variante simétrica. A barra do candidato no card também reimplementa `role="progressbar"` em vez de reusar `Progress` (`NucleusListOverview`).
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`).
**Explicitamente fora (revisores pediram skip no simplify):** 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), e o cast `as unknown as RawOverviewNucleus` da B1 (não é débito novo do A4).
@ -70,7 +70,7 @@ flowchart TD
### Fase 3 — Alert `confirmed` + `Progress` no card
- `src/components/ui/Alert.tsx`: adicionar variant `confirmed` espelhando `pending` com tokens `--estimate-confirmed` / foreground.
- `NucleusInsights`: trocar o `cn(...)` ad-hoc por `variant={gap.status === 'above' ? '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) por `variant={… ? 'confirmed' : 'pending'}` (ou `default` só se neutro — manter pending para noBaseline/noEstimate).
- `NucleusElectoralBaseline`: substituir a barra custom por `Progress` (já usado no overview), preservando `aria-label`.
**Migration:** nenhuma nas Fases 1 e 3. 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).
@ -96,7 +96,8 @@ flowchart TD
- `docs/plans/baseline-eleitoral-tse.md` — plano pai do A3/A4
- `docs/plans/escala-dry-pos-b2.md` / `escala-dry-pos-c3.md` / `escala-dry-pos-c6.md` — precedente pós-`/simplify`
- `src/utilities/nucleusElectoralBaseline.ts` — `detailVoteWhere`, `loadElectionVotes`, `aggregateFederalCandidateTotals`
- `src/lib/electionInsights.ts` / `src/components/campaign/NucleusInsights.tsx` — Gap UI
- `docs/plans/insight-taxa-conversao.md` — A5-1 usa `confirmedInsightAlertClass` até F3
- `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
- `src/components/ui/Progress.tsx` — reuso no card

View file

@ -1,29 +1,31 @@
# Escala e DRY pós-E2 (série TSE 2014/2018 + tendência)
Status: registrado no roadmap (fases pendentes)
Atualizado em: 2026-07-19
Atualizado em: 2026-07-19 (Fase 2 estendida pós-A5-1 / `capture-review-debts`)
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha E, item E7)
Responsável: —
## Contexto
O E2 ([mapa-projecao-municipios.md](mapa-projecao-municipios.md)) entrega seed `pnpm db:seed:tse -- --year=2014|2018|2022` (federal T1 BA nos anos históricos), `computeVoteTrend` ±10% (`increase|stable|decline|noBaseline`), série no card `NucleusElectoralBaseline`, alerta em `NucleusInsights` e card "Tendência histórica" no overview B1 — loader unificado `loadNucleusListElectionOverview` (gap + tendência numa query union) em `nucleusElectoralBaseline.ts`.
O E2 ([mapa-projecao-municipios.md](mapa-projecao-municipios.md)) entrega seed `pnpm db:seed:tse -- --year=2014|2018|2022` (federal T1 BA nos anos históricos), `computeVoteTrend` ±10% (`increase|stable|decline|noBaseline`), série no card `NucleusElectoralBaseline`, alerta em `NucleusInsights` e card "Tendência histórica" no overview B1 — loader unificado `loadNucleusListElectionOverview` (gap + tendência numa query union) em `nucleusElectoralBaseline.ts`. O slice **A5-1 taxa de conversão** ([insight-taxa-conversao.md](insight-taxa-conversao.md), 2026-07-19) estende o mesmo loader com agregado `conversion` (`weightedRate` + `distribution` por faixa) e fetch condicional de `electionTally` — ver Fase 2.
Duas passagens `/simplify` no mesmo branch já limparam o que cabia em cleanup: `HISTORICAL_PRIOR_SERIES_YEARS`, `HISTORICAL_SERIES_YEARS`, `buildUnionGeography`, `aggregateVoteTrend` / `comparableTrendCount`, `formatVoteTrendSeries` / `formatVoteTrendSeriesCompact`, `voteTrendAlertVariant`, merge de labels, `FEDERAL_DEPUTY_OFFICE` no build, remoção de `seriesFromYearTotals` e alias `NucleusListOverviewBaseline2022`.
Os revisores (performance / reuse / quality) marcaram como **importantes e maiores que simplify** os follow-ups abaixo. Débitos que **já têm dono em outro item** ficam fora deste plano (ver Não escopo).
1. **Tendência calculada e exibida duas vezes na aba Visão geral do núcleo.** `NucleusActiveTab.tsx` renderiza `NucleusElectoralBaseline` e `NucleusInsights` em sequência; cada um chama `computeVoteTrend(baseline.series)` e ambos mostram a série 2014→2018→2022 (badge + mensagem no card; alerta + mensagem + série no insights). Duplicação de trabalho no cliente e de informação para o usuário.
2. **Sem testes de integração do loader combinado.** `loadNucleusListElectionOverview` substitui loaders separados de gap e tendência; só há cobertura unitária de `computeVoteTrend` / `aggregateVoteTrend` e unit do overview VM — não há int que exercite a união geográfica, o caso "só tendência sem estimativas comparáveis" e o agregado gap+trend num único fetch.
2. **Sem testes de integração do loader combinado.** `loadNucleusListElectionOverview` substitui loaders separados de gap e tendência; só há cobertura unitária de `computeVoteTrend` / `aggregateVoteTrend`, `computeConversionRate` / `aggregateConversionBand` e unit pontual de electorate→conversão — não há int que exercite a união geográfica, o caso "só tendência sem estimativas comparáveis", o agregado gap+trend **e** conversão ponderada (`Σestimate / Σaptos` + distribuição por faixa) num único fetch.
3. **Tipos de distribuição duplicados.** `NucleusTrendOverviewAggregate` em `nucleusElectoralBaseline.ts` é `Record<VoteTrendStatus, number>` enquanto `electionInsights.ts` já expõe `VoteTrendDistribution` + `emptyVoteTrendDistribution` — mesmo shape, dois nomes.
4. **`VoteTrendResult.ratio` exposto na API pública** mas usado sobretudo em asserts de teste; o produto só consome `status` + `message` na UI.
**Explicitamente fora (revisores pediram skip no simplify 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).
**Já resolvido no simplify A5-1 / capture-review-debts (não reabrir):** tallies só quando há núcleos comparáveis; union de tallies restrita a `comparableIndexes`; `sumElectorateForGeography` indexado por `cityZoneKey` (mesmo padrão de `votes2022`); conversão desacoplada do gate `candidateVotes > 0`; gap no overview via `computeGapVs2022`; `isComparableConversionBand` / `formatElectionNumber` DRY no overview.
**Explicitamente fora (revisores pediram skip no simplify 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.
## 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 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.
- Tipos de distribuição de tendência têm uma única fonte (`VoteTrendDistribution`); loaders e VMs importam de `electionInsights.ts`.
- Guardrails: sem migration, sem Consent (dado público TSE). Access continua `overrideAccess: false`. Identificadores em inglês; strings visíveis em pt-BR.
@ -64,7 +66,7 @@ flowchart TD
### Fase 2 — Testes int do loader combinado
- Novo ou estendido `tests/int/nucleusListElectionOverview.int.spec.ts` (ou seção em spec existente de election baseline).
- Cenários mínimos: (a) dois núcleos com geografia + estimativas → `gapTotal` e contagem `above`/`below`; (b) núcleos com geografia mas sem estimativa confirmada → `gapTotal: null` e `trend` populado; (c) núcleo sem território resolvível → `baseline2022: null`, `trend: null`.
- Cenários mínimos: (a) dois núcleos com geografia + estimativas → `gapTotal` e contagem `above`/`below`; (b) núcleos com geografia mas sem estimativa confirmada → `gapTotal: null`, `trend` populado e `conversion: null`; (c) núcleo sem território resolvível → `baseline2022: null`, `trend: null`, `conversion: null`; (d) dois núcleos comparáveis com `electionTally` na union → `conversion.weightedRate` = `Σestimate / Σaptos` e `conversion.distribution` coerente com `computeConversionRate` por núcleo (inclui território novo com aptos + estimativa e sem votos 2022 do candidato).
- Usar `getPayload` + DB de teste (`teqo_test`); seed fixture TSE via helper, não Neon.
### Fase 3 — Tipos e API
@ -97,7 +99,8 @@ flowchart TD
- `docs/plans/escala-dry-pos-a4.md` / `escala-dry-pos-e1.md` — débitos adjacentes (A7 / E6)
- `src/components/campaign/NucleusActiveTab.tsx` — render duplo baseline + insights
- `src/components/campaign/NucleusElectoralBaseline.tsx` / `NucleusInsights.tsx`
- `src/lib/electionInsights.ts` — `computeVoteTrend`, `VoteTrendDistribution`, `aggregateVoteTrend`
- `docs/plans/insight-taxa-conversao.md` — slice A5-1 (conversão no loader/overview)
- `src/lib/electionInsights.ts` — `computeVoteTrend`, `computeConversionRate`, `aggregateConversionBand`, `VoteTrendDistribution`, `aggregateVoteTrend`
- `src/utilities/nucleusElectoralBaseline.ts` — `loadNucleusListElectionOverview`, `getNucleusElectoralBaseline`
- `src/utilities/nucleusListOverviewPageData.ts`
- `tests/unit/electionInsights.unit.spec.ts` / `tests/unit/nucleusElectoralBaselineUi.unit.spec.ts`

View file

@ -1,7 +1,7 @@
# Escala e DRY pós-reset de senha + foto de perfil
Status: registrado no roadmap (fases pendentes)
Atualizado em: 2026-07-19
Atualizado em: 2026-07-19 (Fase 3 parcial entregue em `main` @ `246ca85`; absorção `capture-review-debts`)
Item do roadmap: [docs/roadmap.md](../roadmap.md) (RS+, fill-in de engenharia pós-MVP)
Responsável: —
@ -11,11 +11,13 @@ O fill-in **Reset de senha self-service + foto de perfil** ([reset-senha-foto-pe
**Já resolvido no simplify (não reabrir):** `CampaignFormActionState` compartilhado; constantes de copy (`CAMPAIGN_PASSWORD_RESET_*`, `CAMPAIGN_LEADERSHIP_FORGOT_PASSWORD_MESSAGE`, `CAMPAIGN_SESSION_EXPIRED_MESSAGE`); `campaignUserShellView` / `CampaignUserShellView`; `canUpdateCampaignUserAvatar` delegando para `canUpdateCampaignUser`; `fieldError` nos forms; `FormActionStatus` no perfil; remoção de wrappers `passwordFormError`/`profileFormError`; exports internos de `resetCampaignPassword`/`changeCampaignPassword`; teste int renomeado para login com senha errada.
**Já resolvido na Fase 3 parcial (merge `main` 2026-07-19, `246ca85` — não reabrir):** `CampaignAuthPageShell` em `login` / `esqueci-senha` / `redefinir-senha` (incl. card de token inválido); `campaignAuthHeadingClassName` + copy compartilhada (`CAMPAIGN_LEADERSHIP_LOGIN_RECOVERY_HINT`, `CAMPAIGN_LEADERSHIP_PHONE_ACCESS_HINT`); login com `inputMode` dinâmico tel/email, `aria-invalid`/`aria-describedby` nos dois campos, copy de primeiro acesso, `w-full` no submit — fecha P1–P3 do critique Impeccable `/campanha/login` da sessão.
## Objetivos
- Auth hot path sem join de `media` em rotas que não exibem avatar (núcleos, apoiadores, planos, etc.).
- Upload de avatar sem segurar transação Postgres aberta durante I/O de blob/sharp.
- Shell compartilhado das páginas públicas de auth (`login`, `esqueci-senha`, `redefinir-senha`).
- Shell compartilhado das páginas públicas de auth (`login`, `esqueci-senha`, `redefinir-senha`) — **parcial ✓** (`CampaignAuthPageShell` + headings/copy; ver Fase 3).
- Um único módulo de validação/campos de senha reutilizado por convite, reset e perfil.
- Login alinhado ao shape `CampaignFormActionState` + `mapCampaignFormActionError` das demais actions de campanha.
- Guardrails: sem novo `Consent`; sem SMS/WhatsApp para token; comportamento de produto 1C inalterado salvo bugs encontrados.
@ -42,7 +44,7 @@ flowchart TD
RS["Reset senha + perfil MVP ✓"] --> F1
F1["Fase 1 — Auth read perf<br/>depth 0 + getCampaignUserWithAvatar<br/>sessão leve em rotas públicas"]
F1 --> F2["Fase 2 — Avatar write perf<br/>blob fora da TX + revalidate estreito"]
F2 --> F3["Fase 3 — DRY auth UI/senha<br/>CampaignAuthPageShell + password fields + login state"]
F2 --> F3["Fase 3 — DRY auth UI/senha<br/>shell ✓ parcial · password fields + login state"]
F3 --> F4["Fase 4 — DRY copy/role + actions<br/>campaignRoleOptions + getCampaignActionContext"]
F4 --> F5["Fase 5 — Opcional<br/>avatar intent único + int change password"]
```
@ -62,7 +64,10 @@ flowchart TD
### Fase 3 — DRY UI e senha
- Extrair **`CampaignAuthPageShell`** (`src/components/campaign/CampaignAuthPageShell.tsx`): markup compartilhado de `login/page.tsx`, `esqueci-senha/page.tsx`, `redefinir-senha/page.tsx` (incluindo card de token inválido).
**Entregue parcialmente (2026-07-19):** `CampaignAuthPageShell` + headings/copy unificados nas três rotas públicas e no card de token inválido; polish de login do critique Impeccable (ver Contexto).
**Pendente nesta fase:**
- Extrair **`CampaignPasswordFields`** (ou `CampaignNewPasswordFieldGroup`): campos `password` + `passwordConfirmation` com `errorProps` — reuso em `ResetPasswordForm`, bloco de senha do perfil e `CampaignInviteForm` (`kind === 'login'`).
- **`src/lib/schemas/campaignPassword.ts` + `invite.ts`:** factory compartilhada `refineMatchingPasswords` / `passwordSchema` (substituir check manual no convite).
- **`LoginForm.tsx` + `actions/auth.ts`:** migrar para `CampaignFormActionState` + `mapCampaignFormActionError`.
@ -99,6 +104,7 @@ flowchart TD
- [escala-dry-pos-visitados-recentemente.md](escala-dry-pos-visitados-recentemente.md) — precedente RS+/VR+
- `src/utilities/campaignAuth.ts`, `src/app/(campaign)/campanha/actions/password.ts`, `profile.ts`
- `src/components/campaign/CampaignProfileSettings.tsx`, `CampaignSidebar.tsx`, `CampaignUserAvatar.tsx`
- `src/components/campaign/CampaignAuthPageShell.tsx` — shell entregue; ver Fase 3 pendente
- `src/app/(campaign)/campanha/login/`, `esqueci-senha/`, `redefinir-senha/`
- `src/lib/schemas/campaignPassword.ts`, `src/lib/schemas/invite.ts`
- `src/collections/CampaignUser.ts`

View file

@ -1,10 +1,12 @@
# Insight: taxa de conversão por núcleo
Status: rascunho
Atualizado em: 2026-07-17
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Campanha → Próximos ciclos")
Status: entregue (slice A5-1, 2026-07-19)
Atualizado em: 2026-07-19
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Janela 3, A5 — primeiro dos cinco insights)
Responsável: —
**Revisão 2026-07-19:** implementado como Alert no stack `NucleusInsights.tsx` (sem `NucleusConversionRate.tsx` separado); overview via `loadNucleusListElectionOverview` + linha em `NucleusListOverview`. Limiares 15%/40% versionados em `electionInsights.ts` _(assumido — validar com produto)_.
## Referência visual (UX Pilot)
Design: [`Baseline-Eleitoral-2022.png`](../design-refs/latest/Baseline-Eleitoral-2022.png) — card "Insights do território", primeira linha: "Taxa de conversão: 18% do eleitorado apto · 850 votos / 4.700 eleitores aptos" (ícone + veredito de uma linha + números de apoio). Implementar como um card do stack `NucleusInsights.tsx` (arquitetura definida em [baseline-eleitoral-tse.md](baseline-eleitoral-tse.md)), com os tokens claros do tema `campaign` em vez da paleta antiga do HTML/PNG.
@ -23,48 +25,47 @@ Com o baseline TSE 2022 importado (ver [baseline-eleitoral-tse.md](baseline-elei
- **Leitura derivada** — sem escrita, sem `Consent`, sem migration. Reusa as collections e helpers do plano baseline.
- **Mesma agregação geográfica** do baseline (cidades∩zonas, via `getNucleusElectoralBaseline`).
- **Limiares versionados** em `src/lib/electionInsights.ts` como constantes (decisão de produto por limiar).
- **Limiares versionados** em `src/lib/electionInsights.ts` como constantes (`CONVERSION_OPPORTUNITY_MAX = 0.15`, `CONVERSION_REDUTO_MIN = 0.40`).
- **Principal = aptos**; linha de apoio inclui % do comparecimento (`aptos − abstencoes`) quando positivo.
- **`lideranca` vê** o insight (mesmo stack da Visão geral).
- **UI:** `Alert` em `NucleusInsights.tsx` (`data-insight="conversion-rate"`), não componente separado.
- **i18n/naming** seguem o AGENTS.md.
## Questões em aberto
- Limiares exatos das faixas (15% / 40% são referência da literatura, não decisão travada) — definir com produto.
- Mostrar conversão contra `aptos` ou contra `comparecimento` (eleitores que efetivamente votaram)? Recomendação: ambos, com `aptos` como principal.
- `lideranca` vê este insight? Recomendação: sim.
- Limiares exatos das faixas — **assumidos 15%/40%** até validação de produto.
- Chip de banda (reduto/consolidado/oportunidade) no título do Alert — adiado (critique Impeccable).
## Abordagem proposta
## Abordagem (as-built)
```mermaid
flowchart LR
Base["getNucleusElectoralBaseline<br/>aptos agregados"]
Base["getNucleusElectoralBaseline<br/>electorate.aptos"]
Est["electoralNucleus.confirmedVoteEstimate"]
Calc["computeConversionRate(aptos, estimate)<br/>rate + faixa"]
Detail["Detalhe: badge + faixa<br/>'X% do eleitorado apto'"]
Overview["Overview: média + distribuição<br/>por faixa sobre o filtro"]
Calc["computeConversionRate(...)"]
Detail["NucleusInsights Alert"]
Overview["loadNucleusListElectionOverview<br/>+ NucleusListOverview"]
Base --> Calc
Est --> Calc
Calc --> Detail
Calc --> Overview
```
- **Helper** `src/lib/electionInsights.ts`: `computeConversionRate(aptos, confirmedVoteEstimate)` → `{ rate, band: 'reduto'|'consolidado'|'oportunidade'|'semEstimativa'|'semBaseline' }`.
- **Componente** `src/components/campaign/NucleusConversionRate.tsx` (server). No overview, bloco agregado no `NucleusListOverview`.
- **Teste int** cenários: `aptos=0`, `confirmedVoteEstimate=null`, faixas limítrofes.
## Arquivos a criar/alterar
- Criar: `src/components/campaign/NucleusConversionRate.tsx`.
- Alterar: `src/lib/electionInsights.ts` (nova função), `src/app/(campaign)/campanha/(app)/nucleos/[slug]/page.tsx` + `nucleusDetailPageData.ts` (detalhe), overview da lista.
- **Helper** [`src/lib/electionInsights.ts`](../../src/lib/electionInsights.ts): `computeConversionRate`, `aggregateConversionBand`, `conversionRateAlertVariant`.
- **Detalhe** [`src/components/campaign/NucleusInsights.tsx`](../../src/components/campaign/NucleusInsights.tsx).
- **Overview** [`src/utilities/nucleusElectoralBaseline.ts`](../../src/utilities/nucleusElectoralBaseline.ts) (`NucleusConversionOverviewAggregate`) + [`NucleusListOverview.tsx`](../../src/components/campaign/NucleusListOverview.tsx).
- **Testes** unitários em `tests/unit/electionInsights.unit.spec.ts` e integração electorate+conversão em `tests/unit/nucleusElectoralBaseline.unit.spec.ts`.
## Dependências
- [baseline-eleitoral-tse.md](baseline-eleitoral-tse.md) (fornece `electionTally` + `getNucleusElectoralBaseline`) — **única dependência dura**.
- [zonas-por-municipio.md](zonas-por-municipio.md) — dependência suave herdada do baseline (melhora a qualidade de `tseZones`; a agregação funciona sem ele — ver revisão 2026-07-17 no plano baseline).
- [zonas-por-municipio.md](zonas-por-municipio.md) — dependência suave herdada do baseline.
## Não escopo
- Previsão estatística de votos (roadmap separado).
- Cruzamento com pesquisa de intenção de voto (domínio inexistente).
- Demais insights A5 (classificação, alavancagem, mobilização, competitiva).
## Referências

View file

@ -91,7 +91,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`, tipo único `VoteTrendDistribution`. [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**), tipo único `VoteTrendDistribution`. [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.
@ -125,7 +125,7 @@ Os designs gerados pelo UX Pilot para os próximos ciclos estão em [`docs/desig
| FD2 UX Field Desk pós-critique | — (critique Impeccable; reusa dashboard + badges) | [field-desk-ux-pos-critique.md](plans/field-desk-ux-pos-critique.md) |
| B5 Escala e DRY pós-B2 | — (sem design novo; reusa geometrias B2 + padrões de scripts CLI) | [escala-dry-pos-b2.md](plans/escala-dry-pos-b2.md) |
| E6 Escala e DRY pós-E1+E3 | — (sem design novo; reusa Metas 2026 + overview B1) | [escala-dry-pos-e1.md](plans/escala-dry-pos-e1.md) |
| E7 Escala e DRY pós-E2 | — (sem design novo; reusa Baseline + Insights + overview tendência) | [escala-dry-pos-e2.md](plans/escala-dry-pos-e2.md) |
| E7 Escala e DRY pós-E2 | — (sem design novo; reusa Baseline + Insights + overview tendência/conversão) | [escala-dry-pos-e2.md](plans/escala-dry-pos-e2.md) |
| D2 Notificações (sino) | `Notificacoes-PWA` (central de notificações) | [notifications.md](plans/notifications.md) |
| D1 PWA `/campanha` | `Notificacoes-PWA` (banner "Instale o app da campanha") | [pwa-campanha.md](plans/pwa-campanha.md) |
| C5 Operação dia D / GOTV | `Dia-D-GOTV` | sem plano ainda — o design é a primeira materialização do item (check-off de apoiadores por seção/zona, progresso "62% da base", filtro "Ainda não confirmados", estado "Sincronizado" offline-tolerante) |
@ -306,7 +306,7 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): **O0+** (ap
- **Visitados recentemente** ✓ (client-side, `localStorage`; MVP entregue 2026-07-19). → [detalhes](plans/visitados-recentemente.md)
- **VR+ Escala e DRY pós-visitados recentemente** (refresh bfcache/storage, DRY tempo relativo, `CampaignLinkListRow`, shell do dashboard) — engenharia pós-MVP; paralelizável com qualquer janela. → [detalhes](plans/escala-dry-pos-visitados-recentemente.md)
- **Reset de senha self-service + foto de perfil** ✓ (Resend + `/campanha/perfil`; MVP entregue 2026-07-19). → [detalhes](plans/reset-senha-foto-perfil.md)
- **RS+ Escala e DRY pós-reset senha/perfil** (auth read `depth: 0`, avatar fora da TX, `CampaignAuthPageShell`, schemas de senha unificados, login em `CampaignFormActionState`) — engenharia pós-MVP; paralelizável com qualquer janela. → [detalhes](plans/escala-dry-pos-reset-senha-perfil.md)
- **RS+ Escala e DRY pós-reset senha/perfil** (auth read `depth: 0`, avatar fora da TX, **`CampaignAuthPageShell` ✓ parcial**, schemas de senha unificados, login em `CampaignFormActionState`) — engenharia pós-MVP; paralelizável com qualquer janela. → [detalhes](plans/escala-dry-pos-reset-senha-perfil.md)
- **Field Desk polish** (Impeccable quieter→polish; critique 28→32) — shells/strips/freshness/filas-first/Mais filtros/empties; em branch local 2026-07-19. → `PRODUCT.md` / `DESIGN.md`
- **FD+ Escala e DRY pós-Field Desk** (`CampaignPageShell` size, heading class, VoteGoals→MetricStrip, datas canônicas, NucleusFilters bag) — engenharia pós-polish; paralelizável com qualquer janela. → [detalhes](plans/escala-dry-pos-field-desk.md)
- **FD2 UX Field Desk pós-critique** (bulk na fila Sem coordenador, glossário inline, outline h2 Filas, empty coordenador+CTA, Sidebar motion cortável) — produto/UX pós-critique; _(validar prioridades com produto)_. → [detalhes](plans/field-desk-ux-pos-critique.md)

View file

@ -1,6 +1,7 @@
import {
CheckCircle2Icon,
MinusIcon,
PercentIcon,
TrendingDownIcon,
TrendingUpIcon,
TriangleAlertIcon,
@ -8,15 +9,21 @@ import {
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import {
computeConversionRate,
computeGapVs2022,
computeVoteTrend,
conversionRateAlertVariant,
formatElectionNumber,
formatVoteTrendSeries,
isComparableConversionBand,
voteTrendAlertVariant,
} from '@/lib/electionInsights'
import { cn } from '@/lib/utils'
import type { NucleusElectoralBaselineViewModel } from '@/utilities/nucleusViewModels'
const confirmedInsightAlertClass =
'border-[color:var(--estimate-confirmed)] bg-[color:var(--estimate-confirmed)] text-[color:var(--estimate-confirmed-foreground)] *:data-[slot=alert-description]:text-[color:var(--estimate-confirmed-foreground)]'
export const NucleusInsights = ({
baseline,
confirmedVoteEstimate,
@ -37,11 +44,25 @@ export const NucleusInsights = ({
? MinusIcon
: TriangleAlertIcon
const conversion = computeConversionRate({
aptos: baseline?.electorate.aptos ?? null,
abstencoes: baseline?.electorate.abstencoes ?? null,
confirmedVoteEstimate,
})
const ConversionIcon =
conversion.band === 'reduto'
? CheckCircle2Icon
: conversion.band === 'oportunidade'
? TriangleAlertIcon
: PercentIcon
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)
return (
<section aria-labelledby="nucleus-insights-heading" className="flex flex-col gap-3">
<h3 id="nucleus-insights-heading" className="sr-only">
@ -50,25 +71,37 @@ export const NucleusInsights = ({
<Alert
data-insight="gap-vs-2022"
variant={gap.status === 'above' ? 'default' : 'pending'}
className={cn(
'rounded-xl px-3.5 py-3',
gap.status === 'above' &&
'border-[color:var(--estimate-confirmed)] bg-[color:var(--estimate-confirmed)] text-[color:var(--estimate-confirmed-foreground)] *:data-[slot=alert-description]:text-[color:var(--estimate-confirmed-foreground)]',
)}
className={cn('rounded-xl px-3.5 py-3', gap.status === 'above' && confirmedInsightAlertClass)}
>
<GapIcon aria-hidden="true" />
<AlertTitle className="font-bold">{gap.message}</AlertTitle>
{supportLine ? <AlertDescription>{supportLine}</AlertDescription> : null}
</Alert>
{showConversion ? (
<Alert
data-insight="conversion-rate"
variant={conversionRateAlertVariant(conversion.band)}
className={cn(
'rounded-xl px-3.5 py-3',
conversion.band === 'reduto' && confirmedInsightAlertClass,
)}
>
<ConversionIcon aria-hidden="true" />
<AlertTitle className="font-bold">{conversion.message}</AlertTitle>
{conversion.supportLine ? (
<AlertDescription>{conversion.supportLine}</AlertDescription>
) : null}
</Alert>
) : null}
{baseline && trend && trend.status !== 'noBaseline' ? (
<Alert
data-insight="vote-trend"
variant={voteTrendAlertVariant(trend.status)}
className={cn(
'rounded-xl px-3.5 py-3',
trend.status === 'increase' &&
'border-[color:var(--estimate-confirmed)] bg-[color:var(--estimate-confirmed)] text-[color:var(--estimate-confirmed-foreground)] *:data-[slot=alert-description]:text-[color:var(--estimate-confirmed-foreground)]',
trend.status === 'increase' && confirmedInsightAlertClass,
)}
>
<TrendIcon aria-hidden="true" />

View file

@ -13,8 +13,6 @@ import { formatRelativeAge } from '@/utilities/formatRelativeAge'
import type { NucleusListOverviewViewModel } from '@/utilities/nucleusListOverviewViewModels'
import { nucleusUpdateKindLabels } from '@/utilities/nucleusUpdateUi'
const numberFormatter = new Intl.NumberFormat('pt-BR')
export const NucleusListOverview = ({
view,
now,
@ -30,7 +28,7 @@ export const NucleusListOverview = ({
},
{
label: 'Meta regular 2026',
value: numberFormatter.format(view.voteGoals.regular),
value: formatElectionNumber(view.voteGoals.regular),
},
]
@ -43,7 +41,7 @@ export const NucleusListOverview = ({
<p className="text-sm text-muted-foreground">
Mostrando agregados de{' '}
<strong className="font-medium text-foreground tabular-nums">
{numberFormatter.format(view.totalFiltered)}
{formatElectionNumber(view.totalFiltered)}
</strong>{' '}
{view.totalFiltered === 1 ? 'núcleo filtrado' : 'núcleos filtrados'}
</p>
@ -54,7 +52,7 @@ export const NucleusListOverview = ({
<CardHeader>
<CardDescription>Estimativa de votos (prioridade)</CardDescription>
<CardTitle className="text-3xl font-semibold tabular-nums tracking-tight">
{numberFormatter.format(view.estimate.confirmedTotal)}
{formatElectionNumber(view.estimate.confirmedTotal)}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
@ -62,8 +60,8 @@ export const NucleusListOverview = ({
<div className="flex flex-wrap items-center justify-between gap-2 text-sm">
<span>{view.estimate.confirmedPercent}% com estimativa confirmada</span>
<span className="text-muted-foreground">
{numberFormatter.format(view.estimate.confirmedCount)} confirmadas ·{' '}
{numberFormatter.format(view.estimate.unconfirmedCount)} pendentes
{formatElectionNumber(view.estimate.confirmedCount)} confirmadas ·{' '}
{formatElectionNumber(view.estimate.unconfirmedCount)} pendentes
</span>
</div>
<Progress
@ -73,33 +71,33 @@ export const NucleusListOverview = ({
</div>
{view.estimate.pendingSuggestionsCount ? (
<Badge variant="estimate-pending" className="w-fit">
{numberFormatter.format(view.estimate.pendingSuggestionsCount)}{' '}
{formatElectionNumber(view.estimate.pendingSuggestionsCount)}{' '}
{view.estimate.pendingSuggestionsCount === 1
? 'sugestão pendente'
: 'sugestões pendentes'}
</Badge>
) : null}
<p className="text-sm text-muted-foreground">
{numberFormatter.format(view.coverage.coordinatedCount)} de{' '}
{numberFormatter.format(view.totalFiltered)} com coordenador
{formatElectionNumber(view.coverage.coordinatedCount)} de{' '}
{formatElectionNumber(view.totalFiltered)} com coordenador
{view.highPriorityCount > 0
? ` · ${numberFormatter.format(view.highPriorityCount)} prioritários`
? ` · ${formatElectionNumber(view.highPriorityCount)} prioritários`
: ''}
</p>
{view.baseline2022 && view.baseline2022.gapTotal !== null ? (
<p className="text-sm text-muted-foreground">
Baseline 2022:{' '}
<span className="font-medium text-estimate-confirmed-foreground">
{numberFormatter.format(view.baseline2022.above)} acima
{formatElectionNumber(view.baseline2022.above)} acima
</span>
{' · '}
<span className="font-medium text-estimate-pending-foreground">
{numberFormatter.format(view.baseline2022.below)} abaixo
{formatElectionNumber(view.baseline2022.below)} abaixo
</span>
{' · '}
<span className="tabular-nums">
{view.baseline2022.gapTotal >= 0 ? '+' : ''}
{numberFormatter.format(view.baseline2022.gapTotal)} gap
{formatElectionNumber(view.baseline2022.gapTotal)} gap
</span>
</p>
) : null}
@ -119,6 +117,27 @@ export const NucleusListOverview = ({
</span>
</p>
) : null}
{view.conversion ? (
<p className="text-sm text-muted-foreground">
Conversão:{' '}
<span className="font-medium tabular-nums text-foreground">
{view.conversion.weightedRate}%
</span>{' '}
do eleitorado apto
{' · '}
<span className="font-medium text-estimate-confirmed-foreground">
{formatElectionNumber(view.conversion.distribution.reduto)} reduto
</span>
{' · '}
<span className="font-medium text-foreground">
{formatElectionNumber(view.conversion.distribution.consolidado)} consolidado
</span>
{' · '}
<span className="font-medium text-estimate-pending-foreground">
{formatElectionNumber(view.conversion.distribution.oportunidade)} oportunidade
</span>
</p>
) : null}
</CardContent>
</Card>

View file

@ -6,6 +6,12 @@ export const NO_ELECTION_BASELINE_MESSAGE =
/** ±10% band between consecutive elections → stable (product default, E2). */
export const VOTE_TREND_STABLE_BAND = 0.1
/** Below this share of aptos → growth opportunity (literature reference, A5). */
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
export type VoteTrendStatus = 'decline' | 'stable' | 'increase' | 'noBaseline'
export type VoteTrendSeries = {
@ -34,6 +40,34 @@ export type GapVs2022Result = {
message: string
}
export type ConversionRateBand =
| 'reduto'
| 'consolidado'
| 'oportunidade'
| 'semEstimativa'
| 'semBaseline'
| 'semAptos'
export type ConversionRateInput = {
aptos: number | null
abstencoes: number | null
confirmedVoteEstimate: number | null
}
export type ConversionRateResult = {
rate: number | null
rateTurnout: number | null
band: ConversionRateBand
message: string
supportLine: string | null
}
export type ConversionBandDistribution = {
reduto: number
consolidado: number
oportunidade: number
}
/** Minimal baseline shape for Gap vs 2022 (full detail VM is assignable). */
export type GapVs2022Baseline = {
candidate: { votes: number }
@ -61,6 +95,32 @@ export const emptyVoteTrendDistribution = (): VoteTrendDistribution => ({
export const comparableTrendCount = (trend: VoteTrendDistribution): number =>
trend.increase + trend.stable + trend.decline
export const emptyConversionBandDistribution = (): ConversionBandDistribution => ({
reduto: 0,
consolidado: 0,
oportunidade: 0,
})
export const aggregateConversionBand = (
bands: readonly ConversionRateBand[],
): ConversionBandDistribution => {
const distribution = emptyConversionBandDistribution()
for (const band of bands) {
if (isComparableConversionBand(band)) {
distribution[band] += 1
}
}
return distribution
}
export const isComparableConversionBand = (
band: ConversionRateBand,
): band is 'reduto' | 'consolidado' | 'oportunidade' =>
band === 'reduto' || band === 'consolidado' || band === 'oportunidade'
export const conversionRateAlertVariant = (band: ConversionRateBand): 'default' | 'pending' =>
band === 'oportunidade' ? 'pending' : 'default'
type TrendPair = { fromVotes: number; toVotes: number; fromYear: number; toYear: number }
const trendPairsByPriority = (series: VoteTrendSeries): TrendPair[] => [
@ -206,3 +266,72 @@ export const computeGapVs2022 = (
message: `Já superamos 2022 em ${percentAbove}%`,
}
}
/**
* Compare a confirmed vote estimate against the electorate size (aptos) in the
* same geography. Optional turnout line uses aptos − abstencoes.
*/
export const computeConversionRate = ({
aptos,
abstencoes,
confirmedVoteEstimate,
}: ConversionRateInput): ConversionRateResult => {
if (aptos === null) {
return {
rate: null,
rateTurnout: null,
band: 'semBaseline',
message: NO_ELECTION_BASELINE_MESSAGE,
supportLine: null,
}
}
if (confirmedVoteEstimate === null) {
return {
rate: null,
rateTurnout: null,
band: 'semEstimativa',
message: 'Sem estimativa confirmada para calcular a conversão',
supportLine: null,
}
}
if (aptos <= 0) {
return {
rate: null,
rateTurnout: null,
band: 'semAptos',
message: 'Sem eleitores aptos no baseline para esta geografia',
supportLine: null,
}
}
const rate = confirmedVoteEstimate / aptos
const percent = Math.round(rate * 100)
let band: ConversionRateBand
if (rate < CONVERSION_OPPORTUNITY_MAX) {
band = 'oportunidade'
} else if (rate >= CONVERSION_REDUTO_MIN) {
band = 'reduto'
} else {
band = 'consolidado'
}
const abst = abstencoes ?? 0
const comparecimento = aptos - abst
const rateTurnout = comparecimento > 0 ? confirmedVoteEstimate / comparecimento : null
let supportLine = `${formatElectionNumber(confirmedVoteEstimate)} votos / ${formatElectionNumber(aptos)} eleitores aptos`
if (rateTurnout !== null) {
supportLine += ` · ${Math.round(rateTurnout * 100)}% do comparecimento`
}
return {
rate,
rateTurnout,
band,
message: `Taxa de conversão: ${percent}% do eleitorado apto`,
supportLine,
}
}

View file

@ -13,7 +13,13 @@ import {
type ElectionTurn,
} from '@/lib/electionResults'
import {
aggregateConversionBand,
aggregateVoteTrend,
computeConversionRate,
computeGapVs2022,
type ConversionBandDistribution,
type ConversionRateBand,
isComparableConversionBand,
type VoteTrendSeries,
type VoteTrendStatus,
} from '@/lib/electionInsights'
@ -225,6 +231,23 @@ const aggregateElectorate = (
return { aptos, validos, brancos, nulos, abstencoes }
}
const sumElectorateForGeography = (
talliesByCityZone: ReadonlyMap<string, ElectionTallyAggregateRow>,
geography: NucleusElectionGeography,
): Pick<NucleusElectoralBaselineViewModel['electorate'], 'aptos' | 'abstencoes'> => {
let aptos = 0
let abstencoes = 0
for (const { cityName, zoneNumber } of geography.cityZonePairs) {
const row = talliesByCityZone.get(cityZoneKey(cityName, zoneNumber))
if (!row) continue
aptos += row.aptos
abstencoes += row.abstencoes
}
return { aptos, abstencoes }
}
export const aggregateNucleusElectoralBaseline = (
geography: NucleusElectionGeography,
votes: readonly ElectionVoteAggregateRow[],
@ -532,9 +555,15 @@ export type NucleusBaseline2022OverviewAggregate = {
export type NucleusTrendOverviewAggregate = Record<VoteTrendStatus, number>
export type NucleusConversionOverviewAggregate = {
weightedRate: number
distribution: ConversionBandDistribution
}
export type NucleusListElectionOverview = {
baseline2022: NucleusBaseline2022OverviewAggregate | null
trend: NucleusTrendOverviewAggregate | null
conversion: NucleusConversionOverviewAggregate | null
}
/**
@ -551,7 +580,7 @@ export const loadNucleusListElectionOverview = async (
)
if (withGeographyIndexes.length === 0) {
return { baseline2022: null, trend: null }
return { baseline2022: null, trend: null, conversion: null }
}
const comparableIndexes: number[] = []
@ -562,7 +591,7 @@ export const loadNucleusListElectionOverview = async (
const unionGeography = buildUnionGeography(geographies, withGeographyIndexes)
if (!unionGeography) {
return { baseline2022: null, trend: null }
return { baseline2022: null, trend: null, conversion: null }
}
const seriesByYear = await loadCandidateSeriesByGeography(payload, user, unionGeography)
@ -579,14 +608,26 @@ export const loadNucleusListElectionOverview = async (
return {
baseline2022: { gapTotal: null, above: 0, below: 0 },
trend,
conversion: null,
}
}
const conversionUnion = buildUnionGeography(geographies, comparableIndexes)
const tallies = conversionUnion
? await loadElectionTallies(payload, user, conversionUnion)
: []
const talliesByCityZone = new Map(
tallies.map((row) => [cityZoneKey(row.cityName, row.zoneNumber), row]),
)
let estimateSum = 0
let candidateVotesSum = 0
let comparableCount = 0
let above = 0
let below = 0
let conversionEstimateSum = 0
let conversionAptosSum = 0
const conversionBands: ConversionRateBand[] = []
for (const index of comparableIndexes) {
const geography = geographies[index]
@ -594,15 +635,36 @@ export const loadNucleusListElectionOverview = async (
if (!geography || estimate == null) continue
const candidateVotes = sumCandidateVotesForGeography(geography, votes2022)
if (candidateVotes <= 0) continue
if (candidateVotes > 0) {
const gap = computeGapVs2022({ candidate: { votes: candidateVotes } }, estimate)
if (gap.status === 'above') above += 1
else if (gap.status === 'below') below += 1
estimateSum += estimate
candidateVotesSum += candidateVotes
comparableCount += 1
}
if (estimate >= candidateVotes) above += 1
else below += 1
estimateSum += estimate
candidateVotesSum += candidateVotes
comparableCount += 1
const electorate = sumElectorateForGeography(talliesByCityZone, geography)
const conversion = computeConversionRate({
aptos: electorate.aptos,
abstencoes: electorate.abstencoes,
confirmedVoteEstimate: estimate,
})
if (isComparableConversionBand(conversion.band)) {
conversionEstimateSum += estimate
conversionAptosSum += electorate.aptos
conversionBands.push(conversion.band)
}
}
const conversion: NucleusConversionOverviewAggregate | null =
conversionAptosSum > 0
? {
weightedRate: Math.round((conversionEstimateSum / conversionAptosSum) * 100),
distribution: aggregateConversionBand(conversionBands),
}
: null
return {
baseline2022: {
gapTotal: comparableCount > 0 ? estimateSum - candidateVotesSum : null,
@ -610,5 +672,6 @@ export const loadNucleusListElectionOverview = async (
below,
},
trend,
conversion,
}
}

View file

@ -173,5 +173,6 @@ export const loadNucleusListOverviewData = async (
upcomingActionPlans,
baseline2022: listElectionOverview.baseline2022,
trend: listElectionOverview.trend,
conversion: listElectionOverview.conversion,
})
}

View file

@ -1,7 +1,7 @@
import type { NucleusPriority } from '@/lib/schemas/nucleus'
import type { CampaignUser, NucleusUpdate } from '@/payload-types'
import type { ActionPlanUpcomingPreviewRecord } from '@/utilities/actionPlanUpcomingPreview'
import type { NucleusBaseline2022OverviewAggregate, NucleusTrendOverviewAggregate } from '@/utilities/nucleusElectoralBaseline'
import type { NucleusBaseline2022OverviewAggregate, NucleusConversionOverviewAggregate, NucleusTrendOverviewAggregate } from '@/utilities/nucleusElectoralBaseline'
import {
aggregateVoteGoals,
type VoteGoalsSumViewModel,
@ -52,6 +52,8 @@ export type NucleusListOverviewViewModel = {
highPriorityCount: number
/** Null when no nucleus in the filtered set has resolvable TSE geography. */
trend: NucleusTrendOverviewAggregate | null
/** Null when no comparable nucleus has aptos + confirmed estimate. */
conversion: NucleusConversionOverviewAggregate | null
recentUpdates: NucleusListOverviewUpdateRecord[]
upcomingActionPlans: ActionPlanUpcomingPreviewRecord[]
}
@ -66,6 +68,7 @@ export const buildNucleusListOverviewViewModel = ({
upcomingActionPlans,
baseline2022 = null,
trend = null,
conversion = null,
}: {
nuclei: NucleusListOverviewNucleusRecord[]
recentUpdates: NucleusListOverviewUpdateRecord[]
@ -73,6 +76,7 @@ export const buildNucleusListOverviewViewModel = ({
upcomingActionPlans: ActionPlanUpcomingPreviewRecord[]
baseline2022?: NucleusBaseline2022OverviewAggregate | null
trend?: NucleusTrendOverviewAggregate | null
conversion?: NucleusConversionOverviewAggregate | null
}): NucleusListOverviewViewModel => {
const totalFiltered = nuclei.length
const confirmedCount = nuclei.filter(
@ -107,6 +111,7 @@ export const buildNucleusListOverviewViewModel = ({
voteGoals,
highPriorityCount,
trend,
conversion,
recentUpdates,
upcomingActionPlans,
}

View file

@ -2,7 +2,17 @@
import { describe, expect, it } from 'vitest'
import { aggregateVoteTrend, comparableTrendCount, computeGapVs2022, computeVoteTrend } from '@/lib/electionInsights'
import {
aggregateConversionBand,
aggregateVoteTrend,
comparableTrendCount,
computeConversionRate,
computeGapVs2022,
computeVoteTrend,
CONVERSION_OPPORTUNITY_MAX,
CONVERSION_REDUTO_MIN,
isComparableConversionBand,
} from '@/lib/electionInsights'
import type { NucleusElectoralBaselineViewModel } from '@/utilities/nucleusViewModels'
const baseline = (candidateVotes: number): NucleusElectoralBaselineViewModel => ({
@ -117,3 +127,69 @@ describe('comparableTrendCount', () => {
).toBe(6)
})
})
describe('computeConversionRate', () => {
it('classifies oportunidade below 15%', () => {
const aptos = 10_000
const estimate = Math.floor(aptos * (CONVERSION_OPPORTUNITY_MAX - 0.001))
expect(computeConversionRate({ aptos, abstencoes: 0, confirmedVoteEstimate: estimate })).toMatchObject({
band: 'oportunidade',
message: `Taxa de conversão: ${Math.round((estimate / aptos) * 100)}% do eleitorado apto`,
})
})
it('classifies consolidado at 15%', () => {
const aptos = 10_000
const estimate = aptos * CONVERSION_OPPORTUNITY_MAX
expect(computeConversionRate({ aptos, abstencoes: 0, confirmedVoteEstimate: estimate }).band).toBe(
'consolidado',
)
})
it('classifies reduto at 40%', () => {
const aptos = 10_000
const estimate = aptos * CONVERSION_REDUTO_MIN
expect(computeConversionRate({ aptos, abstencoes: 0, confirmedVoteEstimate: estimate }).band).toBe(
'reduto',
)
})
it('reports semAptos when aptos is zero', () => {
expect(computeConversionRate({ aptos: 0, abstencoes: 0, confirmedVoteEstimate: 100 })).toMatchObject({
band: 'semAptos',
rate: null,
})
})
it('reports semEstimativa when estimate is null', () => {
expect(computeConversionRate({ aptos: 4700, abstencoes: 680, confirmedVoteEstimate: null })).toMatchObject({
band: 'semEstimativa',
rate: null,
})
})
it('includes turnout percent in support line when comparecimento is positive', () => {
expect(
computeConversionRate({ aptos: 4700, abstencoes: 680, confirmedVoteEstimate: 850 }).supportLine,
).toBe('850 votos / 4.700 eleitores aptos · 21% do comparecimento')
})
})
describe('aggregateConversionBand', () => {
it('counts comparable bands only', () => {
expect(
aggregateConversionBand(['reduto', 'consolidado', 'oportunidade', 'semEstimativa', 'semAptos']),
).toEqual({
reduto: 1,
consolidado: 1,
oportunidade: 1,
})
})
})
describe('isComparableConversionBand', () => {
it('accepts the three comparable bands', () => {
expect(isComparableConversionBand('reduto')).toBe(true)
expect(isComparableConversionBand('semEstimativa')).toBe(false)
})
})

View file

@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'
import { citiesForTerritory } from '@/lib/bahiaTerritories'
import { tseZonesForCity } from '@/lib/bahiaTseZones'
import { BASELINE_TICKET_2022 } from '@/lib/electionResults'
import { computeVoteTrend } from '@/lib/electionInsights'
import { computeConversionRate, computeVoteTrend } from '@/lib/electionInsights'
import {
aggregateNucleusElectoralBaseline,
resolveNucleusElectionGeography,
@ -220,4 +220,23 @@ describe('aggregateNucleusElectoralBaseline', () => {
party: 'PL',
})
})
it('supports conversion rate from electorate aptos and confirmed estimate', () => {
const baseline = aggregateNucleusElectoralBaseline(geography, votes, tallies, {
y2014: 0,
y2018: 0,
})
expect(
computeConversionRate({
aptos: baseline.electorate.aptos,
abstencoes: baseline.electorate.abstencoes,
confirmedVoteEstimate: 850,
}),
).toMatchObject({
band: 'oportunidade',
message: 'Taxa de conversão: 4% do eleitorado apto',
supportLine: '850 votos / 19.000 eleitores aptos · 6% do comparecimento',
})
})
})