mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:53:05 +00:00
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:
parent
1b6de775a0
commit
429b31048f
14 changed files with 444 additions and 71 deletions
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -173,5 +173,6 @@ export const loadNucleusListOverviewData = async (
|
|||
upcomingActionPlans,
|
||||
baseline2022: listElectionOverview.baseline2022,
|
||||
trend: listElectionOverview.trend,
|
||||
conversion: listElectionOverview.conversion,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue