feat(campanha): B147 município v2 shell + faixa de status

Rota paralela /campanha/municipio/<slug>/v2 com selects de nível, tendência e
sinal (modal de motivo opcional), classe territorial e agregado. Soft-dep B134:
motivo opcional no movimento de nível e remoção do campo “voltar atrás” do
write path/lista.

Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-08-02 23:29:05 +00:00
parent 466a34525d
commit 0aa47f8673
No known key found for this signature in database
16 changed files with 1163 additions and 44 deletions

View file

@ -0,0 +1,107 @@
# Impl: Município v2 — shell + faixa de status
Status: aprovado
Atualizado em: 2026-08-02
Issue: #330
Intenção: docs/plans/municipio-v2-shell-status.md
Appetite restante: ~1–1,5 dia eng (herdado)
## Leitura da intenção
- **Outcome:** rota paralela `/campanha/municipio/<slug>/v2` com faixa de status operável (nível · tendência · sinal · classe · silêncio/frescor · agregado de notas); select abre modal de motivo opcional; confirmação grava; rota antiga intacta; leader lockdown.
- **O que NÃO negociar:** leader fora; auto-save no select; motivo obrigatório; hero nome/TI no corpo; painel de ajuda inline; padrão distinto para sinal; expor estimado; matar `/municipios/[slug]`.
- **O que reavaliar:** “nova rota + composição municipality/” (ok); hipótese de só reusar loaders vs extrair view model de status; soft-dep B134 (schema de nível ainda exige motivo + reversalSignals).
## Abordagem recomendada
```mermaid
flowchart LR
Page["municipio/[slug]/v2/page.tsx"]
Loader["loadMunicipalityV2StatusData"]
Strip["MunicipalityV2StatusStrip"]
Modal["MunicipalityV2StatusReasonDialog"]
LevelAPI["POST …/engagement-level"]
TrendAPI["POST …/political-trend"]
SignalFA["createMunicipalityListSignalFormAction"]
Page --> Loader --> Strip
Strip --> Modal
Modal --> LevelAPI
Modal --> TrendAPI
Modal --> SignalFA
```
**Opções consideradas:**
| | A — Thin page + extract shared list controls into strip | B — V2-owned status strip + shared optional-reason dialog; reuse write endpoints | C — Full dual-path ops-sync surface for v2 |
| --- | ------------------------------------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------ |
| | Reusa UI da lista | UI nova densa; writes iguais | Prematuro (OH cutover) |
**Recomendação:** B — because the list cells are Popover/Sheet autosave (trend) or multi-field E14 popover (level), which violate the product rite (select → optional reason modal → confirm). A new strip that calls the same endpoints keeps one write policy without twinning actions.
**Rejeitadas:** A because list UX ≠ select+modal (would force a second mode into list cells or ship the wrong rite). C because B147 is shell-only and appetite forbids dual-path.
### Decisões de engenharia
1. **Rota singular nova** `src/app/(campaign)/campanha/(app)/municipio/[slug]/v2/page.tsx`
Opções: sob `municipios/…/v2` | singular `municipio/…/v2` | query `?v=2`.
**Rec:** singular — matches locked product URL. Rejected: plural (breaks contract); query (pollutes old detail).
2. **Dados da faixa** — thin loader `loadMunicipalityV2StatusData` composing existing detail VM + territorial class + last typed signal preview.
Opções: only `getMunicipalityDetailViewModel` | new loader | dossier loader.
**Rec:** new thin composer over `resolveAccessibleMunicipalityContext` + strategy fields + `computeMunicipalityTerritorialClass` + latest `sinal` update (for type/note aggregate). Rejected: dossier (heavy); detail-only (no last signal type).
3. **Motivo opcional no nível (soft-dep B134)** — relax engagement write schema so empty note OK; stop requiring `reversalSignals` on new writes (omit/empty in snapshot). Align list level control to the same rite (optional note only; drop reversal field from UI).
Opções: leave schema + break “vazio OK” | v2-only bypass with placeholder text | relax schema + update list owner in this PR.
**Rec:** third — product acceptance requires empty OK; edit the owner (schema + list control + action). Historical snapshot scrub / wizard trend step remain on #288 (B134). No Payload migration: `reversalSignals` lives only in `allocationDecision.snapshot` JSON.
4. **Sinal frio (questão aberta da intenção)** — assume A: select value = sentinel “Sem sinal / frio” when cold; age in aggregate/tooltip.
Threshold: `MUNICIPALITY_COLD_SIGNAL_DAYS` (21) from `municipalitySignal.ts` (lista).
5. **Chrome / nome** — no body hero. Path rule → section title `Municípios`; `generateMetadata` uses municipality name. B145 entity-title-in-header stays soft-dep (no twin).
6. **Nível só unrestricted** — strip shows level as readout for advisors; selects for trend/signal for all staff. Gate mirrors list (`canMoveEngagementLevel`).
### Componentes / mudanças
- **`MunicipalityV2Page`** (`src/app/(campaign)/campanha/(app)/municipio/[slug]/v2/page.tsx`): RSC; `requireCampaignPageActor({ gate: 'noLeader' })`; status strip; empty slots for conta/rede/agora (children).
- **`loadMunicipalityV2StatusData`** (`src/utilities/municipality/municipalityV2StatusData.ts`): server-only composer.
- **`buildMunicipalityV2StatusAggregate`** (`src/lib/municipalityV2Status.ts`): pure aggregate text + cold sentinel helpers (unit-tested).
- **`MunicipalityV2StatusStrip`** + **`MunicipalityV2StatusReasonDialog`** (`src/components/campaign/municipality/`): client strip + shared Dialog (motivo opcional).
- **Schema/action/list level:** `municipalityEngagementLevelSchema` note → optional; `reversalSignals` optional/default `''`; list control drops reversal field; int tests updated.
- **Chrome:** path rule `/campanha/municipio/[^/]+/v2`.
- **Form action:** reuse `createMunicipalityListSignalFormAction` (or thin v2 wrapper colocated under the new route).
- **Migration:** nenhuma.
- **Access / Consent:** existing municipality access; no Consent; leader blocked by page gate.
- **UI:** Impeccable C — shape → craft → critique → polish; tokens `data-theme='campaign'`; `CampaignHoverTooltip` + `campaignConceptHref` / `campaignConceptOneLiner`.
### Dados → forma
- Forma: dense control strip (native selects + readouts), not cards/KPIs — qualitative status for CG decisions. Rejected: card tower (old overview); pill cluster for signal (product locked select parity).
## Fases verificáveis
1. **Tracer / server** — schema note optional + reversal optional; loader + pure aggregate; page RSC with strip read-only first; unit tests for aggregate/cold.
2. **UI** — three selects + modal + writes + tooltips + class/silence; placeholders for children sections.
3. **Gates** — `pnpm gate:fast`; entrega via `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- Conta / rede / agora / FAB / cutover (B148–B152).
- Wizard de tendência (resto B134).
- Scrub histórico de `reversalSignals` em snapshots.
- Dual-path / ops-sync.
- Redesign hysteresis UI beyond override/shock checkboxes already in list (keep minimal in modal when violations apply).
## Riscos e mitigação
- **B134 race:** #288 blocked; we land write-policy subset needed for aceite; comment on #288 that schema/list already optional.
- **Hysteresis in modal:** keep override + triangulatedShock checkboxes when violations present (same as list), else confirm is one-tap.
- **Signal body required by schema for non-semanal:** modal “motivo” maps to `body`; empty fails today — relax sinal body to optional in create schema when kind=sinal (motivo opcional), keeping signalType required.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto
- [x] Invariantes AGENTS/engineering-standards
- [x] Testes de domínio: unit aggregate/cold; int engagement level empty note; list control still submits
Self-score decision-quality: 4/5

View file

@ -0,0 +1,35 @@
'use server'
import { createMunicipalityUpdate } from '@/app/(campaign)/campanha/actions/municipalityUpdate'
import {
optionalFormText,
optionalMunicipalitySlugFromForm,
requiredRelationshipFormValue,
} from '@/lib/formData'
import { parseMunicipalitySignalType } from '@/lib/schemas/municipalityUpdate'
import {
runCampaignFormAction,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
import { revalidateMunicipalityListPaths } from '@/utilities/municipality/municipalityRevalidation'
const SIGNAL_SAVED_MESSAGE = 'Sinal registrado.'
export const createMunicipalityV2SignalFormAction = async (
formData: FormData,
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
const municipality = requiredRelationshipFormValue(formData, 'municipalityId')
await createMunicipalityUpdate({
municipality,
kind: 'sinal',
body: optionalFormText(formData, 'body'),
signalType: parseMunicipalitySignalType(optionalFormText(formData, 'signalType')),
})
revalidateMunicipalityListPaths({ slug: optionalMunicipalitySlugFromForm(formData) })
return { message: SIGNAL_SAVED_MESSAGE }
},
genericMessage: 'Não foi possível registrar o sinal. Verifique seu acesso e tente novamente.',
})

View file

@ -0,0 +1,70 @@
import config from '@payload-config'
import { notFound } from 'next/navigation'
import { getPayload } from 'payload'
import { createMunicipalityV2SignalFormAction } from '@/app/(campaign)/campanha/(app)/municipio/[slug]/v2/formActions'
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 { MunicipalityNotFoundError } from '@/utilities/municipality/municipalityPageData'
import { loadMunicipalityV2StatusData } from '@/utilities/municipality/municipalityV2StatusData'
type MunicipalityV2PageProps = {
params: Promise<{ slug: string }>
}
export async function generateMetadata({ params }: MunicipalityV2PageProps) {
const { slug } = await params
const entry = getMunicipalityCatalogEntry(slug)
// Soft-dep B145: entity title belongs in chrome; metadata carries the name until then.
return campaignPageMetadata({ title: entry?.name ?? 'Município' })
}
export default async function MunicipalityV2Page({ params }: MunicipalityV2PageProps) {
const { slug } = await params
const [user, payload] = await Promise.all([
requireCampaignPageActor({ gate: 'noLeader' }),
getPayload({ config }),
])
let status
try {
;({ status } = await loadMunicipalityV2StatusData(payload, user, slug))
} catch (error) {
if (error instanceof MunicipalityNotFoundError) notFound()
throw error
}
return (
<CampaignPageShell>
<div className="flex flex-col gap-8">
<MunicipalityV2StatusStrip
status={status}
signalFormAction={createMunicipalityV2SignalFormAction}
/>
{/* Placeholders for B148–B150 — 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
</h2>
<p className="text-sm text-muted-foreground">Em breve nesta visão.</p>
</section>
<section aria-labelledby="municipio-v2-rede-title" className="flex flex-col gap-2">
<h2 id="municipio-v2-rede-title" className="text-base font-medium text-muted-foreground">
Rede
</h2>
<p className="text-sm text-muted-foreground">Em breve nesta visão.</p>
</section>
<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>
</div>
</CampaignPageShell>
)
}

View file

@ -149,8 +149,12 @@ export const setMunicipalityEngagementLevelRecord = async (
actor: CampaignUser,
input: MunicipalityEngagementLevelInput,
) => {
const { municipality, level, note, reversalSignals, triangulatedShock, override } =
const { municipality, level, note, triangulatedShock, override } =
municipalityEngagementLevelSchema.parse(input)
const levelNote = note ?? null
// allocationDecision.rationale is required; empty Motivo still leaves an
// auditable row without inventing coordinator prose.
const rationale = levelNote ?? 'Sem motivo registrado.'
return withPayloadTransaction(
payload,
@ -190,7 +194,7 @@ export const setMunicipalityEngagementLevelRecord = async (
const updated = await payload.update({
collection: 'municipality',
id: municipality,
data: { engagementLevel: level, levelNote: note },
data: { engagementLevel: level, levelNote },
depth: 0,
user: currentActor,
overrideAccess: false,
@ -203,13 +207,12 @@ export const setMunicipalityEngagementLevelRecord = async (
municipality,
patternId: ENGAGEMENT_LEVEL_PATTERN_ID,
outcome: 'movimento',
rationale: note,
rationale,
// Only the values the decision was taken on — no document dumps
// (the collection caps the serialized snapshot at 16 KB).
snapshot: {
from,
to: level,
reversalSignals,
triangulatedShock,
violations: violations.map((violation) => violation.id),
overridden: violations.length > 0,

View file

@ -45,9 +45,8 @@ type MunicipalityListLevelControlProps = {
/**
* E14 — unlike the trend and estimate cells next to it, this one submits
* explicitly: a movement carries a motivo AND the signals that would reverse
* it, and it may need an override. Auto-saving half of that would file an
* incomplete decision under the coordinator's name.
* explicitly: a movement may need an override / triangulated shock. Motivo is
* optional (B134/B147). Auto-saving would skip the confirmation rite.
*/
export const MunicipalityListLevelControl = ({
municipalityID,
@ -73,7 +72,6 @@ export const MunicipalityListLevelControl = ({
// with it the select — is not mounted.
const [draftLevel, setDraftLevel] = useState<EngagementLevel | ''>('')
const [note, setNote] = useState('')
const [reversalSignals, setReversalSignals] = useState('')
const [triangulatedShock, setTriangulatedShock] = useState(false)
const [override, setOverride] = useState(false)
const [isPending, setIsPending] = useState(false)
@ -142,7 +140,6 @@ export const MunicipalityListLevelControl = ({
const resetDraft = () => {
setDraftLevel(currentLevel ?? '')
setNote('')
setReversalSignals('')
setTriangulatedShock(false)
setOverride(false)
setErrorMessage(null)
@ -173,8 +170,7 @@ export const MunicipalityListLevelControl = ({
{
municipalityId: municipalityID,
level: draftLevel,
note,
reversalSignals,
note: note.trim() ? note : null,
triangulatedShock,
override,
},
@ -205,11 +201,7 @@ export const MunicipalityListLevelControl = ({
}
}
const canSubmit =
isMovement &&
note.trim().length > 0 &&
reversalSignals.trim().length > 0 &&
(violations.length === 0 || override)
const canSubmit = isMovement && (violations.length === 0 || override)
const fieldId = (suffix: string) => `municipality-list-level-${suffix}-${municipalityID}`
const isSheet = variant === 'sheet'
const submitButton = (
@ -283,7 +275,7 @@ export const MunicipalityListLevelControl = ({
</NativeSelect>
</Field>
<Field>
<FieldLabel htmlFor={fieldId('note')}>Motivo</FieldLabel>
<FieldLabel htmlFor={fieldId('note')}>Motivo (opcional)</FieldLabel>
<Textarea
id={fieldId('note')}
value={note}
@ -294,18 +286,6 @@ export const MunicipalityListLevelControl = ({
placeholder="O que mudou para justificar este nível?"
/>
</Field>
<Field>
<FieldLabel htmlFor={fieldId('reversal')}>O que faria voltar atrás</FieldLabel>
<Textarea
id={fieldId('reversal')}
value={reversalSignals}
onChange={(event) => setReversalSignals(event.target.value)}
maxLength={ENGAGEMENT_LEVEL_TEXT_MAX_LENGTH}
rows={2}
className="min-h-16 resize-y"
placeholder="Sinais que fariam rever esta decisão."
/>
</Field>
{isJump ? (
<Field orientation="horizontal" className={isSheet ? 'min-h-11' : undefined}>
<Checkbox

View file

@ -0,0 +1,170 @@
'use client'
import Link from 'next/link'
import { useId, useState, type ReactNode } from 'react'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/Checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Field, FieldContent, FieldLabel } from '@/components/ui/field'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import type { EngagementLevelViolation } from '@/lib/engagementLevel'
import { ENGAGEMENT_LEVEL_TEXT_MAX_LENGTH } from '@/lib/engagementLevel'
export type MunicipalityV2StatusReasonDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description: string
reasonLabel?: string
reasonPlaceholder?: string
confirmLabel?: string
isPending?: boolean
errorMessage?: string | null
/** Level-only: show triangulated-shock checkbox. */
showTriangulatedShock?: boolean
triangulatedShock?: boolean
onTriangulatedShockChange?: (value: boolean) => void
violations?: EngagementLevelViolation[]
override?: boolean
onOverrideChange?: (value: boolean) => void
onConfirm: (reason: string) => void
children?: ReactNode
}
/**
* Shared optional-motivo confirm for nível / tendência / sinal on the v2 strip.
* Empty reason is OK — product rite from B134/B147.
*/
export const MunicipalityV2StatusReasonDialog = ({
open,
onOpenChange,
title,
description,
reasonLabel = 'Motivo (opcional)',
reasonPlaceholder = 'Contexto para esta mudança, se quiser registrar.',
confirmLabel = 'Confirmar',
isPending = false,
errorMessage = null,
showTriangulatedShock = false,
triangulatedShock = false,
onTriangulatedShockChange,
violations = [],
override = false,
onOverrideChange,
onConfirm,
}: MunicipalityV2StatusReasonDialogProps) => {
const id = useId()
const [reason, setReason] = useState('')
const canConfirm = violations.length === 0 || override
const handleOpenChange = (next: boolean) => {
if (!next) setReason('')
onOpenChange(next)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<Field>
<FieldLabel htmlFor={`${id}-reason`}>{reasonLabel}</FieldLabel>
<Textarea
id={`${id}-reason`}
value={reason}
onChange={(event) => setReason(event.target.value)}
maxLength={ENGAGEMENT_LEVEL_TEXT_MAX_LENGTH}
rows={3}
className="min-h-20 resize-y"
placeholder={reasonPlaceholder}
disabled={isPending}
/>
</Field>
{showTriangulatedShock ? (
<Field orientation="horizontal" className="min-h-11">
<Checkbox
id={`${id}-shock`}
checked={triangulatedShock}
onCheckedChange={(checked) => onTriangulatedShockChange?.(checked === true)}
disabled={isPending}
/>
<FieldContent>
<FieldLabel htmlFor={`${id}-shock`}>
Choque triangulado (dois níveis de uma vez)
</FieldLabel>
</FieldContent>
</Field>
) : null}
{violations.length > 0 ? (
<>
<Alert className="py-2">
<AlertDescription className="text-xs">
<ul className="list-disc space-y-1 pl-4">
{violations.map((violation) => (
<li key={violation.id}>{violation.message}</li>
))}
</ul>
</AlertDescription>
</Alert>
<Field orientation="horizontal" className="min-h-11">
<Checkbox
id={`${id}-override`}
checked={override}
onCheckedChange={(checked) => onOverrideChange?.(checked === true)}
disabled={isPending}
/>
<FieldContent>
<FieldLabel htmlFor={`${id}-override`}>
Registrar mesmo assim, ciente das ressalvas
</FieldLabel>
</FieldContent>
</Field>
</>
) : null}
{errorMessage ? (
<Alert variant="destructive" className="py-2">
<AlertDescription className="text-xs">{errorMessage}</AlertDescription>
</Alert>
) : null}
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
disabled={isPending}
onClick={() => handleOpenChange(false)}
>
Cancelar
</Button>
<Button
type="button"
disabled={!canConfirm || isPending}
onClick={() => onConfirm(reason)}
>
{isPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
{confirmLabel}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Conceitos:{' '}
<Link href="/campanha/conceitos" className="underline underline-offset-2">
glossário da campanha
</Link>
.
</p>
</div>
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,467 @@
'use client'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useEffect, useId, useRef, useState, type ReactNode } from 'react'
import type { MunicipalityListEngagementLevelResponse } from '@/app/(campaign)/campanha/(app)/municipios/engagement-level/types'
import type { MunicipalityListPoliticalTrendResponse } from '@/app/(campaign)/campanha/(app)/municipios/political-trend/types'
import { MunicipalityV2StatusReasonDialog } from '@/components/campaign/municipality/MunicipalityV2StatusReasonDialog'
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
import { Badge } from '@/components/ui/Badge'
import { Field, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { campaignHoverExplanationClassName } from '@/lib/campaignHoverTooltip'
import { campaignConceptHref, campaignConceptOneLiner } from '@/lib/campaignIntelligenceConcepts'
import { postCampaignJson } from '@/lib/campaignJsonRequest'
import {
EMPTY_ENGAGEMENT_LEVEL_LABEL,
ENGAGEMENT_LEVEL_RULES,
engagementLevelRank,
engagementLevels,
formatEngagementLevelLabel,
getEngagementLevelViolations,
isEngagementLevel,
type EngagementLevel,
type EngagementLevelViolation,
} from '@/lib/engagementLevel'
import {
parsePoliticalTrendStatusFormValue,
politicalTrendStatuses,
} from '@/lib/schemas/municipality'
import {
municipalitySignalTypeLabels,
municipalitySignalTypes,
parseMunicipalitySignalType,
type MunicipalitySignalType,
} from '@/lib/schemas/municipalityUpdate'
import { cn } from '@/lib/utils'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import {
formatTerritorialClassWhy,
politicalTrendLabels,
territorialClassBadgeVariant,
territorialClassLabels,
} from '@/utilities/municipality/municipalityLabels'
import { formatMunicipalitySignalAgeLabel } from '@/utilities/municipality/municipalitySignal'
import {
buildMunicipalityV2StatusAggregate,
MUNICIPALITY_V2_SIGNAL_COLD_VALUE,
resolveMunicipalityV2SignalSelectState,
type MunicipalityV2StatusViewModel,
} from '@/utilities/municipality/municipalityV2StatusView'
const ENGAGEMENT_LEVEL_ENDPOINT = '/campanha/municipios/engagement-level'
const POLITICAL_TREND_ENDPOINT = '/campanha/municipios/political-trend'
const LEVEL_ERROR = 'Não foi possível registrar o nível. Tente novamente.'
const TREND_ERROR = 'Não foi possível salvar a tendência. Tente novamente.'
const SIGNAL_ERROR = 'Não foi possível registrar o sinal. Tente novamente.'
type PendingAxis = 'level' | 'trend' | 'signal' | null
type MunicipalityV2StatusStripProps = {
status: MunicipalityV2StatusViewModel
signalFormAction: (formData: FormData) => Promise<CampaignFormActionState>
}
const ConceptTooltip = ({
conceptId,
label,
children,
}: {
conceptId: 'nivel-de-envolvimento' | 'classe-territorial' | 'pauta-do-silencio'
label: string
children: ReactNode
}) => (
<CampaignHoverTooltip
side="bottom"
align="start"
content={
<div className="flex max-w-64 flex-col gap-2 text-sm">
<p>{campaignConceptOneLiner(conceptId)}</p>
<Link
href={campaignConceptHref(conceptId)}
className="font-medium underline underline-offset-2"
>
Ver em Conceitos
</Link>
</div>
}
>
<button
type="button"
aria-label={`${label}: mais informações`}
className={cn(
'rounded-md text-left outline-none focus-visible:ring-2 focus-visible:ring-ring',
campaignHoverExplanationClassName,
)}
>
{children}
</button>
</CampaignHoverTooltip>
)
export const MunicipalityV2StatusStrip = ({
status,
signalFormAction,
}: MunicipalityV2StatusStripProps) => {
const router = useRouter()
const formId = useId()
const abortRef = useRef<AbortController | null>(null)
const [level, setLevel] = useState<EngagementLevel | null>(status.engagementLevel)
const [levelNote, setLevelNote] = useState(status.levelNote)
const [levelChangedAt, setLevelChangedAt] = useState(status.levelChangedAt)
const [trendStatus, setTrendStatus] = useState(status.politicalTrendStatus)
const [trendNote, setTrendNote] = useState(status.politicalTrendNote)
const [signalType, setSignalType] = useState(status.lastSignalType)
const [signalBody, setSignalBody] = useState(status.lastSignalBody)
const [lastSignalAt, setLastSignalAt] = useState(status.lastSignalAt)
const [pendingAxis, setPendingAxis] = useState<PendingAxis>(null)
const [draftLevel, setDraftLevel] = useState<EngagementLevel | null>(null)
const [draftTrend, setDraftTrend] = useState<typeof trendStatus>(null)
const [draftSignal, setDraftSignal] = useState<MunicipalitySignalType | null>(null)
const [triangulatedShock, setTriangulatedShock] = useState(false)
const [override, setOverride] = useState(false)
const [serverBlock, setServerBlock] = useState<EngagementLevelViolation[] | null>(null)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [isPending, setIsPending] = useState(false)
useEffect(() => () => abortRef.current?.abort(), [])
// Adopt RSC refresh values when the server revalidates after a write.
useEffect(() => {
setLevel(status.engagementLevel)
setLevelNote(status.levelNote)
setLevelChangedAt(status.levelChangedAt)
setTrendStatus(status.politicalTrendStatus)
setTrendNote(status.politicalTrendNote)
setSignalType(status.lastSignalType)
setSignalBody(status.lastSignalBody)
setLastSignalAt(status.lastSignalAt)
}, [status])
const signalSelect = resolveMunicipalityV2SignalSelectState({
signalType,
lastSignalAt,
})
const aggregate = buildMunicipalityV2StatusAggregate({
levelNote,
trendNote,
signalBody,
signalType,
lastSignalAt,
engagementLevel: level,
})
const levelViolations =
pendingAxis === 'level' && draftLevel
? [
...getEngagementLevelViolations({
from: level,
to: draftLevel,
levelChangedAt,
now: new Date(),
triangulatedShock,
}),
...(serverBlock ?? []),
]
: []
const uniqueLevelViolations = levelViolations.filter(
(violation, index, list) => list.findIndex((item) => item.id === violation.id) === index,
)
const isJump =
pendingAxis === 'level' &&
draftLevel !== null &&
level !== null &&
Math.abs(engagementLevelRank[draftLevel] - engagementLevelRank[level]) >
ENGAGEMENT_LEVEL_RULES.maxStepsWithoutShock
const closeDialog = () => {
setPendingAxis(null)
setDraftLevel(null)
setDraftTrend(null)
setDraftSignal(null)
setTriangulatedShock(false)
setOverride(false)
setServerBlock(null)
setErrorMessage(null)
}
const confirmLevel = async (reason: string) => {
if (!draftLevel || isPending) return
const controller = new AbortController()
abortRef.current = controller
setIsPending(true)
setErrorMessage(null)
setServerBlock(null)
try {
const { payload } = await postCampaignJson<MunicipalityListEngagementLevelResponse>(
ENGAGEMENT_LEVEL_ENDPOINT,
{
municipalityId: status.id,
level: draftLevel,
note: reason.trim() ? reason : null,
triangulatedShock,
override,
},
controller.signal,
)
if (payload.status === 'success') {
setLevel(payload.savedLevel.level)
setLevelNote(payload.savedLevel.note)
setLevelChangedAt(payload.savedLevel.changedAt)
closeDialog()
router.refresh()
return
}
if (payload.status === 'blocked') {
setServerBlock(payload.violations)
return
}
setErrorMessage(payload.message || LEVEL_ERROR)
} catch {
if (controller.signal.aborted) return
setErrorMessage(LEVEL_ERROR)
} finally {
setIsPending(false)
}
}
const confirmTrend = async (reason: string) => {
if (pendingAxis !== 'trend' || isPending) return
const controller = new AbortController()
abortRef.current = controller
setIsPending(true)
setErrorMessage(null)
try {
const { payload } = await postCampaignJson<MunicipalityListPoliticalTrendResponse>(
POLITICAL_TREND_ENDPOINT,
{
municipalityId: status.id,
status: draftTrend,
note: reason.trim() ? reason : null,
},
controller.signal,
)
if (payload.status === 'success') {
setTrendStatus(payload.savedTrend.status)
setTrendNote(payload.savedTrend.note)
closeDialog()
router.refresh()
return
}
setErrorMessage(payload.message || TREND_ERROR)
} catch {
if (controller.signal.aborted) return
setErrorMessage(TREND_ERROR)
} finally {
setIsPending(false)
}
}
const confirmSignal = async (reason: string) => {
if (!draftSignal || isPending) return
setIsPending(true)
setErrorMessage(null)
try {
const formData = new FormData()
formData.set('municipalityId', String(status.id))
formData.set('municipalitySlug', status.slug)
formData.set('signalType', draftSignal)
if (reason.trim()) formData.set('body', reason.trim())
const result = await signalFormAction(formData)
if (result.status !== 'success') {
setErrorMessage(result.message || SIGNAL_ERROR)
return
}
setSignalType(draftSignal)
setSignalBody(reason.trim() ? reason.trim() : null)
setLastSignalAt(new Date().toISOString())
closeDialog()
router.refresh()
} catch {
setErrorMessage(SIGNAL_ERROR)
} finally {
setIsPending(false)
}
}
const dialogTitle =
pendingAxis === 'level'
? 'Confirmar nível de envolvimento'
: pendingAxis === 'trend'
? 'Confirmar tendência'
: pendingAxis === 'signal'
? 'Confirmar sinal'
: ''
const dialogDescription =
pendingAxis === 'level' && draftLevel
? formatEngagementLevelLabel(draftLevel)
: pendingAxis === 'trend'
? draftTrend
? politicalTrendLabels[draftTrend]
: 'Não registrada'
: pendingAxis === 'signal' && draftSignal
? municipalitySignalTypeLabels[draftSignal]
: ''
return (
<section aria-labelledby={`${formId}-status-title`} className="flex flex-col gap-3">
<h2 id={`${formId}-status-title`} className="text-base font-medium">
Status
</h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Field>
<ConceptTooltip conceptId="nivel-de-envolvimento" label="Nível">
<FieldLabel htmlFor={`${formId}-level`}>Nível</FieldLabel>
</ConceptTooltip>
{status.canMoveEngagementLevel ? (
<NativeSelect
id={`${formId}-level`}
className="min-h-11 w-full"
value={level ?? ''}
aria-busy={isPending && pendingAxis === 'level' ? true : undefined}
onChange={(event) => {
const next = event.target.value
if (!isEngagementLevel(next) || next === level) {
// Reset select to current when choosing empty / same.
event.target.value = level ?? ''
return
}
setDraftLevel(next)
setPendingAxis('level')
setTriangulatedShock(false)
setOverride(false)
setServerBlock(null)
setErrorMessage(null)
}}
>
<NativeSelectOption value="">{EMPTY_ENGAGEMENT_LEVEL_LABEL}</NativeSelectOption>
{engagementLevels.map((option) => (
<NativeSelectOption key={option} value={option}>
{formatEngagementLevelLabel(option)}
</NativeSelectOption>
))}
</NativeSelect>
) : (
<p id={`${formId}-level`} className="flex min-h-11 items-center text-sm">
{level ? formatEngagementLevelLabel(level) : EMPTY_ENGAGEMENT_LEVEL_LABEL}
</p>
)}
</Field>
<Field>
<FieldLabel htmlFor={`${formId}-trend`}>Tendência</FieldLabel>
<NativeSelect
id={`${formId}-trend`}
className="min-h-11 w-full"
value={trendStatus ?? ''}
aria-busy={isPending && pendingAxis === 'trend' ? true : undefined}
onChange={(event) => {
const next = parsePoliticalTrendStatusFormValue(event.target.value)
if (next === trendStatus) return
setDraftTrend(next)
setPendingAxis('trend')
setErrorMessage(null)
}}
>
<NativeSelectOption value="">Não registrada</NativeSelectOption>
{politicalTrendStatuses.map((option) => (
<NativeSelectOption key={option} value={option}>
{politicalTrendLabels[option]}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
<Field>
<ConceptTooltip conceptId="pauta-do-silencio" label="Sinal">
<FieldLabel htmlFor={`${formId}-signal`}>Sinal</FieldLabel>
</ConceptTooltip>
<NativeSelect
id={`${formId}-signal`}
className="min-h-11 w-full"
value={signalSelect.value}
aria-busy={isPending && pendingAxis === 'signal' ? true : undefined}
onChange={(event) => {
const raw = event.target.value
if (raw === MUNICIPALITY_V2_SIGNAL_COLD_VALUE) {
event.target.value = signalSelect.value
return
}
const next = parseMunicipalitySignalType(raw)
if (!next || next === signalType) {
event.target.value = signalSelect.value
return
}
setDraftSignal(next)
setPendingAxis('signal')
setErrorMessage(null)
}}
>
{signalSelect.isCold ? (
<NativeSelectOption value={MUNICIPALITY_V2_SIGNAL_COLD_VALUE}>
{signalSelect.label}
</NativeSelectOption>
) : null}
{municipalitySignalTypes.map((option) => (
<NativeSelectOption key={option} value={option}>
{municipalitySignalTypeLabels[option]}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
<div className="flex flex-col gap-2">
<ConceptTooltip conceptId="classe-territorial" label="Classe territorial">
<span className="text-sm font-medium">Classe</span>
</ConceptTooltip>
<div className="flex min-h-11 flex-wrap items-center gap-2">
<Badge variant={territorialClassBadgeVariant[status.territorialClass.class]}>
{territorialClassLabels[status.territorialClass.class]}
</Badge>
<ConceptTooltip conceptId="pauta-do-silencio" label="Frescor">
<span className="text-xs text-muted-foreground">
{formatMunicipalitySignalAgeLabel(signalSelect.ageInDays)}
</span>
</ConceptTooltip>
</div>
<p className="text-xs text-muted-foreground">
{formatTerritorialClassWhy(status.territorialClass.factors)}
</p>
</div>
</div>
<p className="text-sm text-muted-foreground" data-slot="status-aggregate">
{aggregate}
</p>
<MunicipalityV2StatusReasonDialog
open={pendingAxis !== null}
onOpenChange={(open) => {
if (!open) {
closeDialog()
}
}}
title={dialogTitle}
description={dialogDescription}
isPending={isPending}
errorMessage={errorMessage}
showTriangulatedShock={Boolean(isJump)}
triangulatedShock={triangulatedShock}
onTriangulatedShockChange={setTriangulatedShock}
violations={uniqueLevelViolations}
override={override}
onOverrideChange={setOverride}
onConfirm={(reason) => {
if (pendingAxis === 'level') void confirmLevel(reason)
else if (pendingAxis === 'trend') void confirmTrend(reason)
else if (pendingAxis === 'signal') void confirmSignal(reason)
}}
/>
</section>
)
}

View file

@ -130,6 +130,11 @@ const pathRules: PathRule[] = [
match: (pathname) => /^\/campanha\/municipios\/[^/]+$/.test(pathname),
resolve: () => sectionOnly(campaignPageChromeCatalog.municipios.title),
},
{
// B147 parallel v2 — soft-dep B145 for entity name in chrome; section title for now.
match: (pathname) => /^\/campanha\/municipio\/[^/]+\/v2$/.test(pathname),
resolve: () => sectionOnly(campaignPageChromeCatalog.municipios.title),
},
{
match: (pathname) => pathname === '/campanha/territorios',
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.territorios),

View file

@ -89,15 +89,14 @@ export const MUNICIPALITY_ENGAGEMENT_LEVEL_UNRESTRICTED_MESSAGE =
'Somente a coordenação geral ou o candidato move o nível de envolvimento.'
/**
* E14 — a movement is never just the new level: the motivo and the signals
* that would reverse it are what make the decision auditable later, so both
* are required by the schema rather than by the form alone.
* E14 + B134 — a movement still records hysteresis/override in the snapshot;
* Motivo is optional free text (empty OK). "O que faria voltar atrás" left the
* write model (B147 lands the policy soft-dep; historical snapshot scrub stays B134).
*/
export const municipalityEngagementLevelSchema = z.object({
municipality: positiveRelationshipId,
level: z.enum(engagementLevels),
note: z.string().trim().min(1).max(ENGAGEMENT_LEVEL_TEXT_MAX_LENGTH),
reversalSignals: z.string().trim().min(1).max(ENGAGEMENT_LEVEL_TEXT_MAX_LENGTH),
note: trimmedNullableText(ENGAGEMENT_LEVEL_TEXT_MAX_LENGTH),
/** Licenses a two-level jump (research §6.8). */
triangulatedShock: z.boolean().default(false),
/** Accepts the listed violations knowingly; recorded in the decision snapshot. */

View file

@ -83,7 +83,8 @@ export const municipalityUpdateCreateSchema = z
path: ['needs'],
})
}
} else if (!data.body) {
} else if (data.kind !== 'sinal' && !data.body) {
// Sinal inherits B147/B134 optional-motivo: type is enough; body/note is optional.
context.addIssue({
code: 'custom',
message: 'Informe o texto da atualização.',

View file

@ -27,9 +27,12 @@ export const revalidateMunicipalityListPaths = ({
if (scope === 'detail' || scope === 'both') {
if (detailSlug) {
revalidatePath(`/campanha/municipios/${detailSlug}`, 'page')
// B147 parallel v2 surface — same municipality, singular path.
revalidatePath(`/campanha/municipio/${detailSlug}/v2`, 'page')
return
}
revalidatePath('/campanha/municipios/[slug]', 'page')
revalidatePath('/campanha/municipio/[slug]/v2', 'page')
}
}

View file

@ -0,0 +1,78 @@
import 'server-only'
import type { Payload } from 'payload'
import type { MunicipalitySignalType } from '@/lib/schemas/municipalityUpdate'
import type { CampaignUser } from '@/payload-types'
import { isCampaignUnrestricted } from '@/utilities/campaignAccess'
import {
getMunicipalityDetailViewModel,
resolveAccessibleMunicipalityContext,
type AccessibleMunicipalityContext,
} from '@/utilities/municipality/municipalityPageData'
import { resolveMunicipalityLastSignalAt } from '@/utilities/municipality/municipalitySignal'
import { computeMunicipalityTerritorialClass } from '@/utilities/municipality/municipalityTerritorialClass'
import { loadMunicipalityUpdatesFeed } from '@/utilities/municipality/municipalityUpdatePageData'
import type { MunicipalityV2StatusViewModel } from '@/utilities/municipality/municipalityV2StatusView'
import { loadMunicipalityPledges } from '@/utilities/votePledgeData'
import { aggregateMunicipalityPledgesFromRows } from '@/utilities/votePledgeViews'
export type { MunicipalityV2StatusViewModel }
const loadLatestSignal = async (
payload: Payload,
user: CampaignUser,
municipalityID: number,
): Promise<{ type: MunicipalitySignalType | null; body: string | null }> => {
const feed = await loadMunicipalityUpdatesFeed(payload, user, municipalityID, {
page: 1,
kind: 'sinal',
})
const latest = feed.updates[0]
if (!latest) return { type: null, body: null }
return { type: latest.signalType, body: latest.body }
}
export const loadMunicipalityV2StatusData = async (
payload: Payload,
user: CampaignUser,
municipalitySlug: string,
): Promise<{ context: AccessibleMunicipalityContext; status: MunicipalityV2StatusViewModel }> => {
const context = await resolveAccessibleMunicipalityContext(payload, user, municipalitySlug)
const [view, pledges, latestSignal] = await Promise.all([
getMunicipalityDetailViewModel(payload, context, user),
loadMunicipalityPledges(payload, user, context.id),
loadLatestSignal(payload, user, context.id),
])
const strategy = view.strategy
if (!strategy) {
// Page gate is noLeader; strategy should always be present for staff.
throw new Error('Municipality v2 status requires a staff strategy view model.')
}
const pledgeAggregate = aggregateMunicipalityPledgesFromRows(pledges)
const lastSignalAt = resolveMunicipalityLastSignalAt(
view.lastUpdateAt,
pledgeAggregate.lastPledgeAt,
)
return {
context,
status: {
id: view.id,
name: view.name,
slug: view.slug,
canMoveEngagementLevel: isCampaignUnrestricted(user),
engagementLevel: strategy.engagementLevel,
levelNote: strategy.levelNote,
levelChangedAt: strategy.levelChangedAt,
politicalTrendStatus: strategy.politicalTrend.status,
politicalTrendNote: strategy.politicalTrend.note,
lastSignalAt,
lastSignalType: latestSignal.type,
lastSignalBody: latestSignal.body,
territorialClass: computeMunicipalityTerritorialClass(view.slug),
},
}
}

View file

@ -0,0 +1,116 @@
/**
* Pure view helpers + status strip view model for municipality v2 (B147).
* Client-safe — no Payload / server-only imports.
*/
import {
EMPTY_ENGAGEMENT_LEVEL_LABEL,
formatEngagementLevelLabel,
type EngagementLevel,
} from '@/lib/engagementLevel'
import {
municipalitySignalTypeLabels,
type MunicipalitySignalType,
} from '@/lib/schemas/municipalityUpdate'
import type { PoliticalTrendStatus } from '@/utilities/municipality/municipalityLabels'
import {
isMunicipalitySignalCold,
MUNICIPALITY_COLD_SIGNAL_DAYS,
municipalitySignalAgeInDays,
} from '@/utilities/municipality/municipalitySignal'
import type { MunicipalityTerritorialClassification } from '@/utilities/municipality/municipalityTerritorialClass'
export type MunicipalityV2StatusViewModel = {
id: number
name: string
slug: string
canMoveEngagementLevel: boolean
engagementLevel: EngagementLevel | null
levelNote: string | null
levelChangedAt: string | null
politicalTrendStatus: PoliticalTrendStatus | null
politicalTrendNote: string | null
lastSignalAt: string | null
lastSignalType: MunicipalitySignalType | null
lastSignalBody: string | null
territorialClass: MunicipalityTerritorialClassification
}
/** Sentinel select value when the município is cold / has no typed signal. */
export const MUNICIPALITY_V2_SIGNAL_COLD_VALUE = '__cold__'
export type MunicipalityV2StatusNotes = {
levelNote: string | null
trendNote: string | null
signalBody: string | null
signalType: MunicipalitySignalType | null
lastSignalAt: string | null
engagementLevel: EngagementLevel | null
}
export type MunicipalityV2SignalSelectState = {
/** Current `<select>` value — signal type or cold sentinel. */
value: MunicipalitySignalType | typeof MUNICIPALITY_V2_SIGNAL_COLD_VALUE
/** Short label for the control (and cold readout). */
label: string
ageInDays: number | null
isCold: boolean
}
/**
* Product assumption (intention open Q): cold → sentinel “Sem sinal / frio”
* in the strip; age lives in aggregate/tooltip.
*/
export const resolveMunicipalityV2SignalSelectState = (
input: {
signalType: MunicipalitySignalType | null
lastSignalAt: string | null
},
now: Date = new Date(),
): MunicipalityV2SignalSelectState => {
const ageInDays = municipalitySignalAgeInDays(input.lastSignalAt, now)
const isCold = isMunicipalitySignalCold(ageInDays)
if (isCold || !input.signalType) {
const daysLabel =
ageInDays === null ? `frio (≥${MUNICIPALITY_COLD_SIGNAL_DAYS} d)` : `frio (${ageInDays} d)`
return {
value: MUNICIPALITY_V2_SIGNAL_COLD_VALUE,
label: `Sem sinal / ${daysLabel}`,
ageInDays,
isCold: true,
}
}
return {
value: input.signalType,
label: municipalitySignalTypeLabels[input.signalType],
ageInDays,
isCold: false,
}
}
/**
* Aggregate under the status strip: latest notes from level · trend · signal,
* with absence/age when there is nothing to quote.
*/
export const buildMunicipalityV2StatusAggregate = (
input: MunicipalityV2StatusNotes,
now: Date = new Date(),
): string => {
const levelPart = input.levelNote?.trim()
? input.levelNote.trim()
: input.engagementLevel
? `${formatEngagementLevelLabel(input.engagementLevel)} — sem motivo`
: EMPTY_ENGAGEMENT_LEVEL_LABEL
const trendPart = input.trendNote?.trim() ? input.trendNote.trim() : 'Tendência sem nota'
const signalState = resolveMunicipalityV2SignalSelectState(input, now)
const signalPart = input.signalBody?.trim()
? input.signalBody.trim()
: signalState.isCold
? signalState.label
: `${signalState.label} — sem nota`
return `${levelPart} · ${trendPart} · ${signalPart}`
}

View file

@ -41,7 +41,6 @@ describe('municipality engagement level (E14)', () => {
municipality: municipality.id,
level: 'n3',
note: 'Prefeitura aliada e duas lideranças novas.',
reversalSignals: 'Rompimento do prefeito ou pledges abaixo de 40% da meta.',
})
fixtures.touchMunicipality(municipality.id)
@ -61,10 +60,32 @@ describe('municipality engagement level (E14)', () => {
expect(decision.snapshot).toMatchObject({
from: null,
to: 'n3',
reversalSignals: 'Rompimento do prefeito ou pledges abaixo de 40% da meta.',
violations: [],
overridden: false,
})
expect(decision.snapshot).not.toHaveProperty('reversalSignals')
})
it('accepts an empty motivo and still writes the movement', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const updated = await setMunicipalityEngagementLevelRecord(payload, coordinator, {
municipality: municipality.id,
level: 'n1',
note: null,
})
fixtures.touchMunicipality(municipality.id)
expect(updated.engagementLevel).toBe('n1')
expect(updated.levelNote).toBeNull()
const decisions = await findDecisions(municipality.id)
expect(decisions.docs).toHaveLength(1)
const decision = decisions.docs[0]!
fixtures.own('allocationDecision', decision.id)
expect(decision.rationale).toBe('Sem motivo registrado.')
})
it('holds a movement that breaks the rules and writes nothing', async () => {
@ -76,7 +97,6 @@ describe('municipality engagement level (E14)', () => {
municipality: municipality.id,
level: 'n1',
note: 'Entrada no mapa como presença de mandato.',
reversalSignals: 'Nenhuma agenda executada no trimestre.',
})
fixtures.touchMunicipality(municipality.id)
@ -86,7 +106,6 @@ describe('municipality engagement level (E14)', () => {
municipality: municipality.id,
level: 'n3',
note: 'Achismo da semana.',
reversalSignals: 'Nada.',
}).catch((error: unknown) => error)
expect(blocked).toBeInstanceOf(EngagementLevelBlockedError)
@ -115,7 +134,6 @@ describe('municipality engagement level (E14)', () => {
municipality: municipality.id,
level: 'n1',
note: 'Entrada no mapa.',
reversalSignals: 'Agenda vazia por um trimestre.',
})
fixtures.touchMunicipality(municipality.id)
@ -123,7 +141,6 @@ describe('municipality engagement level (E14)', () => {
municipality: municipality.id,
level: 'n2',
note: 'Adversário abriu comitê e o prefeito pediu presença.',
reversalSignals: 'Comitê adversário fechar.',
override: true,
})
expect(moved.engagementLevel).toBe('n2')
@ -164,7 +181,6 @@ describe('municipality engagement level (E14)', () => {
municipality: municipality.id,
level: 'n3',
note: 'Três sinais independentes de invasão na mesma semana.',
reversalSignals: 'Adversário recuar do território.',
triangulatedShock: true,
})
expect(moved.engagementLevel).toBe('n3')
@ -191,7 +207,6 @@ describe('municipality engagement level (E14)', () => {
municipality: municipality.id,
level: 'n2',
note: 'Quero subir o meu município.',
reversalSignals: 'Nada.',
}),
).rejects.toThrow('coordenação geral ou o candidato')
@ -218,7 +233,6 @@ describe('municipality engagement level (E14)', () => {
municipality: municipality.id,
level: 'n4',
note: 'Prioridade máxima do ciclo.',
reversalSignals: 'Queda de captura por dois meses.',
})
fixtures.touchMunicipality(municipality.id)
const decisions = await findDecisions(municipality.id)

View file

@ -114,6 +114,13 @@ describe('campaign municipality update domain', () => {
body: 'Visita adversária confirmada.',
}).success,
).toBe(false)
expect(
municipalityUpdateCreateSchema.safeParse({
municipality: 1,
kind: 'sinal',
signalType: 'invasao',
}).success,
).toBe(true)
})
it('strips forged author and timestamps from input', () => {

View file

@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import {
buildMunicipalityV2StatusAggregate,
MUNICIPALITY_V2_SIGNAL_COLD_VALUE,
resolveMunicipalityV2SignalSelectState,
} from '@/utilities/municipality/municipalityV2StatusView'
describe('municipalityV2StatusView', () => {
it('uses cold sentinel when there is no recent signal', () => {
const state = resolveMunicipalityV2SignalSelectState(
{ signalType: null, lastSignalAt: null },
new Date('2026-08-02T12:00:00.000Z'),
)
expect(state.value).toBe(MUNICIPALITY_V2_SIGNAL_COLD_VALUE)
expect(state.isCold).toBe(true)
expect(state.label).toContain('Sem sinal')
})
it('keeps the typed signal when still warm', () => {
const state = resolveMunicipalityV2SignalSelectState(
{
signalType: 'invasao',
lastSignalAt: '2026-08-01T12:00:00.000Z',
},
new Date('2026-08-02T12:00:00.000Z'),
)
expect(state.value).toBe('invasao')
expect(state.isCold).toBe(false)
expect(state.label).toBe('Invasão')
})
it('builds the aggregate from the three note axes', () => {
const aggregate = buildMunicipalityV2StatusAggregate(
{
engagementLevel: 'n2',
levelNote: 'Subiu o nível',
trendNote: 'Tendência melhor',
signalType: 'esfriamento',
signalBody: 'Rede parou',
lastSignalAt: '2026-08-01T12:00:00.000Z',
},
new Date('2026-08-02T12:00:00.000Z'),
)
expect(aggregate).toBe('Subiu o nível · Tendência melhor · Rede parou')
})
it('names absences when notes are empty', () => {
const aggregate = buildMunicipalityV2StatusAggregate(
{
engagementLevel: 'n1',
levelNote: null,
trendNote: null,
signalType: null,
signalBody: null,
lastSignalAt: null,
},
new Date('2026-08-02T12:00:00.000Z'),
)
expect(aggregate).toContain('sem motivo')
expect(aggregate).toContain('Tendência sem nota')
expect(aggregate).toContain('Sem sinal')
})
})