mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
Merge branch 'cursor/4fa27bb1'
This commit is contained in:
commit
34cff01535
24 changed files with 12277 additions and 192 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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.' }
|
||||
|
|
|
|||
|
|
@ -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.' }
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
},
|
||||
}
|
||||
|
|
|
|||
12174
src/migrations/20260730_010601_simplify_municipality_signal_fields.json
Normal file
12174
src/migrations/20260730_010601_simplify_municipality_signal_fields.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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;`)
|
||||
}
|
||||
|
|
@ -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'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue