From af829e1099fe4dee774bc286fbd5a50520370d4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Thu, 30 Jul 2026 02:15:26 -0300 Subject: [PATCH] fix(campanha): restore vote wizard violation banner on scenario jump (B76) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep violationEditedScenario when navigating to the inconsistent scenario so média shows warning immediately after pessimista breaks order; clear on draft/shortcut edits. Adds regression unit tests and roadmap B76. --- docs/plans/ajuste-votos-wizard.md | 4 + .../corrigir-coerencia-wizard-ajuste-votos.md | 111 ++++++++++++++++++ docs/roadmap.md | 13 +- .../shared/WizardExpectedVotesStep.tsx | 17 ++- .../wizardExpectedVotesStep.unit.spec.tsx | 70 +++++++++++ 5 files changed, 208 insertions(+), 7 deletions(-) create mode 100644 docs/plans/corrigir-coerencia-wizard-ajuste-votos.md create mode 100644 tests/unit/wizardExpectedVotesStep.unit.spec.tsx diff --git a/docs/plans/ajuste-votos-wizard.md b/docs/plans/ajuste-votos-wizard.md index 04031189..3caa3ce5 100644 --- a/docs/plans/ajuste-votos-wizard.md +++ b/docs/plans/ajuste-votos-wizard.md @@ -36,6 +36,10 @@ Responsável: — - Preview cobertura E8 no passo; wiring completo A1 (sinal/tendência/resumo B63/B64). +### Regressão conhecida (2026-07-30 → ~~B76~~ ✓) + +~~Jump+warning ao confirmar pessimista `> média` quebrava: `useEffect` limpava `violationEditedScenario` na troca de `?cenario=`.~~ **Corrigido em B76** — preservar estado no jump ao cenário violado; limpar ao editar draft/atalho. + ## Design (Impeccable) Âncoras: `PRODUCT.md` (Feel the action; Clarity under pressure) / `DESIGN.md` · `VoteEstimateScenarioInputs` / `MunicipalityListExpectedVotesControl` · tema `campaign`. diff --git a/docs/plans/corrigir-coerencia-wizard-ajuste-votos.md b/docs/plans/corrigir-coerencia-wizard-ajuste-votos.md new file mode 100644 index 00000000..b2a0cffc --- /dev/null +++ b/docs/plans/corrigir-coerencia-wizard-ajuste-votos.md @@ -0,0 +1,111 @@ +# Corrigir coerência do wizard de ajuste de votos (B61) + +Status: **entregue** (2026-07-30) +Atualizado em: 2026-07-30 +Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item **B76** — regressão UX-1 / B61) +Impeccable: B — correção na etapa existente `WizardExpectedVotesStep` +Appetite: ~0,25–0,5 dia eng; bug de estado client + pins de regressão; sem migration/action +Responsável: — + +## As-built (entrega) + +- `WizardExpectedVotesStep`: `useEffect` de troca de `?cenario=` **não** zera `violationEditedScenario` quando o cenário atual é o `violatingScenario` do jump; draft/atalho limpam o estado de violação ao editar. +- `tests/unit/wizardExpectedVotesStep.unit.spec.tsx`: pessimista `> média` → média com alerta no primeiro paint após rerender; banner some ao editar draft. + +## Design (Impeccable) + +Âncoras: `PRODUCT.md` (Clarity under pressure) / `DESIGN.md` · tema `campaign` · precedente [ajuste-votos-wizard.md](ajuste-votos-wizard.md) (B61 ✓). + +Na implementação: craft compacto → critique → polish (só o banner e o fluxo de jump — sem reshape). + +Brief compacto: + +- **Persona / contexto:** CG no celular ajustando pessimista depois da média; digitou um número que quebra a ordem e precisa entender **imediatamente** onde corrigir. +- **Job principal:** ao confirmar um cenário que viola `pessimistic ≤ central ≤ optimistic`, **pular ao cenário inconsistente com warning visível** e atalho “Voltar para [cenário que acabou de editar]”. +- **Estratégia de cor:** Restrained; banner `pending` / `estimate-pending` já usado em B61 — não inventar terceiro tom. +- **Edit where you see:** não — submit explícito por cenário (exceção já travada em B61). +- **Anti-goals:** segunda árvore de decisão (“menor que média?” como modal); toast sem jump; permitir avançar ao otimista enquanto média ainda inconsistente com pessimista recém-confirmado. + +## Dados → decisão → apresentação + +Dados: N/A — três inteiros por cenário; a decisão é coerência ordinal, não KPI novo. + +## Contexto + +**B61 ✓** entregou `WizardExpectedVotesStep` com ritual **média → pessimista → otimista** e violação via `getWizardVoteViolation` + `router.replace` ao cenário quebrado + banner + link de retorno ([ajuste-votos-wizard.md](ajuste-votos-wizard.md), [fluxos-acao-primeiro-inicio.md](fluxos-acao-primeiro-inicio.md) passo 3). + +**Regressão observada (2026-07-30):** ao confirmar **pessimista** com valor **não menor que média** (`pessimistic > central`), o fluxo deveria abrir **média com warning**. Na prática: média **sem warning**, o ritual segue (otimista, checagens adicionais) e o warning só aparece tarde. + +**Causa raiz (auditoria de código):** em `WizardExpectedVotesStep.tsx`, o `useEffect` que re-hidrata o draft ao trocar `currentScenario` executa `setViolationEditedScenario(null)` em **qualquer** mudança de cenário — inclusive no `router.replace` disparado por violação. O jump até a média ocorre, mas o estado que alimenta o banner é zerado antes da pintura → **média sem warning**. O CG pode confirmar de novo, “consertar” média para cima sem entender o conflito com pessimista já confirmado, e só ver warning num passo posterior (otimista) — exatamente o desvio reportado. + +Contrato esperado (ritual A1, árvore implícita): + +```text +Média → Pessimista → (menor que média?) → [sim] → Otimista → (maior que média?) → [sim] → Salvar + | + [não] → Média **com warning** → … +``` + +## Objetivos + +- Restaurar o contrato B61: violação ao confirmar pessimista `> central` → navega a `central` **com banner** + atalho de retorno ao pessimista editado. +- O mesmo para otimista `< central` e qualquer violação que `getVoteEstimateOrderViolation` aponte — banner visível **no primeiro paint** do cenário violado. +- **Não** limpar `violationEditedScenario` no `useEffect` de troca de cenário quando a troca veio de jump por violação; limpar só em: confirmação válida, clique em “Voltar para…”, edição consciente do draft, ou abandono do fluxo. +- Pins: unit do componente (jsdom) reproduzindo pointer sequence pessimista→jump média→banner presente; manter/estender `wizardVoteEstimate.unit.spec.ts` e e2e de violação se existente. +- Sem migration, collection, server action ou mudança de contrato JSON (`getVoteEstimateOrderViolation` permanece a fonte). + +## Decisões travadas + +- **Correção no estado client, não na ordem ritual.** A ordem média→pessimista→otimista e o invariante `pessimistic ≤ central ≤ optimistic` não mudam. **Rejeitado:** reordenar passos; modal “menor que média?”; segunda passagem de validação só no otimista. +- **Preservar `violationEditedScenario` através do jump.** **Rejeitado:** duplicar mensagem em query string; banner global fora do passo (o CG perde o contexto do cenário a corrigir). +- **i18n:** ids `violationEditedScenario`, `violatingScenario`; copy de banner já em `wizardVoteEstimate.ts` / `campaignWizardCopy.ts`. + +## Questões em aberto + +- **Limpar violação ao editar o draft no cenário violado?** **Opções:** A limpar ao primeiro keystroke | B manter até confirmar ou usar atalho de retorno. **Recomendação:** **A** — editar = o CG está corrigindo; banner obsoleto atrapalha. _(assumido)_ + +## Abordagem proposta + +```mermaid +flowchart TD + Confirm["CTA confirma cenário"] + Check{"getWizardVoteViolation"} + Jump["router.replace → cenário violado"] + Banner["Banner + Voltar para editado"] + Next["Próximo cenário ritual"] + Save["POST batch + saved"] + Confirm --> Check + Check -->|violação| Jump + Jump --> Banner + Check -->|ok + há próximo| Next + Check -->|ok + otimista| Save +``` + +Componentes: + +- **`WizardExpectedVotesStep.tsx`:** refinar `useEffect` — não resetar `violationEditedScenario` quando `currentScenario === activeViolation?.violatingScenario` após jump; opcional ref `pendingViolationFromScenario` se o timing de `router.replace` + render exigir. +- **`tests/unit/wizardExpectedVotesStep.unit.spec.tsx`** (novo) ou estender spec existente: simular confirmação em pessimista com `300` e média `200` → após replace, banner visível sem segundo clique. +- **`tests/unit/campaignHomeActionButton.unit.spec.tsx` / e2e:** smoke de navegação até violação se já houver spec B61. + +## Dependências + +- Dura: **B61 ✓** (superfície a corrigir). Soft: **B60 ✓** (município pré-selecionado). Não bloqueia B63/B64/B75. + +## Não escopo + +- Wiring A1 completo (passos 5–8 do fluxo-acao-primeiro). +- Mudar `getVoteEstimateOrderViolation` ou hook de `Municipality` — servidor já falha fechado com a mesma ordem. +- Preview de cobertura E8 no passo (adiado em B61). + +## Rabbit holes + +- **Reimplementar a árvore de decisão do pedido como fluxo explícito com branches Sim/Não.** **Mitigação:** manter um cenário visível + violação automática no confirm — é o que B61 já modelou. +- **Sincronizar violação via URL (`?violation=central`).** **Mitigação:** estado local com guard no `useEffect` basta; URL nova complica deep-link e B75 skip. + +## Adiado com gatilho + +- Unificar parsing pt-BR do input com `VoteEstimateScenarioInputs` — já registrado em B61 “Explicitamente fora”. + +## Referências + +- [ajuste-votos-wizard.md](ajuste-votos-wizard.md) · [fluxos-acao-primeiro-inicio.md](fluxos-acao-primeiro-inicio.md) · `src/lib/wizardVoteEstimate.ts` · `src/lib/voteEstimate.ts` · `WizardExpectedVotesStep.tsx` diff --git a/docs/roadmap.md b/docs/roadmap.md index 665449ef..87749090 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-30 — **B74** gap strip v2; **B75** header mobile do wizard; **B73** remover bottom nav; ~~**B66**~~ ✓ busca no focus; ~~**B71**~~ ✓ polish busca; ~~**B61**~~ ✓; ~~**B51**~~ ✓; ~~**B52**~~ ✓; ~~**B72**~~ ✓; **B69**–**B70** A4 liderança; ~~**B62**~~ ✓ simplificar sinal (E11 triage 1 por presença); ~~**B56**~~ ✓; ~~**B67**~~ ✓; ~~**B68**~~ ✓; ~~**B59**~~ ✓; ~~**B65**~~ ✓; ~~**B60**~~ ✓; ~~**B48**~~ ✓; ~~**B49**~~ ✓; ~~**B50**~~ ✓; B53–B55; wizards ~~B60~~ ✓/~~**B61**~~ ✓/B63–B64. +Atualizado em: 2026-07-30 — ~~**B76**~~ ✓ coerência wizard ajuste votos; **B74** gap strip v2; **B75** header mobile do wizard; **B73** remover bottom nav; ~~**B66**~~ ✓ busca no focus; ~~**B71**~~ ✓ polish busca; ~~**B61**~~ ✓; ~~**B51**~~ ✓; ~~**B52**~~ ✓; ~~**B72**~~ ✓; **B69**–**B70** A4 liderança; ~~**B62**~~ ✓ simplificar sinal (E11 triage 1 por presença); ~~**B56**~~ ✓; ~~**B67**~~ ✓; ~~**B68**~~ ✓; ~~**B59**~~ ✓; ~~**B65**~~ ✓; ~~**B60**~~ ✓; ~~**B48**~~ ✓; ~~**B49**~~ ✓; ~~**B50**~~ ✓; B53–B55; wizards ~~B60~~ ✓/~~**B61**~~ ✓/~~**B76**~~ ✓/B63–B64. Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc). ## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026) @@ -83,7 +83,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re ### Demais itens abertos -- **UX-1 fluxos ação-primeiro (prioridade pós-sessão 29/07)** — evidência: [snapshot](plans/sessao-observada-coordenador-2026-07-29-snapshot.md) · O9–O11 em CUSTOMER.md · **rascunho:** [fluxos-acao-primeiro-inicio.md](plans/fluxos-acao-primeiro-inicio.md). Soft deadline: **03/08**. **Chassis Início:** ~~**B43**~~ ✓ → ~~**B44**~~ ✓ → ~~**B45**~~ ✓ → ~~**B46**~~ ✓ → ~~**B47**~~ ✓ → ~~**B58**~~ ✓ → ~~**B67**~~ ✓ (pan strip) → ~~**B65**~~ ✓ (âncora inferior mobile) → ~~**B66**~~ ✓ (focus limpa tela + animação) → ~~**B48**~~ ✓ → ~~**B49**~~ ✓ → ~~**B68**~~ ✓ → ~~**B71**~~ ✓ (polish resultados busca) · ~~**B72**~~ ✓ (gap strip) → **B74** (gap strip v2) → **B73** (remover bottom nav mobile) → ~~**B50**~~ ✓ → ~~**B51**~~ ✓ → ~~**B52**~~ ✓ → **B53–B55** · ~~**B56**~~ ✓ · **B57** futuro. **Wizards:** ~~**B59**~~ ✓ → **B75** (header mobile wizard) → ~~**B60**~~ ✓/~~**B61**~~ ✓/**B64** · ~~**B62**~~ ✓ → **B63** · **B69** → **B70** (A4 liderança). +- **UX-1 fluxos ação-primeiro (prioridade pós-sessão 29/07)** — evidência: [snapshot](plans/sessao-observada-coordenador-2026-07-29-snapshot.md) · O9–O11 em CUSTOMER.md · **rascunho:** [fluxos-acao-primeiro-inicio.md](plans/fluxos-acao-primeiro-inicio.md). Soft deadline: **03/08**. **Chassis Início:** ~~**B43**~~ ✓ → ~~**B44**~~ ✓ → ~~**B45**~~ ✓ → ~~**B46**~~ ✓ → ~~**B47**~~ ✓ → ~~**B58**~~ ✓ → ~~**B67**~~ ✓ (pan strip) → ~~**B65**~~ ✓ (âncora inferior mobile) → ~~**B66**~~ ✓ (focus limpa tela + animação) → ~~**B48**~~ ✓ → ~~**B49**~~ ✓ → ~~**B68**~~ ✓ → ~~**B71**~~ ✓ (polish resultados busca) · ~~**B72**~~ ✓ (gap strip) → **B74** (gap strip v2) → **B73** (remover bottom nav mobile) → ~~**B50**~~ ✓ → ~~**B51**~~ ✓ → ~~**B52**~~ ✓ → **B53–B55** · ~~**B56**~~ ✓ · **B57** futuro. **Wizards:** ~~**B59**~~ ✓ → **B75** (header mobile wizard) → ~~**B60**~~ ✓/~~**B61**~~ ✓/~~**B76**~~ ✓ /**B64** · ~~**B62**~~ ✓ → **B63** · **B69** → **B70** (A4 liderança). - ~~**B43** Início em branco + Quadro~~ — **entregue 2026-07-29** (`/campanha` blank; staff dashboard em `/campanha/quadro`; liderança em `/campanha/contatos`; nav Início+Quadro / Início+Meus contatos; `requireCampaignPageActor` manda leader bloqueado → `/campanha/contatos`; sem migration) · [plano](plans/inicio-em-branco-quadro.md) - ~~**B44** Botão de ação do Início + lista horizontal~~ — **entregue 2026-07-29** (`CampaignHomeActionButton` + `CampaignHomeActionStrip`; Tooltip fine / long-press Drawer coarse; montado no Início por B45 ✓) · ~1d · Janela 1 · Impeccable C · [plano](plans/botao-acao-inicio-strip.md) - ~~**B45** Catálogo de ações por persona~~ — **entregue 2026-07-29** (`src/lib/campaignHomeActions.ts` + `CampaignHomeActions`; staff 6 ações com `uncovered-municipalities` → `?coverage=sem_assessor&sort=votos`; leader 2 com `my-contacts` → `/campanha/contatos`; demais inertes; ilha client por limite RSC↔ícones Lucide) · [plano](plans/catalogo-acoes-inicio-por-persona.md) @@ -91,7 +91,8 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re - **B67** Restaurar pan/scroll na strip de ações — **entregue 2026-07-29** (scrollbar oculta mantida; `pointer-fine` click+arrasta + touch pan nativo; limiar 8px; unit drag vs click) · Depende de B44 ✓ / B58 ✓ · ~0,25–0,5d · Janela 1 · Impeccable B · [plano](plans/restaurar-pan-strip-acoes-inicio.md) - **B59** ~~Chassis do wizard~~ — **entregue 2026-07-29** (`CampaignWizardShell`; `/campanha/acoes/*` com layout staff-only; Voltar `md:hidden`; caption sticky de município; catálogo B45 com hrefs; passo ponte até B60) · [plano](plans/chassis-wizard-campanha.md) - **B60** ~~Etapa busca município nos wizards~~ — **entregue 2026-07-29** (`WizardMunicipalitySearchStep`; `searchStaffMunicipalityHits` + modo `wizard-municipality` no `POST /campanha/home-search`; select → `?municipio=`; stub B61 com caption sticky; chips recentes adiados — sem `recentVisits` no repo) · [plano](plans/busca-municipio-wizard.md) -- **B61** ~~Etapa ajuste de votos (média→pessimista→otimista; atalhos 2×/±50/±100; coerência com jump+warning)~~ — **entregue 2026-07-29** (`WizardExpectedVotesStep`; `?cenario=`; batch POST expected-votes ao concluir otimista; stub B60 substituído só em `atualizar-votos`) · [plano](plans/ajuste-votos-wizard.md) +- **B61** ~~Etapa ajuste de votos (média→pessimista→otimista; atalhos 2×/±50/±100; coerência com jump+warning)~~ — **entregue 2026-07-29** (`WizardExpectedVotesStep`; `?cenario=`; batch POST expected-votes ao concluir otimista; stub B60 substituído só em `atualizar-votos`) · [plano](plans/ajuste-votos-wizard.md) · ~~regressão jump+warning → B76~~ ✓ +- **B76** ~~Corrigir coerência do wizard de ajuste de votos~~ — **entregue 2026-07-30** (`useEffect` preserva `violationEditedScenario` no jump ao cenário violado; limpa ao editar draft/atalho; unit `wizardExpectedVotesStep.unit.spec.ts`) · Depende de B61 ✓ · [plano](plans/corrigir-coerencia-wizard-ajuste-votos.md) - ~~**B62** Simplificar modelo de sinal~~ — **entregue 2026-07-29** (sinal = tipo + texto; migration drop `signal_source`/`triangulated`; labels mesa sem "broker" — `Rede esfriou`/`Adversário apareceu`/`Alguém pediu algo`; E11 sem dimensão triangulado: presença = confirmado, **triage 1**; forms/lista/detalhe/dossiê alinhados; e2e stale-B43 corrigido de bônus) · [plano](plans/simplificar-modelo-sinal.md) - **B63** Wizard registrar sinal (grid de tipos + Drawer info + texto + “Pular” se origem ≠ register-signal) · Depende de B59 + B62 · soft B60 · ~1–1,25d · Janela 1 · Impeccable C · [plano](plans/wizard-registro-sinal.md) - **B64** Wizard mudar tendência (grid colorido ≠ atual + Drawer info + justificativa com prefill/Limpar/Salvar + “Pular” sempre) · Depende de B59 · soft B60 · soft B24 ✓ · ~1–1,25d · Janela 1 · Impeccable C · [plano](plans/wizard-mudar-tendencia.md) @@ -246,6 +247,7 @@ flowchart TD B59n["B59 Chassis wizard ✓
shell + município sticky"] B60n["B60 Busca município
nos wizards"] B61n["B61 ✓ Ajuste votos
cenários + atalhos"] + B76n["B76 ✓ Coerência wizard
ajuste votos"] B62n["B62 ✓ Simplificar
modelo de sinal"] B63n["B63 Wizard sinal
tipo + texto"] B64n["B64 Wizard tendência
escolha + justificativa"] @@ -375,6 +377,7 @@ flowchart TD B48n -.UX/loader.-> B60n B66n -.paridade focus.-> B60n B60n -.município.-> B61n + B61n -.regressão jump.-> B76n B60n -.município standalone.-> B63n B60n -.município standalone.-> B64n B61n -.prefill votos.-> B64n @@ -477,7 +480,7 @@ Paralelizáveis agora: **B73** (remover bottom nav — paralelo a B74); **B74** ### Sequência por janela (só pendentes) -**Janela 1 — agora → 05/08 (convenções):** **prioridade pós-29/07 — ~~B43–B48~~ ✓ → ~~B67~~ ✓ (pan strip) **→ ~~B65~~ ✓** (âncora inferior) **→ ~~B66~~ ✓** (focus limpa busca) **→ ~~B68~~ ✓\*\* **→ ~~B71~~ ✓** (polish resultados) **· ~~B72~~ ✓** / **B74** (gap strip) **→ B73** (remover bottom nav) **→** **wizards ~~B59~~ ✓ → B75** (header mobile) → ~~B60~~ ✓/~~B61~~ ✓∥B64 · ~~B62~~ ✓ → B63 · B69 → B70 (soft 03/08) · **B49–B55** · [fluxos](plans/fluxos-acao-primeiro-inicio.md); smoke + onboarding Onda 0; **E4R ✓** aplicar seed em produção após smoke; demais entregues/pendentes da janela inalterados no detalhe abaixo — foco operacional = polish Início B74 + chrome B73/B75 + busca B49+ + wizards B63–B64 + A4 B69/B70. +**Janela 1 — agora → 05/08 (convenções):** **prioridade pós-29/07 — ~~B43–B48~~ ✓ → ~~B67~~ ✓ (pan strip) **→ ~~B65~~ ✓** (âncora inferior) **→ ~~B66~~ ✓** (focus limpa busca) **→ ~~B68~~ ✓\*\* **→ ~~B71~~ ✓** (polish resultados) **· ~~B72~~ ✓** / **B74** (gap strip) **→ B73** (remover bottom nav) **→** **wizards ~~B59~~ ✓ → B75** (header mobile) → ~~B60~~ ✓/~~B61~~ ✓/~~B76~~ ✓ ∥B64 · ~~B62~~ ✓ → B63 · B69 → B70 (soft 03/08) · **B49–B55** · [fluxos](plans/fluxos-acao-primeiro-inicio.md); smoke + onboarding Onda 0; **E4R ✓** aplicar seed em produção após smoke; demais entregues/pendentes da janela inalterados no detalhe abaixo — foco operacional = polish Início B74 + chrome B73/B75 + busca B49+ + wizards B63–B64 + A4 B69/B70. **Janela 2 — 05/08 → 16/08 (pré-propaganda):** C2 dados reais assim que o jurídico liberar; ~~**E9** fila de alocação~~ (entregue 2026-07-24, antecipada da janela 2); ~~**E16** dossiê do município~~ (entregue 2026-07-25, antecipado da janela 2); D2 se sobrar folga. @@ -487,7 +490,7 @@ Paralelizáveis agora: **B73** (remover bottom nav — paralelo a B74); **B74** ### Cortes seguros / não cortáveis -**Não cortáveis:** Onda 0 (jurídico/Consent); ~~**B43–B48** chassis UX-1 + busca Municípios~~ ✓; ~~**B67** pan na strip de ações~~ ✓ (entregue 2026-07-29 — B58 escondia a barra; drag fine restaurado); ~~**B65** âncora inferior mobile~~ ✓ (entregue 2026-07-29); ~~**B66** focus limpa busca~~ ✓ (entregue 2026-07-30); **B59–B61** etapas mínimas do wizard de votos (chassis + município sticky + busca município + ajuste — cortar = ações do Início continuam inertes no ritual A1 **e** o CG perde o contexto de município no meio do passo); **B75** header mobile do wizard (cortar = Voltar+título no conteúdo competem com Mandate Red+sidebar no meio do ritual — emenda explícita a B59); ~~**B62** simplificar sinal~~ ✓ (entregue 2026-07-29); **B69** simplificar liderança (schema `exclusive` + limpeza — cortar = A4/B70 sem modelo e ficha continua inchada); ~~**E4R** seed da planilha~~ (entregue 2026-07-24 — ainda aplicar em produção após smoke); ~~**E8** conta da cadeira~~ (entregue 2026-07-24); ~~**C12** registro-fundação~~ (entregue 2026-07-24); C2 dados reais; assimetria declarado×estimado (relação de campo); ~~**E9** fila de alocação~~ (entregue 2026-07-24 — era o mínimo de "inteligência, não planilha"); ~~**B19** gerenciar assessores~~ (entregue 2026-07-24 — CG/candidato não entram em `/admin`); **B39** lembrar de mim / sessão longa (default de 2 h derruba sessão no onboarding/campo — atrito diário de adoção). +**Não cortáveis:** Onda 0 (jurídico/Consent); ~~**B43–B48** chassis UX-1 + busca Municípios~~ ✓; ~~**B67** pan na strip de ações~~ ✓ (entregue 2026-07-29 — B58 escondia a barra; drag fine restaurado); ~~**B65** âncora inferior mobile~~ ✓ (entregue 2026-07-29); ~~**B66** focus limpa busca~~ ✓ (entregue 2026-07-30); **B59–B61** etapas mínimas do wizard de votos (chassis + município sticky + busca município + ajuste — cortar = ações do Início continuam inertes no ritual A1 **e** o CG perde o contexto de município no meio do passo); ~~**B76** coerência do ajuste de votos~~ ✓ (entregue 2026-07-30); **B75** header mobile do wizard (cortar = Voltar+título no conteúdo competem com Mandate Red+sidebar no meio do ritual — emenda explícita a B59); ~~**B62** simplificar sinal~~ ✓ (entregue 2026-07-29); **B69** simplificar liderança (schema `exclusive` + limpeza — cortar = A4/B70 sem modelo e ficha continua inchada); ~~**E4R** seed da planilha~~ (entregue 2026-07-24 — ainda aplicar em produção após smoke); ~~**E8** conta da cadeira~~ (entregue 2026-07-24); ~~**C12** registro-fundação~~ (entregue 2026-07-24); C2 dados reais; assimetria declarado×estimado (relação de campo); ~~**E9** fila de alocação~~ (entregue 2026-07-24 — era o mínimo de "inteligência, não planilha"); ~~**B19** gerenciar assessores~~ (entregue 2026-07-24 — CG/candidato não entram em `/admin`); **B39** lembrar de mim / sessão longa (default de 2 h derruba sessão no onboarding/campo — atrito diário de adoção). **Cortes seguros** (se o prazo apertar, nesta ordem): ~~**B71**~~ ✓; **B74** gap strip v2 (B72 já apertou uma vez — cortar = strip um pouco esparça); **B68** sugestões na busca aberta (digitação B48 continua; cortar = empty mudo no focus até folga); **B64** wizard tendência standalone (A1 ainda pode embutir tendência via B24 ✓ / passo curto; cortar = sem grid colorido + prefill até folga); **B63** wizard sinal standalone (A1 ainda pode embutir select pós-B62; cortar = sem grid/Drawer de tipos até folga); **B70** wizard liderança standalone (A1 ainda pode embutir passo curto pós-B69; cortar = sem grid 2/3/4 até folga — pacote Cairu sofre); **B57** delta 7d (Início continua com total+cobertura do B56; Δ era futuro de propósito); **B56** resumo no Início (Quadro já tem os mesmos agregados — cortar = Início sem briefing numérico); **B55** ações WA/share na busca (linha continua clicável); **B54** grid/cap (empilhar sempre); **B51**/**B53**/**B52**/**B50**/**B49** grupos secundários da busca (nessa ordem — Municípios B48 fica); **D5** inbox→rascunhos (manter registro manual + `wa.me`); **D4** envio bridge (manter `wa.me`); **D3** fundação do canal (atalho `wa.me` continua); **E12** camada TI (rollup manual por lista; **E17** já dá a leitura regional básica no Início); **E13** planejador de giros (rebaixado na fila de corte em 2026-07-24: "perna"/agenda é a restrição dominante nomeada em campo — cortar só depois de E12; agenda segue manual com J-A/J-B como guia); **E15** backtest (pós-eleição por definição — cortar = perder o aprendizado 2030); **E11** motor v1 (manter a fila E9 ✓ sem sugestões); **B13** símbolo proporcional (manter quantis/LQ como escala); ~~**E14** níveis~~ (entregue 2026-07-27 — `priority` continua, intocado); ~~**B8 F2** polígonos~~ (entregue 2026-07-26 — o mapa não é mais agregado em Salvador); **D2** push (adiar); **A6**; ~~**B14** município mais próximo~~ (entregue 2026-07-26); ~~**B38** sidebar recolhido em tablet~~ (entregue 2026-07-26); ~~**B40** login biométrico~~ (entregue 2026-07-28 — senha + B39 ✓ continuam, e cortar teria custado só o atalho de digital/Face ID; era o mais caro e o primeiro a cair desta leva B39–B42); **B42** polimento mobile da lista de municípios (Popover e card longos continuam usáveis — cortar custa conforto de teclado e densidade no celular); ~~**B41** scroll horizontal + sticky~~ (entregue 2026-07-27) (a tabela continua legível com zoom/pan da página — cortar custa o overflow em tablet; cai **depois** de B42 porque o bug de layout em mesa é mais caro que o polish mobile); ~~**B18** filtros salvos~~ ✓ entregue (lista + filtros manuais continuam; Visitados cobrem “voltar”); **B21** página dos Territórios de Identidade (o painel do E17 ✓ no Início continua entregando a leitura regional) — **B25** cai junto por definição (sem destino, sem link); ~~**B17** seletor de colunas~~ ✓ entregue (tabela completa continua; scroll horizontal); ~~**B22** explicação no header~~ (entregue 2026-07-26); ~~**B23** tooltip nas células~~ (entregue 2026-07-26); ~~**B24** auto-save + justificativa na Tendência~~ (entregue 2026-07-26); **B27** combobox + chips nos Assessores (a lista de checkboxes com "Salvar" continua atribuindo — cortar custa a mesma coerência do B24 mais o tempo do CG procurando nomes numa coluna durante o onboarding; corte junto com B24, já que dividem o endpoint); **B28** e-mail + celular na lista de lideranças (a ficha `/liderancas/[id]` continua mostrando o contato — cortar custa o gesto de copiar/abrir ZAP na varredura; quick win barato de cortar e de fazer); ~~**B30** convite de completar cadastro por WhatsApp na lista~~ (entregue 2026-07-26); **B31** coluna de dobradinhas nas lideranças (o `RelationMultiSelect` da ficha interna continua vinculando — cortar custa o preenchimento em lote do onboarding, uma ficha aberta por liderança, e mantém o vínculo invisível na varredura; corte junto com B27, já que dividem a peça de célula, e antes de B29 só se a mesa já tiver preenchido as dobradinhas à mão); ~~**B32** editar Status de apoio na célula~~ (entregue 2026-07-26); **B34** chips editáveis de municípios na lista de lideranças (o texto simples `row.municipalityNames.join(', ')` continua legível e a ficha `/liderancas/[id]` continua vinculando município via formulário — cortar custa o link direto ao município, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha; corte junto com B31, já que dividem a mesma peça de célula editável, e antes de B29 pelo mesmo motivo de coerência); **B37** chips editáveis de municípios na lista de dobradinhas (o número `municipalityCount` continua legível e a ficha de município continua vinculando dobradinha pelo `MunicipalityStrategyForm` — cortar custa o link direto, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha do município; corte junto com B34, já que dividem a mesma lib e a mesma decisão de container); ~~**B36** coluna de lideranças na lista de dobradinhas~~ (entregue 2026-07-26; a contagem `leadershipCount` era o fallback e a ficha `/liderancas/[id]` continua vinculando dobradinha via formulário — cortar custa o link direto à liderança, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha; corte junto com B31, já que dividem a mesma mutação/schema/lock); ~~**B33** sort + filtro no header das dobradinhas~~ (entregue 2026-07-26); **B29** sort + filtro no header das lideranças (a busca por nome continua funcionando e o escopo do assessor já reduz a lista — cortar custa o recorte por status/setor/município no onboarding da rede e mantém o header rico como cópia única de municípios; por ser o item mais caro desta faixa (~1,5–2 dias com a extração do head), cai **antes** do B26, que é o que sustenta o gate de adoção); **B26** registrar sinal na célula (o formulário completo em `/[slug]?tab=updates` continua sendo o caminho — cortar custa o laço da fila do E9 ✓ e o registro em campo pelo celular, que é justamente onde o gate de adoção se decide; por isso cai **depois** de B22/B23/B24); ~~**C13** renomear Plano de Ação → Atividade~~ (entregue 2026-07-25); **B20** prioritários no Início (lista `?priority=alta` continua; só perde o atalho — mas a seção atual mente, então cortar = conviver com o bug); fill-in **renomear coluna Cobertura** (a lista continua legível com o rótulo longo — corte = header mais largo); fill-in **remover coluna Tipo** (a lista continua completa; só mantém densidade morta — corte = conviver com a coluna); ~~fill-in **remover lista de zonas do Início**~~ ✓ (entregue 2026-07-28 — o mapa B8 F2 ✓ continua abrindo cada ZE); ~~**B16** filtros no header~~ (entregue 2026-07-25); ~~**B15** sort por coluna~~ (entregue 2026-07-24); ~~**A11**/**E17**~~ (entregues 2026-07-24); ~~**E18** documentação de conceitos~~ (entregue 2026-07-24); ~~**E16** dossiê~~ (entregue 2026-07-25); demais débitos/fill-ins. diff --git a/src/components/campaign/shared/WizardExpectedVotesStep.tsx b/src/components/campaign/shared/WizardExpectedVotesStep.tsx index 88c3c023..14419971 100644 --- a/src/components/campaign/shared/WizardExpectedVotesStep.tsx +++ b/src/components/campaign/shared/WizardExpectedVotesStep.tsx @@ -76,11 +76,22 @@ export const WizardExpectedVotesStep = ({ useEffect(() => { if (previousScenarioRef.current === currentScenario) return + setDraft(draftTextForVoteValue(confirmed[currentScenario])) setDraftError(null) - setViolationEditedScenario(null) + + const violation = violationEditedScenario != null ? getWizardVoteViolation(confirmed) : null + const keepViolationState = + violationEditedScenario != null && + violation != null && + violation.violatingScenario === currentScenario + + if (!keepViolationState) { + setViolationEditedScenario(null) + } + previousScenarioRef.current = currentScenario - }, [confirmed, currentScenario]) + }, [confirmed, currentScenario, violationEditedScenario]) const activeViolation = violationEditedScenario != null ? getWizardVoteViolation(confirmed) : null const showViolationBanner = @@ -99,6 +110,7 @@ export const WizardExpectedVotesStep = ({ setDraft(draftTextForVoteValue(nextValue)) setDraftError(null) setSaveError(null) + setViolationEditedScenario(null) inputRef.current?.focus() } @@ -214,6 +226,7 @@ export const WizardExpectedVotesStep = ({ setDraft(event.currentTarget.value.replace(/\D/g, '')) setDraftError(null) setSaveError(null) + setViolationEditedScenario(null) }} onFocus={(event) => event.currentTarget.select()} disabled={isPending} diff --git a/tests/unit/wizardExpectedVotesStep.unit.spec.tsx b/tests/unit/wizardExpectedVotesStep.unit.spec.tsx new file mode 100644 index 00000000..de1e8ca3 --- /dev/null +++ b/tests/unit/wizardExpectedVotesStep.unit.spec.tsx @@ -0,0 +1,70 @@ +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { WizardExpectedVotesStep } from '@/components/campaign/shared/WizardExpectedVotesStep' + +const replace = vi.fn() + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ replace }), +})) + +vi.mock('next/link', () => ({ + default: ({ children, href, ...props }: { children: React.ReactNode; href: string }) => ( + + {children} + + ), +})) + +vi.mock('@/lib/campaignJsonRequest', () => ({ + postCampaignJson: vi.fn(), +})) + +const defaultProps = { + actionSlug: 'atualizar-votos', + municipalityId: 1, + municipalityName: 'Cairu', + municipalitySlug: 'cairu', + initialExpectedVotes: { pessimistic: 100, central: 200, optimistic: 300 }, +} + +describe('WizardExpectedVotesStep', () => { + afterEach(() => { + cleanup() + replace.mockClear() + }) + + it('shows violation banner on média immediately after pessimista breaks order', () => { + const { rerender } = render( + , + ) + + fireEvent.change(screen.getByLabelText(/Pessimista em Cairu/i), { target: { value: '900' } }) + fireEvent.click(screen.getByRole('button', { name: 'Ajustar estimativa pessimista →' })) + + expect(replace).toHaveBeenCalledWith(expect.stringContaining('cenario=central')) + + rerender() + + const step = screen.getByRole('main', { name: /Qual a nova estimativa média/i }) + expect(within(step).getByRole('alert').textContent).toMatch(/Pessimista/i) + expect(within(step).getByRole('button', { name: /Voltar para pessimista/i })).toBeTruthy() + }) + + it('clears violation banner when média draft is edited', () => { + const { rerender } = render( + , + ) + + fireEvent.change(screen.getByLabelText(/Pessimista em Cairu/i), { target: { value: '900' } }) + fireEvent.click(screen.getByRole('button', { name: 'Ajustar estimativa pessimista →' })) + rerender() + + const step = screen.getByRole('main', { name: /Qual a nova estimativa média/i }) + expect(within(step).getByRole('alert')).toBeTruthy() + + fireEvent.change(screen.getByLabelText(/Média em Cairu/i), { target: { value: '250' } }) + expect(within(step).queryByRole('alert')).toBeNull() + }) +})