B150 — Município v2 bloco Agora (encaminhamento + sugestão + visita)

- Loader e seção Agora na rota /campanha/municipio/[slug]/v2
- Encaminhamento editável com auto-save via POST /municipios/next-steps
- Até 2 sugestões E11 com resolve/dispensa existente; silêncio explícito
- Visita E13 condensada em uma linha + link ao compositor de giros
- Revalidação v2 ao resolver sugestão; helper compartilhado de empty state E11

Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-08-03 00:14:10 +00:00
parent deefe566b0
commit 6955cccc6b
No known key found for this signature in database
13 changed files with 507 additions and 26 deletions

View file

@ -0,0 +1,67 @@
# Impl: Município v2 — Agora (encaminhamento + sugestão + visita)
Status: aprovado
Atualizado em: 2026-08-03
Issue: #333
Intenção: docs/plans/municipio-v2-agora.md
Appetite restante: herdado (~0,5–1 dia eng)
## Leitura da intenção
- **Outcome:** Bloco “Agora” na rota v2 com encaminhamento editável no lugar, até duas sugestões acionáveis (ou silêncio explícito) e visita condensada em uma linha.
- **O que NÃO negociar:** um só motor E11; resolve/dispensa existente; `nextSteps` como fonte única; sem checklist E13 expandida; staff only (`noLeader`); silêncio explícito.
- **O que reavaliar:** hipótese de componente monolítico — separar loader server, controle client de `nextSteps` e seção RSC; JSON route (precedente political-trend) vs form action (precedente AdvisorDebouncedTextCell).
## Abordagem recomendada
```mermaid
flowchart TB
page[v2/page.tsx] --> loader[loadMunicipalityV2AgoraData]
loader --> VM[strategy.nextSteps + E11 slice 0-2 + E13 summary]
page --> section[MunicipalityV2AgoraSection RSC]
section --> next[MunicipalityV2NextStepsControl]
section --> cards[SuggestionCard × 0-2]
section --> visit[formatVisitEligibilitySummary + link giros]
next -->|POST /municipios/next-steps| action[updateMunicipalityStrategy partial]
cards -->|resolveSuggestionFormAction| E11[resolveSuggestion]
```
**Opções consideradas:** A) reutilizar `SuggestionsPanel` inteiro | B) compor loader + peças novas enxutas | C) novo motor de sugestão v2
**Recomendação:** B — mesmos loaders/ações; UI condensada sem statute/card E13.
**Rejeitadas:** A (torre de chrome do dashboard); C (twin motor).
### Componentes / mudanças
- **`formatVisitEligibilitySummary`** (`src/utilities/visit/visitEligibility.ts`): headline + frase curta; teste unitário.
- **`loadMunicipalityV2AgoraData`** (`src/utilities/municipality/municipalityV2AgoraData.ts`): `Promise.all` detail VM + suggestions + visit.
- **`MunicipalityV2NextStepsControl`** + **`/municipios/next-steps/route.ts`**: autosave debounced (precedente political-trend JSON).
- **`MunicipalityV2AgoraSection`**: RSC que monta os três sub-blocos.
- **`resolveSuggestion`**: `revalidateMunicipalityListPaths` inclui v2.
- **Migration:** sem migration.
### Dados → forma
- Encaminhamento: textarea auto-save (edit where you see).
- Sugestões: 0–2 `SuggestionCard` compactos; silêncio copiado do OverviewTab.
- Visita: uma linha “Elegível|Não elegível · motivo” + link compositor.
## Fases verificáveis
1. **Schema/server** — summary helper, loader, next-steps route, revalidation fix.
2. **UI** — section + control + wire v2 page.
3. **Gates** — `pnpm gate:fast`; push via `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- FAB B151; Conta/Rede B148–B149; compositor completo.
## Riscos e mitigação
- Partial strategy update sobrescrever outros campos → action só envia `nextSteps`.
- Sugestão stale na v2 → revalidate v2 no resolve.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto
- [x] Invariantes AGENTS/engineering-standards
- [x] Testes de domínio previstos (unit summary visit)

View file

@ -2,13 +2,16 @@ import config from '@payload-config'
import { notFound } from 'next/navigation'
import { getPayload } from 'payload'
import { resolveSuggestionFormAction } from '@/app/(campaign)/campanha/(app)/suggestionFormActions'
import { createMunicipalityV2SignalFormAction } from '@/app/(campaign)/campanha/(app)/municipio/[slug]/v2/formActions'
import { MunicipalityV2AgoraSection } from '@/components/campaign/municipality/MunicipalityV2AgoraSection'
import { MunicipalityV2NetworkSection } from '@/components/campaign/municipality/MunicipalityV2NetworkSection'
import { MunicipalityV2StatusStrip } from '@/components/campaign/municipality/MunicipalityV2StatusStrip'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { campaignPageMetadata } from '@/lib/campaignPageChrome'
import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { loadMunicipalityV2AgoraData } from '@/utilities/municipality/municipalityV2AgoraData'
import { MunicipalityNotFoundError } from '@/utilities/municipality/municipalityPageData'
import { loadMunicipalityV2NetworkData } from '@/utilities/municipality/municipalityV2NetworkData'
import { loadMunicipalityV2StatusData } from '@/utilities/municipality/municipalityV2StatusData'
@ -33,13 +36,16 @@ export default async function MunicipalityV2Page({ params }: MunicipalityV2PageP
let status
let network
let agora
try {
const loaded = await Promise.all([
const [statusLoaded, networkLoaded, agoraLoaded] = await Promise.all([
loadMunicipalityV2StatusData(payload, user, slug),
loadMunicipalityV2NetworkData(payload, user, slug),
loadMunicipalityV2AgoraData(payload, user, slug),
])
;({ status } = loaded[0])
network = loaded[1]
;({ status } = statusLoaded)
network = networkLoaded
agora = agoraLoaded
} catch (error) {
if (error instanceof MunicipalityNotFoundError) notFound()
throw error
@ -53,7 +59,7 @@ export default async function MunicipalityV2Page({ params }: MunicipalityV2PageP
signalFormAction={createMunicipalityV2SignalFormAction}
/>
{/* Placeholders for B148–B150 — keep the vertical rhythm without shipping twin content. */}
{/* Placeholder for B148 — keep the vertical rhythm without shipping twin content. */}
<section aria-labelledby="municipio-v2-conta-title" className="flex flex-col gap-2">
<h2 id="municipio-v2-conta-title" className="text-base font-medium text-muted-foreground">
Conta local
@ -61,13 +67,7 @@ export default async function MunicipalityV2Page({ params }: MunicipalityV2PageP
<p className="text-sm text-muted-foreground">Em breve nesta visão.</p>
</section>
<MunicipalityV2NetworkSection network={network} />
<section aria-labelledby="municipio-v2-agora-title" className="flex flex-col gap-2">
<h2 id="municipio-v2-agora-title" className="text-base font-medium text-muted-foreground">
Agora
</h2>
<p className="text-sm text-muted-foreground">Em breve nesta visão.</p>
</section>
<MunicipalityV2AgoraSection agora={agora} resolveAction={resolveSuggestionFormAction} />
</div>
</CampaignPageShell>
)

View file

@ -30,7 +30,7 @@ import {
import { loadMunicipalityElectoralBaseline } from '@/utilities/municipality/municipalityElectoralBaseline'
import { loadMunicipalityGoalAccount } from '@/utilities/municipality/municipalityGoalAccount'
import type { getMunicipalityDetailViewModel } from '@/utilities/municipality/municipalityPageData'
import { formatSilenceAgeLabel } from '@/utilities/municipality/municipalitySignal'
import { formatMunicipalitySuggestionEmptyMessage } from '@/utilities/municipality/municipalitySignal'
import { loadMunicipalityTicketPartners } from '@/utilities/municipality/municipalityTicketPartnerData'
import { loadMunicipalitySuggestions } from '@/utilities/municipality/municipalityTriggers'
import {
@ -149,17 +149,11 @@ const SuggestionsCard = async ({
activeCount={bundle.suggestions.length}
resolveAction={resolveSuggestionFormAction}
emptyState={
silence ? (
<p className="text-sm text-muted-foreground">
Nenhum padrão do catálogo dispara aqui — e o município{' '}
{formatSilenceAgeLabel(silence.lastSignalAgeDays)}. Silêncio é pergunta: vale checar a
rede e o registro.
</p>
) : (
<p className="text-sm text-muted-foreground">
Nenhum padrão do catálogo dispara aqui agora.
</p>
)
<p className="text-sm text-muted-foreground">
{formatMunicipalitySuggestionEmptyMessage(
silence ? { lastSignalAgeDays: silence.lastSignalAgeDays } : null,
)}
</p>
}
/>
)

View file

@ -0,0 +1,42 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { municipalityStaffEditSafeMessages } from '@/app/(campaign)/campanha/(app)/municipios/municipalityStaffEditMessages'
import { updateMunicipalityStrategy } from '@/app/(campaign)/campanha/actions/municipality'
import { trimmedNullableText, positiveRelationshipId } from '@/lib/schemas/primitives'
import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
import { revalidateMunicipalityListPaths } from '@/utilities/municipality/municipalityRevalidation'
import type { MunicipalityNextStepsResponse } from './types'
export type { MunicipalityNextStepsResponse } from './types'
export const dynamic = 'force-dynamic'
const bodySchema = z.object({
municipalityId: positiveRelationshipId,
nextSteps: trimmedNullableText(4000),
})
export const POST = campaignJsonMutationRoute(
{
bodySchema,
safeMessages: municipalityStaffEditSafeMessages,
genericMessage:
'Não foi possível salvar o encaminhamento. Verifique seu acesso e tente novamente.',
},
async ({ municipalityId, nextSteps }) => {
const updated = await updateMunicipalityStrategy({
municipality: municipalityId,
nextSteps,
})
revalidateMunicipalityListPaths({ slug: updated.slug, scope: 'detail' })
return NextResponse.json<MunicipalityNextStepsResponse>({
status: 'success',
message: 'Encaminhamento atualizado.',
savedNextSteps: updated.nextSteps ?? null,
})
},
)

View file

@ -0,0 +1,10 @@
export type MunicipalityNextStepsResponse =
| {
status: 'success'
message: string
savedNextSteps: string | null
}
| {
status: 'error'
message: string
}

View file

@ -19,6 +19,7 @@ import { DEFAULT_VOTE_ESTIMATE_SCENARIO } from '@/lib/voteEstimate'
import type { AllocationDecision, CampaignUser } from '@/payload-types'
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
import { CAMPAIGN_STAFF_QUADRO_PATH } from '@/utilities/campaignPageActor'
import { revalidateMunicipalityListPaths } from '@/utilities/municipality/municipalityRevalidation'
import { loadMunicipalitySuggestions } from '@/utilities/municipality/municipalityTriggers'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
@ -143,10 +144,9 @@ export const resolveSuggestion = async (
const { payload, actor } = await getCampaignActionContext()
const { municipalitySlug, postponeDays } = await resolveSuggestionRecord(payload, actor, input)
// The two surfaces that render the queue: the staff dashboard panel and the
// município detail card.
// The surfaces that render the queue: staff dashboard, legacy detail, v2 Agora.
revalidatePath(CAMPAIGN_STAFF_QUADRO_PATH)
revalidatePath(`/campanha/municipios/${municipalitySlug}`)
revalidateMunicipalityListPaths({ slug: municipalitySlug, scope: 'detail' })
return { postponeDays }
}

View file

@ -0,0 +1,81 @@
import { ArrowRightIcon } from 'lucide-react'
import Link from 'next/link'
import type { ComponentProps } from 'react'
import { MunicipalityV2NextStepsControl } from '@/components/campaign/municipality/MunicipalityV2NextStepsControl'
import { SuggestionCard } from '@/components/campaign/suggestion/SuggestionCard'
import { Button } from '@/components/ui/button'
import type { MunicipalityV2AgoraViewModel } from '@/utilities/municipality/municipalityV2AgoraView'
import { buildTourComposerHref } from '@/utilities/visit/visitPlannerUrl'
type MunicipalityV2AgoraSectionProps = {
agora: MunicipalityV2AgoraViewModel
resolveAction: ComponentProps<typeof SuggestionCard>['resolveAction']
}
/**
* B150 — "o que fazer agora": encaminhamento editável, até duas sugestões, visita
* em uma linha. Server component; os sub-blocos interativos são ilhas client.
*/
export const MunicipalityV2AgoraSection = ({
agora,
resolveAction,
}: MunicipalityV2AgoraSectionProps) => {
const tourHref = buildTourComposerHref({ region: agora.visit.region })
return (
<section aria-labelledby="municipio-v2-agora-title" className="flex flex-col gap-6">
<h2 id="municipio-v2-agora-title" className="text-base font-medium text-muted-foreground">
Agora
</h2>
<div className="flex flex-col gap-4 rounded-xl border p-4">
<MunicipalityV2NextStepsControl
municipalityID={agora.municipalityID}
municipalityName={agora.municipalityName}
defaultValue={agora.nextSteps}
/>
</div>
<div className="flex flex-col gap-3">
<h3 className="text-sm font-medium">Sugestões</h3>
{agora.suggestions.length > 0 ? (
<div className="flex flex-col gap-4">
{agora.suggestions.map((suggestion) => (
<SuggestionCard
key={`${suggestion.municipalityID}-${suggestion.patternId}`}
suggestion={suggestion}
resolveAction={resolveAction}
/>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">{agora.suggestionSilence}</p>
)}
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-0.5">
<h3 className="text-sm font-medium">Visita do candidato</h3>
{agora.visit.summary ? (
<p className="text-sm text-muted-foreground">
<span className="font-medium text-foreground">{agora.visit.summary.headline}</span>
{' · '}
{agora.visit.summary.detail}
</p>
) : (
<p className="text-sm text-muted-foreground">
Dados insuficientes para avaliar a elegibilidade de visita.
</p>
)}
</div>
<Button asChild variant="outline" size="sm" className="shrink-0">
<Link href={tourHref}>
Abrir compositor de giros
<ArrowRightIcon className="size-4" aria-hidden="true" />
</Link>
</Button>
</div>
</section>
)
}

View file

@ -0,0 +1,135 @@
'use client'
import { useRouter } from 'next/navigation'
import { useEffect, useId, useRef, useState, type ChangeEvent } from 'react'
import type { MunicipalityNextStepsResponse } from '@/app/(campaign)/campanha/(app)/municipios/next-steps/types'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import { campaignInlineInputClassName } from '@/lib/campaignInlineInput'
import { postCampaignJson } from '@/lib/campaignJsonRequest'
import { cn } from '@/lib/utils'
const NEXT_STEPS_ENDPOINT = '/campanha/municipios/next-steps'
const SAVE_DEBOUNCE_MS = 600
const SAVE_ERROR_MESSAGE = 'Não foi possível salvar o encaminhamento. Tente novamente.'
const NEXT_STEPS_MAX_LENGTH = 4000
const normalizeNextSteps = (value: string | null | undefined): string | null => {
const trimmed = value?.trim()
return trimmed ? trimmed : null
}
type MunicipalityV2NextStepsControlProps = {
municipalityID: number
municipalityName: string
defaultValue: string | null
}
export const MunicipalityV2NextStepsControl = ({
municipalityID,
municipalityName,
defaultValue,
}: MunicipalityV2NextStepsControlProps) => {
const statusId = useId()
const router = useRouter()
const displayDefault = defaultValue ?? ''
const [value, setValue] = useState(displayDefault)
const [isPending, setIsPending] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const lastSaved = useRef(normalizeNextSteps(displayDefault) ?? '')
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const requestId = useRef(0)
const textareaRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
if (textareaRef.current && document.activeElement === textareaRef.current) return
const next = defaultValue ?? ''
setValue(next)
lastSaved.current = normalizeNextSteps(next) ?? ''
}, [defaultValue, municipalityID])
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
}
}, [])
const save = (nextRaw: string) => {
const normalized = normalizeNextSteps(nextRaw) ?? ''
if (normalized === lastSaved.current) return
const id = ++requestId.current
setIsPending(true)
setErrorMessage(null)
void postCampaignJson<MunicipalityNextStepsResponse>(
NEXT_STEPS_ENDPOINT,
{ municipalityId: municipalityID, nextSteps: normalized || null },
).then(({ ok, payload }) => {
if (id !== requestId.current) return
setIsPending(false)
if (!ok || payload.status !== 'success') {
setErrorMessage(
payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE,
)
return
}
lastSaved.current = normalizeNextSteps(payload.savedNextSteps) ?? ''
router.refresh()
})
}
const scheduleSave = (nextRaw: string) => {
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => {
timerRef.current = null
save(nextRaw)
}, SAVE_DEBOUNCE_MS)
}
return (
<div className="flex flex-col gap-1.5">
<label htmlFor={`${statusId}-next-steps`} className="text-sm font-medium">
Próximo passo combinado
<span className="sr-only"> para {municipalityName}</span>
</label>
<div className="relative">
<Textarea
ref={textareaRef}
id={`${statusId}-next-steps`}
value={value}
rows={3}
maxLength={NEXT_STEPS_MAX_LENGTH}
placeholder="O que a equipe combinou fazer a seguir neste município?"
aria-label={`Encaminhamento de ${municipalityName}`}
aria-busy={isPending}
aria-describedby={errorMessage ? `${statusId}-next-steps-error` : undefined}
className={cn(campaignInlineInputClassName, 'min-h-24 resize-y py-2')}
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => {
setValue(event.currentTarget.value)
setErrorMessage(null)
scheduleSave(event.currentTarget.value)
}}
onBlur={() => {
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
save(value)
}}
/>
{isPending ? (
<Spinner className="pointer-events-none absolute top-2 right-2 size-3.5" />
) : null}
</div>
{errorMessage ? (
<p id={`${statusId}-next-steps-error`} className="text-sm text-destructive" role="alert">
{errorMessage}
</p>
) : (
<p className="text-xs text-muted-foreground">Salva automaticamente ao editar.</p>
)}
</div>
)
}

View file

@ -56,3 +56,11 @@ export const formatMunicipalitySignalAgeLabel = (ageInDays: number | null): stri
*/
export const formatSilenceAgeLabel = (ageInDays: number | null): string =>
ageInDays === null ? 'nunca recebeu sinal' : `sem sinal há ${ageInDays} dias`
/** E11 empty queue copy — shared by the detail overview and the v2 Agora block. */
export const formatMunicipalitySuggestionEmptyMessage = (
silence: { lastSignalAgeDays: number | null } | null,
): string =>
silence
? `Nenhum padrão do catálogo dispara aqui — e o município ${formatSilenceAgeLabel(silence.lastSignalAgeDays)}. Silêncio é pergunta: vale checar a rede e o registro.`
: 'Nenhum padrão do catálogo dispara aqui agora.'

View file

@ -0,0 +1,56 @@
import 'server-only'
import type { Payload } from 'payload'
import type { CampaignUser } from '@/payload-types'
import {
getMunicipalityDetailViewModel,
resolveAccessibleMunicipalityContext,
} from '@/utilities/municipality/municipalityPageData'
import { formatMunicipalitySuggestionEmptyMessage } from '@/utilities/municipality/municipalitySignal'
import { loadMunicipalitySuggestions } from '@/utilities/municipality/municipalityTriggers'
import type { MunicipalityV2AgoraViewModel } from '@/utilities/municipality/municipalityV2AgoraView'
import { formatVisitEligibilitySummary } from '@/utilities/visit/visitEligibility'
import { loadMunicipalityVisitEligibility } from '@/utilities/visit/visitPlannerData'
const MAX_AGORA_SUGGESTIONS = 2
export const loadMunicipalityV2AgoraData = async (
payload: Payload,
user: CampaignUser,
municipalitySlug: string,
): Promise<MunicipalityV2AgoraViewModel> => {
const context = await resolveAccessibleMunicipalityContext(payload, user, municipalitySlug)
const [view, suggestionBundle, visit] = await Promise.all([
getMunicipalityDetailViewModel(payload, context, user),
loadMunicipalitySuggestions(payload, user, { municipalityID: context.id }),
loadMunicipalityVisitEligibility(payload, user, municipalitySlug),
])
const strategy = view.strategy
if (!strategy) {
throw new Error('Municipality v2 Agora requires a staff strategy view model.')
}
const silence = suggestionBundle.silence.find((entry) => entry.municipalityID === context.id)
const suggestionSilence =
suggestionBundle.suggestions.length === 0
? formatMunicipalitySuggestionEmptyMessage(
silence ? { lastSignalAgeDays: silence.lastSignalAgeDays } : null,
)
: null
return {
municipalityID: view.id,
municipalityName: view.name,
nextSteps: strategy.nextSteps,
suggestions: suggestionBundle.suggestions
.slice(0, MAX_AGORA_SUGGESTIONS)
.map(({ metrics: _metrics, ...card }) => card),
suggestionSilence,
visit: {
summary: visit.candidate ? formatVisitEligibilitySummary(visit.candidate.eligibility) : null,
region: visit.candidate?.region ?? null,
},
}
}

View file

@ -0,0 +1,15 @@
import type { SuggestionCardData } from '@/components/campaign/suggestion/SuggestionCard'
import type { BahiaIdentityTerritory } from '@/lib/bahiaTerritories'
import type { VisitEligibilitySummary } from '@/utilities/visit/visitEligibility'
export type MunicipalityV2AgoraViewModel = {
municipalityID: number
municipalityName: string
nextSteps: string | null
suggestions: SuggestionCardData[]
suggestionSilence: string | null
visit: {
summary: VisitEligibilitySummary | null
region: BahiaIdentityTerritory | null
}
}

View file

@ -285,3 +285,45 @@ export const visitConditionLabels: Record<VisitConditionId, string> = {
janela: 'Janela política',
encaixe: 'Encaixe em giro',
}
export type VisitEligibilitySummary = {
eligible: boolean
headline: string
detail: string
}
/**
* B150 — one-line readout for the v2 "Agora" block: eligible or not, plus a
* short reason. Full checklist stays on the FAB/detail card (E13).
*/
export const formatVisitEligibilitySummary = (
eligibility: VisitEligibility,
): VisitEligibilitySummary => {
const allMet =
eligibility.metCount === VISIT_CONDITIONS.length && eligibility.contraindication === null
if (allMet) {
return {
eligible: true,
headline: 'Elegível',
detail: 'As cinco condições estão atendidas.',
}
}
if (eligibility.contraindication) {
return {
eligible: false,
headline: 'Não elegível',
detail: visitContraindicationLabels[eligibility.contraindication.id],
}
}
const firstUnmet = eligibility.conditions.find((condition) => !condition.met)
return {
eligible: false,
headline: 'Não elegível',
detail: firstUnmet
? `${visitConditionLabels[firstUnmet.id]}: ${firstUnmet.detail}`
: 'Condições incompletas.',
}
}

View file

@ -7,6 +7,7 @@ import {
calendarPhaseLabels,
calendarPhaseVisitProduct,
evaluateVisitEligibility,
formatVisitEligibilitySummary,
resolveCalendarPhase,
VISIT_CONDITIONS,
VISIT_CONTRAINDICATIONS,
@ -190,6 +191,36 @@ describe('visit planner copy', () => {
})
})
describe('formatVisitEligibilitySummary', () => {
it('reads eligible when all five conditions are met', () => {
const summary = formatVisitEligibilitySummary(evaluateVisitEligibility(input()))
expect(summary).toEqual({
eligible: true,
headline: 'Elegível',
detail: 'As cinco condições estão atendidas.',
})
})
it('reads not eligible with the contraindication headline when one fires', () => {
const summary = formatVisitEligibilitySummary(
evaluateVisitEligibility(
input({ leadershipCount: 0, pledgeCount: 0, territorialClass: 'sem_base' }),
),
)
expect(summary.eligible).toBe(false)
expect(summary.headline).toBe('Não elegível')
expect(summary.detail).toBe(visitContraindicationLabels.perdida)
})
it('names the first unmet condition when there is no contraindication', () => {
const summary = formatVisitEligibilitySummary(
evaluateVisitEligibility(input({ projectedValidVotes: 1_000, projectedValidVotesCut: 10_000 })),
)
expect(summary.eligible).toBe(false)
expect(summary.detail).toContain(visitConditionLabels.volume)
})
})
type CandidateOverrides = {
slug: string
name?: string