From 429b31048f2f54390169c033162c74b69cb8261f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Sun, 19 Jul 2026 14:05:11 -0300 Subject: [PATCH] Deliver A5 slice 1 conversion-rate insight and absorb review debts. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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+. --- ...src-components-campaign-NucleusInsights.md | 17 +++ docs/plans/escala-dry-pos-a4.md | 9 +- docs/plans/escala-dry-pos-e2.md | 17 ++- .../escala-dry-pos-reset-senha-perfil.md | 14 +- docs/plans/insight-taxa-conversao.md | 43 +++--- docs/roadmap.md | 6 +- src/components/campaign/NucleusInsights.tsx | 47 ++++++- .../campaign/NucleusListOverview.tsx | 47 +++++-- src/lib/electionInsights.ts | 129 ++++++++++++++++++ src/utilities/nucleusElectoralBaseline.ts | 79 +++++++++-- src/utilities/nucleusListOverviewPageData.ts | 1 + .../nucleusListOverviewViewModels.ts | 7 +- tests/unit/electionInsights.unit.spec.ts | 78 ++++++++++- .../nucleusElectoralBaseline.unit.spec.ts | 21 ++- 14 files changed, 444 insertions(+), 71 deletions(-) create mode 100644 .impeccable/critique/2026-07-19T15-22-00Z__src-components-campaign-NucleusInsights.md 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` 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` diff --git a/docs/plans/escala-dry-pos-reset-senha-perfil.md b/docs/plans/escala-dry-pos-reset-senha-perfil.md index aad7e9f9..439d77be 100644 --- a/docs/plans/escala-dry-pos-reset-senha-perfil.md +++ b/docs/plans/escala-dry-pos-reset-senha-perfil.md @@ -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
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 = ({ + {showConversion ? ( + + + ) : null} + {baseline && trend && trend.status !== 'noBaseline' ? (