Merge branch 'cursor/418dd605'

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-28 14:05:25 -03:00
commit 596d1b13dd
28 changed files with 15089 additions and 34 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,12 +1,59 @@
# E11 — Motor de sugestões v1 (dado → decisão com humano no loop)
Status: rascunho
Atualizado em: 2026-07-24 (refs sincronizadas pós-remodelagem Municípios + hardening)
Status: entregue (2026-07-28)
Atualizado em: 2026-07-28 (as-built; revisões da auditoria pré-implementação registradas abaixo)
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Inteligência de campanha", E11; plano-mestre [inteligencia-campanha.md](inteligencia-campanha.md))
Impeccable: C — superfície nova de sugestões (painel/fila em `/campanha`), sem design-ref
Appetite: ~3 dias eng; sem migration própria (lê E8/E10/E14 e grava via `allocationDecision` de C12)
Appetite: ~3 dias eng; **uma migration mínima** (`20260728_041547_add_allocation_decision_adiada_outcome` — o plano dizia "sem migration", ver revisão)
Responsável: —
## As-built (2026-07-28)
- **Catálogo:** `src/lib/suggestionCatalog.ts` (client-safe — o formulário de descarte oferece os "descartes" do padrão como quick-picks, então o browser precisa dos textos): 8 padrões com predicados puros sobre `MunicipalityTriggerInput` (números já derivados, sem artefato), textos do §6.1, triagem 1–5 com rótulos do §6.2 (`suggestionTriageLabels`), âncoras ilustrativas em `SUGGESTION_ANCHORS` (interno), janelas de supressão (`aceita` 14d / `descarta` 30d / `adiada` até `suppressUntil` do snapshot) e `isSuggestionSuppressedByDecision` — pura, com a regra "nível 1 fura a fila": um gatilho que volta como nível 1 atravessa supressão registrada em urgência menor. Diferencial estrutural codificado (§6.3): P2 cede a P3 e K-B; P7 cede a K-A quando há esforço registrado no ciclo.
- **Avaliador:** `src/utilities/municipalityTriggers.ts` (`server-only`), no molde de `visitPlannerData.ts` (leitura própria de `municipality` — o select do scope compartilhado não tem `engagementLevel`/`lastUpdateAt`): 6 leituras em `Promise.all` (pledges, contagem de lideranças exportada de `visitPlannerData`, metas `cache()`, sinais `kind: 'sinal'` ≤28d, atividades ≥now−42d, últimas `allocationDecision` ≤90d por padrão), LQ por ano com a fórmula exata do E10, cortes de catálogo memoizados por processo (mediana + quartil superior + padrão intracampo estadual). Pauta do silêncio = alta/N2+ sem gatilho DISPARADO (supressão não conta como silêncio) e sem sinal ≥30d.
- **Decisão:** `resolveSuggestionRecord` (`actions/suggestion.ts`) **reavalia o padrão server-side** — o snapshot registra os fatores do momento da decisão, nunca os do render; padrão fora da fila recusa com `SUGGESTION_STALE_MESSAGE` (safe message). Transação + lock `municipality-suggestion:{id}:{patternId}` antes de reler a última decisão. `adiada` deriva a duração do nível recomputado (7d; 14d nos níveis 4–5) e grava `suppressUntil` no snapshot. Aceite exige a ação do menu (zod valida contra o catálogo); descarte exige `alternativeReading` (zod + hook da collection). Casca `runCampaignFormAction` em `(app)/suggestionFormActions.ts` — compartilhada pelas duas superfícies.
- **UI:** `src/components/campaign/suggestion/` — `SuggestionsPanel` (RSC; estatuto no rodapé; `CampaignInfoHint` → conceitos) + `SuggestionCard` (ilha client: Aceitar com radios do menu — 1º item pré-selecionado, é a checagem barata — + nota opcional; Adiar em um toque com a duração no rótulo; Descartar com quick-picks + textarea obrigatória; submit explícito — a exceção prevista para flows com nota/confirmação, mesmo precedente do E14). Dashboard: slot Suspense `suggestionsSlot` (top-5 + pauta do silêncio com chips). Detalhe do município: card na `OverviewTab` após elegibilidade de visita, lista completa; município silencioso diz isso no empty state. Foco gerenciado na troca trigger↔form (abrir foca o primeiro campo; cancelar devolve ao trigger). Cor contida: só nível 1 usa `destructive`.
- **Conceitos (E18):** `triagem-de-sugestoes` e `pauta-do-silencio` em `diagnostico`.
- **Testes:** `suggestionCatalog.unit.spec.ts` (45 casos — red/green por predicado, exclusividade dos diferenciais, supressão incl. pierce de nível 1 e snapshot malformado) e `campaignSuggestions.int.spec.ts` (9 casos — P7 end-to-end com decisões governando a fila, K-A vs P7, P6 com priorização mudando a triagem, escopo do advisor/leader, silêncio, action: aceite/adiamento/descarte/stale/fora-de-escopo). Padrões ancorados no artefato (P1/P2/P3/P5/K-B) não são encenáveis por linhas do banco — os int tests que precisam de propriedade de artefato alocam municípios pelo alocador das fixtures até um qualificar (taxas medidas: 109/435 quartil superior, 205/435 quietos; slug explícito colidiria com o alocador, que é uma sequence com wrap).
- **Medidas:** First Load JS de `/campanha` 268 → 277 kB (catálogo + ilha do card; medido pós-simplify); suíte completa 453 testes em ~84s.
## Pós-`/simplify` (mesma sessão, 2026-07-28)
Três revisores paralelos (qualidade, performance, reuso); tudo aplicado sem mudança de comportamento observável, exceto onde dito:
- **Perf P1 — o dashboard varria `votePledge` duas vezes por request:** o avaliador re-agregava com `aggregatePledgesByMunicipality` o que `loadMunicipalityScope` (React `cache()`, já pago pelo `getCampaignDashboardData` no mesmo request) segura em `pledgeAggregates`. O caminho sem filtro agora reusa o scope; o caminho com `municipalityID` (detalhe/action) mantém a agregação de 1 município — puxar o scope inteiro ali seria o erro inverso.
- **Janela de decisões derivada, não chutada:** buscava 90 dias de `allocationDecision` quando nada acima de `max(supressões, adiamento máximo)` = 30d pode suprimir — `SUGGESTION_INPUT_WINDOWS.decisionDays` agora é computada das constantes (linhas jsonb crescem com uso real).
- **Bundle do cliente — a lição do B14 de novo:** o catálogo importava `ELECTION_YEAR_*` de `lib/electionResults`, cujo primeiro import é `bahiaTerritories` — três números arrastavam a tabela de territórios para o chunk do card. Anos locais com comentário apontando o espelho.
- **`triggerSummary` era copy morta** (8 parágrafos que nada renderizava — o card mostra leitura + fatores + menu): campo deletado do tipo e dos 8 padrões.
- **`silence.totalCount` era estado derivado** (sempre `entries.length`): o bundle expõe `silence: MunicipalitySilenceEntry[]` e a faixa deriva as contagens.
- **Filtro de tipo de sinal desceu para o `where`** (`signalType: { in: [invasao, visita_adversario, proposta_broker] }`) — menos linhas trafegadas e morreu o único cast do delivery.
- **LQ 2022 reusa `classification.lq`** (mesma fórmula, memoizada) em vez de recomputar ao lado — `lqForYear` fica só para 2014/2018.
- **Copy dos fatores alinhada ao canônico** de `formatDominanceAgainstOwnStandard` ("… o padrão estadual **do candidato**", sem prefixo "LQ") — "próprio" era uma terceira variação do possessivo que a docstring do E10 já proibia.
- **Card adotou os primitivos `ui/field`** (`FieldSet`/`FieldLegend`/`FieldLabel`, idioma do `ActivityForm`) no lugar de fieldset/legend/label à mão; `DAY_MS` (3 declarações no delivery) e `SUGGESTION_TEXT_MAX_LENGTH` unificados no catálogo; `formatSilenceAgeLabel` mudou para `municipalitySignal.ts`, ao lado do formatter irmão de idade de sinal.
- **Correção de registro:** as suítes são **685 unit / 453 int** — o número anterior desta página ("suíte 453") era só a fase int.
**Recomendações que ficaram (maiores que o cleanup):** consolidar as ~4 leituras de pledges/lideranças que a `OverviewTab` do detalhe faz por três loaders não coordenados (conta da cadeira + elegibilidade + sugestões — composição pré-existente, o E11 só somou o 4º caminho barato); ~~extrair `firstErrorMessage`/`toMessageOnlyState` no 3º call site~~ (feito na rodada 2 — o rebase entregou o 3º site); mover `formatDominanceAgainstOwnStandard` para `lib/` (cadeia com `formatRatioAsPercentLabel`) quando um segundo módulo `lib` precisar dele; os memos de estatística do catálogo (quartil superior, padrão intracampo estadual) migram para `municipalityPotential.ts` quando ganharem um 2º consumidor (precedente E13).
### Rodada 2 — pós-rebase sobre main (2026-07-28)
O rebase trouxe B17/B29/B32+/B37/B40; os três revisores confirmaram que **nenhuma premissa do E11 morreu** (o card corretamente NÃO adota a máquina de célula do B32+ — formulários inline com submit explícito não têm o problema "fechar desmonta o Alert"; o guard de convenções aceita `suggestionFormActions.ts`; o cache hit do scope sobreviveu — verificado o mecanismo da chave `JSON.stringify(where)`), mas o rebase **entregou o 3º call site** da extração adiada na rodada 1: `RelationChipCell` (B37, agora em main) extrai a mensagem do mesmo `CampaignFormActionState`. Aplicado:
- **P2 real — `rationale` composta estourava o cap de 2000 da collection:** nota no cap + prefixo do rótulo do menu > 2000 → Payload rejeitava o create e o usuário via o erro genérico, sem retry possível. Clamp `slice(0, SUGGESTION_TEXT_MAX_LENGTH)` (o prefixo carrega a decisão; só uma nota máxima perde a cauda) + caso int pinando (nota de 2000 chars grava com rationale = 2000).
- **`firstFormActionMessage`** em `utilities/campaignFormFields.ts` (client-safe, dono de `fieldError`) — adotado no card, no `RelationChipCell` (que antes ignorava `fieldErrors`) e no `toMessageOnlyState` de apoiadores.
- **Um canal por falha:** o card anunciava em dois (linha `role="alert"` + toast) — o card nunca desmonta na falha, então a linha inline basta; o toast ficou só para sucesso (o card sai da fila).
- **`metrics` não viaja mais ao cliente** (~209 B/card): o painel monta `SuggestionCardData` campo a campo; o view model completo (com metrics para o snapshot da action) fica no servidor.
- Miudezas: `SUGGESTION_OUTCOMES` exportado do schema (3ª grafia da lista morreu); fallback de snapshot malformado e `decisionDays` derivados de `Math.min/max` sobre os níveis (recalibração não inverte silenciosamente); `metrics` 100% lidos de `input` (contrato "os números sobre os quais a decisão foi tomada" provável por construção); IIFE de `fieldShareOfValid2022` virou irmã nomeada de `lqForYear`; `DAY_MS` do int spec importado do catálogo.
**Ficou (registrado):** juntar as 6 leituras do avaliador num estágio único para ator irrestrito; a tripla leitura de pledges da `OverviewTab` (pré-existente, acima).
## Revisões da auditoria (2026-07-28)
- **"Sem migration" caiu:** o enum real de `allocationDecision.outcome` era `aceita | descarta | movimento` — o plano citava `aceita|descartada|adiada` (valores que nunca existiram). "Adiar" exigiu a migration `add_allocation_decision_adiada_outcome` (precedente exato: E14 adicionou `movimento`). Registrar adiamento como descarte especial foi rejeitado — envenenaria o dado de leitura alternativa do backtest (E15).
- **Refs mortas corrigidas:** `electionInsights.ts` (deletado no Pass 2) e o stack `NucleusInsights` (não existe; o precedente vivo é `MunicipalityVisitEligibilityCard`) saíram da abordagem; "bundle A9+ no dashboard" — o dashboard usa `loadMunicipalityScope`, e o avaliador faz leitura própria (mesma justificativa do planner E13).
- **P3 sem `runningAgain2026`:** o campo existe mas está 100% `desconhecido` e sem read path — o gatilho usa só o artefato (campo forte + fatia intracampo < 0,5× o padrão estadual próprio, com gate de volume na mediana); "concorre de novo?" é o 1º item do menu (inteligência), não gatilho.
- **Proxies assumidos (calibráveis, E15):** P7 "delta ≈ 0" = cobertura < meta + `lastPledgeAt` ≥ 21d (trajetória por versions fica para a fase 2); P2 sem NEC (mesmo corte do E10 — gatilho segue sendo E11 fase 2 precisar do eixo de competição); persistência/histerese sem tabela nova — janelas temporais nos próprios gatilhos + supressão pela última decisão; o rebaixamento caro continua protegido no write path do E14.
- **Pauta do silêncio inclui `priority: alta`** além de N2+ — E14 nasceu sem backfill; só N2+ daria pauta vazia até a triagem de níveis acontecer.
## Design (Impeccable)
Âncoras: `PRODUCT.md` (princípios 2, 5 e 7) / `DESIGN.md` (register `product`) · `CampaignPageShell`/`CampaignMetricStrip` · precedente de stack de insights (Alert + badge) do detalhe do município.

File diff suppressed because one or more lines are too long

View file

@ -18,6 +18,7 @@ import {
mapCampaignFormActionError,
type CampaignFormErrorState,
} from '@/utilities/campaignFormActionError'
import { firstFormActionMessage } from '@/utilities/campaignFormFields'
export type SupporterVoteIntentionFormState = {
status?: 'success'
@ -36,14 +37,8 @@ const safeVoteIntentionMessages = [
] as const
const toMessageOnlyState = (mapped: CampaignFormErrorState<unknown>): { message?: string } => {
if (mapped.message) return { message: mapped.message }
if (!mapped.fieldErrors) return {}
const firstFieldMessage = Object.values(mapped.fieldErrors)
.flat()
.find((message) => message.length > 0)
return firstFieldMessage ? { message: firstFieldMessage } : {}
const message = firstFormActionMessage(mapped)
return message ? { message } : {}
}
export const setSupporterVoteIntentionFormAction = async (

View file

@ -11,6 +11,7 @@ import { MunicipalityUpdateForm } from '@/components/campaign/municipality/Munic
import { MunicipalityVisitEligibilityCard } from '@/components/campaign/municipality/MunicipalityVisitEligibilityCard'
import { MunicipalityZoneNeighborhoodsCard } from '@/components/campaign/municipality/MunicipalityZoneNeighborhoodsCard'
import { CampaignListPagination } from '@/components/campaign/shared/CampaignListPagination'
import { SuggestionsPanel } from '@/components/campaign/suggestion/SuggestionsPanel'
import type { VoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
import type { getCampaignUser } from '@/utilities/campaignAuth'
import { loadFederalCandidateOptions } from '@/utilities/electionCandidateOptions'
@ -25,6 +26,8 @@ import { municipalityElectionGeographyForSlug } from '@/utilities/municipalityEl
import { loadMunicipalityElectoralBaseline } from '@/utilities/municipalityElectoralBaseline'
import { loadMunicipalityGoalAccount } from '@/utilities/municipalityGoalAccount'
import type { getMunicipalityDetailViewModel } from '@/utilities/municipalityPageData'
import { formatSilenceAgeLabel } from '@/utilities/municipalitySignal'
import { loadMunicipalitySuggestions } from '@/utilities/municipalityTriggers'
import {
loadMunicipalityUpdatesFeed,
parseMunicipalityUpdateFeedParams,
@ -42,6 +45,7 @@ import {
import type { getPayload } from 'payload'
import { Suspense } from 'react'
import { resolveSuggestionFormAction } from '../../suggestionFormActions'
import { declareVotesFormAction, estimateVotesFormAction } from './pledgeFormActions'
import { createMunicipalityUpdateFormAction } from './updateFormActions'
@ -99,6 +103,18 @@ export const OverviewTab = async ({
>
<VisitEligibilityCard payloadUser={{ payload, user }} slug={view.slug} />
</Suspense>
{/* E11 — the evaluator adds its own reads (signals, agenda, decisions),
so it streams like the eligibility card above. */}
<Suspense
fallback={
<div
aria-hidden="true"
className="h-40 animate-pulse rounded-xl border bg-muted/40"
/>
}
>
<SuggestionsCard payloadUser={{ payload, user }} municipalityID={view.id} />
</Suspense>
</>
) : null}
<MunicipalityPledgesPanel pledges={pledges} estimateFormAction={estimateVotesFormAction} />
@ -106,6 +122,44 @@ export const OverviewTab = async ({
)
}
/**
* E11 — the município's own triggered patterns, full list (the dashboard shows
* the statewide top-5). A silent prioritized município says so instead of
* showing a bare "nothing here": silence is the question (§6.4).
*/
const SuggestionsCard = async ({
payloadUser: { payload, user },
municipalityID,
}: {
payloadUser: PayloadUser
municipalityID: number
}) => {
const bundle = await loadMunicipalitySuggestions(payload, user, { municipalityID })
const silence = bundle.silence.find((entry) => entry.municipalityID === municipalityID)
return (
<SuggestionsPanel
titleId="municipality-suggestions-title"
suggestions={bundle.suggestions}
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>
)
}
/>
)
}
const GoalAccountCard = async ({
payloadUser: { payload, user },
municipalityID,

View file

@ -7,6 +7,10 @@ import { CampaignDashboard } from '@/components/campaign/dashboard/CampaignDashb
import { LeaderContactsPanel } from '@/components/campaign/leadership/LeaderContactsPanel'
import { MunicipalityMapPanelDynamic } from '@/components/campaign/map/MunicipalityMapPanelDynamic'
import { MunicipalityEstimateScenarioProvider } from '@/components/campaign/municipality/MunicipalityEstimateScenarioContext'
import {
SuggestionSilenceStrip,
SuggestionsPanel,
} from '@/components/campaign/suggestion/SuggestionsPanel'
import type { CampaignUser } from '@/payload-types'
import { isCampaignLeader, isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
@ -14,6 +18,9 @@ import { getCampaignDashboardData } from '@/utilities/campaignDashboardData'
import { loadFederalCandidateOptions } from '@/utilities/electionCandidateOptions'
import { loadLeaderContactsPageData } from '@/utilities/leaderContactsPageData'
import { loadMunicipalityMapBundle } from '@/utilities/municipalityMapData'
import { loadMunicipalitySuggestions } from '@/utilities/municipalityTriggers'
import { resolveSuggestionFormAction } from './suggestionFormActions'
export const dynamic = 'force-dynamic'
@ -63,10 +70,54 @@ export default async function CampaignHomePage({ searchParams }: CampaignHomePag
</Suspense>
) : null
}
suggestionsSlot={
isStaff ? (
// Streams too: the evaluator adds its own reads (signals, agenda,
// decisions) and must not hold the KPI shell.
<Suspense
fallback={
<div
aria-hidden="true"
className="h-48 w-full animate-pulse rounded-xl border bg-muted/40"
/>
}
>
<DashboardSuggestionsSection payload={payload} user={user} />
</Suspense>
) : null
}
/>
)
}
/** E11 — the decision queue: top-5 by triage plus the monthly silence review. */
const DashboardSuggestionsSection = async ({
payload,
user,
}: {
payload: Payload
user: CampaignUser
}) => {
const bundle = await loadMunicipalitySuggestions(payload, user)
return (
<SuggestionsPanel
titleId="dashboard-suggestions-title"
suggestions={bundle.suggestions.slice(0, 5)}
activeCount={bundle.suggestions.length}
showMunicipality
resolveAction={resolveSuggestionFormAction}
emptyState={
<p className="text-sm text-muted-foreground">
Nenhum padrão do catálogo dispara nos seus municípios agora.
</p>
}
>
<SuggestionSilenceStrip entries={bundle.silence} />
</SuggestionsPanel>
)
}
const DashboardMapSection = async ({
payload,
user,

View file

@ -0,0 +1,54 @@
'use server'
import { resolveSuggestion } from '@/app/(campaign)/campanha/actions/suggestion'
import {
FormDataBoundaryError,
optionalFormText,
requiredFormText,
requiredRelationshipFormValue,
} from '@/lib/formData'
import { SUGGESTION_OUTCOMES, SUGGESTION_STALE_MESSAGE } from '@/lib/schemas/suggestion'
import { suggestionPatternIds } from '@/lib/suggestionCatalog'
import {
runCampaignFormAction,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
/**
* E11 — accept / postpone / dismiss one suggestion card. Lives at the `(app)`
* segment root because the SAME card renders on the two staff surfaces
* (dashboard panel and município detail); zod inside `resolveSuggestion` owns
* the real validation, the boundary only narrows the enum strings.
*/
export const resolveSuggestionFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
const patternIdRaw = requiredFormText(formData, 'patternId')
const patternId = suggestionPatternIds.find((id) => id === patternIdRaw)
if (!patternId) throw new FormDataBoundaryError('patternId', 'Padrão desconhecido.')
const outcomeRaw = requiredFormText(formData, 'outcome')
const outcome = SUGGESTION_OUTCOMES.find((value) => value === outcomeRaw)
if (!outcome) throw new FormDataBoundaryError('outcome', 'Decisão desconhecida.')
const { postponeDays } = await resolveSuggestion({
municipality: requiredRelationshipFormValue(formData, 'municipality'),
patternId,
outcome,
chosenActionId: optionalFormText(formData, 'chosenActionId'),
note: optionalFormText(formData, 'note'),
alternativeReading: optionalFormText(formData, 'alternativeReading'),
})
const message =
outcome === 'aceita'
? 'Decisão registrada.'
: outcome === 'adiada'
? `Sugestão adiada por ${postponeDays ?? 7} dias.`
: 'Sugestão descartada — leitura alternativa registrada.'
return { message }
},
safeMessages: [SUGGESTION_STALE_MESSAGE],
genericMessage: 'Não foi possível registrar a decisão. Tente novamente.',
})

View file

@ -0,0 +1,151 @@
'use server'
import { revalidatePath } from 'next/cache'
import type { Payload } from 'payload'
import {
SUGGESTION_STALE_MESSAGE,
suggestionDecisionSchema,
type SuggestionDecisionInput,
} from '@/lib/schemas/suggestion'
import {
DAY_MS,
getSuggestionPattern,
isSuggestionSuppressedByDecision,
SUGGESTION_TEXT_MAX_LENGTH,
suggestionPostponeDays,
} from '@/lib/suggestionCatalog'
import { DEFAULT_VOTE_ESTIMATE_SCENARIO } from '@/lib/voteEstimate'
import type { AllocationDecision, CampaignUser } from '@/payload-types'
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
import { loadMunicipalitySuggestions } from '@/utilities/municipalityTriggers'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
type ResolvedSuggestion = {
decision: AllocationDecision
municipalitySlug: string
/** Set on `adiada` — how long the suggestion snoozes. */
postponeDays: number | null
}
/**
* E11 — record the human decision about one triggered pattern as an
* `allocationDecision` (C12). The pattern is RE-EVALUATED server-side so the
* snapshot records the factors as they are at decision time, never as the
* client rendered them; a pattern that no longer fires refuses with a stale
* message instead of recording a decision about a queue that moved.
*/
export const resolveSuggestionRecord = async (
payload: Payload,
actor: CampaignUser,
input: SuggestionDecisionInput,
): Promise<ResolvedSuggestion> => {
const data = suggestionDecisionSchema.parse(input)
const now = new Date()
// Evaluation reads the actor's scope (`overrideAccess: false` throughout),
// so a município the actor cannot read yields "stale", never a decision.
const bundle = await loadMunicipalitySuggestions(payload, actor, {
municipalityID: data.municipality,
now,
})
const triggered = bundle.suggestions.find((suggestion) => suggestion.patternId === data.patternId)
if (!triggered) throw new Error(SUGGESTION_STALE_MESSAGE)
const pattern = getSuggestionPattern(data.patternId)
const postponeDays =
data.outcome === 'adiada' ? suggestionPostponeDays(triggered.triageLevel) : null
const suppressUntil =
postponeDays === null ? null : new Date(now.getTime() + postponeDays * DAY_MS).toISOString()
const chosenAction = data.chosenActionId
? (pattern.menu.find((action) => action.id === data.chosenActionId) ?? null)
: null
const note = data.note?.trim()
const composedRationale =
data.outcome === 'aceita'
? `${chosenAction?.label ?? 'Ação do menu'}${note ? ` — ${note}` : ''}`
: data.outcome === 'adiada'
? `Adiada por ${postponeDays} dias.${note ? ` ${note}` : ''}`
: (note ?? 'Descartada — leitura alternativa registrada.')
// The note alone may already be at the cap the collection enforces, so the
// composed prefix (menu label / postpone sentence) can push past it — and a
// rejected create would read as a generic error no retry could fix. The
// prefix carries the decision; only a maximal note loses its tail.
const rationale = composedRationale.slice(0, SUGGESTION_TEXT_MAX_LENGTH)
const decision = await withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadCampaignActor(payload, actor, req)
// Lock BEFORE re-reading the latest decision: two staff resolving the
// same card at once must serialize, or both record "the" decision.
await acquireTextAdvisoryLocks(payload, req, [
`municipality-suggestion:${data.municipality}:${data.patternId}`,
])
const latest = await payload.find({
collection: 'allocationDecision',
where: {
and: [
{ municipality: { equals: data.municipality } },
{ patternId: { equals: data.patternId } },
],
},
sort: '-createdAt',
limit: 1,
depth: 0,
select: { outcome: true, snapshot: true, createdAt: true },
user: currentActor,
overrideAccess: false,
req,
})
const existing = latest.docs[0]
if (existing && isSuggestionSuppressedByDecision(existing, triggered, now)) {
throw new Error(SUGGESTION_STALE_MESSAGE)
}
return payload.create({
collection: 'allocationDecision',
data: {
municipality: data.municipality,
patternId: data.patternId,
outcome: data.outcome,
rationale,
...(data.outcome === 'descarta' ? { alternativeReading: data.alternativeReading } : {}),
snapshot: {
triageLevel: triggered.triageLevel,
factors: triggered.factors,
metrics: triggered.metrics,
scenario: DEFAULT_VOTE_ESTIMATE_SCENARIO,
...(chosenAction ? { chosenActionId: chosenAction.id } : {}),
...(postponeDays !== null ? { postponeDays, suppressUntil } : {}),
},
},
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
},
{ beginFailureMessage: 'Não foi possível iniciar o registro da decisão.' },
)
return { decision, municipalitySlug: triggered.municipalitySlug, postponeDays }
}
export const resolveSuggestion = async (
input: SuggestionDecisionInput,
): Promise<{ postponeDays: number | null }> => {
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.
revalidatePath('/campanha')
revalidatePath(`/campanha/municipios/${municipalitySlug}`)
return { postponeDays }
}

View file

@ -101,11 +101,14 @@ export const AllocationDecision: CollectionConfig = {
required: true,
// `aceita`/`descarta` is the triage of a SUGGESTED pattern (E11). A level
// movement (E14) is neither — it is a decision taken outright — so it
// gets its own value instead of masquerading as an acceptance.
// gets its own value instead of masquerading as an acceptance. `adiada`
// (E11) is "not now": recording it as a special-cased dismissal would
// poison the alternative-reading data the E15 backtest depends on.
options: [
{ label: 'Aceita', value: 'aceita' },
{ label: 'Descarta', value: 'descarta' },
{ label: 'Movimento', value: 'movimento' },
{ label: 'Adiada', value: 'adiada' },
],
},
{

View file

@ -58,11 +58,14 @@ export const CampaignDashboard = ({
view,
userName,
mapSlot = null,
suggestionsSlot = null,
}: {
view: StaffDashboardView
userName: string
/** Server-streamed map section (composition keeps this component map-agnostic). */
mapSlot?: ReactNode
/** Server-streamed suggestion queue (E11) — right after the KPIs: it is the "onde ajo". */
suggestionsSlot?: ReactNode
}) => (
<CampaignPageShell>
<header className="flex flex-col gap-1">
@ -110,6 +113,8 @@ export const CampaignDashboard = ({
/>
</section>
{suggestionsSlot}
<div className="grid gap-6 lg:grid-cols-2">
<section
aria-labelledby="dashboard-missing-title"

View file

@ -23,6 +23,7 @@ import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/Popover'
import { sameIdSet } from '@/lib/sameIdSet'
import { cn } from '@/lib/utils'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { firstFormActionMessage } from '@/utilities/campaignFormFields'
const COLLAPSED_CHIP_ROWS = 3
/** Matches `gap-1.5` on the chip row, and `max-h-18` (72px) is this math at a 20px chip. */
@ -350,7 +351,7 @@ export const RelationChipCell = ({
// Undo only THIS delta: reverting to the server baseline would also wipe
// a sibling toggle from the same burst that already saved.
setOptimistic((current) => withDelta(current ?? latestIds.current, changedIds, !assigned))
const message = result.message ?? updateErrorMessage
const message = firstFormActionMessage(result) ?? updateErrorMessage
setFeedback({ kind: 'error', message })
toast.error(message)
})

View file

@ -0,0 +1,340 @@
'use client'
import Link from 'next/link'
import { useEffect, useId, useRef, useState, useTransition } from 'react'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { FieldLabel, FieldLegend, FieldSet } from '@/components/ui/field'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import {
getSuggestionPattern,
SUGGESTION_TEXT_MAX_LENGTH,
suggestionPostponeDays,
suggestionTriageLabels,
type SuggestionPatternId,
type SuggestionTriageLevel,
} from '@/lib/suggestionCatalog'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { firstFormActionMessage } from '@/utilities/campaignFormFields'
/** The slice of the evaluator's view model the card renders — texts come from the catalog. */
export type SuggestionCardData = {
municipalityID: number
municipalityName: string
municipalitySlug: string
patternId: SuggestionPatternId
triageLevel: SuggestionTriageLevel
factors: string[]
}
type SuggestionResolveAction = (
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
const GENERIC_ERROR_MESSAGE = 'Não foi possível registrar a decisão. Tente novamente.'
/**
* E11 — one triggered pattern as a decision card: the reading, the observed
* facts, the ordered action menu, and the three ways a human answers it.
* Accept and dismiss are EXPLICIT submits (they carry a choice or a reading —
* the `campanha-edit-where-you-see` exception for confirmation flows, same as
* E14); postpone is a single labeled tap. Nothing here auto-decides.
*/
export const SuggestionCard = ({
suggestion,
showMunicipality = false,
resolveAction,
}: {
suggestion: SuggestionCardData
/** Dashboard names the município; the detail card is already inside it. */
showMunicipality?: boolean
resolveAction: SuggestionResolveAction
}) => {
const pattern = getSuggestionPattern(suggestion.patternId)
const postponeDays = suggestionPostponeDays(suggestion.triageLevel)
const baseId = useId()
const titleId = `${baseId}-title`
const [mode, setMode] = useState<'aceitar' | 'descartar' | null>(null)
// The first menu action is the cheap 48h check — the recommended next step,
// so it starts selected and one tap away under field pressure.
const [chosenActionId, setChosenActionId] = useState(pattern.menu[0]?.id ?? '')
const [note, setNote] = useState('')
const [alternativeReading, setAlternativeReading] = useState('')
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [isPending, startTransition] = useTransition()
// Opening a form UNMOUNTS the trigger row (and vice versa), which would drop
// keyboard focus on <body>: send it to the first field on open, back to the
// matching trigger on cancel. On success the whole card leaves the queue —
// the toast's live region is the announcement then.
const formRef = useRef<HTMLFormElement | null>(null)
const triggersRef = useRef<HTMLDivElement | null>(null)
const previousModeRef = useRef<typeof mode>(null)
useEffect(() => {
const previous = previousModeRef.current
previousModeRef.current = mode
if (mode !== null) {
formRef.current?.querySelector<HTMLElement>('input, textarea, button')?.focus()
return
}
if (previous !== null) triggersRef.current?.querySelector('button')?.focus()
}, [mode])
const submit = (fields: Record<string, string>) => {
setErrorMessage(null)
const formData = new FormData()
formData.set('municipality', String(suggestion.municipalityID))
formData.set('patternId', suggestion.patternId)
for (const [key, value] of Object.entries(fields)) {
if (value) formData.set(key, value)
}
startTransition(async () => {
const result = await resolveAction({}, formData)
if (result.status === 'success') {
// The card leaves the queue when the revalidated RSC lands — the same
// transition — so the toast is the persistent copy of what happened.
toast.success(result.message)
setMode(null)
return
}
// One channel only: the card never unmounts on failure, so the inline
// `role="alert"` line is the announcement — a toast would say it twice.
setErrorMessage(firstFormActionMessage(result) ?? GENERIC_ERROR_MESSAGE)
})
}
return (
<article
aria-labelledby={titleId}
aria-busy={isPending}
data-pending={isPending || undefined}
className="flex flex-col gap-3 rounded-lg border p-4 transition-opacity data-pending:opacity-60"
>
<header className="flex flex-wrap items-center gap-x-2 gap-y-1">
<Badge
variant={suggestion.triageLevel === 1 ? 'destructive' : 'outline'}
aria-label={`Nível ${suggestion.triageLevel} — ${suggestionTriageLabels[suggestion.triageLevel]}`}
>
Nível {suggestion.triageLevel}
</Badge>
<h3 id={titleId} className="text-sm font-semibold">
{pattern.title}
</h3>
{showMunicipality ? (
<Link
href={`/campanha/municipios/${suggestion.municipalitySlug}`}
className="ml-auto text-sm font-medium text-primary underline-offset-4 hover:underline"
>
{suggestion.municipalityName}
</Link>
) : null}
</header>
<p className="text-sm text-muted-foreground">{pattern.probableReading}</p>
{suggestion.factors.length ? (
<ul
className="m-0 flex list-none flex-col gap-0.5 p-0 text-xs [&>li]:mt-0"
aria-label="Fatos observados"
>
{suggestion.factors.map((factor) => (
<li key={factor} className="flex gap-1.5">
<span aria-hidden="true" className="text-foreground/40">
•
</span>
<span className="text-foreground/80">{factor}</span>
</li>
))}
</ul>
) : null}
{/* The menu IS a sequence — cheap before expensive — so the numbering
carries information, it is not scaffolding. */}
<ol className="m-0 flex list-none flex-col gap-1 p-0 text-sm [&>li]:mt-0">
{pattern.menu.map((action, index) => (
<li key={action.id} className="flex gap-2">
<span className="shrink-0 font-medium text-muted-foreground tabular-nums">
{index + 1}º
</span>
<span>{action.label}</span>
</li>
))}
</ol>
<p className="text-xs text-muted-foreground">
<span className="font-medium">Contraindicação:</span> {pattern.contraindication}
</p>
{mode === null ? (
<div ref={triggersRef} className="flex flex-wrap gap-2">
<Button
variant="outline"
className="min-h-11"
disabled={isPending}
onClick={() => {
setErrorMessage(null)
setMode('aceitar')
}}
>
Aceitar
</Button>
<Button
variant="outline"
className="min-h-11"
disabled={isPending}
onClick={() => submit({ outcome: 'adiada' })}
>
{isPending ? <Spinner className="size-4" /> : null}
Adiar {postponeDays} dias
</Button>
<Button
variant="ghost"
className="min-h-11"
disabled={isPending}
onClick={() => {
setErrorMessage(null)
setMode('descartar')
}}
>
Descartar
</Button>
</div>
) : null}
{mode === 'aceitar' ? (
<form
ref={formRef}
className="flex flex-col gap-3 rounded-lg bg-muted/40 p-3"
onSubmit={(event) => {
event.preventDefault()
submit({ outcome: 'aceita', chosenActionId, note })
}}
>
<FieldSet className="gap-2">
<FieldLegend variant="label">Qual ação do menu foi tomada?</FieldLegend>
{pattern.menu.map((action) => (
<label key={action.id} className="flex min-h-11 items-center gap-2.5 text-sm">
<input
type="radio"
name={`${baseId}-action`}
value={action.id}
checked={chosenActionId === action.id}
onChange={() => setChosenActionId(action.id)}
className="size-4 shrink-0 accent-primary"
/>
<span>{action.label}</span>
</label>
))}
</FieldSet>
<FieldLabel htmlFor={`${baseId}-note`}>
Nota <span className="font-normal text-muted-foreground">(opcional)</span>
</FieldLabel>
<Textarea
id={`${baseId}-note`}
value={note}
maxLength={SUGGESTION_TEXT_MAX_LENGTH}
onChange={(event) => setNote(event.target.value)}
placeholder="Quem executa, prazo combinado, contexto…"
rows={2}
/>
<div className="flex flex-wrap gap-2">
<Button type="submit" className="min-h-11" disabled={isPending || !chosenActionId}>
{isPending ? <Spinner className="size-4" /> : null}
Registrar decisão
</Button>
<Button
type="button"
variant="ghost"
className="min-h-11"
disabled={isPending}
onClick={() => setMode(null)}
>
Cancelar
</Button>
</div>
</form>
) : null}
{mode === 'descartar' ? (
<form
ref={formRef}
className="flex flex-col gap-3 rounded-lg bg-muted/40 p-3"
onSubmit={(event) => {
event.preventDefault()
if (!alternativeReading.trim()) {
setErrorMessage('Informe a leitura alternativa ao descartar a sugestão.')
return
}
submit({ outcome: 'descarta', alternativeReading })
}}
>
<FieldSet className="gap-2">
<FieldLegend variant="label">O que explica melhor estes dados?</FieldLegend>
<p className="text-xs text-muted-foreground">
O descarte registra a leitura que venceu a sugestão — é o dado que o backtest compara
com o resultado.
</p>
{pattern.alternativeReadings.map((reading) => (
<label key={reading} className="flex min-h-11 items-center gap-2.5 text-sm">
<input
type="radio"
name={`${baseId}-reading`}
value={reading}
checked={alternativeReading === reading}
onChange={() => {
setErrorMessage(null)
setAlternativeReading(reading)
}}
className="size-4 shrink-0 accent-primary"
/>
<span>{reading}</span>
</label>
))}
</FieldSet>
<FieldLabel htmlFor={`${baseId}-reading-text`}>Leitura alternativa</FieldLabel>
<Textarea
id={`${baseId}-reading-text`}
value={alternativeReading}
maxLength={SUGGESTION_TEXT_MAX_LENGTH}
onChange={(event) => {
setErrorMessage(null)
setAlternativeReading(event.target.value)
}}
placeholder="Edite ou escreva a leitura que explica melhor o caso…"
rows={2}
required
/>
<div className="flex flex-wrap gap-2">
<Button type="submit" className="min-h-11" disabled={isPending}>
{isPending ? <Spinner className="size-4" /> : null}
Descartar com leitura
</Button>
<Button
type="button"
variant="ghost"
className="min-h-11"
disabled={isPending}
onClick={() => setMode(null)}
>
Cancelar
</Button>
</div>
</form>
) : null}
{errorMessage ? (
<p role="alert" className="text-sm font-medium text-destructive">
{errorMessage}
</p>
) : null}
<span aria-live="polite" className="sr-only">
{isPending ? 'Registrando decisão…' : ''}
</span>
</article>
)
}

View file

@ -0,0 +1,170 @@
import Link from 'next/link'
import type { ComponentProps, ReactNode } from 'react'
import { CampaignInfoHint } from '@/components/campaign/shared/CampaignInfoHint'
import {
SuggestionCard,
type SuggestionCardData,
} from '@/components/campaign/suggestion/SuggestionCard'
import { Button } from '@/components/ui/button'
import { CAMPAIGN_CONCEPTS_PATH, campaignConceptHref } from '@/lib/campaignIntelligenceConcepts'
import { SUGGESTION_STATUTE } from '@/lib/suggestionCatalog'
import { formatSilenceAgeLabel } from '@/utilities/municipalitySignal'
import type { MunicipalitySilenceEntry } from '@/utilities/municipalityTriggers'
/**
* E11 — the suggestion queue as a panel: triage-ordered cards, each a
* data→decision pattern with its menu, plus the statute line that keeps the
* catalog honest (§6: menu for judgment, never a rule). Server component; the
* cards are the interactive islands.
*/
export const SuggestionsPanel = ({
titleId,
suggestions,
activeCount,
showMunicipality = false,
resolveAction,
emptyState,
children,
}: {
titleId: string
suggestions: readonly SuggestionCardData[]
/** Total triggered in scope — the header says when the list below is a cut. */
activeCount: number
showMunicipality?: boolean
resolveAction: ComponentProps<typeof SuggestionCard>['resolveAction']
emptyState: ReactNode
/** Dashboard appends the silence strip here. */
children?: ReactNode
}) => (
<section aria-labelledby={titleId} className="flex flex-col gap-4 rounded-xl border p-4">
<div className="flex flex-wrap items-center gap-x-1 gap-y-2">
<h2 id={titleId} className="text-base font-medium">
Sugestões
</h2>
<CampaignInfoHint label="Sobre as sugestões">
<div className="flex flex-col gap-2">
<p>
O produto vigia os municípios e dispara os <strong>padrões dado→decisão</strong> do
catálogo da campanha, ordenados pela triagem (nível 1 = estoque em risco; nível 5 =
otimização). Cada card traz a leitura provável, os fatos observados e o menu de ações —
do barato ao caro.
</p>
<p>
A decisão é sempre humana: aceitar, adiar ou descartar fica registrado para o backtest —
inclusive a leitura alternativa de um descarte.
</p>
<Link
href={campaignConceptHref('triagem-de-sugestoes')}
className="font-medium text-primary underline underline-offset-4"
>
Como a triagem ordena a fila
</Link>
<Link
href={CAMPAIGN_CONCEPTS_PATH}
className="font-medium text-primary underline underline-offset-4"
>
Todos os conceitos
</Link>
</div>
</CampaignInfoHint>
{activeCount > 0 ? (
<span className="ml-auto text-sm text-muted-foreground tabular-nums">
{suggestions.length < activeCount
? `${suggestions.length} de ${activeCount} ativas`
: activeCount === 1
? '1 ativa'
: `${activeCount} ativas`}
</span>
) : null}
</div>
{suggestions.length ? (
<ul className="m-0 flex list-none flex-col gap-3 p-0 [&>li]:mt-0">
{suggestions.map(
({
municipalityID,
municipalityName,
municipalitySlug,
patternId,
triageLevel,
factors,
}) => (
<li key={`${municipalityID}:${patternId}`}>
<SuggestionCard
// Picked field by field: the loader's view model also carries the
// snapshot `metrics`, which only the server action reads — spreading
// the whole object would serialize them into the RSC payload.
suggestion={{
municipalityID,
municipalityName,
municipalitySlug,
patternId,
triageLevel,
factors,
}}
showMunicipality={showMunicipality}
resolveAction={resolveAction}
/>
</li>
),
)}
</ul>
) : (
emptyState
)}
{children}
<p className="text-xs text-muted-foreground">{SUGGESTION_STATUTE}</p>
</section>
)
/**
* "Pauta do silêncio" (§6.4): the prioritized municípios where NOTHING fired
* and nothing was recorded for a month. A list of questions, not of tasks —
* each chip opens the município so the answer starts with the registry.
*/
export const SuggestionSilenceStrip = ({
entries,
}: {
entries: readonly MunicipalitySilenceEntry[]
}) => {
if (entries.length === 0) return null
const shown = entries.slice(0, 6)
return (
<section aria-labelledby="suggestion-silence-title" className="flex flex-col gap-2">
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<h3 id="suggestion-silence-title" className="text-sm font-medium">
Pauta do silêncio
</h3>
<span className="text-xs text-muted-foreground tabular-nums">
{entries.length === 1 ? '1 município' : `${entries.length} municípios`}
</span>
</div>
<p className="text-xs text-muted-foreground">
Priorizados sem nenhum gatilho e sem sinal há mais de um mês — silêncio é pergunta, não
conforto. A resposta começa auditando o registro, nunca despachando agenda.
</p>
<ul className="m-0 flex list-none flex-wrap items-center gap-2 p-0 [&>li]:mt-0">
{shown.map((entry) => (
<li key={entry.municipalityID}>
<Button asChild variant="outline" className="min-h-11">
<Link href={`/campanha/municipios/${entry.municipalitySlug}`}>
{entry.municipalityName}
<span className="sr-only">, {formatSilenceAgeLabel(entry.lastSignalAgeDays)}</span>
</Link>
</Button>
</li>
))}
{entries.length > shown.length ? (
<li className="text-sm text-muted-foreground tabular-nums">
+{entries.length - shown.length}
</li>
) : null}
</ul>
</section>
)
}

View file

@ -34,6 +34,8 @@ export type CampaignConceptId =
| 'benchmark-intra-ti'
| 'elegibilidade-para-visita'
| 'fase-do-calendario'
| 'triagem-de-sugestoes'
| 'pauta-do-silencio'
export type CampaignConceptCategoryId = 'base' | 'diagnostico' | 'meta'
@ -286,6 +288,35 @@ export const campaignIntelligenceConcepts: ReadonlyArray<CampaignIntelligenceCon
whereItAppears:
'Início (staff), visão geral e coluna "Cobertura da meta" na lista de municípios, coluna homônima em `/campanha/territorios`, e o card "Conta da cadeira" no detalhe.',
},
{
id: 'triagem-de-sugestoes',
categoryID: 'diagnostico',
title: 'Triagem das sugestões (níveis 1–5)',
oneLiner:
'A ordem da fila de sugestões: risco confirmado primeiro, otimização por último — nunca um score.',
formula:
'Cada padrão do catálogo entra num nível fixo: 1 = estoque em risco confirmado (reduto com sinal de adversário triangulado), 2 = falha de canal, 3 = cobertura zero onde a meta exige, 4 = janela de oportunidade, 5 = otimização e higiene. Empate dentro do nível é decidido por votos em jogo (déficit da meta). Uma decisão registrada suprime o padrão por uma janela — exceto quando ele volta como nível 1.',
example:
'Um reduto do bloco central com sinal de invasão triangulado entra no nível 1 e fura qualquer supressão; um município grande sem rede entra no nível 3 quando priorizado; pledges parados são nível 5 até o diagnóstico.',
whyItMatters:
'A precedência vem de custos assimétricos: estoque quase-certo em risco vale mais que oportunidade hipotética, e mediação quebrada não se recompra em semanas. A sugestão é sempre um menu para o julgamento do staff — o produto nunca decide sozinho, e cada decisão (inclusive a leitura descartada) fica registrada para o backtest.',
whereItAppears:
'Painel "Sugestões" no Início (staff) e card homônimo na visão geral do município.',
},
{
id: 'pauta-do-silencio',
categoryID: 'diagnostico',
title: 'Pauta do silêncio',
oneLiner:
'Municípios priorizados onde nenhum padrão dispara e nada é registrado há mais de um mês — silêncio é pergunta, não conforto.',
formula:
'Entra na pauta o município com prioridade alta ou nível N2+ onde nenhum padrão do catálogo dispara E o último sinal registrado (atualização ou pledge) tem mais de um mês — ou nunca existiu.',
example:
'Um município priorizado sem atualização e sem pledge desde o início do ciclo aparece na pauta mesmo sem nenhum gatilho — a pergunta é se está tudo bem ou se ninguém está digitando.',
whyItMatters:
'O sistema só enxerga onde há rede registrando (viés da base): sem esta pauta, os desertos ficam mudos e o investimento se realimenta onde já há registro. A revisão mensal transforma a ausência de dado em pergunta explícita — e a resposta certa é auditar o registro, nunca despachar agenda.',
whereItAppears: 'Faixa "Pauta do silêncio" no painel de sugestões do Início (staff).',
},
]
export const campaignConceptsByCategory = (

View file

@ -0,0 +1,54 @@
import { z } from 'zod'
import { positiveRelationshipId } from '@/lib/schemas/primitives'
import {
getSuggestionPattern,
SUGGESTION_TEXT_MAX_LENGTH,
suggestionPatternIds,
} from '@/lib/suggestionCatalog'
/** Safe message: the queue moved between render and submit — never a bug. */
export const SUGGESTION_STALE_MESSAGE =
'A sugestão não está mais ativa — a fila foi atualizada. Recarregue para ver o estado atual.'
/** The three outcomes a card can record — one spelling for zod and the form boundary. */
export const SUGGESTION_OUTCOMES = ['aceita', 'descarta', 'adiada'] as const
/**
* E11 — one recorded decision about one triggered pattern. What each outcome
* requires is schema, not form courtesy: an acceptance without the chosen
* menu action, or a dismissal without the alternative reading, is exactly the
* unauditable record the registry exists to prevent (§6.3/§6.4).
*/
export const suggestionDecisionSchema = z
.object({
municipality: positiveRelationshipId,
patternId: z.enum(suggestionPatternIds),
outcome: z.enum(SUGGESTION_OUTCOMES),
/** The menu action taken — required (and validated against the menu) on `aceita`. */
chosenActionId: z.string().trim().max(120).optional(),
note: z.string().trim().max(SUGGESTION_TEXT_MAX_LENGTH).optional(),
/** The reading that beat the suggestion — required on `descarta`. */
alternativeReading: z.string().trim().max(SUGGESTION_TEXT_MAX_LENGTH).optional(),
})
.superRefine((data, context) => {
if (data.outcome === 'aceita') {
const menu = getSuggestionPattern(data.patternId).menu
if (!data.chosenActionId || !menu.some((action) => action.id === data.chosenActionId)) {
context.addIssue({
code: 'custom',
path: ['chosenActionId'],
message: 'Escolha qual ação do menu foi tomada.',
})
}
}
if (data.outcome === 'descarta' && !data.alternativeReading?.trim()) {
context.addIssue({
code: 'custom',
path: ['alternativeReading'],
message: 'Informe a leitura alternativa ao descartar a sugestão.',
})
}
})
export type SuggestionDecisionInput = z.input<typeof suggestionDecisionSchema>

View file

@ -0,0 +1,659 @@
import {
formatElectionNumber,
formatVoteSharePercent,
oneDecimalFormatter,
} from '@/lib/electionFormat'
import type { EngagementLevel } from '@/lib/engagementLevel'
import { engagementLevelRank } from '@/lib/engagementLevel'
import type { AllocationDecision } from '@/payload-types'
import type { MunicipalityTerritorialClass } from '@/utilities/municipalityTerritorialClass'
/**
* Mirrors `ELECTION_YEAR_*` from `lib/electionResults` WITHOUT importing it:
* that module's first import is `bahiaTerritories`, and this catalog ships in
* the client card's chunk — three numbers must not drag the territory table
* into the browser (the B14 lesson, again).
*/
const YEAR_2014 = 2014
const YEAR_2018 = 2018
const YEAR_2022 = 2022
/**
* E11 "motor de sugestões" — the versioned, curated catalog of data→decision
* patterns from the research report (§6.1), as pure predicates over derived
* numbers plus the exact copy the cards render. The evaluator
* (`utilities/municipalityTriggers.ts`) assembles the inputs; this module owns
* WHAT fires and WHAT it says, so a pattern's trigger and its text are
* reviewed in the same diff (the same contract E18 keeps for concepts).
*
* Client-safe on purpose: the dismiss form offers each pattern's alternative
* readings as quick picks, so the browser needs the texts — and the predicates
* are plain arithmetic over an input of numbers, no artifact import.
*
* Every cut below is ILLUSTRATIVE and calibratable (statute of the whole
* catalog, §6): the backtest (E15) is the calibration path. None of these
* numbers may be rendered in the UI as an exact threshold (§6.4 gaming).
*/
export const suggestionPatternIds = ['P1', 'P2', 'P3', 'P5', 'P6', 'P7', 'K-A', 'K-B'] as const
export type SuggestionPatternId = (typeof suggestionPatternIds)[number]
/** §6.2 — 1 estoque em risco confirmado … 5 otimização/higiene. */
export type SuggestionTriageLevel = 1 | 2 | 3 | 4 | 5
/** The research names for the five levels — shown with the number, never instead of it. */
export const suggestionTriageLabels: Record<SuggestionTriageLevel, string> = {
1: 'Estoque em risco',
2: 'Falha de canal',
3: 'Cobertura zero',
4: 'Janela de oportunidade',
5: 'Otimização e higiene',
}
export type TriggeredPattern = {
patternId: SuggestionPatternId
triageLevel: SuggestionTriageLevel
/** Observed facts behind the trigger — the card never shows the label alone. */
factors: string[]
}
/**
* Everything a predicate may look at, already derived. Counts and ratios are
* observations; the `*Cut` fields are catalog-wide references the evaluator
* computes once (medians/quartile over the 435), so one suggestion means the
* same thing for every viewer.
*/
export type MunicipalityTriggerInput = {
priority: 'alta' | 'normal' | null
engagementLevel: EngagementLevel | null
territorialClass: MunicipalityTerritorialClass
inCoreBlock: boolean
/** Location quotient per election year (E10 formula); null without electorate. */
lqByYear: Record<number, number | null>
ownVotesByYear: Record<number, number>
projectedValidVotes: number
projectedValidVotesCut: number
projectedValidVotesUpperCut: number
uncapturedFieldVotes: number
uncapturedFieldVotesCut: number
captureRate2022: number | null
/** Campo federal votes ÷ valid votes, 2022 — "captura agregada do campo". */
fieldShareOfValid2022: number | null
/** Own votes ÷ campo federal votes, 2022. */
intraFieldShare2022: number | null
/** The candidate's own statewide intra-field share, 2022 — the relative standard. */
intraFieldShareStateStandard2022: number | null
advisorCount: number
leadershipCount: number
pledgeCount: number
/** Days since max(lastUpdateAt, lastPledgeAt); null = never. */
lastSignalAgeDays: number | null
/** Days since the last pledge declaration/estimate; null = never. */
lastPledgeAgeDays: number | null
/** Central-scenario goal coverage (E8 semantics). */
coverageRatio: number | null
coverageDeficit: number
/** Most severe adversary signal within the window, if any (P1 variant). */
adversarySignal: { triangulated: boolean } | null
/** Any non-draft activity recently held or upcoming (the "agenda" leg of P1). */
hasRecentOrUpcomingActivity: boolean
/** Activities `realizado` within the effort window (K-A's "esforço no ciclo"). */
completedActivityCount: number
}
/**
* Illustrative anchors, versioned in one object like
* `TERRITORIAL_CLASS_ANCHORS` / `visitPlannerAnchors` — recalibration (E15) is
* a one-line diff here, never a UI string.
*/
const SUGGESTION_ANCHORS = {
/** P1: days without pledge/sinal before a reduto reads dormant (research: 3–4 weeks; E9's cold threshold). */
dormantSignalDays: 21,
/** P1 variant: own 2022 vote at or below this multiple of 2018 reads as a drop. */
voteDropRatio: 0.85,
/** P3: campo share of local valid votes that reads "campo forte". */
strongFieldShareOfValid: 0.35,
/** P3: own intra-field share below this multiple of the own statewide standard. */
weakIntraFieldMultiple: 0.5,
/** P5: minimum LQ growth across the 2014→2022 series to read as rising. */
risingLqGrowthRatio: 1.2,
/** P7: days without pledge movement in a prioritized município. */
stagnantPledgeDays: 21,
/** K-A: days without pledge movement AFTER recorded effort. */
investedStagnantPledgeDays: 28,
/** K-B: capture rate below this reads as minimal. */
minimalCaptureRate: 0.02,
/** Silence review: days without any signal (monthly cadence, §6.4 viés da base). */
silenceDays: 30,
} as const
/**
* §6.4 churn mitigation — how long a recorded decision keeps the same pattern
* out of the queue. A postponement carries its own `suppressUntil` in the
* snapshot; these cover accept/dismiss.
*/
export const SUGGESTION_SUPPRESSION_DAYS = {
aceita: 14,
descarta: 30,
} as const
/** 7 days by default; low-urgency levels (4–5) snooze for 14. */
export const suggestionPostponeDays = (level: SuggestionTriageLevel): number =>
level >= 4 ? 14 : 7
/** Derived over every level so a recalibration cannot silently invert them. */
const TRIAGE_LEVELS: readonly SuggestionTriageLevel[] = [1, 2, 3, 4, 5]
const SHORTEST_POSTPONE_DAYS = Math.min(...TRIAGE_LEVELS.map(suggestionPostponeDays))
const LONGEST_POSTPONE_DAYS = Math.max(...TRIAGE_LEVELS.map(suggestionPostponeDays))
/** Windows the evaluator uses when it assembles signal/agenda inputs. */
export const SUGGESTION_INPUT_WINDOWS = {
/** Days an adversary signal stays "recent" for P1. */
adversarySignalDays: 28,
/** Days back an activity still counts as agenda (P1) or effort (K-A). */
activityDays: 42,
/**
* Days back the evaluator fetches decisions: nothing older than the longest
* suppression window (or the longest postpone) can still hide a pattern, so
* the fetch window is derived instead of guessed — decision rows are jsonb
* and grow with real use.
*/
decisionDays: Math.max(
SUGGESTION_SUPPRESSION_DAYS.aceita,
SUGGESTION_SUPPRESSION_DAYS.descarta,
LONGEST_POSTPONE_DAYS,
),
} as const
/** One place for the day math the suggestion modules share. */
export const DAY_MS = 86_400_000
const decisionSnapshotValue = (snapshot: unknown, key: string): unknown => {
if (typeof snapshot !== 'object' || snapshot === null || Array.isArray(snapshot)) return null
return (snapshot as Record<string, unknown>)[key]
}
/** Cap shared by the zod schema and the card's textareas (the collection's own limit). */
export const SUGGESTION_TEXT_MAX_LENGTH = 2000
export type SuggestionDecisionLike = {
outcome: AllocationDecision['outcome']
createdAt: string
snapshot: unknown
}
/**
* §6.4 churn mitigation: a recorded decision keeps the same pattern out of the
* queue for a window — except that a CONFIRMED risk (level 1) fires through a
* decision recorded at lower urgency ("nível 1 fura a fila"). Pure so the rule
* that decides what the coordinator does NOT see is pinned by unit tests.
*/
export const isSuggestionSuppressedByDecision = (
decision: SuggestionDecisionLike,
triggered: Pick<TriggeredPattern, 'triageLevel'>,
now: Date,
): boolean => {
const decidedLevelRaw = decisionSnapshotValue(decision.snapshot, 'triageLevel')
const decidedLevel = typeof decidedLevelRaw === 'number' ? decidedLevelRaw : null
if (triggered.triageLevel === 1 && (decidedLevel === null || decidedLevel > 1)) return false
const decidedAtMs = new Date(decision.createdAt).getTime()
if (Number.isNaN(decidedAtMs)) return false
if (decision.outcome === 'adiada') {
const untilRaw = decisionSnapshotValue(decision.snapshot, 'suppressUntil')
const untilMs = typeof untilRaw === 'string' ? new Date(untilRaw).getTime() : Number.NaN
if (!Number.isNaN(untilMs)) return now.getTime() < untilMs
// Malformed snapshot: fall back to the shortest postpone rather than never expiring.
return now.getTime() < decidedAtMs + SHORTEST_POSTPONE_DAYS * DAY_MS
}
if (decision.outcome === 'aceita') {
return now.getTime() < decidedAtMs + SUGGESTION_SUPPRESSION_DAYS.aceita * DAY_MS
}
if (decision.outcome === 'descarta') {
return now.getTime() < decidedAtMs + SUGGESTION_SUPPRESSION_DAYS.descarta * DAY_MS
}
return false
}
type SuggestionMenuAction = {
id: string
label: string
}
export type SuggestionPattern = {
id: SuggestionPatternId
title: string
probableReading: string
/** The "descartar antes" list — quick picks of the dismiss form. */
alternativeReadings: string[]
/** Ordered cheap-before-expensive (§6.2); the first item is the 48h check. */
menu: SuggestionMenuAction[]
contraindication: string
evaluate: (input: MunicipalityTriggerInput) => TriggeredPattern | null
}
/** Statute of the whole catalog (§6 header) — every card shows it. */
export const SUGGESTION_STATUTE =
'Hipótese de literatura (relatório §6.1) — menu para o julgamento do staff, nunca regra automática; cortes ilustrativos, calibráveis pelo backtest.'
const formatLqMultiple = (value: number): string => `${oneDecimalFormatter.format(value)}×`
const isEngagedAtLeastN2 = (level: EngagementLevel | null): boolean =>
level !== null && engagementLevelRank[level] >= engagementLevelRank.n2
const isPrioritized = (input: MunicipalityTriggerInput): boolean =>
input.priority === 'alta' || isEngagedAtLeastN2(input.engagementLevel)
const signalAgeFactor = (ageInDays: number | null): string =>
ageInDays === null
? 'Nunca recebeu sinal registrado'
: `Sem sinal registrado há ${ageInDays} dias`
/**
* P3's structural condition, shared so P2/K-B can exclude it (the machine owns
* the structural differential — §6.3): the campo holds the município, and the
* candidate's slice of the campo is far below his own statewide standard.
*/
const holdsP3Condition = (input: MunicipalityTriggerInput): boolean => {
const { fieldShareOfValid2022, intraFieldShare2022, intraFieldShareStateStandard2022 } = input
if (
fieldShareOfValid2022 === null ||
intraFieldShare2022 === null ||
intraFieldShareStateStandard2022 === null ||
intraFieldShareStateStandard2022 <= 0
) {
return false
}
return (
fieldShareOfValid2022 >= SUGGESTION_ANCHORS.strongFieldShareOfValid &&
intraFieldShare2022 <
intraFieldShareStateStandard2022 * SUGGESTION_ANCHORS.weakIntraFieldMultiple
)
}
/**
* K-B's structural condition, shared so P2 can exclude it: apparent field
* headroom, but the candidate's capture stayed minimal across the whole
* series — someone else consolidated the município.
*/
const holdsKbCondition = (input: MunicipalityTriggerInput): boolean => {
if (input.uncapturedFieldVotes < input.uncapturedFieldVotesCut) return false
if (input.captureRate2022 === null) return false
if (input.captureRate2022 >= SUGGESTION_ANCHORS.minimalCaptureRate) return false
if (holdsP3Condition(input)) return false
const seriesLqs = [YEAR_2014, YEAR_2018, YEAR_2022]
.map((year) => input.lqByYear[year])
.filter((lq): lq is number => typeof lq === 'number')
if (seriesLqs.length < 2) return false
return seriesLqs.every((lq) => lq < 1)
}
const patternP1: SuggestionPattern = {
id: 'P1',
title: 'Reduto dormente ou ameaçado',
probableReading:
'Vulnerabilidade à invasão (Ames): estoque quase-certo sem vigilância registrada é o alvo mais barato de um adversário racional.',
alternativeReadings: [
'Dormência de registro — a rede trabalha e não digita',
'Broker ocupado, mas fiel',
],
menu: [
{
id: 'checagem-rede',
label: 'Checagem de rede em 48h — o responsável liga e atualiza os pledges',
},
{ id: 'presenca-curta', label: 'Presença pessoal curta se a ameaça se confirmar' },
{ id: 'segunda-linha', label: 'Ativar a segunda linha de lideranças' },
{ id: 'escalar-federacao', label: 'Se a invasão for intracampo, escalar à federação' },
],
contraindication:
'Não despejar agenda cara antes da checagem barata; não superinvestir em reduto já no teto.',
evaluate: (input) => {
if (input.territorialClass !== 'reduto' || !input.inCoreBlock) return null
const threatened = input.adversarySignal !== null
const own2018 = input.ownVotesByYear[YEAR_2018] ?? 0
const own2022 = input.ownVotesByYear[YEAR_2022] ?? 0
const dropped = own2018 > 0 && own2022 <= own2018 * SUGGESTION_ANCHORS.voteDropRatio
const dormant =
(input.lastSignalAgeDays === null ||
input.lastSignalAgeDays >= SUGGESTION_ANCHORS.dormantSignalDays) &&
!input.hasRecentOrUpcomingActivity
if (!threatened && !dropped && !dormant) return null
const lq2022 = input.lqByYear[YEAR_2022]
const factors: string[] = []
if (typeof lq2022 === 'number') {
factors.push(`${formatLqMultiple(lq2022)} o padrão estadual do candidato`)
}
if (threatened) {
factors.push(
input.adversarySignal?.triangulated
? 'Sinal de adversário registrado e triangulado'
: 'Sinal de adversário registrado (fonte única)',
)
}
if (dropped) {
factors.push(
`Votação própria caiu de ${formatElectionNumber(own2018)} (2018) para ${formatElectionNumber(own2022)} (2022)`,
)
}
if (dormant) factors.push(`${signalAgeFactor(input.lastSignalAgeDays)}, sem agenda no período`)
const triageLevel: SuggestionTriageLevel = input.adversarySignal
? input.adversarySignal.triangulated
? 1
: 2
: 3
return { patternId: 'P1', triageLevel, factors }
},
}
const patternP2: SuggestionPattern = {
id: 'P2',
title: 'Ataque: campo forte, captura baixa',
probableReading:
'Expansão clássica: voto de campo sem dono para deputado federal — recepção antes de agenda.',
alternativeReadings: [
'Correligionário já capturou o campo (ver P3)',
'Barreira de entrada alta / "posse" de candidato regional',
'Disputa local fechada — perdida vestida de oportunidade',
],
menu: [
{ id: 'recrutar-lideranca', label: 'Recrutar liderança ou organização local ANTES de agenda' },
{ id: 'atividade-aliada', label: 'Atividade com organização aliada' },
{ id: 'dobradinha-local', label: 'Dobradinha com estadual forte no município' },
{ id: 'agenda-com-recepcao', label: 'Agenda só quando houver recepção pronta' },
],
contraindication:
'Disputa local fechada + dominância adversária = município perdido vestido de oportunidade (persuasão ≈ 0).',
evaluate: (input) => {
if (input.territorialClass !== 'expansao') return null
if (input.uncapturedFieldVotes < input.uncapturedFieldVotesCut) return null
if (input.projectedValidVotes < input.projectedValidVotesCut) return null
// Structural differential (§6.3): if a correligionário holds it, or the
// series says "perdida", those patterns speak — not the attack call.
if (holdsP3Condition(input) || holdsKbCondition(input)) return null
const factors = [`${formatElectionNumber(input.uncapturedFieldVotes)} votos do campo sem dono`]
if (input.captureRate2022 !== null) {
factors.push(`Captura de ${formatVoteSharePercent(input.captureRate2022)} do teto do campo`)
}
return { patternId: 'P2', triageLevel: 4, factors }
},
}
const patternP3: SuggestionPattern = {
id: 'P3',
title: 'Correligionário na frente',
probableReading: 'O município está organizado — por outro candidato do campo (Nicolau).',
alternativeReadings: [
'Acordo de território vigente',
'Correligionário em saída — herança aberta (acelerar a entrada)',
],
menu: [
{ id: 'inteligencia', label: 'Inteligência: concorre de novo? A força vem de que broker?' },
{ id: 'heranca-aberta', label: 'Herança aberta → entrada rápida nas lideranças órfãs' },
{ id: 'flanquear', label: 'Ativo e forte → flanquear segmento não coberto' },
{ id: 'nao-agressao', label: 'Acordo de não-agressão via federação' },
],
contraindication:
'Guerra aberta em reduto de correligionário forte: custo alto, envenena a lista de que todos dependem.',
evaluate: (input) => {
if (!holdsP3Condition(input)) return null
// Volume gate (calibrável): the menu costs intelligence work — worth it
// where the campo vote at stake is material, or a strong-campo interior
// town of 2 000 válidos floods the queue in week one.
if (input.projectedValidVotes < input.projectedValidVotesCut) return null
const factors: string[] = []
if (input.fieldShareOfValid2022 !== null) {
factors.push(
`Campo captura ${formatVoteSharePercent(input.fieldShareOfValid2022)} dos válidos`,
)
}
if (input.intraFieldShare2022 !== null && input.intraFieldShareStateStandard2022 !== null) {
factors.push(
`Fatia intracampo de ${formatVoteSharePercent(input.intraFieldShare2022)} (padrão estadual do candidato: ${formatVoteSharePercent(input.intraFieldShareStateStandard2022)})`,
)
}
return { patternId: 'P3', triageLevel: 4, factors }
},
}
const patternP5: SuggestionPattern = {
id: 'P5',
title: 'Expansão acima do padrão',
probableReading: 'Trajetória dos vencedores em ato: base → região → dispersão.',
alternativeReadings: [
'Evento único ou broker recém-chegado, não tendência',
'Meta subestimada — a régua é que está errada',
],
menu: [
{
id: 'dobrar-aposta',
label: 'Dobrar a aposta com custo marginal baixo (material, atividades)',
},
{ id: 'formalizar-rede', label: 'Formalizar a rede emergente antes que esfrie' },
{ id: 'replicar-vizinhanca', label: 'Replicar por vizinhança no mesmo território' },
{ id: 'subir-meta', label: 'Subir a meta e realimentar a decomposição' },
],
contraindication: 'Não canibalizar a agenda de defesa pela novidade; multidão ≠ tendência.',
evaluate: (input) => {
if (input.territorialClass === 'sem_base') return null
const lq2014 = input.lqByYear[YEAR_2014]
const lq2018 = input.lqByYear[YEAR_2018]
const lq2022 = input.lqByYear[YEAR_2022]
const rising =
typeof lq2014 === 'number' &&
typeof lq2018 === 'number' &&
typeof lq2022 === 'number' &&
lq2014 < lq2018 &&
lq2018 < lq2022 &&
lq2022 >= lq2014 * SUGGESTION_ANCHORS.risingLqGrowthRatio
const overCovered = input.coverageRatio !== null && input.coverageRatio > 1
if (!rising && !overCovered) return null
const factors: string[] = []
if (rising) {
factors.push(
`LQ subiu de ${formatLqMultiple(lq2014)} (2014) para ${formatLqMultiple(lq2022)} (2022)`,
)
}
if (overCovered && input.coverageRatio !== null) {
factors.push(`Cobertura da meta em ${formatVoteSharePercent(input.coverageRatio)}`)
}
return { patternId: 'P5', triageLevel: 4, factors }
},
}
const patternP6: SuggestionPattern = {
id: 'P6',
title: 'Município grande sem rede',
probableReading:
'Voto órfão (histórico sem rede registrada) ou deserto (nunca teve) — o censo de rede separa os dois.',
alternativeReadings: [
'Voto órfão — a rede existe, só não está registrada (censo primeiro)',
'Deserto real com campo fraco — despriorizar por escrito',
],
menu: [
{ id: 'censo-rede', label: 'Censo de rede via federação e organizações' },
{ id: 'nomear-responsavel', label: 'Nomear responsável (mesmo externo)' },
{ id: 'tratar-como-ataque', label: 'Deserto com campo forte → tratar como ataque (P2)' },
{ id: 'despriorizar', label: 'Deserto com campo fraco → despriorizar por escrito' },
],
contraindication:
'Agenda para "abrir" o município sem estrutura de recepção — visita sem broker evapora (Nielsen).',
evaluate: (input) => {
if (input.projectedValidVotes < input.projectedValidVotesUpperCut) return null
if (input.leadershipCount > 0 || input.advisorCount > 0 || input.pledgeCount > 0) return null
const factors = [
`${formatElectionNumber(input.projectedValidVotes)} válidos projetados — quartil superior do catálogo`,
'Nenhuma liderança, assessor ou pledge registrado',
]
return { patternId: 'P6', triageLevel: isPrioritized(input) ? 3 : 5, factors }
},
}
const patternP7: SuggestionPattern = {
id: 'P7',
title: 'Pledges estagnados vs. meta',
probableReading:
'Leitura tripla: (i) a rede parou · (ii) a rede trabalha e não registra · (iii) a rede saturou o alcance. Auditoria amostral separa (ii); pledges no teto histórico denunciam (iii).',
alternativeReadings: [
'A rede trabalha e não registra — consertar o fluxo (sede digita)',
'A rede saturou o alcance — teto histórico atingido',
],
menu: [
{ id: 'diagnostico-diferencial', label: 'Diagnóstico diferencial pelo responsável (1 semana)' },
{ id: 'consertar-registro', label: 'Se falta registro: consertar o fluxo (sede digita)' },
{ id: 'reativacao', label: 'Se a rede parou: reativação pessoal da rede' },
{
id: 'recrutar-ou-realocar',
label: 'Se saturou: recrutar novas lideranças ou realocar a meta',
},
],
contraindication:
'Não cobrar a rede pelo número antes de auditar o registro — pune quem trabalha e ensina a inflar.',
evaluate: (input) => {
if (!isPrioritized(input)) return null
if (input.pledgeCount === 0) return null
if (input.coverageRatio === null || input.coverageRatio >= 1) return null
if (
input.lastPledgeAgeDays === null ||
input.lastPledgeAgeDays < SUGGESTION_ANCHORS.stagnantPledgeDays
) {
return null
}
// With recorded effort in the cycle, the sharper diagnosis is K-A.
if (input.completedActivityCount > 0) return null
const factors = [
`Cobertura em ${formatVoteSharePercent(input.coverageRatio)} da meta`,
`Último pledge há ${input.lastPledgeAgeDays} dias`,
]
return { patternId: 'P7', triageLevel: 5, factors }
},
}
const patternKA: SuggestionPattern = {
id: 'K-A',
title: 'Não responde a investimento',
probableReading:
'O terreno pode não responder — mas execução ruim e sub-registro respondem igual. Autópsia execução × terreno antes de culpar o município.',
alternativeReadings: [
'Sub-registro — o esforço responde, ninguém digita',
'Execução ruim, não terreno ruim',
'Resposta lenta em município grande, não nula',
],
menu: [
{
id: 'autopsia',
label:
'Autópsia execução × terreno (1 semana) — descartar sub-registro antes de culpar o terreno',
},
{ id: 'rebaixar-um-nivel', label: 'Rebaixar UM nível com sinais de reversão ex-ante' },
{ id: 'realocar-irmao', label: 'Realocar para município-irmão do território com headroom' },
],
contraindication:
'Não rebaixar dentro da janela de proteção; resposta lenta ≠ nula em município grande.',
evaluate: (input) => {
if (input.completedActivityCount === 0) return null
if (input.coverageRatio === null || input.coverageRatio >= 1) return null
if (
input.lastPledgeAgeDays !== null &&
input.lastPledgeAgeDays < SUGGESTION_ANCHORS.investedStagnantPledgeDays
) {
return null
}
const factors = [
`${formatElectionNumber(input.completedActivityCount)} atividade(s) realizada(s) no ciclo recente`,
input.lastPledgeAgeDays === null
? 'Nenhum pledge registrado até hoje'
: `Pledges parados há ${input.lastPledgeAgeDays} dias`,
]
return { patternId: 'K-A', triageLevel: 5, factors }
},
}
const patternKB: SuggestionPattern = {
id: 'K-B',
title: 'Perdida vestida de oportunidade',
probableReading:
'Perdida neste ciclo, não nesta década: o headroom é miragem enquanto a dominância de outro se mantém.',
alternativeReadings: [
'Efeito-hub — contiguidade estratégica com reduto vizinho',
'Mudança local iminente ("se fulano sair, reavaliar")',
],
menu: [
{
id: 'classificar-n0-n1',
label: 'Classificar N0/N1 por escrito e tirar da lista de tentação',
},
{ id: 'sinal-reversao', label: 'Registrar o sinal de reversão ("se fulano sair, reavaliar")' },
{ id: 'semente-minima', label: 'Semente mínima se contígua a reduto' },
],
contraindication: 'Roll-off sozinho não é tese de entrada; não queimar a relação local ao sair.',
evaluate: (input) => {
if (!holdsKbCondition(input)) return null
const factors = [`${formatElectionNumber(input.uncapturedFieldVotes)} votos de campo aparentes`]
if (input.captureRate2022 !== null) {
factors.push(
`Captura de ${formatVoteSharePercent(input.captureRate2022)} do teto, mínima em toda a série`,
)
}
return { patternId: 'K-B', triageLevel: 5, factors }
},
}
export const suggestionPatterns: readonly SuggestionPattern[] = [
patternP1,
patternP2,
patternP3,
patternP5,
patternP6,
patternP7,
patternKA,
patternKB,
]
const patternById = new Map(suggestionPatterns.map((pattern) => [pattern.id, pattern]))
export const getSuggestionPattern = (id: SuggestionPatternId): SuggestionPattern => {
const pattern = patternById.get(id)
if (!pattern) throw new Error(`Padrão de sugestão desconhecido: ${id}`)
return pattern
}
/** All patterns evaluated, sorted by triage level (1 first). Pure. */
export const evaluateMunicipalityTriggers = (input: MunicipalityTriggerInput): TriggeredPattern[] =>
suggestionPatterns
.map((pattern) => pattern.evaluate(input))
.filter((triggered): triggered is TriggeredPattern => triggered !== null)
.sort((left, right) => left.triageLevel - right.triageLevel)
/**
* "Pauta do silêncio" (§6.4 viés da base): a prioritized município where NO
* pattern fired and nothing was recorded for a month is a question, not
* comfort. Includes `priority: alta` beside N2+ because E14 shipped without a
* backfill — levels are still being triaged.
*/
export const shouldEnterSilenceReview = (
input: MunicipalityTriggerInput,
triggeredCount: number,
): boolean =>
triggeredCount === 0 &&
isPrioritized(input) &&
(input.lastSignalAgeDays === null || input.lastSignalAgeDays >= SUGGESTION_ANCHORS.silenceDays)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,14 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TYPE "public"."enum_allocation_decision_outcome" ADD VALUE 'adiada';`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "allocation_decision" ALTER COLUMN "outcome" SET DATA TYPE text;
DROP TYPE "public"."enum_allocation_decision_outcome";
CREATE TYPE "public"."enum_allocation_decision_outcome" AS ENUM('aceita', 'descarta', 'movimento');
ALTER TABLE "allocation_decision" ALTER COLUMN "outcome" SET DATA TYPE "public"."enum_allocation_decision_outcome" USING "outcome"::"public"."enum_allocation_decision_outcome";`)
}

View file

@ -32,6 +32,7 @@ import * as migration_20260725_170000_whatsapp_subscription_consent_key from './
import * as migration_20260725_213000_rename_action_plan_to_activity from './20260725_213000_rename_action_plan_to_activity';
import * as migration_20260727_032523_add_users_roles from './20260727_032523_add_users_roles';
import * as migration_20260727_161752_add_municipality_engagement_level from './20260727_161752_add_municipality_engagement_level';
import * as migration_20260728_041547_add_allocation_decision_adiada_outcome from './20260728_041547_add_allocation_decision_adiada_outcome';
import * as migration_20260728_041958_add_campaign_webauthn_credentials from './20260728_041958_add_campaign_webauthn_credentials';
export const migrations = [
@ -205,6 +206,11 @@ export const migrations = [
down: migration_20260727_161752_add_municipality_engagement_level.down,
name: '20260727_161752_add_municipality_engagement_level',
},
{
up: migration_20260728_041547_add_allocation_decision_adiada_outcome.up,
down: migration_20260728_041547_add_allocation_decision_adiada_outcome.down,
name: '20260728_041547_add_allocation_decision_adiada_outcome'
},
{
up: migration_20260728_041958_add_campaign_webauthn_credentials.up,
down: migration_20260728_041958_add_campaign_webauthn_credentials.down,

View file

@ -600,7 +600,7 @@ export interface AllocationDecision {
id: number;
municipality: number | Municipality;
patternId: string;
outcome: 'aceita' | 'descarta' | 'movimento';
outcome: 'aceita' | 'descarta' | 'movimento' | 'adiada';
rationale: string;
alternativeReading?: string | null;
/**

View file

@ -3,6 +3,22 @@ export const fieldError = (
field: string,
): string | undefined => fieldErrors?.[field]?.[0]
/**
* Flatten a form-action failure state to ONE message for controls that render
* a single line (suggestion cards, chip cells, the supporter intention
* control): the top-level message when the action set one, otherwise the
* first non-empty field error.
*/
export const firstFormActionMessage = (state: {
message?: string
fieldErrors?: Record<string, string[]>
}): string | undefined => {
if (state.message) return state.message
return Object.values(state.fieldErrors ?? {})
.flat()
.find((message) => message.length > 0)
}
export const errorProps = (
fieldErrors: Record<string, string[]> | undefined,
field: string,

View file

@ -47,3 +47,11 @@ export const formatMunicipalitySignalAgeLabel = (ageInDays: number | null): stri
if (ageInDays === 1) return 'há 1 dia'
return `há ${ageInDays} dias`
}
/**
* The same age as a lowercase clause for prose ("o município nunca recebeu
* sinal", "…sem sinal há 40 dias") — E11's silence review embeds it
* mid-sentence, where the cell label above would break the flow.
*/
export const formatSilenceAgeLabel = (ageInDays: number | null): string =>
ageInDays === null ? 'nunca recebeu sinal' : `sem sinal há ${ageInDays} dias`

View file

@ -0,0 +1,453 @@
import 'server-only'
import type { Payload, Where } from 'payload'
import {
federalBaselineMunicipalitySlugs,
getMunicipalityFederalBaseline,
getStatewideFederalTotals,
type MunicipalityFederalBaseline,
} from '@/lib/bahiaElectionAggregates'
import { ELECTION_YEAR_2014, ELECTION_YEAR_2018, ELECTION_YEAR_2022 } from '@/lib/electionResults'
import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
import type { MunicipalitySignalType } from '@/lib/schemas/municipalityUpdate'
import {
DAY_MS,
evaluateMunicipalityTriggers,
isSuggestionSuppressedByDecision,
shouldEnterSilenceReview,
SUGGESTION_INPUT_WINDOWS,
suggestionPatternIds,
type MunicipalityTriggerInput,
type SuggestionPatternId,
type SuggestionTriageLevel,
} from '@/lib/suggestionCatalog'
import { DEFAULT_VOTE_ESTIMATE_SCENARIO } from '@/lib/voteEstimate'
import type { AllocationDecision, CampaignUser, Municipality } from '@/payload-types'
import { loadMunicipalityScope } from '@/utilities/campaignMunicipalityScope'
import { computeGoalCoverage } from '@/utilities/goalCoverage'
import { loadStatewideSuggestedGoals } from '@/utilities/municipalityGoalAccount'
import {
catalogMedianProjectedValidVotes,
catalogMedianUncapturedFieldVotes,
computeAllMunicipalityPotentials,
uncapturedFieldVotes,
} from '@/utilities/municipalityPotential'
import {
municipalitySignalAgeInDays,
resolveMunicipalityLastSignalAt,
} from '@/utilities/municipalitySignal'
import { computeMunicipalityTerritorialClass } from '@/utilities/municipalityTerritorialClass'
import { relationshipId, uniqueRelationshipIds } from '@/utilities/relationship'
import { countLeadershipsByMunicipality } from '@/utilities/visitPlannerData'
import { aggregatePledgesByMunicipality } from '@/utilities/votePledgeData'
import { emptyMunicipalityPledgeAggregate } from '@/utilities/votePledgeViews'
/**
* E11 loader — evaluates the suggestion catalog over the actor's municípios
* and filters what a recorded decision still suppresses. A stateless read on
* every load (the plan's locked decision): the only thing persisted is the
* DECISION (`allocationDecision`, C12), never the suggestion.
*
* It reads `municipality` itself instead of `loadMunicipalityScope` for the
* same reason `visitPlannerData.ts` documents: the shared select carries
* neither `engagementLevel` nor `lastUpdateAt`, and widening it would charge
* the dashboard, the map and the list for fields only the evaluator reads.
*/
type TriggerMunicipalityDoc = Pick<
Municipality,
| 'id'
| 'name'
| 'slug'
| 'advisors'
| 'priority'
| 'expectedVotes'
| 'engagementLevel'
| 'lastUpdateAt'
>
/** The numbers the decision snapshot records — what the decision was made ON. */
type SuggestionMetrics = {
lq2022: number | null
coverageRatio: number | null
coverageDeficit: number
projectedValidVotes: number
pledgeCount: number
leadershipCount: number
advisorCount: number
lastSignalAgeDays: number | null
lastPledgeAgeDays: number | null
}
type MunicipalitySuggestionViewModel = {
municipalityID: number
municipalityName: string
municipalitySlug: string
patternId: SuggestionPatternId
triageLevel: SuggestionTriageLevel
factors: string[]
metrics: SuggestionMetrics
}
export type MunicipalitySilenceEntry = {
municipalityID: number
municipalityName: string
municipalitySlug: string
/** null = nothing was ever recorded here. */
lastSignalAgeDays: number | null
}
export type MunicipalitySuggestionsBundle = {
/** Triage order (level 1 first), ties broken by coverage deficit then size. */
suggestions: MunicipalitySuggestionViewModel[]
/** "Pauta do silêncio" — prioritized municípios where nothing fired and nothing was recorded. */
silence: MunicipalitySilenceEntry[]
evaluatedCount: number
}
/** P1's threat leg: signal types that read as an adversary move. */
const ADVERSARY_SIGNAL_TYPES: MunicipalitySignalType[] = [
'invasao',
'visita_adversario',
'proposta_broker',
]
/** Agenda legs: drafts are intentions and cancellations never happened. */
const AGENDA_ACTIVITY_STATUSES = ['planejado', 'confirmado', 'realizado'] as const
const isoDaysAgo = (now: Date, days: number): string =>
new Date(now.getTime() - days * DAY_MS).toISOString()
/**
* Upper-quartile cut of projected valid votes over the catalog (P6's "válidos
* no quartil superior"), memoized once per process like the medians it sits
* next to — the artifact is immutable.
*/
let projectedValidVotesUpperCutMemo: number | null = null
const projectedValidVotesUpperCut = (): number => {
if (projectedValidVotesUpperCutMemo !== null) return projectedValidVotesUpperCutMemo
const values = computeAllMunicipalityPotentials()
.map((potential) => potential.projectedValidVotes)
.filter((value) => value > 0)
.sort((left, right) => left - right)
const index = Math.min(Math.floor(values.length * 0.75), Math.max(values.length - 1, 0))
projectedValidVotesUpperCutMemo = values[index] ?? 0
return projectedValidVotesUpperCutMemo
}
/**
* The candidate's own statewide intra-field share, 2022 — P3's relative
* standard (own votes ÷ campo federal votes, both statewide).
*/
let intraFieldStandardMemo: number | null | undefined
const intraFieldShareStateStandard2022 = (): number | null => {
if (intraFieldStandardMemo !== undefined) return intraFieldStandardMemo
let campoVotes = 0
for (const slug of federalBaselineMunicipalitySlugs()) {
campoVotes +=
getMunicipalityFederalBaseline(slug).campoFederalVotesByYear[String(ELECTION_YEAR_2022)] ?? 0
}
const { ownVotes } = getStatewideFederalTotals(ELECTION_YEAR_2022)
intraFieldStandardMemo = campoVotes > 0 ? ownVotes / campoVotes : null
return intraFieldStandardMemo
}
/** Location quotient for one year — the SAME formula as E10's classifier. */
const lqForYear = (baseline: MunicipalityFederalBaseline, year: number): number | null => {
const own = baseline.votesByYear[String(year)] ?? 0
const valid = baseline.validVotesByYear[String(year)] ?? 0
const totals = getStatewideFederalTotals(year)
if (valid <= 0 || totals.validVotes <= 0 || totals.ownVotes <= 0) return null
return own / valid / (totals.ownVotes / totals.validVotes)
}
/** Campo federal votes ÷ valid votes, 2022 — P3's "captura agregada do campo". */
const fieldShareOfValid2022 = (baseline: MunicipalityFederalBaseline): number | null => {
const valid = baseline.validVotesByYear[String(ELECTION_YEAR_2022)] ?? 0
const campo = baseline.campoFederalVotesByYear[String(ELECTION_YEAR_2022)] ?? 0
return valid > 0 ? campo / valid : null
}
type LatestDecision = Pick<AllocationDecision, 'outcome' | 'snapshot' | 'createdAt'>
const compareSuggestions = (
left: MunicipalitySuggestionViewModel,
right: MunicipalitySuggestionViewModel,
): number => {
if (left.triageLevel !== right.triageLevel) return left.triageLevel - right.triageLevel
// §6.2 tie-break: more votes at stake for the meta wins.
if (left.metrics.coverageDeficit !== right.metrics.coverageDeficit) {
return right.metrics.coverageDeficit - left.metrics.coverageDeficit
}
if (left.metrics.projectedValidVotes !== right.metrics.projectedValidVotes) {
return right.metrics.projectedValidVotes - left.metrics.projectedValidVotes
}
return left.municipalityName.localeCompare(right.municipalityName, 'pt-BR')
}
export type MunicipalitySuggestionsFilter = {
/** Restrict to one município (detail card, and the action's server recompute). */
municipalityID?: number
/** Injected so suppression windows and ages are testable. */
now?: Date
}
export const loadMunicipalitySuggestions = async (
payload: Payload,
user: CampaignUser,
{ municipalityID, now = new Date() }: MunicipalitySuggestionsFilter = {},
): Promise<MunicipalitySuggestionsBundle> => {
const where: Where = municipalityID ? { id: { equals: municipalityID } } : {}
const result = await payload.find({
collection: 'municipality',
where,
depth: 0,
limit: 0,
pagination: false,
select: {
name: true,
slug: true,
advisors: true,
priority: true,
expectedVotes: true,
engagementLevel: true,
lastUpdateAt: true,
},
user,
overrideAccess: false,
})
const municipalities = result.docs as TriggerMunicipalityDoc[]
const municipalityIDs = municipalities.map((municipality) => municipality.id)
if (municipalityIDs.length === 0) {
return { suggestions: [], silence: [], evaluatedCount: 0 }
}
const [
pledgeAggregates,
leadershipCounts,
{ suggestedGoalBySlug, potentialBySlug },
signals,
activities,
decisions,
] = await Promise.all([
// The unfiltered read is the dashboard's, and the dashboard already paid
// for the scope (React cache) — re-aggregating here would scan the whole
// votePledge table a second time on the same request.
municipalityID
? aggregatePledgesByMunicipality(payload, municipalityIDs)
: loadMunicipalityScope(payload, user, {}).then((scope) => scope.pledgeAggregates),
countLeadershipsByMunicipality(payload, municipalityIDs),
loadStatewideSuggestedGoals(payload, user),
payload.find({
collection: 'municipalityUpdate',
where: {
and: [
{ municipality: { in: municipalityIDs } },
{ kind: { equals: 'sinal' } },
{ signalType: { in: ADVERSARY_SIGNAL_TYPES } },
{
createdAt: {
greater_than: isoDaysAgo(now, SUGGESTION_INPUT_WINDOWS.adversarySignalDays),
},
},
],
},
depth: 0,
limit: 0,
pagination: false,
select: { municipality: true, triangulated: true },
user,
overrideAccess: false,
}),
payload.find({
collection: 'activity',
where: {
and: [
{ municipality: { in: municipalityIDs } },
{ startAt: { greater_than: isoDaysAgo(now, SUGGESTION_INPUT_WINDOWS.activityDays) } },
{ status: { in: [...AGENDA_ACTIVITY_STATUSES] } },
],
},
depth: 0,
limit: 0,
pagination: false,
select: { municipality: true, status: true },
user,
overrideAccess: false,
}),
payload.find({
collection: 'allocationDecision',
where: {
and: [
{ municipality: { in: municipalityIDs } },
{ patternId: { in: [...suggestionPatternIds] } },
{ createdAt: { greater_than: isoDaysAgo(now, SUGGESTION_INPUT_WINDOWS.decisionDays) } },
],
},
sort: '-createdAt',
depth: 0,
limit: 0,
pagination: false,
select: {
municipality: true,
patternId: true,
outcome: true,
snapshot: true,
createdAt: true,
},
user,
overrideAccess: false,
}),
])
// Most severe adversary signal per município: triangulated wins.
const adversarySignalByMunicipality = new Map<number, { triangulated: boolean }>()
for (const signal of signals.docs) {
const id = relationshipId(signal.municipality)
if (id === null) continue
const current = adversarySignalByMunicipality.get(id)
if (current?.triangulated) continue
adversarySignalByMunicipality.set(id, { triangulated: Boolean(signal.triangulated) })
}
const agendaByMunicipality = new Map<number, { hasAgenda: boolean; completedCount: number }>()
for (const activity of activities.docs) {
const id = relationshipId(activity.municipality)
if (id === null) continue
const entry = agendaByMunicipality.get(id) ?? { hasAgenda: false, completedCount: 0 }
entry.hasAgenda = true
if (activity.status === 'realizado') entry.completedCount += 1
agendaByMunicipality.set(id, entry)
}
// Newest decision per (município, pattern): the find is sorted descending.
const latestDecisionByKey = new Map<string, LatestDecision>()
for (const decision of decisions.docs) {
const id = relationshipId(decision.municipality)
if (id === null) continue
const key = `${id}:${decision.patternId}`
if (!latestDecisionByKey.has(key)) latestDecisionByKey.set(key, decision)
}
const suggestions: MunicipalitySuggestionViewModel[] = []
const silenceEntries: MunicipalitySilenceEntry[] = []
for (const municipality of municipalities) {
const catalogEntry = getMunicipalityCatalogEntry(municipality.slug)
const suggestedGoalByScenario = suggestedGoalBySlug.get(municipality.slug)
const potential = potentialBySlug.get(municipality.slug)
// Off-catalog rows are leftover data with no derived potential to reason about.
if (!catalogEntry || !suggestedGoalByScenario || !potential) continue
const baseline = getMunicipalityFederalBaseline(municipality.slug)
const classification = computeMunicipalityTerritorialClass(municipality.slug)
const pledgeAggregate =
pledgeAggregates.get(municipality.id) ?? emptyMunicipalityPledgeAggregate
const coverage = computeGoalCoverage(
municipality.expectedVotes,
suggestedGoalByScenario,
pledgeAggregate,
DEFAULT_VOTE_ESTIMATE_SCENARIO,
)
const lastSignalAt = resolveMunicipalityLastSignalAt(
municipality.lastUpdateAt ?? null,
pledgeAggregate.lastPledgeAt,
)
const agenda = agendaByMunicipality.get(municipality.id)
const input: MunicipalityTriggerInput = {
priority: municipality.priority ?? null,
engagementLevel: municipality.engagementLevel ?? null,
territorialClass: classification.class,
inCoreBlock: classification.inCoreBlock,
lqByYear: {
[ELECTION_YEAR_2014]: lqForYear(baseline, ELECTION_YEAR_2014),
[ELECTION_YEAR_2018]: lqForYear(baseline, ELECTION_YEAR_2018),
// 2022 already exists on the memoized classification — same formula,
// and reusing it keeps a second writing from ever drifting.
[ELECTION_YEAR_2022]: classification.lq,
},
ownVotesByYear: {
[ELECTION_YEAR_2014]: baseline.votesByYear[String(ELECTION_YEAR_2014)] ?? 0,
[ELECTION_YEAR_2018]: baseline.votesByYear[String(ELECTION_YEAR_2018)] ?? 0,
[ELECTION_YEAR_2022]: baseline.votesByYear[String(ELECTION_YEAR_2022)] ?? 0,
},
projectedValidVotes: potential.projectedValidVotes,
projectedValidVotesCut: catalogMedianProjectedValidVotes(),
projectedValidVotesUpperCut: projectedValidVotesUpperCut(),
uncapturedFieldVotes: uncapturedFieldVotes(baseline),
uncapturedFieldVotesCut: catalogMedianUncapturedFieldVotes(),
captureRate2022: potential.captureRate2022,
fieldShareOfValid2022: fieldShareOfValid2022(baseline),
intraFieldShare2022: potential.intraFieldShareByYear[ELECTION_YEAR_2022] ?? null,
intraFieldShareStateStandard2022: intraFieldShareStateStandard2022(),
advisorCount: uniqueRelationshipIds(municipality.advisors).length,
leadershipCount: leadershipCounts.get(municipality.id) ?? 0,
pledgeCount: pledgeAggregate.pledgeCount,
lastSignalAgeDays: municipalitySignalAgeInDays(lastSignalAt, now),
lastPledgeAgeDays: municipalitySignalAgeInDays(pledgeAggregate.lastPledgeAt, now),
coverageRatio: coverage.coverageRatio,
coverageDeficit: coverage.deficit,
adversarySignal: adversarySignalByMunicipality.get(municipality.id) ?? null,
hasRecentOrUpcomingActivity: agenda?.hasAgenda ?? false,
completedActivityCount: agenda?.completedCount ?? 0,
}
const triggered = evaluateMunicipalityTriggers(input)
// Silence looks at what FIRED, not at what a decision still suppresses:
// a dismissed pattern was seen and answered — that is not silence.
if (shouldEnterSilenceReview(input, triggered.length)) {
silenceEntries.push({
municipalityID: municipality.id,
municipalityName: municipality.name,
municipalitySlug: municipality.slug,
lastSignalAgeDays: input.lastSignalAgeDays,
})
}
// Every field comes from `input` — the snapshot's contract is "the numbers
// the decision was made ON", which is exactly what the predicates saw.
const metrics: SuggestionMetrics = {
lq2022: input.lqByYear[ELECTION_YEAR_2022],
coverageRatio: input.coverageRatio,
coverageDeficit: input.coverageDeficit,
projectedValidVotes: input.projectedValidVotes,
pledgeCount: input.pledgeCount,
leadershipCount: input.leadershipCount,
advisorCount: input.advisorCount,
lastSignalAgeDays: input.lastSignalAgeDays,
lastPledgeAgeDays: input.lastPledgeAgeDays,
}
for (const pattern of triggered) {
const decision = latestDecisionByKey.get(`${municipality.id}:${pattern.patternId}`)
if (decision && isSuggestionSuppressedByDecision(decision, pattern, now)) continue
suggestions.push({
municipalityID: municipality.id,
municipalityName: municipality.name,
municipalitySlug: municipality.slug,
patternId: pattern.patternId,
triageLevel: pattern.triageLevel,
factors: pattern.factors,
metrics,
})
}
}
suggestions.sort(compareSuggestions)
silenceEntries.sort((left, right) => {
// Never-recorded first (the loudest silence), then oldest signal first.
const leftAge = left.lastSignalAgeDays ?? Number.POSITIVE_INFINITY
const rightAge = right.lastSignalAgeDays ?? Number.POSITIVE_INFINITY
if (leftAge !== rightAge) return rightAge - leftAge
return left.municipalityName.localeCompare(right.municipalityName, 'pt-BR')
})
return {
suggestions,
silence: silenceEntries,
evaluatedCount: municipalities.length,
}
}

View file

@ -30,10 +30,11 @@ export type VisitCondition = {
/**
* The contraindication is an ADVISORY, never a block: "a geografia serve à
* política" (T5), so the coordination overrides it whenever a political reason
* outweighs the numbers. Recording that override writes an
* `allocationDecision`, which has no application write path yet — it is E11's
* first job, and until then the override lives in the head of whoever ignored
* the warning.
* outweighs the numbers. `allocationDecision` write paths exist since E14
* (level movements) and E11 (`resolveSuggestion` — aceita/descarta/adiada);
* wiring THIS advisory's override into the registry is a small follow-up on
* that action, and until then it lives in the head of whoever ignored the
* warning.
*/
export const VISIT_CONTRAINDICATIONS = ['perdida', 'sem_rede', 'no_teto'] as const

View file

@ -59,8 +59,10 @@ type PlannerMunicipalityDoc = Pick<
* Lideranças per município in ONE query with a Map, never N counts — the
* precedent is `leadershipCount` in `organizationData.ts`. Intentional admin
* bypass: counts only, over município ids the actor was already allowed to read.
* Exported for the suggestion evaluator (E11), which assembles the same
* network inputs over the same scope.
*/
const countLeadershipsByMunicipality = async (
export const countLeadershipsByMunicipality = async (
payload: Payload,
municipalityIDs: ReadonlyArray<number>,
): Promise<Map<number, number>> => {

View file

@ -0,0 +1,467 @@
// @vitest-environment node
import { getPayload, type Payload, type RequiredDataFromCollectionSlug } from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import { resolveSuggestionRecord } from '@/app/(campaign)/campanha/actions/suggestion'
import { SUGGESTION_STALE_MESSAGE } from '@/lib/schemas/suggestion'
import { DAY_MS } from '@/lib/suggestionCatalog'
import type { CampaignUser, Municipality } from '@/payload-types'
import config from '@/payload.config'
import {
computeAllMunicipalityPotentials,
computeMunicipalityPotential,
} from '@/utilities/municipalityPotential'
import { loadMunicipalitySuggestions } from '@/utilities/municipalityTriggers'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
import { stub } from '../helpers/stub'
/** Create data with server-derived fields (e.g. `slug`) intentionally omitted. */
type ActivityCreateData = RequiredDataFromCollectionSlug<'activity'>
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
/**
* E11's loader meets real rows here: the access scope, the four auxiliary
* queries (pledges, leadership counts, signals→agenda inputs, decisions) and
* the suppression wiring. Trigger predicates themselves are pure and pinned in
* `suggestionCatalog.unit.spec.ts` — this spec drives the ROW-controllable
* patterns (P7, K-A, P6) because the artifact-anchored ones (P1/P2/P3/P5/K-B)
* cannot be staged from the database: their conditions live in the committed
* TSE artifact, and hand-picking catalog slugs would race the fixture
* allocator (a wrapping sequence — an explicit slug is never exclusively
* owned). Where a test needs an artifact property (P6's upper quartile, the
* silence review's "nothing fires"), it claims municípios through the
* allocator until one qualifies — measured hit rates: 109/435 top-quartile,
* 205/435 quiet.
*/
/** Reset the row-controllable fields a previous test (or seed) may have set. */
const normalizeMunicipality = async (
municipality: Municipality,
data: Partial<Pick<Municipality, 'priority' | 'expectedVotes' | 'engagementLevel'>> = {},
) => {
await payload.update({
collection: 'municipality',
id: municipality.id,
data: {
advisors: [],
priority: 'normal',
engagementLevel: null,
expectedVotes: { pessimistic: null, central: null, optimistic: null },
...data,
},
depth: 0,
})
}
const suggestionsFor = (user: CampaignUser, municipalityID?: number) =>
loadMunicipalitySuggestions(payload, user, { municipalityID })
const backdatePledgeDeclaredAt = async (pledgeID: number, days: number) => {
await payload.db.drizzle.execute(
`UPDATE vote_pledge SET declared_at = now() - interval '${days} days' WHERE id = ${pledgeID}`,
)
}
/** A prioritized município with one stale pledge and a real goal: P7's stage. */
const stageStalePledge = async (
fixtures: ReturnType<typeof campaignFixtures>,
municipality: Municipality,
) => {
await normalizeMunicipality(municipality, {
priority: 'alta',
expectedVotes: { pessimistic: null, central: 5000, optimistic: null },
})
fixtures.touchMunicipality(municipality.id)
const contact = await fixtures.createContact()
const leadership = await fixtures.createLeadership({
contact: contact.id,
municipalities: [municipality.id],
})
const pledge = await fixtures.createVotePledge({
leadership: leadership.id,
municipality: municipality.id,
declaredVotes: 100,
})
await backdatePledgeDeclaredAt(pledge.id, 30)
}
describe('loadMunicipalitySuggestions (E11)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('fires P7 on a prioritized stale-pledge município and lets decisions govern it', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
await stageStalePledge(fixtures, municipality)
const findP7 = async () => {
const bundle = await suggestionsFor(coordinator, municipality.id)
return bundle.suggestions.find(
(suggestion) =>
suggestion.patternId === 'P7' && suggestion.municipalityID === municipality.id,
)
}
const triggered = await findP7()
expect(triggered).toBeDefined()
expect(triggered?.triageLevel).toBe(5)
expect(triggered?.metrics.pledgeCount).toBe(1)
expect(triggered?.metrics.lastPledgeAgeDays).toBeGreaterThanOrEqual(29)
expect(triggered?.factors.join(' ')).toContain('dias')
// A dismissal recorded now keeps the pattern out of the queue…
const dismissed = await payload.create({
collection: 'allocationDecision',
data: {
municipality: municipality.id,
patternId: 'P7',
outcome: 'descarta',
rationale: 'A rede trabalha e não registra.',
alternativeReading: 'Sub-registro: consertar o fluxo antes de cobrar.',
snapshot: { triageLevel: 5 },
createdAt: new Date(Date.now() - 60_000).toISOString(),
},
depth: 0,
})
fixtures.own('allocationDecision', dismissed.id)
expect(await findP7()).toBeUndefined()
// …until a NEWER postponement whose window already lapsed lets it resurface.
const postponed = await payload.create({
collection: 'allocationDecision',
data: {
municipality: municipality.id,
patternId: 'P7',
outcome: 'adiada',
rationale: 'Adiado por 14 dias.',
snapshot: {
triageLevel: 5,
suppressUntil: new Date(Date.now() - DAY_MS).toISOString(),
},
},
depth: 0,
})
fixtures.own('allocationDecision', postponed.id)
expect(await findP7()).toBeDefined()
})
it('hands the stagnation case to K-A once effort is recorded in the cycle', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
await stageStalePledge(fixtures, municipality)
const activity = await payload.create({
collection: 'activity',
data: stub<ActivityCreateData>({
title: fixtures.value('Caminhada Centro'),
kind: 'caminhada',
status: 'realizado',
startAt: new Date(Date.now() - 10 * DAY_MS).toISOString(),
municipality: municipality.id,
}),
depth: 0,
})
fixtures.own('activity', activity.id)
const bundle = await suggestionsFor(coordinator, municipality.id)
const byPattern = new Map(
bundle.suggestions.map((suggestion) => [suggestion.patternId, suggestion]),
)
expect(byPattern.get('K-A')).toBeDefined()
// The differential is exclusive: recorded effort means the sharper K-A
// diagnosis speaks, never both.
expect(byPattern.get('P7')).toBeUndefined()
})
it('fires P6 on a top-quartile município with zero network, prioritization raising its triage', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
// The evaluator's own cut convention, recomputed here so a drifting
// artifact fails this line, not the loop below.
const values = computeAllMunicipalityPotentials()
.map((potential) => potential.projectedValidVotes)
.filter((value) => value > 0)
.sort((left, right) => left - right)
const upperCut = values[Math.min(Math.floor(values.length * 0.75), values.length - 1)] ?? 0
let municipality: Municipality | null = null
for (let attempt = 0; attempt < 20; attempt += 1) {
const candidate = await fixtures.getMunicipality()
if (computeMunicipalityPotential(candidate.slug).projectedValidVotes >= upperCut) {
municipality = candidate
break
}
}
if (!municipality) throw new Error('Nenhum município do quartil superior em 20 alocações.')
await normalizeMunicipality(municipality)
fixtures.touchMunicipality(municipality.id)
const findP6 = async () => {
const bundle = await suggestionsFor(coordinator, municipality!.id)
return bundle.suggestions.find((suggestion) => suggestion.patternId === 'P6')
}
expect((await findP6())?.triageLevel).toBe(5)
await payload.update({
collection: 'municipality',
id: municipality.id,
data: { priority: 'alta' },
depth: 0,
})
expect((await findP6())?.triageLevel).toBe(3)
// Any registered network kills the orphan reading.
const contact = await fixtures.createContact()
await fixtures.createLeadership({ contact: contact.id, municipalities: [municipality.id] })
expect(await findP6()).toBeUndefined()
})
it('scopes the queue to what the actor can read', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const advisor = await fixtures.createCampaignUser('advisor')
const leader = await fixtures.createCampaignUser('leader')
const foreign = await fixtures.getMunicipality()
await stageStalePledge(fixtures, foreign)
const administered = await fixtures.getMunicipality()
await normalizeMunicipality(administered)
await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id])
// The coordinator sees the foreign município's queue…
const coordinatorBundle = await suggestionsFor(coordinator, foreign.id)
expect(coordinatorBundle.suggestions.some((suggestion) => suggestion.patternId === 'P7')).toBe(
true,
)
// …the advisor's full queue never leaves their portfolio…
const advisorBundle = await suggestionsFor(advisor)
expect(advisorBundle.evaluatedCount).toBe(1)
for (const suggestion of advisorBundle.suggestions) {
expect(suggestion.municipalityID).toBe(administered.id)
}
for (const entry of advisorBundle.silence) {
expect(entry.municipalityID).toBe(administered.id)
}
// …asking for the foreign município directly evaluates nothing…
const advisorForeign = await suggestionsFor(advisor, foreign.id)
expect(advisorForeign.evaluatedCount).toBe(0)
expect(advisorForeign.suggestions).toEqual([])
// …and the leader (lockdown) cannot even open the queue: municipality read
// access refuses outright. The staff-only route gates are what keep the
// loader off a leader's request in the app.
await expect(suggestionsFor(leader)).rejects.toThrow()
})
it('puts a prioritized silent município on the silence review, and a fresh signal takes it off', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
// A quiet município cannot be staged — quietness means NO artifact-anchored
// pattern fires — so claim through the allocator until one qualifies
// (measured: 205 of 435 with zeroed rows).
let municipality: Municipality | null = null
for (let attempt = 0; attempt < 10; attempt += 1) {
const candidate = await fixtures.getMunicipality()
await normalizeMunicipality(candidate, { priority: 'alta' })
fixtures.touchMunicipality(candidate.id)
const bundle = await suggestionsFor(coordinator, candidate.id)
const silent = bundle.silence.some((entry) => entry.municipalityID === candidate.id)
if (bundle.suggestions.length === 0 && silent) {
municipality = candidate
break
}
}
if (!municipality) throw new Error('Nenhum município silencioso em 10 alocações.')
const author = await fixtures.createCampaignUser('coordinator')
await fixtures.createMunicipalityUpdate({
municipality: municipality.id,
author: author.id,
kind: 'nota',
body: 'Conversa com o presidente do sindicato.',
})
const bundle = await suggestionsFor(coordinator, municipality.id)
expect(bundle.silence.some((entry) => entry.municipalityID === municipality.id)).toBe(false)
})
})
describe('resolveSuggestionRecord (E11)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
const ownDecisions = async (
fixtures: ReturnType<typeof campaignFixtures>,
municipalityID: number,
) => {
const decisions = await payload.find({
collection: 'allocationDecision',
where: { municipality: { equals: municipalityID } },
sort: 'createdAt',
depth: 0,
pagination: false,
})
decisions.docs.forEach((decision) => fixtures.own('allocationDecision', decision.id))
return decisions.docs
}
it('accepting records the chosen menu action with the decision-time snapshot', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
await stageStalePledge(fixtures, municipality)
const { decision, postponeDays } = await resolveSuggestionRecord(payload, coordinator, {
municipality: municipality.id,
patternId: 'P7',
outcome: 'aceita',
chosenActionId: 'diagnostico-diferencial',
note: 'Assessor confere com a rede até sexta.',
})
await ownDecisions(fixtures, municipality.id)
expect(postponeDays).toBeNull()
expect(decision.outcome).toBe('aceita')
expect(decision.decidedBy).toBe(coordinator.id)
expect(decision.rationale).toContain('Diagnóstico diferencial')
expect(decision.rationale).toContain('Assessor confere')
expect(decision.snapshot).toMatchObject({
triageLevel: 5,
scenario: 'central',
chosenActionId: 'diagnostico-diferencial',
})
// The acceptance now suppresses the pattern on the next load.
const bundle = await suggestionsFor(coordinator, municipality.id)
expect(bundle.suggestions.some((suggestion) => suggestion.patternId === 'P7')).toBe(false)
})
it('clamps the composed rationale so a maximal note still records', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
await stageStalePledge(fixtures, municipality)
// The note alone sits at the collection cap; the menu-label prefix would
// push the composition past it and Payload would reject the create with a
// message no retry could fix.
const { decision } = await resolveSuggestionRecord(payload, coordinator, {
municipality: municipality.id,
patternId: 'P7',
outcome: 'aceita',
chosenActionId: 'diagnostico-diferencial',
note: 'x'.repeat(2000),
})
await ownDecisions(fixtures, municipality.id)
expect(decision.rationale).toHaveLength(2000)
expect(decision.rationale).toContain('Diagnóstico diferencial')
})
it('postponing snoozes with a recorded suppressUntil', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
await stageStalePledge(fixtures, municipality)
const { decision, postponeDays } = await resolveSuggestionRecord(payload, coordinator, {
municipality: municipality.id,
patternId: 'P7',
outcome: 'adiada',
})
await ownDecisions(fixtures, municipality.id)
// P7 sits on the optimization levels — the longer snooze.
expect(postponeDays).toBe(14)
const snapshot = decision.snapshot as { postponeDays?: number; suppressUntil?: string }
expect(snapshot.postponeDays).toBe(14)
expect(new Date(snapshot.suppressUntil ?? 0).getTime()).toBeGreaterThan(Date.now())
const bundle = await suggestionsFor(coordinator, municipality.id)
expect(bundle.suggestions.some((suggestion) => suggestion.patternId === 'P7')).toBe(false)
})
it('dismissing requires the alternative reading and a second resolve reads stale', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
await stageStalePledge(fixtures, municipality)
await expect(
resolveSuggestionRecord(payload, coordinator, {
municipality: municipality.id,
patternId: 'P7',
outcome: 'descarta',
}),
).rejects.toThrow()
const { decision } = await resolveSuggestionRecord(payload, coordinator, {
municipality: municipality.id,
patternId: 'P7',
outcome: 'descarta',
alternativeReading: 'A rede trabalha e não registra — auditar antes.',
})
await ownDecisions(fixtures, municipality.id)
expect(decision.alternativeReading).toContain('auditar antes')
// The queue moved: deciding the same pattern again refuses as stale.
await expect(
resolveSuggestionRecord(payload, coordinator, {
municipality: municipality.id,
patternId: 'P7',
outcome: 'aceita',
chosenActionId: 'diagnostico-diferencial',
}),
).rejects.toThrow(SUGGESTION_STALE_MESSAGE)
})
it('refuses a pattern that is not currently triggered, and an out-of-scope advisor', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const advisor = await fixtures.createCampaignUser('advisor')
const municipality = await fixtures.getMunicipality()
await stageStalePledge(fixtures, municipality)
// P6 needs zero network — this município has a leadership, so it is stale.
await expect(
resolveSuggestionRecord(payload, coordinator, {
municipality: municipality.id,
patternId: 'P6',
outcome: 'aceita',
chosenActionId: 'censo-rede',
}),
).rejects.toThrow(SUGGESTION_STALE_MESSAGE)
// The advisor does not administer this município: the scoped evaluation
// sees nothing, so the resolve reads stale — existence never leaks.
await expect(
resolveSuggestionRecord(payload, advisor, {
municipality: municipality.id,
patternId: 'P7',
outcome: 'aceita',
chosenActionId: 'diagnostico-diferencial',
}),
).rejects.toThrow(SUGGESTION_STALE_MESSAGE)
expect(await ownDecisions(fixtures, municipality.id)).toHaveLength(0)
})
})

View file

@ -0,0 +1,468 @@
import { describe, expect, it } from 'vitest'
import {
evaluateMunicipalityTriggers,
getSuggestionPattern,
isSuggestionSuppressedByDecision,
shouldEnterSilenceReview,
SUGGESTION_SUPPRESSION_DAYS,
suggestionPatternIds,
suggestionPatterns,
suggestionPostponeDays,
type MunicipalityTriggerInput,
type SuggestionDecisionLike,
type SuggestionPatternId,
} from '@/lib/suggestionCatalog'
/**
* A quiet, healthy manutenção município: nothing may fire on it. Every
* pattern's green case is a minimal deviation from this baseline, so a test
* that goes red names exactly the condition that broke.
*/
const input = (overrides: Partial<MunicipalityTriggerInput> = {}): MunicipalityTriggerInput => ({
priority: 'normal',
engagementLevel: null,
territorialClass: 'manutencao',
inCoreBlock: false,
lqByYear: { 2014: 1, 2018: 1, 2022: 1 },
ownVotesByYear: { 2014: 900, 2018: 1000, 2022: 1000 },
projectedValidVotes: 8_000,
projectedValidVotesCut: 10_000,
projectedValidVotesUpperCut: 20_000,
uncapturedFieldVotes: 2_000,
uncapturedFieldVotesCut: 3_000,
captureRate2022: 0.2,
fieldShareOfValid2022: 0.3,
intraFieldShare2022: 0.1,
intraFieldShareStateStandard2022: 0.1,
advisorCount: 1,
leadershipCount: 2,
pledgeCount: 3,
lastSignalAgeDays: 3,
lastPledgeAgeDays: 3,
coverageRatio: 0.6,
coverageDeficit: 400,
adversarySignal: null,
hasRecentOrUpcomingActivity: false,
completedActivityCount: 0,
...overrides,
})
const evaluate = (id: SuggestionPatternId, triggerInput: MunicipalityTriggerInput) =>
getSuggestionPattern(id).evaluate(triggerInput)
describe('suggestionCatalog — catalog integrity', () => {
it('ships exactly the 8 v1 patterns, in declared id order', () => {
expect(suggestionPatterns.map((pattern) => pattern.id)).toEqual([...suggestionPatternIds])
})
it('every pattern carries complete card copy', () => {
for (const pattern of suggestionPatterns) {
expect(pattern.title.trim()).not.toHaveLength(0)
expect(pattern.probableReading.trim()).not.toHaveLength(0)
expect(pattern.contraindication.trim()).not.toHaveLength(0)
expect(pattern.alternativeReadings.length).toBeGreaterThan(0)
expect(pattern.menu.length).toBeGreaterThan(0)
const actionIds = pattern.menu.map((action) => action.id)
expect(new Set(actionIds).size).toBe(actionIds.length)
}
})
it('getSuggestionPattern refuses an unknown id', () => {
expect(() => getSuggestionPattern('P99' as SuggestionPatternId)).toThrow()
})
})
describe('P1 — reduto dormente ou ameaçado', () => {
const reduto = (overrides: Partial<MunicipalityTriggerInput> = {}) =>
input({
territorialClass: 'reduto',
inCoreBlock: true,
lqByYear: { 2014: 2.5, 2018: 2.6, 2022: 2.4 },
...overrides,
})
it('fires at level 3 on a dormant core reduto', () => {
const triggered = evaluate('P1', reduto({ lastSignalAgeDays: 30 }))
expect(triggered).toMatchObject({ patternId: 'P1', triageLevel: 3 })
expect(triggered?.factors.join(' ')).toContain('30 dias')
})
it('never-signaled counts as dormant', () => {
expect(evaluate('P1', reduto({ lastSignalAgeDays: null }))?.triageLevel).toBe(3)
})
it('escalates to level 2 on a single-source adversary signal and 1 when triangulated', () => {
expect(evaluate('P1', reduto({ adversarySignal: { triangulated: false } }))?.triageLevel).toBe(
2,
)
expect(evaluate('P1', reduto({ adversarySignal: { triangulated: true } }))?.triageLevel).toBe(1)
})
it('fires on a 2018→2022 vote drop even with a fresh signal', () => {
const triggered = evaluate(
'P1',
reduto({ ownVotesByYear: { 2018: 1000, 2022: 800 }, lastSignalAgeDays: 2 }),
)
expect(triggered?.triageLevel).toBe(3)
expect(triggered?.factors.join(' ')).toContain('2018')
})
it('stays silent when the reduto is watched: fresh signal or agenda in the window', () => {
expect(evaluate('P1', reduto({ lastSignalAgeDays: 5 }))).toBeNull()
expect(
evaluate('P1', reduto({ lastSignalAgeDays: 30, hasRecentOrUpcomingActivity: true })),
).toBeNull()
})
it('requires the core block — a trophy reduto outside it is P10 territory, not P1', () => {
expect(evaluate('P1', reduto({ inCoreBlock: false, lastSignalAgeDays: 30 }))).toBeNull()
})
})
describe('P2 — ataque: campo forte, captura baixa', () => {
const attack = (overrides: Partial<MunicipalityTriggerInput> = {}) =>
input({
territorialClass: 'expansao',
uncapturedFieldVotes: 5_000,
projectedValidVotes: 12_000,
...overrides,
})
it('fires at level 4 on a big open-field município', () => {
expect(evaluate('P2', attack())).toMatchObject({ patternId: 'P2', triageLevel: 4 })
})
it('yields to P3 when a correligionário holds the campo', () => {
const held = attack({
fieldShareOfValid2022: 0.5,
intraFieldShare2022: 0.03,
intraFieldShareStateStandard2022: 0.1,
})
expect(evaluate('P2', held)).toBeNull()
expect(evaluate('P3', held)).toMatchObject({ patternId: 'P3', triageLevel: 4 })
})
it('yields to K-B when the series says perdida', () => {
const lost = attack({
captureRate2022: 0.01,
lqByYear: { 2014: 0.3, 2018: 0.2, 2022: 0.25 },
})
expect(evaluate('P2', lost)).toBeNull()
expect(evaluate('K-B', lost)).toMatchObject({ patternId: 'K-B', triageLevel: 5 })
})
it('needs volume and open field', () => {
expect(evaluate('P2', attack({ projectedValidVotes: 5_000 }))).toBeNull()
expect(evaluate('P2', attack({ uncapturedFieldVotes: 1_000 }))).toBeNull()
})
})
describe('P3 — correligionário na frente', () => {
const held = (overrides: Partial<MunicipalityTriggerInput> = {}) =>
input({
fieldShareOfValid2022: 0.5,
intraFieldShare2022: 0.04,
intraFieldShareStateStandard2022: 0.1,
projectedValidVotes: 12_000,
...overrides,
})
it('fires when the campo is strong and the own intra-field slice is far below standard', () => {
expect(evaluate('P3', held())).toMatchObject({ patternId: 'P3', triageLevel: 4 })
})
it('stays silent below the volume cut or without the campo', () => {
expect(evaluate('P3', held({ projectedValidVotes: 5_000 }))).toBeNull()
expect(evaluate('P3', held({ fieldShareOfValid2022: 0.2 }))).toBeNull()
expect(evaluate('P3', held({ intraFieldShare2022: 0.09 }))).toBeNull()
})
it('tolerates missing artifact cells', () => {
expect(evaluate('P3', held({ intraFieldShareStateStandard2022: null }))).toBeNull()
})
})
describe('P5 — expansão acima do padrão', () => {
it('fires on a rising LQ series', () => {
const triggered = evaluate('P5', input({ lqByYear: { 2014: 0.5, 2018: 0.8, 2022: 1.2 } }))
expect(triggered).toMatchObject({ patternId: 'P5', triageLevel: 4 })
expect(triggered?.factors.join(' ')).toContain('2014')
})
it('fires on coverage above 100%', () => {
expect(evaluate('P5', input({ coverageRatio: 1.2 }))?.triageLevel).toBe(4)
})
it('demands real growth, not monotonic noise', () => {
expect(evaluate('P5', input({ lqByYear: { 2014: 1, 2018: 1.05, 2022: 1.1 } }))).toBeNull()
})
it('never fires on sem_base', () => {
expect(evaluate('P5', input({ territorialClass: 'sem_base', coverageRatio: 1.2 }))).toBeNull()
})
})
describe('P6 — município grande sem rede', () => {
const orphan = (overrides: Partial<MunicipalityTriggerInput> = {}) =>
input({
projectedValidVotes: 25_000,
leadershipCount: 0,
advisorCount: 0,
pledgeCount: 0,
...overrides,
})
it('fires at level 5 without prioritization and 3 with it', () => {
expect(evaluate('P6', orphan())?.triageLevel).toBe(5)
expect(evaluate('P6', orphan({ priority: 'alta' }))?.triageLevel).toBe(3)
expect(evaluate('P6', orphan({ engagementLevel: 'n2' }))?.triageLevel).toBe(3)
})
it('any network kills the trigger', () => {
expect(evaluate('P6', orphan({ leadershipCount: 1 }))).toBeNull()
expect(evaluate('P6', orphan({ advisorCount: 1 }))).toBeNull()
expect(evaluate('P6', orphan({ pledgeCount: 1 }))).toBeNull()
})
it('requires the upper quartile', () => {
expect(evaluate('P6', orphan({ projectedValidVotes: 15_000 }))).toBeNull()
})
})
describe('P7 — pledges estagnados vs. meta', () => {
const stagnant = (overrides: Partial<MunicipalityTriggerInput> = {}) =>
input({ priority: 'alta', lastPledgeAgeDays: 25, ...overrides })
it('fires at level 5 on a prioritized município with a registering-but-stale network', () => {
expect(evaluate('P7', stagnant())).toMatchObject({ patternId: 'P7', triageLevel: 5 })
})
it('prioritization can come from the engagement level', () => {
expect(evaluate('P7', stagnant({ priority: 'normal', engagementLevel: 'n3' }))).not.toBeNull()
expect(evaluate('P7', stagnant({ priority: 'normal' }))).toBeNull()
})
it('hands the case to K-A when there was recorded effort in the cycle', () => {
expect(evaluate('P7', stagnant({ completedActivityCount: 1 }))).toBeNull()
})
it('needs pledges to exist, be stale, and coverage to be short', () => {
expect(evaluate('P7', stagnant({ pledgeCount: 0 }))).toBeNull()
expect(evaluate('P7', stagnant({ lastPledgeAgeDays: 10 }))).toBeNull()
expect(evaluate('P7', stagnant({ coverageRatio: 1.1 }))).toBeNull()
})
})
describe('K-A — não responde a investimento', () => {
const invested = (overrides: Partial<MunicipalityTriggerInput> = {}) =>
input({ completedActivityCount: 2, lastPledgeAgeDays: 30, ...overrides })
it('fires when effort was recorded and pledges did not move', () => {
expect(evaluate('K-A', invested())).toMatchObject({ patternId: 'K-A', triageLevel: 5 })
})
it('a município that never pledged despite effort also fires', () => {
const triggered = evaluate('K-A', invested({ lastPledgeAgeDays: null }))
expect(triggered?.factors.join(' ')).toContain('Nenhum pledge')
})
it('recent pledge movement or met coverage silences it', () => {
expect(evaluate('K-A', invested({ lastPledgeAgeDays: 10 }))).toBeNull()
expect(evaluate('K-A', invested({ coverageRatio: 1.05 }))).toBeNull()
expect(evaluate('K-A', invested({ completedActivityCount: 0 }))).toBeNull()
})
})
describe('K-B — perdida vestida de oportunidade', () => {
const mirage = (overrides: Partial<MunicipalityTriggerInput> = {}) =>
input({
territorialClass: 'marginal',
uncapturedFieldVotes: 5_000,
captureRate2022: 0.01,
lqByYear: { 2014: 0.3, 2018: 0.2, 2022: 0.25 },
...overrides,
})
it('fires at level 5 on apparent headroom with a minimal-capture series', () => {
expect(evaluate('K-B', mirage())).toMatchObject({ patternId: 'K-B', triageLevel: 5 })
})
it('a single strong year in the series breaks the "perdida" reading', () => {
expect(evaluate('K-B', mirage({ lqByYear: { 2014: 0.3, 2018: 1.4, 2022: 0.25 } }))).toBeNull()
})
it('capture above minimal or no headroom silences it', () => {
expect(evaluate('K-B', mirage({ captureRate2022: 0.05 }))).toBeNull()
expect(evaluate('K-B', mirage({ uncapturedFieldVotes: 1_000 }))).toBeNull()
})
it('yields to P3 when the campo itself holds the município', () => {
expect(
evaluate(
'K-B',
mirage({
fieldShareOfValid2022: 0.5,
intraFieldShare2022: 0.03,
intraFieldShareStateStandard2022: 0.1,
}),
),
).toBeNull()
})
})
describe('evaluateMunicipalityTriggers — ordering and silence', () => {
it('returns triggered patterns sorted by triage level', () => {
const triggered = evaluateMunicipalityTriggers(
input({
territorialClass: 'reduto',
inCoreBlock: true,
lqByYear: { 2014: 2.5, 2018: 2.6, 2022: 2.4 },
adversarySignal: { triangulated: true },
coverageRatio: 1.2,
}),
)
expect(triggered.map((entry) => entry.patternId)).toEqual(['P1', 'P5'])
expect(triggered[0]?.triageLevel).toBe(1)
})
it('returns nothing on the healthy baseline', () => {
expect(evaluateMunicipalityTriggers(input())).toEqual([])
})
it('silence review needs prioritization, no triggers and a silent month', () => {
expect(shouldEnterSilenceReview(input({ priority: 'alta', lastSignalAgeDays: 35 }), 0)).toBe(
true,
)
expect(shouldEnterSilenceReview(input({ priority: 'alta', lastSignalAgeDays: null }), 0)).toBe(
true,
)
expect(
shouldEnterSilenceReview(input({ engagementLevel: 'n2', lastSignalAgeDays: 40 }), 0),
).toBe(true)
expect(shouldEnterSilenceReview(input({ priority: 'alta', lastSignalAgeDays: 10 }), 0)).toBe(
false,
)
expect(shouldEnterSilenceReview(input({ priority: 'alta', lastSignalAgeDays: 35 }), 1)).toBe(
false,
)
expect(shouldEnterSilenceReview(input({ lastSignalAgeDays: 35 }), 0)).toBe(false)
})
it('postpone durations: 7 days for urgent levels, 14 for optimization levels', () => {
expect(suggestionPostponeDays(1)).toBe(7)
expect(suggestionPostponeDays(3)).toBe(7)
expect(suggestionPostponeDays(4)).toBe(14)
expect(suggestionPostponeDays(5)).toBe(14)
})
it('suppression windows exist for both terminal outcomes', () => {
expect(SUGGESTION_SUPPRESSION_DAYS.aceita).toBeGreaterThan(0)
expect(SUGGESTION_SUPPRESSION_DAYS.descarta).toBeGreaterThan(0)
})
})
describe('isSuggestionSuppressedByDecision — churn windows (§6.4)', () => {
const now = new Date('2026-07-28T12:00:00.000Z')
const daysAgo = (days: number): string =>
new Date(now.getTime() - days * 86_400_000).toISOString()
const decision = (overrides: Partial<SuggestionDecisionLike>): SuggestionDecisionLike => ({
outcome: 'descarta',
createdAt: daysAgo(1),
snapshot: { triageLevel: 5 },
...overrides,
})
it('a dismissal suppresses inside its window and expires after it', () => {
expect(isSuggestionSuppressedByDecision(decision({}), { triageLevel: 5 }, now)).toBe(true)
expect(
isSuggestionSuppressedByDecision(
decision({ createdAt: daysAgo(SUGGESTION_SUPPRESSION_DAYS.descarta + 1) }),
{ triageLevel: 5 },
now,
),
).toBe(false)
})
it('an acceptance suppresses for its own, shorter window', () => {
expect(
isSuggestionSuppressedByDecision(
decision({ outcome: 'aceita', createdAt: daysAgo(7) }),
{
triageLevel: 4,
},
now,
),
).toBe(true)
expect(
isSuggestionSuppressedByDecision(
decision({ outcome: 'aceita', createdAt: daysAgo(SUGGESTION_SUPPRESSION_DAYS.aceita + 1) }),
{ triageLevel: 4 },
now,
),
).toBe(false)
})
it('a postponement suppresses until its snapshot suppressUntil, then resurfaces', () => {
const future = new Date(now.getTime() + 86_400_000).toISOString()
const past = daysAgo(1)
expect(
isSuggestionSuppressedByDecision(
decision({ outcome: 'adiada', snapshot: { triageLevel: 5, suppressUntil: future } }),
{ triageLevel: 5 },
now,
),
).toBe(true)
expect(
isSuggestionSuppressedByDecision(
decision({
outcome: 'adiada',
createdAt: daysAgo(10),
snapshot: { triageLevel: 5, suppressUntil: past },
}),
{ triageLevel: 5 },
now,
),
).toBe(false)
})
it('a malformed postponement snapshot falls back to the shortest postpone, never forever', () => {
expect(
isSuggestionSuppressedByDecision(
decision({ outcome: 'adiada', createdAt: daysAgo(1), snapshot: null }),
{ triageLevel: 5 },
now,
),
).toBe(true)
expect(
isSuggestionSuppressedByDecision(
decision({ outcome: 'adiada', createdAt: daysAgo(8), snapshot: null }),
{ triageLevel: 5 },
now,
),
).toBe(false)
})
it('a confirmed risk (level 1) pierces a decision recorded at lower urgency', () => {
const dismissedAtLevel3 = decision({ snapshot: { triageLevel: 3 } })
expect(isSuggestionSuppressedByDecision(dismissedAtLevel3, { triageLevel: 1 }, now)).toBe(false)
// Seen and decided AT level 1: the window holds.
expect(
isSuggestionSuppressedByDecision(
decision({ snapshot: { triageLevel: 1 } }),
{
triageLevel: 1,
},
now,
),
).toBe(true)
// Lower-urgency re-trigger of a level-3 dismissal: the window holds too.
expect(isSuggestionSuppressedByDecision(dismissedAtLevel3, { triageLevel: 3 }, now)).toBe(true)
})
it('a level movement (E14) never suppresses a suggestion', () => {
expect(
isSuggestionSuppressedByDecision(decision({ outcome: 'movimento' }), { triageLevel: 5 }, now),
).toBe(false)
})
})