diff --git a/.impeccable/critique/2026-07-19T15-22-00Z__src-components-campaign-NucleusInsights.md b/.impeccable/critique/2026-07-19T15-22-00Z__src-components-campaign-NucleusInsights.md
new file mode 100644
index 00000000..3f736f80
--- /dev/null
+++ b/.impeccable/critique/2026-07-19T15-22-00Z__src-components-campaign-NucleusInsights.md
@@ -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.
diff --git a/docs/plans/escala-dry-pos-a4.md b/docs/plans/escala-dry-pos-a4.md
index be8d7193..1eede402 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: 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
diff --git a/docs/plans/escala-dry-pos-e2.md b/docs/plans/escala-dry-pos-e2.md
index cd2fe369..4392a1ed 100644
--- a/docs/plans/escala-dry-pos-e2.md
+++ b/docs/plans/escala-dry-pos-e2.md
@@ -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
Mostrando agregados de{' '}
- {numberFormatter.format(view.totalFiltered)}
+ {formatElectionNumber(view.totalFiltered)}
{' '}
{view.totalFiltered === 1 ? 'núcleo filtrado' : 'núcleos filtrados'}
depth 0 + getCampaignUserWithAvatar
sessão leve em rotas públicas"]
F1 --> F2["Fase 2 — Avatar write perf
blob fora da TX + revalidate estreito"]
- F2 --> F3["Fase 3 — DRY auth UI/senha
CampaignAuthPageShell + password fields + login state"]
+ F2 --> F3["Fase 3 — DRY auth UI/senha
shell ✓ parcial · password fields + login state"]
F3 --> F4["Fase 4 — DRY copy/role + actions
campaignRoleOptions + getCampaignActionContext"]
F4 --> F5["Fase 5 — Opcional
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`
diff --git a/docs/plans/insight-taxa-conversao.md b/docs/plans/insight-taxa-conversao.md
index 0072433f..d80d08f2 100644
--- a/docs/plans/insight-taxa-conversao.md
+++ b/docs/plans/insight-taxa-conversao.md
@@ -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
aptos agregados"]
+ Base["getNucleusElectoralBaseline
electorate.aptos"]
Est["electoralNucleus.confirmedVoteEstimate"]
- Calc["computeConversionRate(aptos, estimate)
rate + faixa"]
- Detail["Detalhe: badge + faixa
'X% do eleitorado apto'"]
- Overview["Overview: média + distribuição
por faixa sobre o filtro"]
+ Calc["computeConversionRate(...)"]
+ Detail["NucleusInsights Alert"]
+ Overview["loadNucleusListElectionOverview
+ 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
diff --git a/docs/roadmap.md b/docs/roadmap.md
index a4ba2920..cf675dfe 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -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)
diff --git a/src/components/campaign/NucleusInsights.tsx b/src/components/campaign/NucleusInsights.tsx
index 526408a2..04a49529 100644
--- a/src/components/campaign/NucleusInsights.tsx
+++ b/src/components/campaign/NucleusInsights.tsx
@@ -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 (
@@ -50,25 +71,37 @@ export const NucleusInsights = ({
+ Conversão:{' '} + + {view.conversion.weightedRate}% + {' '} + do eleitorado apto + {' · '} + + {formatElectionNumber(view.conversion.distribution.reduto)} reduto + + {' · '} + + {formatElectionNumber(view.conversion.distribution.consolidado)} consolidado + + {' · '} + + {formatElectionNumber(view.conversion.distribution.oportunidade)} oportunidade + +
+ ) : null} diff --git a/src/lib/electionInsights.ts b/src/lib/electionInsights.ts index 2ab51f1c..8fc70e42 100644 --- a/src/lib/electionInsights.ts +++ b/src/lib/electionInsights.ts @@ -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, + } +} diff --git a/src/utilities/nucleusElectoralBaseline.ts b/src/utilities/nucleusElectoralBaseline.ts index f66a4a3a..5e04ad54 100644 --- a/src/utilities/nucleusElectoralBaseline.ts +++ b/src/utilities/nucleusElectoralBaseline.ts @@ -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