Merge branch 'cursor/4fa27bb1'

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-29 23:34:56 -03:00
commit 34cff01535
24 changed files with 12277 additions and 192 deletions

View file

@ -7,6 +7,7 @@ alwaysApply: false
## Status
- **B62 entregue em código (2026-07-29):** modelo de sinal simplificado — `municipalityUpdate` kind `sinal` agora é só `signalType` + `body`; migration `20260730_010601_simplify_municipality_signal_fields` dropa `signal_source`/`triangulated` (o gerador reemitiu `ADD VALUE 'adiada'` por drift de snapshot — removido à mão, e o snapshot novo sara a cadeia); labels/descriptions dos 5 tipos reescritas para a mesa (`Rede esfriou`, `Adversário apareceu`, `Alguém pediu algo` — sem "broker" no modelo de sinal; `activity.origin` `pedido_broker` e a copy do suggestionCatalog ficaram fora por decisão da sessão). Forms: `MunicipalitySignalFields` (lista B26) e o bloco inline de `MunicipalityUpdateForm` perderam Fonte/Triangulado; feed/dossiê (`MunicipalityUpdateFeed` + `municipalityUpdatePageData`) só exibem o badge do tipo. **E11: presença de sinal de adversário lê como confirmada — triage 1** (decisão do usuário: "eles não vão adicionar sinais improváveis"), `adversarySignal: { present: true } | null`, fator único "Sinal de adversário registrado", glossário E18 alinhado. Fixa B63. Bônus: teste e2e `campaignMunicipalities` "advisor scopes…" estava obsoleto desde B43 (home da liderança) — atualizado para `/campanha/contatos` + redirect pós-B43, e o pin de bypass do `municipalityElectoralBaseline` baixado 9→8 (drift pré-existente). Gate: tsc/lint/format/cycles/knip(P3 pré-existente)/876 unit/489 int/e2e alvo verde/build (`/campanha` 233 kB); Aikido 0 findings. Plano: [`simplificar-modelo-sinal.md`](../../../docs/plans/simplificar-modelo-sinal.md). Desbloqueia **B63** (wizard registrar sinal).
- **E10+ entregue em código (2026-07-29):** Escala e DRY pós-E10 — F1: `loadMunicipalityScopeFromDocs` no `municipalityPageData.ts` evita a segunda query de `municipality` quando `isPagedByPayload === false`; F2: `computeAllMunicipalityPotentials` memoizado por processo (retorna cache `Map<string, MunicipalityPotential>`); F3: `TooltipProvider` movido para o layout de `(app)`, restaurando o agrupamento de delay do Radix para os tooltips das listas; F4: DRY nos filtros multi do mobile em `MunicipalityFilters` via `.map` sobre `municipalityFilterDefinitions` (que ganhou `emptyLabel`). Sem migration/UI nova. Plano: [`escala-dry-pos-e10.md`](../../../docs/plans/escala-dry-pos-e10.md).
- **B60 entregue em código (2026-07-29):** etapa de busca de município nos wizards UX-1 — `WizardMunicipalitySearchStep` (debounce B47, `POST /campanha/home-search` modo `wizard-municipality`, só municípios scoped); select = `wizardActionHref` com `?municipio=<slug>`; loader compartilhado `searchStaffMunicipalityHits` (extraído de B48); `HomeSearchMunicipalityVoteTrailing` + `HomeSearchHitRow` link; stub B61 `WizardMunicipalitySelectedStub` com caption sticky; `WizardMunicipalityPlaceholderStep` removido. Sem migration/Consent. Unit `campaignActionRoutes`; int `searchStaffMunicipalityHits`; e2e wizard → `?municipio=cairu`. Plano: [`busca-municipio-wizard.md`](../../../docs/plans/busca-municipio-wizard.md). Desbloqueia **B61** / **B63** / **B64** / **B70**.
- **B52 entregue em código (2026-07-29):** quarto grupo da busca global do Início — **Dobradinhas** via `searchHomeStateDeputies` + `HomeSearchStateDeputyGroup` (após Atividades); `POST /campanha/home-search` agrega `stateDeputies`; match word-start em nome ou partido; secundário `partido · N municípios`; assessor vê hits (staff read); `municipalityIdsByStateDeputyIds` em `stateDeputyData`. Int `homeSearchStateDeputies.int.spec.ts`. WA = **B55** (sem telefone na entidade). Plano: [`busca-global-resultados-dobradinhas.md`](../../../docs/plans/busca-global-resultados-dobradinhas.md). Próximo na cadeia busca: **B53** demandas.

View file

@ -70,6 +70,7 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap.
**Recently resolved (2026-07-25): Engineering Consolidation Pass 2** (tracker: [`docs/IMPROVE-CODE-QUALITY-PLAN.md`](docs/IMPROVE-CODE-QUALITY-PLAN.md) § Pass 2; architecture: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md); agent map: `.cursor/rules/codebase-map.mdc`). Highlights: **W1 sistema de listas** — colunas como dado em `CampaignTable` (seams B17/B18), URL state compartilhado (`campaignListUrl` + módulos por domínio; o god module `municipalityUi.ts` virou `municipalityListUrl`/`municipalityListFilters`/`municipalityLabels`/`municipalitySignal`), shells (`CampaignListFooter`, `CampaignListEmptyState`), 8 superfícies migradas (municípios, lideranças, organizações, dobradinhas, demandas, apoiadores, assessores, atividades-shells), B16+ absorvido (`useOptimistic`, hrefs por serializador canônico, facet por slugs, facets no mesmo `Promise.all`); **W2** — fronteiras: `slug`/`phone`/`wordStartFilter`/`voteEstimate` movidos para `lib/`, 21 loaders `server-only`, `campaignRoles.ts` client-safe (sidebar sem o barrel de access), `votePledgeViews` (contract) vs `votePledgeData` (loaders), ciclo supporter quebrado, `components/campaign` em 15 subpastas de domínio; decisão D1: sem `src/domains/`, sem ports-and-adapters; **W4a** — `electionInsights.ts` (941 linhas, 2 exports vivos) deletado; sobreviventes em `electionFormat.ts`/`voteTrend.ts`; E10 nasce limpo; **W4b** — knip `exports`/`types`/`enumMembers` em **ERROR** no CI (0 findings; kit ui sem peças mortas); **W4c** — tabs das páginas de detalhe extraídas (`MunicipalityDetailTabs`, `ActivityOverviewTab`) + factory `detailTabUi`; **W4d** — `runCampaignFormAction` fecha o C8 F4 (13 ações em 7 arquivos; exceções documentadas); **W4e** — tipos de fonte única; **W4f** — copy "Praça"→"Município" varrida (apoiadores desambiguado: cidade do contato = "Cidade"); **D3** — consent do WhatsApp por chave estável (`whatsapp-inscricao`, fail-closed). Suites: 46 unit / 48 int / 7 e2e.
**Recently resolved (2026-07-29):** **B62** Simplificar modelo de sinal — `municipalityUpdate` kind `sinal` is now `signalType` + `body` only: migration `20260730_010601_simplify_municipality_signal_fields` drops `signal_source` and `triangulated` (the generator re-emitted `ADD VALUE 'adiada'` from snapshot drift — removed by hand; this migration's committed `.json` heals the chain), and the five signal-type labels/descriptions were rewritten for the field team (`Rede esfriou`, `Adversário apareceu`, `Alguém pediu algo` — no "broker" in the signal model; `activity.origin`'s `Pedido de broker` and suggestionCatalog copy were explicitly left out by session decision). `MunicipalitySignalFields` (B26 list control) and the inline block in `MunicipalityUpdateForm` lost Fonte/Triangulado; `municipalityUpdatePageData` + `MunicipalityUpdateFeed` (detail + dossier) render only the type badge. **E11 changed semantics: any adversary signal in the window reads as confirmed — P1 triage 1** (session decision, replacing the triangulated/single-source split), `MunicipalityTriggerInput.adversarySignal: { present: true } | null`, single factor copy, E18 glossary aligned. Bonus gate fixes: the `campaignMunicipalities` "advisor scopes…" e2e was stale since B43 (leader home → `/campanha/contatos`) and was updated, and the `municipalityElectoralBaseline` bypass pin dropped 9→8 (pre-existing main drift). Gate: 876 unit / 489 int / targeted e2e green / build; Aikido 0 findings. Plano: [`docs/plans/simplificar-modelo-sinal.md`](docs/plans/simplificar-modelo-sinal.md).
**Recently resolved (2026-07-29):** **B59** Chassis do wizard de campanha — rotas staff em `/campanha/acoes/<slug>` (`campaignActionRoutes.ts`, `campaignActionEntryHref`), layout `acoes/layout.tsx` (gate staff, coluna `max-w-lg`), `CampaignWizardShell` (voltar mobile, caption de município sticky, `trailingAction`, foco no `h1` ao trocar passo), cinco páginas-ponte até B60 e strip do Início com `href` nos fluxos wizard. Sem migration. Plano: [`docs/plans/chassis-wizard-campanha.md`](docs/plans/chassis-wizard-campanha.md).
**Recently resolved (2026-07-29):** **B49** Busca global — resultados Lideranças no Início staff: o mesmo `POST /campanha/home-search` agora inclui `searchHomeLeaderships` em paralelo com municípios e assessores; word-start no `contact.name` com pré-filtro `buildLeadershipListWhere` (`contains`) refinado em memória, cap 25, `municipalitiesSummary` via `truncatedNamesLabel`, desempate por `updatedAt`; assessor vê só a carteira (`overrideAccess: false`). Client: `HomeSearchLeadershipGroup` entre Municípios e Assessores, `HomeSearchHitRow` compartilhado, empty state via `homeSearchHasAnyHits`; linha → `/campanha/liderancas/[id]`. Sem migration. Plano: [`docs/plans/busca-global-resultados-liderancas.md`](docs/plans/busca-global-resultados-liderancas.md).

View file

@ -1,12 +1,14 @@
# Simplificar modelo de sinal (tipo + texto; sem fonte/triangulado)
Status: rascunho
Status: entregue em código 2026-07-29
Atualizado em: 2026-07-29
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B62 — UX-1 / C12 sinais)
Impeccable: B — schema + forms existentes (`MunicipalitySignalFields`, B26, admin); UI nova de escolha = **B63**
Appetite: ~0,75–1 dia eng; migration drop + zod/collection/UI lista/detail; rename de labels; ajuste E11
Responsável: —
**Revisão 2026-07-29 (as-built):** entregue conforme o plano, com duas decisões da sessão e dois achados: (1) **E11 triage 1 por presença** — o usuário decidiu que sinal de adversário registrado lê como confirmado ("eles não vão adicionar sinais improváveis"), divergindo do default "ramo fonte única" abaixo; `adversarySignal: { present: true } | null`, P1 triage 1, fator único "Sinal de adversário registrado". (2) **scrub "broker" = só o modelo de sinal** — `activity.origin` (`Pedido de broker`) e as strings "broker" do suggestionCatalog (staff-only) ficaram para o gatilho já registrado. Achados da auditoria incorporados: form duplicado em `MunicipalityUpdateForm.tsx`, readouts `municipalityUpdatePageData.ts` + `MunicipalityUpdateFeed.tsx` (detalhe + dossiê), glossário `campaignIntelligenceConcepts.ts`; migration gerada reemitia `ADD VALUE 'adiada'` (drift de snapshot da cadeia `.json` — removido à mão; o snapshot desta migration sara a cadeia). Bônus de gate: e2e `campaignMunicipalities` "advisor scopes…" obsoleto desde B43 (home liderança → `/campanha/contatos`, redirect pós-B43) atualizado; pin de bypass do `municipalityElectoralBaseline` 9→8 (drift pré-existente em main). Gate: 876 unit / 489 int / e2e alvo verde / build; Aikido 0 findings.
## Dados → decisão → apresentação
Dados: N/A neste item como superfície nova — remove campos; labels novas alimentam **B63** e B26.
@ -33,7 +35,7 @@ E11 ✓ lê `triangulated` em sinais de adversário (`municipalityTriggers.ts` /
| `outro` | Outro | Fato importante que não encaixa acima |
- Atualizar `MunicipalitySignalFields`, B26 form, detail/dossier readouts, int tests, seeds se houver.
- E11: remover branch `triangulated` do input de sugestão; copy do catálogo que cita “triangulado” / “fonte única” no eixo de adversário → presença do sinal basta (recalibrar texto mínimo; **não** reabrir E15).
- E11: remover branch `triangulated` do input de sugestão; copy do catálogo que cita "triangulado" / "fonte única" no eixo de adversário → presença do sinal basta e **lê como confirmada (triage 1 — decisão da sessão 2026-07-29)**; recalibrar texto mínimo; **não** reabrir E15.
- Sem Consent novo. Sem UI de grid do wizard (isso é **B63**).
## Decisões travadas
@ -82,6 +84,9 @@ Componentes:
- **Rename enum `proposta_broker`.** Revisitar se `broker` ainda aparecer em UI/admin após labels (grep pt-BR + admin).
- **Novos signalType values.** Revisitar com evidência de mesa (tipos que caem sempre em `outro`).
- **`FieldError` de `signalType` no detalhe (`MunicipalityUpdateForm`).** Gap pré-existente: o form inline não renderiza erro de servidor no select de tipo (a lista B26 já usa `MunicipalitySignalFields` com `FieldError`). **Gatilho:** **B63** (wizard + unificação dos dois forms de sinal).
- **Unificar `MunicipalitySignalFields` + bloco inline do detalhe.** Duplicação aceitável pós-B62 (só tipo + textarea); **gatilho:** **B63** (3º call site do wizard).
- **Consulta de existência de sinais no loader E11.** Hoje `limit: 0` na janela de 28d é barato; se o volume crescer, trocar por agregado/exists — **gatilho:** fila de sugestões lenta ou >N sinais/dia por município em produção.
## Referências

File diff suppressed because one or more lines are too long

View file

@ -4,7 +4,6 @@ import { revalidatePath } from 'next/cache'
import { createMunicipalityUpdate } from '@/app/(campaign)/campanha/actions/municipalityUpdate'
import {
checkboxFormValue,
optionalFormText,
optionalIntegerFormValue,
requiredFormText,
@ -42,8 +41,6 @@ export const createMunicipalityUpdateFormAction = async (
activeVolunteers: optionalIntegerFormValue(formData, 'activeVolunteers'),
newSupports: optionalIntegerFormValue(formData, 'newSupports'),
signalType: parseMunicipalitySignalType(optionalFormText(formData, 'signalType')),
signalSource: optionalFormText(formData, 'signalSource'),
triangulated: checkboxFormValue(formData, 'triangulated'),
})
revalidatePath('/campanha/municipios/[slug]', 'page')
return { message: 'Atualização registrada com sucesso.' }

View file

@ -7,7 +7,6 @@ import {
} from '@/app/(campaign)/campanha/actions/municipality'
import { createMunicipalityUpdate } from '@/app/(campaign)/campanha/actions/municipalityUpdate'
import {
checkboxFormValue,
nullableFormText,
optionalFormText,
optionalMunicipalitySlugFromForm,
@ -99,8 +98,6 @@ export const createMunicipalityListSignalFormAction = async (
kind: 'sinal',
body: optionalFormText(formData, 'body'),
signalType: parseMunicipalitySignalType(optionalFormText(formData, 'signalType')),
signalSource: optionalFormText(formData, 'signalSource'),
triangulated: checkboxFormValue(formData, 'triangulated'),
})
revalidateMunicipalityListPaths({ slug: optionalMunicipalitySlugFromForm(formData) })
return { message: 'Sinal registrado.' }

View file

@ -18,9 +18,7 @@ import {
} from '@/lib/schemas/municipalityUpdate'
import {
canCreateMunicipalityUpdate,
canManageCampaignStaffField,
canMutateMunicipalityUpdate,
canReadCampaignStaffField,
canReadMunicipalityUpdate,
canSetMunicipalityUpdateAuthor,
} from '@/utilities/campaignAccess'
@ -58,15 +56,12 @@ const validateMunicipalityUpdateKind: CollectionBeforeValidateHook = ({
if (typeof nextData.signalType !== 'string') {
throw new APIError('Informe o tipo do sinal.', 400)
}
if (!nonEmptyText(nextData.signalSource)) throw new APIError('Informe a fonte do sinal.', 400)
} else if (!nonEmptyText(nextData.body)) {
throw new APIError('Informe o texto da atualização.', 400)
}
if (kind !== 'sinal') {
data.signalType = null
data.signalSource = null
data.triangulated = false
}
return data
@ -296,29 +291,5 @@ export const MunicipalityUpdate: CollectionConfig = {
condition: (_, siblingData) => siblingData.kind === 'sinal',
},
},
{
name: 'signalSource',
type: 'text',
label: 'Fonte do sinal',
maxLength: 160,
admin: {
condition: (_, siblingData) => siblingData.kind === 'sinal',
},
},
{
name: 'triangulated',
type: 'checkbox',
label: 'Triangulado',
defaultValue: false,
access: {
create: canManageCampaignStaffField,
read: canReadCampaignStaffField,
update: canManageCampaignStaffField,
},
admin: {
condition: (_, siblingData) => siblingData.kind === 'sinal',
description: 'Confirmado por mais de uma fonte de campo.',
},
},
],
}

View file

@ -2,10 +2,7 @@
import { useState } from 'react'
import { Checkbox } from '@/components/ui/Checkbox'
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Textarea } from '@/components/ui/textarea'
import {
@ -29,8 +26,6 @@ export const MunicipalitySignalFields = ({
const [signalType, setSignalType] = useState<MunicipalitySignalType | ''>('')
const bodyId = `${idPrefix}-body`
const signalTypeId = `${idPrefix}-signal-type`
const signalSourceId = `${idPrefix}-signal-source`
const triangulatedId = `${idPrefix}-triangulated`
return (
<div className="flex flex-col gap-3">
@ -65,23 +60,6 @@ export const MunicipalitySignalFields = ({
<FieldError>{fieldError(fieldErrors, 'signalType')}</FieldError>
) : null}
</Field>
<Field>
<FieldLabel htmlFor={signalSourceId}>Fonte</FieldLabel>
<Input
id={signalSourceId}
name="signalSource"
maxLength={160}
required
className="min-h-11"
/>
{fieldError(fieldErrors, 'signalSource') ? (
<FieldError>{fieldError(fieldErrors, 'signalSource')}</FieldError>
) : null}
</Field>
<Label htmlFor={triangulatedId} className="flex min-h-11 items-center gap-3 font-normal">
<Checkbox id={triangulatedId} name="triangulated" value="on" />
<span>Triangulado — confirmado por mais de uma fonte independente</span>
</Label>
</div>
)
}

View file

@ -59,13 +59,9 @@ export const MunicipalityUpdateFeed = ({ updates }: { updates: MunicipalityUpdat
) : (
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{update.body}</p>
)}
{update.kind === 'sinal' ? (
{update.kind === 'sinal' && update.signalType ? (
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
{update.signalType ? (
<Badge variant="outline">{municipalitySignalTypeLabels[update.signalType]}</Badge>
) : null}
{update.signalSource ? <span>Fonte: {update.signalSource}</span> : null}
{update.triangulated ? <span>Triangulado</span> : null}
<Badge variant="outline">{municipalitySignalTypeLabels[update.signalType]}</Badge>
</div>
) : null}
{update.activeVolunteers != null || update.newSupports != null ? (

View file

@ -118,50 +118,34 @@ export const MunicipalityUpdateForm = ({
) : null}
</Field>
{kind === 'sinal' ? (
<>
<div className="grid gap-4 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="municipality-update-signal-type">Tipo do sinal</FieldLabel>
<NativeSelect
id="municipality-update-signal-type"
name="signalType"
defaultValue=""
required
>
<NativeSelectOption value="">Selecione</NativeSelectOption>
{municipalitySignalTypes.map((entry) => (
<NativeSelectOption key={entry} value={entry}>
{municipalitySignalTypeLabels[entry]}
</NativeSelectOption>
))}
</NativeSelect>
<FieldDescription>Escolha o fato político observado:</FieldDescription>
<ul className="flex flex-col gap-1 text-xs text-muted-foreground">
{municipalitySignalTypes.map((entry) => (
<li key={entry}>
<span className="font-medium text-foreground">
{municipalitySignalTypeLabels[entry]}:
</span>{' '}
{municipalitySignalTypeDescriptions[entry]}
</li>
))}
</ul>
</Field>
<Field>
<FieldLabel htmlFor="municipality-update-signal-source">Fonte</FieldLabel>
<Input
id="municipality-update-signal-source"
name="signalSource"
maxLength={160}
required
/>
</Field>
</div>
<label className="flex min-h-11 items-center gap-2 text-sm">
<input name="triangulated" type="checkbox" className="size-4" />
Triangulado — confirmado por mais de uma fonte independente
</label>
</>
<Field>
<FieldLabel htmlFor="municipality-update-signal-type">Tipo do sinal</FieldLabel>
<NativeSelect
id="municipality-update-signal-type"
name="signalType"
defaultValue=""
required
className="sm:w-72"
>
<NativeSelectOption value="">Selecione</NativeSelectOption>
{municipalitySignalTypes.map((entry) => (
<NativeSelectOption key={entry} value={entry}>
{municipalitySignalTypeLabels[entry]}
</NativeSelectOption>
))}
</NativeSelect>
<FieldDescription>Escolha o fato político observado:</FieldDescription>
<ul className="flex flex-col gap-1 text-xs text-muted-foreground">
{municipalitySignalTypes.map((entry) => (
<li key={entry}>
<span className="font-medium text-foreground">
{municipalitySignalTypeLabels[entry]}:
</span>{' '}
{municipalitySignalTypeDescriptions[entry]}
</li>
))}
</ul>
</Field>
) : null}
</>
)}

View file

@ -295,9 +295,9 @@ export const campaignIntelligenceConcepts: ReadonlyArray<CampaignIntelligenceCon
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.',
'Cada padrão do catálogo entra num nível fixo: 1 = estoque em risco confirmado (reduto com sinal de adversário registrado), 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.',
'Um reduto do bloco central com sinal de invasão registrado 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:

View file

@ -27,18 +27,18 @@ export type MunicipalitySignalType = (typeof municipalitySignalTypes)[number]
export const municipalitySignalTypeLabels: Record<MunicipalitySignalType, string> = {
invasao: 'Invasão',
esfriamento: 'Esfriamento',
visita_adversario: 'Visita adversária',
proposta_broker: 'Proposta a broker',
esfriamento: 'Rede esfriou',
visita_adversario: 'Adversário apareceu',
proposta_broker: 'Alguém pediu algo',
outro: 'Outro',
}
export const municipalitySignalTypeDescriptions: Record<MunicipalitySignalType, string> = {
invasao: 'Adversário ocupando ou ganhando espaço antes dominado pela campanha.',
esfriamento: 'Queda de mobilização, compromisso ou resposta dos aliados locais.',
visita_adversario: 'Agenda ou presença de adversário com possível impacto no município.',
proposta_broker: 'Pedido, oferta ou negociação feita por um intermediário político.',
outro: 'Fato político relevante que não se encaixa nos tipos anteriores.',
invasao: 'Adversário ocupando nosso espaço.',
esfriamento: 'Aliados pararam de responder ou caíram.',
visita_adversario: 'Visita ou agenda dele no município.',
proposta_broker: 'Liderança ou intermediário pediu ou ofereceu algo.',
outro: 'Fato importante que não encaixa acima.',
}
export const parseMunicipalitySignalType = (
@ -63,8 +63,6 @@ export const municipalityUpdateCreateSchema = z
activeVolunteers: optionalCount,
newSupports: optionalCount,
signalType: z.enum(municipalitySignalTypes).optional(),
signalSource: trimmedOptionalText(160),
triangulated: z.boolean().optional(),
})
.superRefine((data, context) => {
if (data.kind === 'semanal') {
@ -100,13 +98,6 @@ export const municipalityUpdateCreateSchema = z
path: ['signalType'],
})
}
if (!data.signalSource) {
context.addIssue({
code: 'custom',
message: 'Informe a fonte do sinal.',
path: ['signalSource'],
})
}
}
})

View file

@ -95,8 +95,8 @@ export type MunicipalityTriggerInput = {
coverageRatio: number | null
coverageDeficit: number
/** Most severe adversary signal within the window, if any (P1 variant). */
adversarySignal: { triangulated: boolean } | null
/** Adversary signal within the window (P1 variant) — presence reads as confirmed. */
hasAdversarySignal: boolean
/** 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"). */
@ -314,7 +314,7 @@ const patternP1: SuggestionPattern = {
evaluate: (input) => {
if (input.territorialClass !== 'reduto' || !input.inCoreBlock) return null
const threatened = input.adversarySignal !== null
const threatened = input.hasAdversarySignal
const own2018 = input.ownVotesByYear[YEAR_2018] ?? 0
const own2022 = input.ownVotesByYear[YEAR_2022] ?? 0
const dropped = own2018 > 0 && own2022 <= own2018 * SUGGESTION_ANCHORS.voteDropRatio
@ -330,11 +330,7 @@ const patternP1: SuggestionPattern = {
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)',
)
factors.push('Sinal de adversário registrado')
}
if (dropped) {
factors.push(
@ -343,11 +339,7 @@ const patternP1: SuggestionPattern = {
}
if (dormant) factors.push(`${signalAgeFactor(input.lastSignalAgeDays)}, sem agenda no período`)
const triageLevel: SuggestionTriageLevel = input.adversarySignal
? input.adversarySignal.triangulated
? 1
: 2
: 3
const triageLevel: SuggestionTriageLevel = input.hasAdversarySignal ? 1 : 3
return { patternId: 'P1', triageLevel, factors }
},
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,13 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "municipality_update" DROP COLUMN "signal_source";
ALTER TABLE "municipality_update" DROP COLUMN "triangulated";`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "municipality_update" ADD COLUMN "signal_source" varchar;
ALTER TABLE "municipality_update" ADD COLUMN "triangulated" boolean DEFAULT false;`)
}

View file

@ -34,6 +34,7 @@ import * as migration_20260727_032523_add_users_roles from './20260727_032523_ad
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';
import * as migration_20260730_010601_simplify_municipality_signal_fields from './20260730_010601_simplify_municipality_signal_fields';
export const migrations = [
{
@ -209,11 +210,16 @@ export const migrations = [
{
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'
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,
name: '20260728_041958_add_campaign_webauthn_credentials'
name: '20260728_041958_add_campaign_webauthn_credentials',
},
{
up: migration_20260730_010601_simplify_municipality_signal_fields.up,
down: migration_20260730_010601_simplify_municipality_signal_fields.down,
name: '20260730_010601_simplify_municipality_signal_fields'
},
];

View file

@ -796,11 +796,6 @@ export interface MunicipalityUpdate {
newSupports?: number | null;
body?: string | null;
signalType?: ('invasao' | 'esfriamento' | 'visita_adversario' | 'proposta_broker' | 'outro') | null;
signalSource?: string | null;
/**
* Confirmado por mais de uma fonte de campo.
*/
triangulated?: boolean | null;
updatedAt: string;
createdAt: string;
}
@ -1652,8 +1647,6 @@ export interface MunicipalityUpdateSelect<T extends boolean = true> {
newSupports?: T;
body?: T;
signalType?: T;
signalSource?: T;
triangulated?: T;
updatedAt?: T;
createdAt?: T;
}

View file

@ -238,7 +238,7 @@ export const loadMunicipalitySuggestions = async (
depth: 0,
limit: 0,
pagination: false,
select: { municipality: true, triangulated: true },
select: { municipality: true },
user,
overrideAccess: false,
}),
@ -283,14 +283,12 @@ export const loadMunicipalitySuggestions = async (
}),
])
// Most severe adversary signal per município: triangulated wins.
const adversarySignalByMunicipality = new Map<number, { triangulated: boolean }>()
// Any adversary signal in the window reads as confirmed (B62: sinal = tipo + texto).
const adversarySignalMunicipalityIDs = new Set<number>()
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) })
adversarySignalMunicipalityIDs.add(id)
}
const agendaByMunicipality = new Map<number, { hasAgenda: boolean; completedCount: number }>()
@ -371,7 +369,7 @@ export const loadMunicipalitySuggestions = async (
lastPledgeAgeDays: municipalitySignalAgeInDays(pledgeAggregate.lastPledgeAt, now),
coverageRatio: coverage.coverageRatio,
coverageDeficit: coverage.deficit,
adversarySignal: adversarySignalByMunicipality.get(municipality.id) ?? null,
hasAdversarySignal: adversarySignalMunicipalityIDs.has(municipality.id),
hasRecentOrUpcomingActivity: agenda?.hasAgenda ?? false,
completedActivityCount: agenda?.completedCount ?? 0,
}

View file

@ -25,8 +25,6 @@ export type MunicipalityUpdateViewModel = {
activeVolunteers: number | null
newSupports: number | null
signalType: MunicipalitySignalType | null
signalSource: string | null
triangulated: boolean
}
export type MunicipalityUpdateFeedState = {
@ -101,8 +99,6 @@ export const loadMunicipalityUpdatesFeed = async (
activeVolunteers: doc.activeVolunteers ?? null,
newSupports: doc.newSupports ?? null,
signalType: doc.signalType ?? null,
signalSource: doc.signalSource ?? null,
triangulated: Boolean(doc.triangulated),
}
}),
totalDocs: result.totalDocs,

View file

@ -48,11 +48,9 @@ test.describe('Atividades — registro-fundação', () => {
await page.goto(`${campaign.baseURL}/campanha/municipios/${municipality.slug}?tab=updates`)
await page.getByLabel('Tipo', { exact: true }).selectOption('sinal')
await expect(page.getByText('Invasão:', { exact: true })).toBeVisible()
await expect(
page.getByText('Adversário ocupando ou ganhando espaço antes dominado pela campanha.'),
).toBeVisible()
await expect(page.getByText('Esfriamento:', { exact: true })).toBeVisible()
await expect(page.getByText('Visita adversária:', { exact: true })).toBeVisible()
await expect(page.getByText('Proposta a broker:', { exact: true })).toBeVisible()
await expect(page.getByText('Adversário ocupando nosso espaço.')).toBeVisible()
await expect(page.getByText('Rede esfriou:', { exact: true })).toBeVisible()
await expect(page.getByText('Adversário apareceu:', { exact: true })).toBeVisible()
await expect(page.getByText('Alguém pediu algo:', { exact: true })).toBeVisible()
})
})

View file

@ -209,9 +209,6 @@ test.describe('Municípios — jornadas por papel', () => {
.getByRole('textbox', { name: 'Texto' })
.fill('Liderança local reportou visita adversária na feira.')
await signalForm.getByLabel('Tipo do sinal').selectOption('visita_adversario')
await signalForm
.getByRole('textbox', { name: 'Fonte', exact: true })
.fill('Liderança local / WhatsApp')
await signalForm.getByRole('button', { name: 'Registrar sinal', exact: true }).click()
await expect(page.getByText('Sinal registrado.', { exact: true })).toBeVisible()
@ -295,15 +292,18 @@ test.describe('Municípios — jornadas por papel', () => {
await page.getByRole('button', { name: 'Salvar estimativa' }).click()
await expect(page.getByText('Média: 90')).toBeVisible()
// Leader home is the contact tool; municipalities redirect away.
// Leader home is the blank Início (B43); the contact tool lives at
// /campanha/contatos and municipalities redirect there.
// Each `createCampaignUser` call mints its own password — the advisor's
// `password` variable above does NOT unlock the leader account.
await campaign.login(page, leaderPhone, leaderAccount.password)
await page.goto(`${campaign.baseURL}/campanha`)
await page.goto(`${campaign.baseURL}/campanha/contatos`)
await expect(page.getByText('Cadastre apoiadores pelo celular.')).toBeVisible()
await page.goto(`${campaign.baseURL}/campanha/municipios`)
await expect(page).toHaveURL(`${campaign.baseURL}/campanha`)
// The leader redirect aborts `goto`'s load event (ERR_ABORTED); the
// redirect itself is the assertion, and `toHaveURL` retries onto it.
await page.goto(`${campaign.baseURL}/campanha/municipios`).catch(() => {})
await expect(page).toHaveURL(`${campaign.baseURL}/campanha/contatos`)
const supporterName = fixtures.value('Apoiador Liderança')
const supporterPhone = fixtures.phone()

View file

@ -105,7 +105,6 @@ describe('campaign municipality update domain', () => {
kind: 'sinal',
body: 'Ex-prefeito retirou o apoio na feira.',
signalType: 'esfriamento',
signalSource: 'Assessora local',
}).success,
).toBe(true)
expect(
@ -113,7 +112,6 @@ describe('campaign municipality update domain', () => {
municipality: 1,
kind: 'sinal',
body: 'Visita adversária confirmada.',
signalSource: 'Equipe local',
}).success,
).toBe(false)
})

View file

@ -293,10 +293,9 @@ describe('campaign cell edit overlay', () => {
)
const dialog = await screen.findByRole('dialog')
// Both selects are `required`: an invalid form never fires submit, and the
// The select is `required`: an invalid form never fires submit, and the
// case would pass for the wrong reason.
fireEvent.change(screen.getByLabelText('Tipo do sinal'), { target: { value: 'invasao' } })
fireEvent.change(screen.getByLabelText('Fonte'), { target: { value: 'Liderança local' } })
const submit = dialog.querySelector<HTMLButtonElement>('[data-slot="drawer-footer"] button')
expect(submit?.textContent).toContain('Registrar sinal')

View file

@ -42,7 +42,7 @@ const input = (overrides: Partial<MunicipalityTriggerInput> = {}): MunicipalityT
lastPledgeAgeDays: 3,
coverageRatio: 0.6,
coverageDeficit: 400,
adversarySignal: null,
hasAdversarySignal: false,
hasRecentOrUpcomingActivity: false,
completedActivityCount: 0,
...overrides,
@ -92,11 +92,8 @@ describe('P1 — reduto dormente ou ameaçado', () => {
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('escalates to level 1 on any adversary signal — presence reads as confirmed', () => {
expect(evaluate('P1', reduto({ hasAdversarySignal: true }))?.triageLevel).toBe(1)
})
it('fires on a 2018→2022 vote drop even with a fresh signal', () => {
@ -318,7 +315,7 @@ describe('evaluateMunicipalityTriggers — ordering and silence', () => {
territorialClass: 'reduto',
inCoreBlock: true,
lqByYear: { 2014: 2.5, 2018: 2.6, 2022: 2.4 },
adversarySignal: { triangulated: true },
hasAdversarySignal: true,
coverageRatio: 1.2,
}),
)