B77+: simplify vote wizard helpers after single-screen delivery.

Unify violation highlights with getWizardVoteViolation, share the expected-votes save error constant, and drop dead parseWizardVoteDraft plus unused municipalitySlug prop.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-30 03:40:20 -03:00
parent f2bdb1ce3c
commit 625f25c984
9 changed files with 36 additions and 85 deletions

View file

@ -67,7 +67,6 @@ export default async function CampaignActionWizardPage({
actionSlug={slug}
municipalityId={municipality.id}
municipalityName={municipality.name}
municipalitySlug={municipality.slug}
initialExpectedVotes={toVoteEstimateScenarioViewModel(municipality.expectedVotes)}
/>
)

View file

@ -2,6 +2,9 @@ import type { VoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
export const MUNICIPALITY_EXPECTED_VOTES_ENDPOINT = '/campanha/municipios/expected-votes' as const
export const MUNICIPALITY_EXPECTED_VOTES_SAVE_ERROR_MESSAGE =
'Não foi possível salvar os votos estimados. Tente novamente.' as const
export type MunicipalityListExpectedVotesResponse =
| {
status: 'success'

View file

@ -2,6 +2,7 @@
import {
MUNICIPALITY_EXPECTED_VOTES_ENDPOINT,
MUNICIPALITY_EXPECTED_VOTES_SAVE_ERROR_MESSAGE,
type MunicipalityListExpectedVotesResponse,
} from '@/app/(campaign)/campanha/(app)/municipios/expected-votes/types'
import { useMunicipalityEstimateScenarioOptional } from '@/components/campaign/municipality/MunicipalityEstimateScenarioContext'
@ -21,7 +22,6 @@ import {
import type { MunicipalityPledgeCoverageView } from '@/utilities/votePledgeViews'
const AUTOSAVE_MS = 600
const SAVE_ERROR_MESSAGE = 'Não foi possível salvar os votos estimados. Tente novamente.'
/**
* `municipalityName` is the Drawer's subject line and part of the trigger's
@ -57,7 +57,7 @@ export const MunicipalityListExpectedVotesControl = ({
endpoint: MUNICIPALITY_EXPECTED_VOTES_ENDPOINT,
buildBody: (values) => ({ municipalityId: municipalityID, expectedVotes: values }),
readSaved: (payload) => payload.savedExpectedVotes,
errorMessage: SAVE_ERROR_MESSAGE,
errorMessage: MUNICIPALITY_EXPECTED_VOTES_SAVE_ERROR_MESSAGE,
pendingMessage: 'Salvando votos estimados.',
})

View file

@ -5,6 +5,7 @@ import { useState, useTransition } from 'react'
import {
MUNICIPALITY_EXPECTED_VOTES_ENDPOINT,
MUNICIPALITY_EXPECTED_VOTES_SAVE_ERROR_MESSAGE,
type MunicipalityListExpectedVotesResponse,
} from '@/app/(campaign)/campanha/(app)/municipios/expected-votes/types'
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
@ -16,28 +17,26 @@ import { wizardActionHref } from '@/lib/campaignActionRoutes'
import { postCampaignJson } from '@/lib/campaignJsonRequest'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
import {
WIZARD_VOTES_NEXT_FLOW_PLACEHOLDER,
WIZARD_VOTES_SAVE_ERROR_MESSAGE,
WIZARD_VOTES_FINAL_CTA_LABEL,
WIZARD_VOTES_SAVED_MESSAGE,
wizardFlowTitleForSlug,
wizardNextStepPlaceholder,
wizardNextStepTitle,
} from '@/lib/campaignWizardCopy'
import { type VoteEstimateScenario, type VoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
import {
applyVoteShortcut,
getWizardVoteViolation,
getWizardVoteViolationHighlights,
VOTE_SHORTCUTS,
voteShortcutLabels,
wizardVoteFinalCtaLabel,
type VoteShortcut,
type WizardVoteViolation,
} from '@/lib/wizardVoteEstimate'
type WizardExpectedVotesStepProps = {
actionSlug: string
municipalityId: number
municipalityName: string
municipalitySlug: string
initialExpectedVotes: VoteEstimateScenarioViewModel
}
@ -45,22 +44,17 @@ export const WizardExpectedVotesStep = ({
actionSlug,
municipalityId,
municipalityName,
municipalitySlug: _municipalitySlug,
initialExpectedVotes,
}: WizardExpectedVotesStepProps) => {
const [isPending, startTransition] = useTransition()
const [estimates, setEstimates] = useState(initialExpectedVotes)
const [focusedScenario, setFocusedScenario] = useState<VoteEstimateScenario>('central')
const [violationMessage, setViolationMessage] = useState<string | null>(null)
const [highlightScenarios, setHighlightScenarios] = useState<ReadonlySet<VoteEstimateScenario>>(
new Set(),
)
const [violation, setViolation] = useState<WizardVoteViolation | null>(null)
const [saveError, setSaveError] = useState<string | null>(null)
const [phase, setPhase] = useState<'editing' | 'saved'>('editing')
const clearViolationState = () => {
setViolationMessage(null)
setHighlightScenarios(new Set())
if (violation) setViolation(null)
}
const handleValuesChange = (next: VoteEstimateScenarioViewModel) => {
@ -77,10 +71,9 @@ export const WizardExpectedVotesStep = ({
const handleConfirm = () => {
if (isPending) return
const violation = getWizardVoteViolation(estimates)
if (violation) {
setViolationMessage(violation.message)
setHighlightScenarios(new Set(getWizardVoteViolationHighlights(estimates)))
const nextViolation = getWizardVoteViolation(estimates)
if (nextViolation) {
setViolation(nextViolation)
setSaveError(null)
return
}
@ -97,7 +90,11 @@ export const WizardExpectedVotesStep = ({
)
if (!ok || payload.status !== 'success') {
setSaveError(payload.status === 'error' ? payload.message : WIZARD_VOTES_SAVE_ERROR_MESSAGE)
setSaveError(
payload.status === 'error'
? payload.message
: MUNICIPALITY_EXPECTED_VOTES_SAVE_ERROR_MESSAGE,
)
return
}
@ -118,7 +115,7 @@ export const WizardExpectedVotesStep = ({
>
<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>
<p className="text-sm text-muted-foreground">{wizardNextStepPlaceholder(actionSlug)}</p>
<Button asChild className="min-h-11 w-full sm:w-auto">
<Link href={CAMPAIGN_HOME}>Voltar ao Início</Link>
</Button>
@ -149,7 +146,7 @@ export const WizardExpectedVotesStep = ({
autoFocusScenario="central"
activeScenario={focusedScenario}
onFocusScenario={setFocusedScenario}
errorScenarios={highlightScenarios}
errorScenarios={violation ? new Set(violation.highlightScenarios) : undefined}
disabled={isPending}
onValuesChange={handleValuesChange}
/>
@ -169,9 +166,9 @@ export const WizardExpectedVotesStep = ({
))}
</div>
{violationMessage ? (
{violation ? (
<Alert variant="pending" className="border-estimate-pending">
<AlertDescription className="text-sm">{violationMessage}</AlertDescription>
<AlertDescription className="text-sm">{violation.message}</AlertDescription>
</Alert>
) : null}
@ -197,7 +194,7 @@ export const WizardExpectedVotesStep = ({
Salvando…
</>
) : (
wizardVoteFinalCtaLabel
WIZARD_VOTES_FINAL_CTA_LABEL
)}
</Button>
</div>

View file

@ -59,7 +59,5 @@ export const parseWizardMunicipioParam = (
return trimmed ? trimmed : undefined
}
export const hasWizardScenarioParam = (value: string | string[] | undefined): boolean => {
const raw = Array.isArray(value) ? value[0] : value
return raw?.trim() ? true : false
}
export const hasWizardScenarioParam = (value: string | string[] | undefined): boolean =>
parseWizardMunicipioParam(value) !== undefined

View file

@ -31,8 +31,7 @@ export const WIZARD_VOTES_SAVED_MESSAGE = 'Votos estimados atualizados.' as cons
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_FINAL_CTA_LABEL = 'Salvar estimativas →' as const
const WIZARD_NEXT_STEP_GENERIC_PLACEHOLDER = 'Próximo passo deste fluxo em breve.' as const

View file

@ -38,21 +38,10 @@ export const applyVoteShortcut = (value: number | null, shortcut: VoteShortcut):
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 wizardVoteFinalCtaLabel = 'Salvar estimativas →' as const
export type WizardVoteViolation = {
violatingScenario: VoteEstimateScenario
message: string
highlightScenarios: VoteEstimateScenario[]
}
export const getWizardVoteViolation = (
@ -64,16 +53,14 @@ export const getWizardVoteViolation = (
return {
violatingScenario,
message: buildWizardVoteViolationMessage(estimates, violatingScenario),
highlightScenarios: resolveWizardVoteHighlights(estimates, violatingScenario),
}
}
export const getWizardVoteViolationHighlights = (
const resolveWizardVoteHighlights = (
estimates: VoteEstimateScenarioViewModel,
violatingScenario: VoteEstimateScenario,
): VoteEstimateScenario[] => {
const violation = getWizardVoteViolation(estimates)
if (!violation) return []
const { violatingScenario } = violation
const { pessimistic, central, optimistic } = estimates
if (

View file

@ -21,7 +21,6 @@ const defaultProps = {
actionSlug: 'atualizar-votos',
municipalityId: 1,
municipalityName: 'Cairu',
municipalitySlug: 'cairu',
initialExpectedVotes: { pessimistic: 100, central: 200, optimistic: 300 },
}

View file

@ -1,12 +1,7 @@
import { describe, expect, it } from 'vitest'
import { MAX_VOTE_COUNT } from '@/lib/schemas/primitives'
import {
applyVoteShortcut,
getWizardVoteViolation,
getWizardVoteViolationHighlights,
parseWizardVoteDraft,
} from '@/lib/wizardVoteEstimate'
import { applyVoteShortcut, getWizardVoteViolation } from '@/lib/wizardVoteEstimate'
describe('applyVoteShortcut', () => {
it('doubles the current value', () => {
@ -31,49 +26,23 @@ describe('applyVoteShortcut', () => {
})
})
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', () => {
it('flags pessimista above média with readable message and highlights', () => {
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')
expect(violation?.highlightScenarios).toEqual(['pessimistic', 'central'])
})
it('flags otimista below média', () => {
it('flags otimista below média with highlights', () => {
const violation = getWizardVoteViolation({ pessimistic: 100, central: 500, optimistic: 400 })
expect(violation?.violatingScenario).toBe('optimistic')
expect(violation?.message).toContain('Otimista')
})
})
describe('getWizardVoteViolationHighlights', () => {
it('highlights pessimista and média when pessimista is above média', () => {
expect(
getWizardVoteViolationHighlights({ pessimistic: 300, central: 200, optimistic: 400 }),
).toEqual(['pessimistic', 'central'])
})
it('highlights otimista and média when otimista is below média', () => {
expect(
getWizardVoteViolationHighlights({ pessimistic: 100, central: 500, optimistic: 400 }),
).toEqual(['optimistic', 'central'])
})
it('returns empty when estimates are ordered', () => {
expect(
getWizardVoteViolationHighlights({ pessimistic: 100, central: 200, optimistic: 300 }),
).toEqual([])
expect(violation?.highlightScenarios).toEqual(['optimistic', 'central'])
})
})