Merge branch 'cursor/472940a5'

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-30 01:19:12 -03:00
commit 16852980b2
13 changed files with 719 additions and 14 deletions

View file

@ -7,6 +7,7 @@ alwaysApply: false
## Status
- **B61 entregue em código (2026-07-29):** etapa de ajuste de votos no wizard A1 — `WizardExpectedVotesStep` substitui o stub B60 em `atualizar-votos` com `?municipio=`; três passos explícitos média→pessimista→otimista via `?cenario=`; atalhos `2×`/±50/±100; validação client com jump+warning (`getWizardVoteViolation`); **um POST batch** ao concluir otimista (`POST /campanha/municipios/expected-votes`). Helpers em `wizardVoteEstimate.ts` + `wizardActionHref` estendido. Sem migration/Consent. Unit `wizardVoteEstimate`; e2e avanço de cenário + violação. Plano: [`ajuste-votos-wizard.md`](../../../docs/plans/ajuste-votos-wizard.md). Desbloqueia wiring A1 / **B64** (prefill votos).
- **B62 entregue em código (2026-07-29):** modelo de sinal simplificado — `municipalityUpdate` kind `sinal` agora é só `signalType` + `body`; migration `20260730_010601_simplify_municipality_signal_fields` dropa `signal_source`/`triangulated` (o gerador reemitiu `ADD VALUE 'adiada'` por drift de snapshot — removido à mão, e o snapshot novo sara a cadeia); labels/descriptions dos 5 tipos reescritas para a mesa (`Rede esfriou`, `Adversário apareceu`, `Alguém pediu algo` — sem "broker" no modelo de sinal; `activity.origin` `pedido_broker` e a copy do suggestionCatalog ficaram fora por decisão da sessão). Forms: `MunicipalitySignalFields` (lista B26) e o bloco inline de `MunicipalityUpdateForm` perderam Fonte/Triangulado; feed/dossiê (`MunicipalityUpdateFeed` + `municipalityUpdatePageData`) só exibem o badge do tipo. **E11: presença de sinal de adversário lê como confirmada — triage 1** (decisão do usuário: "eles não vão adicionar sinais improváveis"), `adversarySignal: { present: true } | null`, fator único "Sinal de adversário registrado", glossário E18 alinhado. Fixa B63. Bônus: teste e2e `campaignMunicipalities` "advisor scopes…" estava obsoleto desde B43 (home da liderança) — atualizado para `/campanha/contatos` + redirect pós-B43, e o pin de bypass do `municipalityElectoralBaseline` baixado 9→8 (drift pré-existente). Gate: tsc/lint/format/cycles/knip(P3 pré-existente)/876 unit/489 int/e2e alvo verde/build (`/campanha` 233 kB); Aikido 0 findings. Plano: [`simplificar-modelo-sinal.md`](../../../docs/plans/simplificar-modelo-sinal.md). Desbloqueia **B63** (wizard registrar sinal).
- **E10+ entregue em código (2026-07-29):** Escala e DRY pós-E10 — F1: `loadMunicipalityScopeFromDocs` no `municipalityPageData.ts` evita a segunda query de `municipality` quando `isPagedByPayload === false`; F2: `computeAllMunicipalityPotentials` memoizado por processo (retorna cache `Map<string, MunicipalityPotential>`); F3: `TooltipProvider` movido para o layout de `(app)`, restaurando o agrupamento de delay do Radix para os tooltips das listas; F4: DRY nos filtros multi do mobile em `MunicipalityFilters` via `.map` sobre `municipalityFilterDefinitions` (que ganhou `emptyLabel`). Sem migration/UI nova. Plano: [`escala-dry-pos-e10.md`](../../../docs/plans/escala-dry-pos-e10.md).
- **B60 entregue em código (2026-07-29):** etapa de busca de município nos wizards UX-1 — `WizardMunicipalitySearchStep` (debounce B47, `POST /campanha/home-search` modo `wizard-municipality`, só municípios scoped); select = `wizardActionHref` com `?municipio=<slug>`; loader compartilhado `searchStaffMunicipalityHits` (extraído de B48); `HomeSearchMunicipalityVoteTrailing` + `HomeSearchHitRow` link; stub B61 `WizardMunicipalitySelectedStub` com caption sticky; `WizardMunicipalityPlaceholderStep` removido. Sem migration/Consent. Unit `campaignActionRoutes`; int `searchStaffMunicipalityHits`; e2e wizard → `?municipio=cairu`. Plano: [`busca-municipio-wizard.md`](../../../docs/plans/busca-municipio-wizard.md). Desbloqueia **B61** / **B63** / **B64** / **B70**.

View file

@ -1,12 +1,41 @@
# Etapa de ajuste de votos no wizard (cenários + atalhos)
Status: rascunho
Status: **entregue** (2026-07-29)
Atualizado em: 2026-07-29
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B61 — UX-1 wizards)
Impeccable: C — UI nova de passo numérico com coerência entre cenários
Appetite: ~1–1,25 dia eng; 3 sub-passos de cenário + atalhos + navegação de violação; grava via action existente no wiring A1 (neste item: UI + validação client espelhando servidor)
Responsável: —
## As-built (entrega)
### Contrato de URL e ordem de edição
- `?cenario=central|pessimistic|optimistic` em `/campanha/acoes/atualizar-votos?municipio=<slug>`; default `central`; valor inválido → redirect canônico para `central`.
- Ordem ritual **média → pessimista → otimista** (`WIZARD_VOTE_SCENARIO_EDIT_ORDER` em `src/lib/wizardVoteEstimate.ts`); invariante de armazenamento continua `pessimistic ≤ central ≤ optimistic` via `getVoteEstimateOrderViolation`.
- Helpers: `applyVoteShortcut`, `getWizardVoteViolation`, `wizardActionHref(slug, municipio?, scenario?)`.
### Gravação
- **Um POST batch** ao concluir o passo otimista (`POST /campanha/municipios/expected-votes` → `setMunicipalityExpectedVotes`), não três writes por cenário.
- Sucesso: mensagem + placeholder do próximo passo A1 (B63) + link "Voltar ao Início".
### UI
- `WizardExpectedVotesStep` substitui `WizardMunicipalitySelectedStub` **somente** em `atualizar-votos` com município escolhido.
- Um cenário visível; input grande (`text-2xl`/`text-3xl`); atalhos `2×` · `±50` · `±100`; CTA nomeado por cenário.
- Violação: banner warning + `router.replace` para o cenário quebrado + botão "Voltar para [cenário editado]".
- `CampaignWizardShell`: caption sticky; `previousHref` = cenário anterior ou busca B60.
### Testes
- Unit: `tests/unit/wizardVoteEstimate.unit.spec.ts`, `campaignActionRoutes.unit.spec.ts`.
- E2e: `campaignHomeActions.e2e.spec.ts` — avanço média→pessimista; violação pessimista> média → banner + retorno.
### Fora (inalterado)
- Preview cobertura E8 no passo; wiring completo A1 (sinal/tendência/resumo B63/B64).
## Design (Impeccable)
Âncoras: `PRODUCT.md` (Feel the action; Clarity under pressure) / `DESIGN.md` · `VoteEstimateScenarioInputs` / `MunicipalityListExpectedVotesControl` · tema `campaign`.
@ -102,6 +131,25 @@ Componentes:
- **Preview de cobertura E8 no rodapé do passo.** Revisitar se o CG pedir “isso fecha a meta?” durante o ajuste (sessão).
## Já resolvido no simplify (não reabrir)
- Ramo morto em `buildWizardVoteViolationMessage` (cenário `central` com `optimistic < central` — `getVoteEstimateOrderViolation` já devolve `'optimistic'`).
- Estado duplicado de mensagem de violação (`violationReturn` → só `violationEditedScenario` + `activeViolation.message`).
- Banner de violação obsoleto ao voltar de cenário (limpa `violationEditedScenario` na troca).
- `parseWizardVoteDraft` duplicado em `handleConfirm`.
- Alias `WizardVoteEditScenario` = `VoteEstimateScenario`; `getWizardVoteViolation` lazy.
- Constante `MUNICIPALITY_EXPECTED_VOTES_ENDPOINT` compartilhada com `MunicipalityListExpectedVotesControl`.
## Explicitamente fora (pós-/simplify, defer)
| Achado | Gatilho para revisitar |
| ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
| Unificar `parseWizardVoteDraft` com `VoteEstimateScenarioInputs.parseScenarioInput` (pt-BR `"1.200"` difere) | 3ª superfície de input de votos **ou** passe repo-wide de parsing numérico pt-BR |
| Extrair `SAVE_ERROR_MESSAGE` / copy de pending do wizard e da lista | 3º consumidor de POST JSON de célula **ou** helper `readCampaignJsonMutationResult` (precedente B32+) |
| Validar body do wizard com `voteEstimateScenarioFieldsSchema` no client | Gravação por cenário (write parcial) em vez do batch único |
| Helper genérico `readCampaignJsonMutationResult` | 3ª rota/painel wizard com o mesmo shape de resposta JSON |
| Generalizar `parseWizardMunicipioParam` / `resolveWizardScenarioParam` num parser de query do wizard | B63/B64 reutilizarem `?cenario=` ou outro param de passo na mesma rota |
## Referências
- [fluxos-acao-primeiro-inicio.md](fluxos-acao-primeiro-inicio.md) · [cenarios-estimativa-votos.md](cenarios-estimativa-votos.md) · `src/lib/voteEstimate.ts` · `MunicipalityListExpectedVotesControl.tsx` · `VoteEstimateScenarioInputs.tsx` · collection `Municipality` hook de ordem

View file

@ -1,6 +1,6 @@
# Roadmap — Teqo
Atualizado em: 2026-07-29 — ~~**B51**~~ ✓; ~~**B52**~~ ✓; ~~**B72**~~ ✓ gap strip ações; **B71** polish busca (bullets/título/hover full-bleed); **B69**–**B70** A4 liderança; ~~**B62**~~ ✓ simplificar sinal (E11 triage 1 por presença); ~~**B56**~~ ✓; ~~**B67**~~ ✓; ~~**B68**~~ ✓; **B66**; ~~**B59**~~ ✓; ~~**B65**~~ ✓; ~~**B60**~~ ✓; ~~**B48**~~ ✓; ~~**B49**~~ ✓; ~~**B50**~~ ✓; B53–B55; wizards ~~B60~~ ✓/B61–B64.
Atualizado em: 2026-07-29 — ~~**B51**~~ ✓; ~~**B52**~~ ✓; ~~**B72**~~ ✓ gap strip ações; **B71** polish busca (bullets/título/hover full-bleed); **B69**–**B70** A4 liderança; ~~**B62**~~ ✓ simplificar sinal (E11 triage 1 por presença); ~~**B56**~~ ✓; ~~**B67**~~ ✓; ~~**B68**~~ ✓; **B66**; ~~**B59**~~ ✓; ~~**B65**~~ ✓; ~~**B60**~~ ✓; ~~**B61**~~ ✓; ~~**B48**~~ ✓; ~~**B49**~~ ✓; ~~**B50**~~ ✓; B53–B55; wizards ~~B60~~ ✓/~~**B61**~~ ✓/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)
@ -91,7 +91,7 @@ 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=<slug>`; 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) · Depende de B59 · soft B60 · ~1–1,25d · Janela 1 · Impeccable C · [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)
- ~~**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)

View file

@ -1,14 +1,19 @@
import { notFound } from 'next/navigation'
import { notFound, redirect } from 'next/navigation'
import { WizardExpectedVotesStep } from '@/components/campaign/shared/WizardExpectedVotesStep'
import { WizardMunicipalitySearchStep } from '@/components/campaign/shared/WizardMunicipalitySearchStep'
import { WizardMunicipalitySelectedStub } from '@/components/campaign/shared/WizardMunicipalitySelectedStub'
import {
CAMPAIGN_WIZARD_ACTION_SLUGS,
isCampaignWizardActionSlug,
parseWizardMunicipioParam,
resolveWizardScenarioParam,
WIZARD_MUNICIPIO_QUERY_KEY,
WIZARD_SCENARIO_QUERY_KEY,
wizardActionHref,
} from '@/lib/campaignActionRoutes'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
import { toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
import config from '@/payload.config'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { loadMunicipalityScope } from '@/utilities/municipality/campaignMunicipalityScope'
@ -52,5 +57,25 @@ export default async function CampaignActionWizardPage({
notFound()
}
if (slug === CAMPAIGN_WIZARD_ACTION_SLUGS['update-votes']) {
const { scenario, invalid } = resolveWizardScenarioParam(
resolvedSearchParams[WIZARD_SCENARIO_QUERY_KEY],
)
if (invalid) {
redirect(wizardActionHref(slug, municipalitySlug, 'central'))
}
return (
<WizardExpectedVotesStep
actionSlug={slug}
municipalityId={municipality.id}
municipalityName={municipality.name}
municipalitySlug={municipality.slug}
initialExpectedVotes={toVoteEstimateScenarioViewModel(municipality.expectedVotes)}
currentScenario={scenario}
/>
)
}
return <WizardMunicipalitySelectedStub actionSlug={slug} municipalityName={municipality.name} />
}

View file

@ -1,5 +1,7 @@
import type { VoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
export const MUNICIPALITY_EXPECTED_VOTES_ENDPOINT = '/campanha/municipios/expected-votes' as const
export type MunicipalityListExpectedVotesResponse =
| {
status: 'success'

View file

@ -1,6 +1,9 @@
'use client'
import type { MunicipalityListExpectedVotesResponse } from '@/app/(campaign)/campanha/(app)/municipios/expected-votes/types'
import {
MUNICIPALITY_EXPECTED_VOTES_ENDPOINT,
type MunicipalityListExpectedVotesResponse,
} from '@/app/(campaign)/campanha/(app)/municipios/expected-votes/types'
import { useMunicipalityEstimateScenarioOptional } from '@/components/campaign/municipality/MunicipalityEstimateScenarioContext'
import { CampaignCellEditOverlay } from '@/components/campaign/shared/CampaignCellEditOverlay'
import { useCampaignCellAutosave } from '@/components/campaign/shared/useCampaignCellAutosave'
@ -18,7 +21,6 @@ import {
import type { MunicipalityPledgeCoverageView } from '@/utilities/votePledgeViews'
const AUTOSAVE_MS = 600
const EXPECTED_VOTES_ENDPOINT = '/campanha/municipios/expected-votes'
const SAVE_ERROR_MESSAGE = 'Não foi possível salvar os votos estimados. Tente novamente.'
/**
@ -52,7 +54,7 @@ export const MunicipalityListExpectedVotesControl = ({
useCampaignCellAutosave<VoteEstimateScenarioViewModel, MunicipalityListExpectedVotesResponse>({
value: expectedVotes,
equals: voteEstimatesEqual,
endpoint: EXPECTED_VOTES_ENDPOINT,
endpoint: MUNICIPALITY_EXPECTED_VOTES_ENDPOINT,
buildBody: (values) => ({ municipalityId: municipalityID, expectedVotes: values }),
readSaved: (payload) => payload.savedExpectedVotes,
errorMessage: SAVE_ERROR_MESSAGE,

View file

@ -0,0 +1,294 @@
'use client'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useEffect, useRef, useState, useTransition } from 'react'
import {
MUNICIPALITY_EXPECTED_VOTES_ENDPOINT,
type MunicipalityListExpectedVotesResponse,
} from '@/app/(campaign)/campanha/(app)/municipios/expected-votes/types'
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Spinner } from '@/components/ui/Spinner'
import { wizardActionHref } from '@/lib/campaignActionRoutes'
import { postCampaignJson } from '@/lib/campaignJsonRequest'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
import {
WIZARD_VOTES_INVALID_DRAFT_MESSAGE,
WIZARD_VOTES_NEXT_FLOW_PLACEHOLDER,
WIZARD_VOTES_SAVE_ERROR_MESSAGE,
WIZARD_VOTES_SAVED_MESSAGE,
wizardVoteReturnToScenarioLabel,
} from '@/lib/campaignWizardCopy'
import { formatElectionNumber } from '@/lib/electionFormat'
import { cn } from '@/lib/utils'
import { voteEstimateScenarioLabels, type VoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
import {
applyVoteShortcut,
draftTextForVoteValue,
getNextWizardVoteScenario,
getPreviousWizardVoteScenario,
getWizardVoteViolation,
mergeWizardVoteEstimate,
parseWizardVoteDraft,
VOTE_SHORTCUTS,
voteShortcutLabels,
wizardVoteFinalCtaLabel,
wizardVoteStepCtaLabel,
wizardVoteStepTitle,
type VoteShortcut,
type WizardVoteEditScenario,
} from '@/lib/wizardVoteEstimate'
type WizardExpectedVotesStepProps = {
actionSlug: string
municipalityId: number
municipalityName: string
municipalitySlug: string
initialExpectedVotes: VoteEstimateScenarioViewModel
currentScenario: WizardVoteEditScenario
}
export const WizardExpectedVotesStep = ({
actionSlug,
municipalityId,
municipalityName,
municipalitySlug,
initialExpectedVotes,
currentScenario,
}: WizardExpectedVotesStepProps) => {
const router = useRouter()
const [isPending, startTransition] = useTransition()
const [confirmed, setConfirmed] = useState<VoteEstimateScenarioViewModel>(initialExpectedVotes)
const [draft, setDraft] = useState(() =>
draftTextForVoteValue(initialExpectedVotes[currentScenario]),
)
const [draftError, setDraftError] = useState<string | null>(null)
const [saveError, setSaveError] = useState<string | null>(null)
const [phase, setPhase] = useState<'editing' | 'saved'>('editing')
const [violationEditedScenario, setViolationEditedScenario] =
useState<WizardVoteEditScenario | null>(null)
const previousScenarioRef = useRef(currentScenario)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (previousScenarioRef.current === currentScenario) return
setDraft(draftTextForVoteValue(confirmed[currentScenario]))
setDraftError(null)
setViolationEditedScenario(null)
previousScenarioRef.current = currentScenario
}, [confirmed, currentScenario])
const activeViolation = violationEditedScenario != null ? getWizardVoteViolation(confirmed) : null
const showViolationBanner =
violationEditedScenario != null &&
activeViolation != null &&
activeViolation.violatingScenario === currentScenario
const previousScenario = getPreviousWizardVoteScenario(currentScenario)
const previousHref = previousScenario
? wizardActionHref(actionSlug, municipalitySlug, previousScenario)
: wizardActionHref(actionSlug)
const handleShortcut = (shortcut: VoteShortcut) => {
const currentValue = parseWizardVoteDraft(draft)
const nextValue = applyVoteShortcut(currentValue, shortcut)
setDraft(draftTextForVoteValue(nextValue))
setDraftError(null)
setSaveError(null)
inputRef.current?.focus()
}
const handleConfirm = () => {
if (isPending) return
const trimmed = draft.trim()
const parsed = trimmed ? parseWizardVoteDraft(draft) : null
if (trimmed && parsed === null) {
setDraftError(WIZARD_VOTES_INVALID_DRAFT_MESSAGE)
return
}
const nextConfirmed = mergeWizardVoteEstimate(confirmed, currentScenario, parsed)
const violation = getWizardVoteViolation(nextConfirmed)
if (violation) {
setConfirmed(nextConfirmed)
setViolationEditedScenario(currentScenario)
setSaveError(null)
if (violation.violatingScenario !== currentScenario) {
router.replace(wizardActionHref(actionSlug, municipalitySlug, violation.violatingScenario))
}
return
}
setConfirmed(nextConfirmed)
setViolationEditedScenario(null)
setSaveError(null)
setDraftError(null)
const nextScenario = getNextWizardVoteScenario(currentScenario)
if (nextScenario) {
router.replace(wizardActionHref(actionSlug, municipalitySlug, nextScenario))
return
}
startTransition(async () => {
const { ok, payload } = await postCampaignJson<MunicipalityListExpectedVotesResponse>(
MUNICIPALITY_EXPECTED_VOTES_ENDPOINT,
{
municipalityId,
expectedVotes: nextConfirmed,
},
)
if (!ok || payload.status !== 'success') {
setSaveError(payload.status === 'error' ? payload.message : WIZARD_VOTES_SAVE_ERROR_MESSAGE)
return
}
setConfirmed(payload.savedExpectedVotes)
setPhase('saved')
})
}
const handleReturnToEdited = () => {
if (!violationEditedScenario) return
router.replace(wizardActionHref(actionSlug, municipalitySlug, violationEditedScenario))
setViolationEditedScenario(null)
}
const currentValue = confirmed[currentScenario]
const nextScenario = getNextWizardVoteScenario(currentScenario)
const ctaLabel =
nextScenario == null ? wizardVoteFinalCtaLabel : wizardVoteStepCtaLabel(currentScenario)
if (phase === 'saved') {
return (
<CampaignWizardShell
stepTitle="Votos atualizados"
previousHref={CAMPAIGN_HOME}
municipalityLabel={municipalityName}
>
<div className="flex flex-col gap-4" role="status" aria-live="polite">
<p className="text-sm text-muted-foreground">{WIZARD_VOTES_SAVED_MESSAGE}</p>
<p className="text-sm text-muted-foreground">{WIZARD_VOTES_NEXT_FLOW_PLACEHOLDER}</p>
<Button asChild className="min-h-11 w-full sm:w-auto">
<Link href={CAMPAIGN_HOME}>Voltar ao Início</Link>
</Button>
</div>
</CampaignWizardShell>
)
}
return (
<CampaignWizardShell
stepTitle={wizardVoteStepTitle(currentScenario)}
previousHref={previousHref}
municipalityLabel={municipalityName}
>
<div
className="flex flex-col gap-6"
aria-busy={isPending}
data-pending={isPending ? '' : undefined}
>
<div className="flex flex-col gap-2">
<p className="text-sm text-muted-foreground">
Valor atual:{' '}
{currentValue == null ? 'não informado' : formatElectionNumber(currentValue)}
</p>
<label htmlFor="wizard-expected-votes-input" className="sr-only">
{voteEstimateScenarioLabels[currentScenario]} em {municipalityName}
</label>
<Input
ref={inputRef}
id="wizard-expected-votes-input"
type="text"
inputMode="numeric"
autoComplete="off"
value={draft}
onChange={(event) => {
setDraft(event.currentTarget.value.replace(/\D/g, ''))
setDraftError(null)
setSaveError(null)
}}
onFocus={(event) => event.currentTarget.select()}
disabled={isPending}
className={cn(
'h-auto min-h-14 border-input px-3 py-3 text-center text-2xl font-medium tabular-nums',
'md:min-h-16 md:text-3xl',
)}
/>
</div>
<div className="flex flex-wrap gap-2">
{VOTE_SHORTCUTS.map((shortcut) => (
<Button
key={shortcut}
type="button"
variant="outline"
className="min-h-11 min-w-[3.25rem] flex-1 px-2 sm:flex-none"
disabled={isPending}
onClick={() => handleShortcut(shortcut)}
>
{voteShortcutLabels[shortcut]}
</Button>
))}
</div>
{showViolationBanner && activeViolation ? (
<Alert variant="pending" className="border-estimate-pending">
<AlertDescription className="flex flex-col gap-2 text-sm">
<span>{activeViolation.message}</span>
<Button
type="button"
variant="link"
className="h-auto justify-start p-0 text-sm"
onClick={handleReturnToEdited}
>
{wizardVoteReturnToScenarioLabel(
voteEstimateScenarioLabels[violationEditedScenario],
)}
</Button>
</AlertDescription>
</Alert>
) : null}
{draftError ? (
<Alert variant="destructive">
<AlertDescription className="text-sm">{draftError}</AlertDescription>
</Alert>
) : null}
{saveError ? (
<Alert variant="destructive">
<AlertDescription className="text-sm">{saveError}</AlertDescription>
</Alert>
) : null}
<div aria-live="polite" className="sr-only">
{isPending ? 'Salvando votos estimados.' : null}
</div>
<Button
type="button"
className="min-h-11 w-full"
disabled={isPending}
onClick={handleConfirm}
>
{isPending ? (
<>
<Spinner className="size-4" aria-hidden />
Salvando…
</>
) : (
ctaLabel
)}
</Button>
</div>
</CampaignWizardShell>
)
}

View file

@ -1,5 +1,8 @@
/** Client-safe entry paths for UX-1 action wizards under `/campanha/acoes`. */
import type { WizardVoteEditScenario } from '@/lib/wizardVoteEstimate'
import { isWizardVoteEditScenario } from '@/lib/wizardVoteEstimate'
export const CAMPAIGN_ACTIONS_HOME = '/campanha/acoes' as const
export type CampaignWizardActionId =
@ -29,12 +32,21 @@ export const campaignActionEntryHref = (id: CampaignWizardActionId): string =>
export const WIZARD_MUNICIPIO_QUERY_KEY = 'municipio' as const
export const wizardActionHref = (actionSlug: string, municipalitySlug?: string): string => {
export const WIZARD_SCENARIO_QUERY_KEY = 'cenario' as const
export const wizardActionHref = (
actionSlug: string,
municipalitySlug?: string,
scenario?: WizardVoteEditScenario,
): string => {
const base = `${CAMPAIGN_ACTIONS_HOME}/${actionSlug}`
if (!municipalitySlug) {
return base
}
const params = new URLSearchParams({ [WIZARD_MUNICIPIO_QUERY_KEY]: municipalitySlug })
if (scenario) {
params.set(WIZARD_SCENARIO_QUERY_KEY, scenario)
}
return `${base}?${params.toString()}`
}
@ -45,3 +57,17 @@ export const parseWizardMunicipioParam = (
const trimmed = raw?.trim()
return trimmed ? trimmed : undefined
}
export const resolveWizardScenarioParam = (
value: string | string[] | undefined,
): { scenario: WizardVoteEditScenario; invalid: boolean } => {
const raw = Array.isArray(value) ? value[0] : value
const trimmed = raw?.trim()
if (!trimmed) {
return { scenario: 'central', invalid: false }
}
if (isWizardVoteEditScenario(trimmed)) {
return { scenario: trimmed, invalid: false }
}
return { scenario: 'central', invalid: true }
}

View file

@ -1,6 +1,6 @@
import { CAMPAIGN_WIZARD_ACTION_SLUGS } from '@/lib/campaignActionRoutes'
/** Shared copy for UX-1 wizard municipality step (B60). */
/** Shared copy for UX-1 wizard steps (B60 municipality search, B61 vote adjustment). */
export const WIZARD_MUNICIPALITY_STEP_TITLE = 'Em qual município?' as const
export const WIZARD_MUNICIPALITY_SEARCH_LABEL = 'Buscar município' as const
@ -9,9 +9,19 @@ export const WIZARD_MUNICIPALITY_SEARCH_PLACEHOLDER = 'Nome do município…' as
export const WIZARD_MUNICIPALITY_SEARCH_EMPTY = 'Nenhum município encontrado.' as const
/** Placeholder until B61 ships vote adjustment. */
const WIZARD_NEXT_STEP_VOTES_PLACEHOLDER =
'Próximo passo: ajuste de votos estimados neste município.' as const
export const WIZARD_VOTES_SAVED_MESSAGE = 'Votos estimados atualizados.' as const
export const WIZARD_VOTES_NEXT_FLOW_PLACEHOLDER =
'Próximo passo deste fluxo (sinal, tendência ou resumo) em breve.' as const
export const WIZARD_VOTES_SAVE_ERROR_MESSAGE =
'Não foi possível salvar os votos estimados. Tente novamente.' as const
export const WIZARD_VOTES_INVALID_DRAFT_MESSAGE =
'Informe um número válido ou deixe em branco.' as const
export const wizardVoteReturnToScenarioLabel = (scenarioLabel: string): string =>
`Voltar para ${scenarioLabel.toLowerCase()}`
const WIZARD_NEXT_STEP_GENERIC_PLACEHOLDER = 'Próximo passo deste fluxo em breve.' as const
@ -24,7 +34,7 @@ export const wizardNextStepTitle = (actionSlug: string): string => {
export const wizardNextStepPlaceholder = (actionSlug: string): string => {
if (actionSlug === CAMPAIGN_WIZARD_ACTION_SLUGS['update-votes']) {
return WIZARD_NEXT_STEP_VOTES_PLACEHOLDER
return WIZARD_VOTES_NEXT_FLOW_PLACEHOLDER
}
return WIZARD_NEXT_STEP_GENERIC_PLACEHOLDER
}

View file

@ -0,0 +1,145 @@
import { formatElectionNumber } from '@/lib/electionFormat'
import { MAX_VOTE_COUNT } from '@/lib/schemas/primitives'
import {
getVoteEstimateOrderViolation,
VOTE_ESTIMATE_ORDER_ERROR_MESSAGE,
voteEstimateScenarioLabels,
type VoteEstimateScenario,
type VoteEstimateScenarioViewModel,
} from '@/lib/voteEstimate'
/** Mesa ritual order: média → pessimista → otimista (≠ storage order pessimistic ≤ central ≤ optimistic). */
export const WIZARD_VOTE_SCENARIO_EDIT_ORDER = [
'central',
'pessimistic',
'optimistic',
] as const satisfies readonly VoteEstimateScenario[]
export type WizardVoteEditScenario = VoteEstimateScenario
export type VoteShortcut = 'double' | '+50' | '-50' | '+100' | '-100'
export const VOTE_SHORTCUTS: readonly VoteShortcut[] = [
'double',
'+50',
'-50',
'+100',
'-100',
] as const
export const voteShortcutLabels: Record<VoteShortcut, string> = {
double: '2×',
'+50': '+50',
'-50': '−50',
'+100': '+100',
'-100': '−100',
}
export const isWizardVoteEditScenario = (value: string): value is WizardVoteEditScenario =>
(WIZARD_VOTE_SCENARIO_EDIT_ORDER as readonly string[]).includes(value)
export const applyVoteShortcut = (value: number | null, shortcut: VoteShortcut): number => {
const base = value ?? 0
if (shortcut === 'double') {
if (base < 1) return base
return Math.min(base * 2, MAX_VOTE_COUNT)
}
const delta =
shortcut === '+50' ? 50 : shortcut === '-50' ? -50 : shortcut === '+100' ? 100 : -100
return Math.max(0, Math.min(base + delta, MAX_VOTE_COUNT))
}
export const parseWizardVoteDraft = (raw: string): number | null => {
const trimmed = raw.trim()
if (!trimmed) return null
const digitsOnly = trimmed.replace(/\D/g, '')
if (!digitsOnly) return null
const parsed = Number(digitsOnly)
if (!Number.isFinite(parsed)) return null
return Math.min(Math.trunc(parsed), MAX_VOTE_COUNT)
}
export const draftTextForVoteValue = (value: number | null | undefined): string =>
value == null ? '' : String(value)
export const getNextWizardVoteScenario = (
current: WizardVoteEditScenario,
): WizardVoteEditScenario | null => {
const index = WIZARD_VOTE_SCENARIO_EDIT_ORDER.indexOf(current)
if (index < 0 || index >= WIZARD_VOTE_SCENARIO_EDIT_ORDER.length - 1) return null
return WIZARD_VOTE_SCENARIO_EDIT_ORDER[index + 1]!
}
export const getPreviousWizardVoteScenario = (
current: WizardVoteEditScenario,
): WizardVoteEditScenario | null => {
const index = WIZARD_VOTE_SCENARIO_EDIT_ORDER.indexOf(current)
if (index <= 0) return null
return WIZARD_VOTE_SCENARIO_EDIT_ORDER[index - 1]!
}
export const wizardVoteStepTitle = (scenario: WizardVoteEditScenario): string => {
const label = voteEstimateScenarioLabels[scenario]
return `Qual a nova estimativa ${label.toLowerCase()}?`
}
export const wizardVoteStepCtaLabel = (scenario: WizardVoteEditScenario): string => {
const label = voteEstimateScenarioLabels[scenario]
return `Ajustar estimativa ${label.toLowerCase()} →`
}
export const wizardVoteFinalCtaLabel = 'Salvar estimativas →' as const
export type WizardVoteViolation = {
violatingScenario: VoteEstimateScenario
message: string
}
export const getWizardVoteViolation = (
estimates: VoteEstimateScenarioViewModel,
): WizardVoteViolation | null => {
const violatingScenario = getVoteEstimateOrderViolation(estimates)
if (!violatingScenario) return null
return {
violatingScenario,
message: buildWizardVoteViolationMessage(estimates, violatingScenario),
}
}
const buildWizardVoteViolationMessage = (
estimates: VoteEstimateScenarioViewModel,
violatingScenario: VoteEstimateScenario,
): string => {
const { pessimistic, central, optimistic } = estimates
if (
violatingScenario === 'central' &&
pessimistic != null &&
central != null &&
pessimistic > central
) {
return `${voteEstimateScenarioLabels.pessimistic} (${formatElectionNumber(pessimistic)}) não pode ser maior que ${voteEstimateScenarioLabels.central.toLowerCase()} (${formatElectionNumber(central)}).`
}
if (
violatingScenario === 'optimistic' &&
central != null &&
optimistic != null &&
optimistic < central
) {
return `${voteEstimateScenarioLabels.optimistic} (${formatElectionNumber(optimistic)}) não pode ser menor que ${voteEstimateScenarioLabels.central.toLowerCase()} (${formatElectionNumber(central)}).`
}
return VOTE_ESTIMATE_ORDER_ERROR_MESSAGE
}
export const mergeWizardVoteEstimate = (
base: VoteEstimateScenarioViewModel,
scenario: WizardVoteEditScenario,
value: number | null,
): VoteEstimateScenarioViewModel => ({
...base,
[scenario]: value,
})

View file

@ -145,6 +145,57 @@ test.describe('Wizard — busca município (B60)', () => {
await page.waitForURL(/\/campanha\/acoes\/atualizar-votos\?municipio=cairu/)
await expect(page.getByLabel(/Município em atualização: Cairu/i)).toBeVisible()
await expect(page.getByRole('heading', { name: 'Ajustar votos estimados' })).toBeVisible()
await expect(page.getByRole('heading', { name: /Qual a nova estimativa média/i })).toBeVisible()
})
})
test.describe('Wizard — ajuste de votos (B61)', () => {
test('confirming média advances to pessimista step', async ({ campaign, page }) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Geral'),
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto('/campanha/acoes/atualizar-votos?municipio=cairu')
await expect(page.getByRole('heading', { name: /Qual a nova estimativa média/i })).toBeVisible({
timeout: 15000,
})
const input = page.getByLabel(/Média em Cairu/i)
await input.fill('500')
await page.getByRole('button', { name: 'Ajustar estimativa média →' }).click()
await page.waitForURL(/cenario=pessimistic/)
await expect(
page.getByRole('heading', { name: /Qual a nova estimativa pessimista/i }),
).toBeVisible()
})
test('incoherent pessimista shows warning and return link', async ({ campaign, page }) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Geral'),
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto('/campanha/acoes/atualizar-votos?municipio=cairu')
await expect(page.getByRole('heading', { name: /Qual a nova estimativa média/i })).toBeVisible({
timeout: 15000,
})
await page.getByLabel(/Média em Cairu/i).fill('200')
await page.getByRole('button', { name: 'Ajustar estimativa média →' }).click()
await page.waitForURL(/cenario=pessimistic/)
await page.getByLabel(/Pessimista em Cairu/i).fill('900')
await page.getByRole('button', { name: 'Ajustar estimativa pessimista →' }).click()
await page.waitForURL(/cenario=central/)
const step = page.getByRole('main', { name: /Qual a nova estimativa média/i })
await expect(step.getByRole('alert')).toContainText(/Pessimista/i)
await expect(step.getByRole('button', { name: /Voltar para pessimista/i })).toBeVisible()
})
})

View file

@ -7,6 +7,7 @@ import {
isCampaignWizardActionId,
isCampaignWizardActionSlug,
parseWizardMunicipioParam,
resolveWizardScenarioParam,
wizardActionHref,
} from '@/lib/campaignActionRoutes'
@ -34,6 +35,18 @@ describe('campaignActionRoutes', () => {
expect(wizardActionHref('atualizar-votos', 'cairu')).toBe(
`${CAMPAIGN_ACTIONS_HOME}/atualizar-votos?municipio=cairu`,
)
expect(wizardActionHref('atualizar-votos', 'cairu', 'pessimistic')).toBe(
`${CAMPAIGN_ACTIONS_HOME}/atualizar-votos?municipio=cairu&cenario=pessimistic`,
)
})
it('resolves scenario search param with default and invalid flag', () => {
expect(resolveWizardScenarioParam(undefined)).toEqual({ scenario: 'central', invalid: false })
expect(resolveWizardScenarioParam('optimistic')).toEqual({
scenario: 'optimistic',
invalid: false,
})
expect(resolveWizardScenarioParam('invalid')).toEqual({ scenario: 'central', invalid: true })
})
it('parses municipio search param', () => {

View file

@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest'
import { MAX_VOTE_COUNT } from '@/lib/schemas/primitives'
import {
applyVoteShortcut,
getNextWizardVoteScenario,
getPreviousWizardVoteScenario,
getWizardVoteViolation,
mergeWizardVoteEstimate,
parseWizardVoteDraft,
WIZARD_VOTE_SCENARIO_EDIT_ORDER,
wizardVoteStepCtaLabel,
} from '@/lib/wizardVoteEstimate'
describe('applyVoteShortcut', () => {
it('doubles the current value', () => {
expect(applyVoteShortcut(150, 'double')).toBe(300)
})
it('no-ops double when value is below 1', () => {
expect(applyVoteShortcut(0, 'double')).toBe(0)
expect(applyVoteShortcut(null, 'double')).toBe(0)
})
it('clamps at MAX_VOTE_COUNT', () => {
expect(applyVoteShortcut(MAX_VOTE_COUNT - 10, '+50')).toBe(MAX_VOTE_COUNT)
expect(applyVoteShortcut(MAX_VOTE_COUNT, 'double')).toBe(MAX_VOTE_COUNT)
})
it('applies arithmetic shortcuts and floors at zero', () => {
expect(applyVoteShortcut(200, '+50')).toBe(250)
expect(applyVoteShortcut(200, '-50')).toBe(150)
expect(applyVoteShortcut(200, '+100')).toBe(300)
expect(applyVoteShortcut(40, '-100')).toBe(0)
})
})
describe('wizard scenario navigation', () => {
it('walks média → pessimista → otimista', () => {
expect(WIZARD_VOTE_SCENARIO_EDIT_ORDER).toEqual(['central', 'pessimistic', 'optimistic'])
expect(getNextWizardVoteScenario('central')).toBe('pessimistic')
expect(getNextWizardVoteScenario('pessimistic')).toBe('optimistic')
expect(getNextWizardVoteScenario('optimistic')).toBeNull()
expect(getPreviousWizardVoteScenario('pessimistic')).toBe('central')
expect(getPreviousWizardVoteScenario('central')).toBeNull()
})
it('labels CTA per scenario', () => {
expect(wizardVoteStepCtaLabel('central')).toBe('Ajustar estimativa média →')
expect(wizardVoteStepCtaLabel('pessimistic')).toBe('Ajustar estimativa pessimista →')
expect(wizardVoteStepCtaLabel('optimistic')).toBe('Ajustar estimativa otimista →')
})
})
describe('parseWizardVoteDraft', () => {
it('parses digits and empty as null', () => {
expect(parseWizardVoteDraft('')).toBeNull()
expect(parseWizardVoteDraft(' 1.200 ')).toBe(1200)
expect(parseWizardVoteDraft('abc')).toBeNull()
})
})
describe('getWizardVoteViolation', () => {
it('returns null for ordered triples', () => {
expect(getWizardVoteViolation({ pessimistic: 100, central: 200, optimistic: 300 })).toBeNull()
})
it('flags pessimista above média with readable message', () => {
const violation = getWizardVoteViolation({ pessimistic: 300, central: 200, optimistic: 400 })
expect(violation?.violatingScenario).toBe('central')
expect(violation?.message).toContain('Pessimista')
expect(violation?.message).toContain('média')
})
it('flags otimista below média', () => {
const violation = getWizardVoteViolation({ pessimistic: 100, central: 500, optimistic: 400 })
expect(violation?.violatingScenario).toBe('optimistic')
expect(violation?.message).toContain('Otimista')
})
})
describe('mergeWizardVoteEstimate', () => {
it('updates one scenario while keeping the rest', () => {
expect(
mergeWizardVoteEstimate({ pessimistic: 100, central: 200, optimistic: 300 }, 'central', 250),
).toEqual({ pessimistic: 100, central: 250, optimistic: 300 })
})
})