E13: visit eligibility checklist and the tour composer.

Five conditions per município (volume, headroom, rede, janela, encaixe) with
their reason always visible, plus a staff-only composer at
/campanha/atividades/giros that turns an âncora + satélites + semente into draft
activities in one transaction. No `visita` kind and no `tour` entity:
`deputyPresent` already marks the candidate's own agenda.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-27 21:02:41 -03:00
parent c9fbdce471
commit d5b4af2d98
29 changed files with 2648 additions and 100 deletions

File diff suppressed because one or more lines are too long

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-27):** **E13** Planejador de presença/giros — visit eligibility as **five conditions ✓/—** (volume, headroom, rede, janela, encaixe) plus three advisory contraindications, each carrying a counter-offer and **never** blocking; the calendar phase (`construcao|consolidacao|ativacao`) changes the suggested "produto da visita". Pure math in `src/utilities/visitEligibility.ts`, cut points in `src/lib/visitPlannerAnchors.ts` (illustrative, on the `territorialClassAnchors.ts` precedent; the phase turns at midnight in Bahia via the new `formatBahiaCivilDate`), candidates grouped by identity territory in `src/utilities/visitPlannerData.ts` (`server-only`). Surfaces: an "Elegibilidade para visita" card on the municipality `OverviewTab`, and a composer at **`/campanha/atividades/giros`** (staff-only, reached from a "Planejar giro" button — no new sidebar entry) that turns TI → âncora/satélites/semente into draft `activity` rows in one transaction. **Three plan premises died in the pre-implementation audit and should not come back:** `kind: 'visita'` never shipped with C12 and is **not** wanted — `activity.deputyPresent` already IS the candidate-presence marker, so a giro is derivable from `deputyPresent` + território + date window and each stop keeps its real kind; `allocationDecision` still has no application write path, so the "não vá" is advisory text and recording an override is **E11**'s first job; and "satélites contíguos" was never possible — `bahiaTerritories.ts` carries membership only (polygon adjacency exists solely inside the build scripts), so **encaixe = same TI**, said in those words in the UI. Two implementation facts worth keeping: the uncaptured field ceiling and the catalog medians moved out of E10's classifier into `municipalityPotential.ts` (memoized), so the card and the "Classe" column cannot compute the same number twice; and `createTourDraftActivitiesRecord` re-reads every stop's município id with `overrideAccess: false` **inside** the transaction and aborts if one is missing, because `activity` create access is a plain staff boolean and Payload cannot express a per-município constraint on create — without it an advisor could seed drafts statewide by posting ids the composer never offered. Caps: 3 satellites, 8 stops. No migration, no collection, no `Consent`, no persisted `tour` entity. Plano: [`docs/plans/planejador-de-giros.md`](docs/plans/planejador-de-giros.md).
**Recently resolved (2026-07-27):** **`users.roles`** — Payload admin RBAC. Field `roles` (`admin` | `editor`) on `users` (migration `20260727_032523_add_users_roles`, backfill `admin`); `isPayloadAdmin` requires the `admin` role; editors open `/admin` and write `post`/`tag`/`media`; PII/`petition`/campaign collections stay admin-only. Unifying `users` + `campaignUser` was rejected (25 FKs; structural isolation). Plano: [`docs/plans/roles-admin-payload.md`](docs/plans/roles-admin-payload.md).
**Recently resolved (2026-07-27):** **Build resiliente ao global `metadata` ausente** — `resolveSiteMetadata` in `src/utilities/seo.ts` (chain `global.URL` → `NEXT_PUBLIC_SITE_URL` → omit canonical/OG/JSON-LD url; textual defaults for empty global). All public SEO consumers (`layout`, `/abaixo-assinado/[id]`, `/[type]` tree) go through it; convention guard in `codebaseConventions.unit.spec.ts`. Plano: [`docs/plans/build-resiliente-metadata-ausente.md`](docs/plans/build-resiliente-metadata-ausente.md).
@ -163,7 +164,7 @@ The internal `/campanha` area is gated by its own authentication, deliberately k
- **Demandas:** staff-only create/read (`canCreateCampaignDemand`); optional `leadership` provenance selected by staff. Escalated decisions: coordinator **or** candidate.
- **Dobradinhas:** `stateDeputy` entity + `municipality.stateDeputies` / `leadership.stateDeputies` hasMany; UI at `/campanha/dobradinhas`.
- **Map:** staff dashboard (`/campanha`) loads `MunicipalityMapPanel`; `/campanha/municipios` is list/overview only. **Keyed by "map key", not `ibgeCode`** (B8 F2, 2026-07-26): `mapKeyForMunicipality` returns the `municipalitySlug` for `kind === 'zona'` and the `ibgeCode` otherwise, so each of Salvador's 19 ZE is painted, hovered and opened on its own from `bahia-municipality-zones.topo.json`. Every bundle field follows that key (`municipalitiesByMapKey`, `territorialClassByMapKey`, `projectedValidVotesByMapKey`, `valuesByYear`, …) and each key maps to exactly one catalog entry, so rollups assign instead of accumulating. Salvador's **municipal** polygon is still drawn, underneath, as a non-interactive base with no data (`baseKeys` in `BahiaMap`) — two independently simplified meshes do not close on the same coastline, and deleting it opened a gap in the state outline; being out of the key index, it cannot double-count or steal a hover. Feature→key resolution is `featureMapKey` (`municipalitySlug ?? codarea ?? code`) — the three are **mutually exclusive** on the committed meshes, so it is a lookup over distinct names and not a precedence rule; the zone mesh also carries the city's `ibgeCode`, which is deliberately **not** a key property because all 19 zones share it. Since B8+ that contract is exercised on **real features of all three meshes** in `tests/int/bahiaGeometries.int.spec.ts` — over the full 417 / 27 / 19, not on hand-written literals and not on one city feature repeated per zone — and a failure of the zone chunk **degrades instead of killing the map**: `loadLayerFeatures` catches it and paints the state without the ZE (Salvador falls back to its non-interactive base polygon, not to an aggregated one — the bundle is keyed by slug), while `status === 'error'` stays reserved for the municipal mesh. **`competitiveRank` is still per city:** the TSE artifact only carries `federalRankByIbgeCode`, so all 19 zones share the city's placement — which is what the legend already claims, and an int test pins it. Scales (B13): **quantis** (default), **padrão próprio (LQ)**, **posição no município**, **% dos válidos** (B11) and **Total (votos)**. The three relative ones are **discrete classes, never a continuous ramp** — the pure math lives in `src/lib/mapScaleClasses.ts` (quantiles degrade to 3 classes below 10 municípios and the legend says so; LQ is cut on the E10 anchors in `src/lib/territorialClassAnchors.ts`). Diverging red×blue stays exclusive to the compare mode, where it means "Solla × adversário" — do not reuse it for LQ. Quantiles are computed over the **user's access scope**, so an advisor sees his portfolio classed against itself. Rank is unavailable in 2026 (TSE data) and LQ in 2026 reuses the 2022 statewide standard, the same precedent `percentValid` set. An optional proportional-symbol layer sizes bubbles by projected valid votes (area ∝ value) and colours them by the E10 class. A class label never renders alone: the readout always spells out the reason.
- **URLs:** `/campanha/municipios[/slug]`, `/campanha/dobradinhas`, `/campanha/liderancas[/id]`, `/campanha/organizacoes[/slug]`, `/campanha/demandas[/slug]`, `/campanha/atividades[/slug]`, `/campanha/apoiadores`, `/campanha/assessores` (coordinator/candidate), `/campanha/conceitos` (staff glossary), `/campanha/perfil`.
- **URLs:** `/campanha/municipios[/slug]`, `/campanha/dobradinhas`, `/campanha/liderancas[/id]`, `/campanha/organizacoes[/slug]`, `/campanha/demandas[/slug]`, `/campanha/atividades[/slug]`, `/campanha/atividades/giros` (compositor de giro, staff), `/campanha/apoiadores`, `/campanha/assessores` (coordinator/candidate), `/campanha/conceitos` (staff glossary), `/campanha/perfil`.
## Campaign supporters (C2)

View file

@ -1,50 +1,77 @@
# E13 — Planejador de presença e giros (agenda do candidato)
Status: rascunho
Atualizado em: 2026-07-24 (refs sincronizadas pós-remodelagem Municípios + hardening)
Status: **entregue 2026-07-27** (v1 = núcleo decisório; os cortes seguem em "Cortes da v1", cada um com gatilho)
Atualizado em: 2026-07-27 (as-built da implementação acrescentado; no mesmo dia, auditoria contra o repositório antes de implementar — duas premissas falsas derrubadas, refs do C13 corrigidas, questões em aberto fechadas, seção Dados acrescentada; revisão anterior 2026-07-24 sincronizou refs pós-remodelagem Municípios + hardening)
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Inteligência de campanha", E13; plano-mestre [inteligencia-campanha.md](inteligencia-campanha.md))
Impeccable: C — superfície nova de planejamento dentro de `/campanha/planos` (visão "Giros"), sem design-ref
Appetite: ~1,5 dia eng; sem migration própria (usa `actionPlan.origin` de C12)
Impeccable: C — superfície nova de planejamento em `/campanha/atividades/giros` (sub-rota), sem design-ref
Appetite: ~2 dias eng; sem migration (ver "Revisão 2026-07-27", achado 1)
Responsável: —
## Revisão 2026-07-27 — o que a auditoria mudou
O plano foi escrito em 2026-07-24 e o repositório andou. Cinco achados materiais, registrados aqui para que nenhuma fase seja executada contra o estado antigo:
1. **`kind: 'visita'` NÃO entrou na migration do C12 — e não deve entrar.** O C12 entregou `origin` (`dado | pedido_broker | obrigacao_politica`), mas `activityKinds` ([src/lib/schemas/activity.ts](../../src/lib/schemas/activity.ts)) segue sem `visita`; adicioná-lo é `ALTER TYPE ... ADD VALUE`, ou seja, migration. **Decisão: não criar o kind.** `activity.deputyPresent` (checkbox indexado, existe desde o C3) já é o marcador de presença do candidato — é literalmente o que um "planejador de presença" filtra. O giro é derivável por `deputyPresent: true` + TI + janela de datas, e o kind de cada parada continua sendo o real (`reuniao_apoio` no satélite, `comicio`/`ato` na âncora). _Rejeitado:_ novo valor de enum, que custaria migration e criaria um kind sobreposto a `reuniao_apoio`/`ato` sem dizer nada que `deputyPresent` já não diga.
2. **`allocationDecision` não tem write path.** A collection e o access existem (`canCreateAllocationDecision` e irmãos), mas **nada em `src/` cria um registro** — só `tests/int/campaignAllocationDecision.int.spec.ts`. O "aviso com override registrado" exigiria construir a primeira action + zod + testes, trabalho que o appetite de ~1,5d não cobria. **Fora da v1**; o aviso "não vá" aparece como texto, e a gravação vira gatilho do **E11**, que precisa desse write path de qualquer forma.
3. **Rota e collection renomeadas pelo C13 (2026-07-25).** `actionPlan` → `activity`, `/campanha/planos` → `/campanha/atividades`, componentes em `src/components/campaign/activity/`. Não é cosmético: `tests/unit/codebaseConventions.unit.spec.ts` § "banned campaign terminology" **falha o build** se `action[\s_-]?plan`, `plano de a[çc][aã]o` ou `/campanha/planos` aparecer em `src`, `tests` ou `scripts`. O caminho `src/components/campaign/TourComposer*.tsx` da versão anterior também morreu — o Pass 2 W2 organizou `components/campaign` em subpastas de domínio, então o compositor nasce em `src/components/campaign/tour/`.
4. **Não existe grafo de contiguidade em runtime.** [src/lib/bahiaTerritories.ts](../../src/lib/bahiaTerritories.ts) tem só **pertencimento** município→TI (`territoryForCity`, `citiesForTerritory`); adjacência de polígono existe apenas dentro dos scripts de build das malhas. Em v1, "encaixe em giro" significa **mesmo TI**, dito com essas palavras na UI — construir adjacência real é exatamente o rabbit hole "otimizador de rota" que este plano já proibia.
5. **E14 (níveis N0–N4) não foi entregue; E12 e E16 foram.** O gatilho do "não vá" no texto original lê `N0/N1`: em v1 ele lê a classe do **E10 ✓** (`sem_base` / `marginal`) somada a rede vazia. Em compensação, `loadTerritoryOverview` (**E12 ✓**) e o dossiê (**E16 ✓**) já existem e são reusados — o compositor linka o dossiê de cada parada, como previsto.
## As-built 2026-07-27 — o que a implementação descobriu
Entregue como planejado (sem migration, sem collection, sem `Consent`, sem entidade `tour`), com oito desvios que valem registro:
1. **O número de headroom passou a ter uma dona só.** O classificador do **E10** calculava teto-do-campo-não-capturado e a mediana do catálogo por conta própria, e a checklist do E13 precisa exatamente dos mesmos dois números. Em vez de um segundo cálculo, `uncapturedFieldVotes` e as medianas (`catalogMedianUncapturedFieldVotes` / `catalogMedianProjectedValidVotes`, memoizadas) foram extraídas para [municipalityPotential.ts](../../src/utilities/municipalityPotential.ts) e o classificador passou a consumi-las. Sem isso, o card "Elegibilidade" e a coluna "Classe" poderiam discordar na mesma tela — e ninguém teria como saber qual das duas contas estava certa.
2. **A escrita precisou de uma checagem de escopo própria, fail-closed.** O `create` de `activity` é um booleano de staff — Payload não expressa restrição por município no create —, então `createTourDraftActivitiesRecord` relê os ids das paradas com `overrideAccess: false` **dentro** da transação e aborta se algum não voltar. Foi um teste de integração que expôs o buraco: sem essa releitura, um assessor podia semear rascunhos no estado inteiro postando ids que o compositor nunca lhe ofereceu.
3. **"Encaixe em giro" não é avaliável para um município sozinho.** A condição é uma afirmação sobre os vizinhos, então `loadMunicipalityVisitEligibility` roda o **mesmo** loader recortado no TI do município em vez de um segundo avaliador — é assim que o card do detalhe e a proposta do compositor não começam a discordar.
4. **A ordem da composição é âncora → semente → satélites.** A primeira versão preenchia satélites antes e a semente de expansão nunca aparecia, porque os satélites consumiam todas as vagas. Caps: `TOUR_MAX_SATELLITES` 3, `MAX_TOUR_STOPS` 8 (um giro é um dia ou dois), verificado no cliente e revalidado na action.
5. **O seletor de TI recebe cada href já serializado pelo RSC, não importa o serializador** — a lição do **B14**: importar `buildTourComposerHref` num client component arrasta `bahiaTerritories` + `municipalityCatalog` para o bundle do navegador. O href é dado da opção; o cliente só navega. `/campanha/atividades/giros` fechou em **8,61 kB / 226 kB** de First Load JS, na faixa das rotas irmãs; o detalhe do município ficou em 7,3 kB / 232 kB com o card novo.
6. **Duas suítes foram fundidas, não acrescentadas.** Arquivos separados para as views do planejador (unit) e para o compositor (int) empurraram as suítes para timeout; o conteúdo vive em `tests/unit/visitEligibility.unit.spec.ts` e `tests/int/campaignActivity.int.spec.ts`. As listas de ids (`CALENDAR_PHASES`, `VISIT_CONDITIONS`, `VISIT_CONTRAINDICATIONS`) são exportadas e varridas lá para provar que todo rótulo existe — um `const` usado só como tipo é warning de ESLint, e a varredura é o motivo honesto de ele ser público.
7. **O título do rascunho passou a sair do banco, não do formulário (`/simplify`).** A primeira versão compunha `"<giro> — <município>"` na borda do `FormData`, com o nome que o cliente havia enviado, e validava esse nome à mão. A action já relê os municípios das paradas para checar escopo — então ela passou a pedir `name` nessa mesma leitura e a compor o título ali: o cliente só manda o **id**, que é a única coisa que autoriza algo. Com isso a validação artesanal das paradas virou um schema zod (`tourStopDraftsSchema`, colhido dos próprios campos de `activity`) e a checagem de comprimento do título morreu — `MAX_TOUR_NAME_LENGTH` (90) mais o maior nome de município não alcança o limite de 160 do título.
8. **`campaignMunicipalities.e2e.spec.ts` continua instável, e não é desta entrega.** Com a árvore em `git stash` o mesmo arquivo falha, em teste diferente a cada corrida (o flake já registrado no AGENTS.md). Gate verde no resto: `tsc`, `lint`, `format:check`, `knip`, `check:cycles`, 429 testes unit+int, `build`, e scan Aikido sem findings.
## Design (Impeccable)
Âncoras: `PRODUCT.md` (princípios 2 e 5) / `DESIGN.md` (register `product`) · `/campanha/planos` existente (C3: tabs, cards, filtros).
Âncoras: `PRODUCT.md` (princípios 2 e 5) / `DESIGN.md` (register `product`) · `/campanha/atividades` existente (C3: tabs, cards, filtros).
Na implementação: shape → craft → critique → polish (classe C).
Na implementação: shape → craft → critique → **harden** (Fase 4: form/action nova + empty state novo) → polish (classe C). `optimize` fica fora — sem sinal de perf.
Brief compacto:
- **Persona / contexto:** coordenador montando a semana do candidato sob pressão de pedidos ("quem grita mais leva" — T5); precisa dizer não com critério.
- **Persona / contexto:** coordenador montando a semana do candidato sob pressão de pedidos ("quem grita mais leva" — T5); precisa dizer não com critério. A restrição que ele nomeou na sessão de campo é **"perna"** — "não posso marcar um compromisso que eu não possa cumprir".
- **Job principal:** compor giros de 2–3 dias por território com municípios elegíveis — e tornar visível o que NÃO justifica visita.
- **Estratégia de cor:** Restrained; elegibilidade como checklist de 5 condições (✓/—), nunca score numérico com falsa precisão.
- **Edit where you see:** sim — criar `actionPlan` (kind visita/giro) direto do município candidata.
- **Edit where you see:** sim — criar `activity` (com `deputyPresent`) direto do município candidato.
- **Anti-goals:** otimizador de rota (TSP/mapas de estrada); score 0–100 de município; agenda auto-aprovada; expor "não vá" com esse rótulo fora do staff (é despriorização — vocabulário duplo).
## Dados → decisão → apresentação
- **Decisão que a tela autoriza:** "incluir este município no próximo giro, ou oferecer contra-oferta" — e, no detalhe do município, "vale a pena levar o candidato aqui?".
- **Forma:** checklist de 5 condições **✓/—** por município + lista agrupada por TI. **Nunca score numérico**: falsa precisão é o erro documentado ([docs/research](../research/) §6.4) e a disciplina de exigir as cinco é o valor. Nenhum chart novo — a escada de pobreza para esta pergunta termina em texto e lista.
- **Anti-goals** (`PRODUCT.md` §6): score 0–100, gauge de SaaS, quilometragem como métrica, % estadual absoluto, contagem bruta, sugestão automática sem humano no loop.
## Contexto
Relatório §6.7: visita tem efeito modesto e o canal é mobilização do núcleo — "a visita vale o que a rede local converte dela; agenda é multiplicador de estrutura". Elegibilidade = 5 condições (volume, headroom, rede de recepção, janela política, encaixe em giro); o calendário muda o produto (construção jul–ago / consolidação set / ativação última semana); há "não vá" explícitos e intermediários ("mande o coordenador/vídeo/dobradinha"); padrões J-A (município elegível maduro sem visita), J-B (pedida vs. justificada — `actionPlan.origin` de C12), J-C (composição do giro: âncora+satélites+semente, por contiguidade de TI). O `/campanha/planos` (C3) já tem eventos com município/advisors/status; falta a camada de decisão de agenda. A sessão de campo de 2026-07-23 reforçou o item por dois lados: a restrição dominante nomeada é **"perna"/agenda** ("não posso marcar um compromisso que eu não possa cumprir" — o roadmap rebaixou E13 na fila de corte), e o pedido O6 de **dossiê pré-agenda** virou **E16** ([dossie-municipio.md](dossie-municipio.md)) — o compositor de giro linka o dossiê de cada município do giro como preparação da visita.
Relatório §6.7: visita tem efeito modesto e o canal é mobilização do núcleo — "a visita vale o que a rede local converte dela; agenda é multiplicador de estrutura". Elegibilidade = 5 condições (volume, headroom, rede de recepção, janela política, encaixe em giro); o calendário muda o produto (construção jul–ago / consolidação set / ativação última semana); há "não vá" explícitos e intermediários ("mande o coordenador/vídeo/dobradinha"); padrões J-A (município elegível maduro sem visita), J-B (pedida vs. justificada — `activity.origin` de C12), J-C (composição do giro: âncora+satélites+semente, por agrupamento de TI). O `/campanha/atividades` (C3) já tem eventos com município/advisors/status; falta a camada de decisão de agenda. A sessão de campo de 2026-07-23 reforçou o item por dois lados: a restrição dominante nomeada é **"perna"/agenda**, e o pedido O6 de **dossiê pré-agenda** virou **E16 ✓** ([dossie-municipio.md](dossie-municipio.md)) — o compositor de giro linka o dossiê de cada município do giro como preparação da visita.
## Objetivos
- **Elegibilidade por município:** checklist das 5 condições derivadas (volume E8; headroom E8; rede = lideranças ativas + responsável; janela = campo livre de datas + nota manual; encaixe = contiguidade TI com giro existente) — exposta no município e numa lista "candidatas a visita" ordenada.
- **Elegibilidade por município:** checklist das 5 condições derivadas — volume (E8), headroom (E8), rede (lideranças + assessor responsável), janela (dobradinha vinculada ou tendência não-desfavorável, mais nota livre), encaixe (mesmo TI de outra candidata) — exposta no município e numa lista "candidatas a visita" ordenada.
- **Fase do calendário:** rótulo automático (construção/consolidação/ativação por data) mudando o texto do "produto da visita" sugerido.
- **Visão "Giros" em `/campanha/planos`:** agrupar planos de kind visita por giro (TI + intervalo de datas); compositor simples — escolher TI, ver âncora sugerida (maior estoque comprometido), satélites contíguos e 1 semente de expansão (P12), gerar os `actionPlan` em rascunho.
- **J-B na prática:** criar plano a partir de pedido registra `origin=pedido_broker` + contra-oferta sugerida (coordenador/vídeo/parada em giro); painel "pedidos sem dado" para a reunião.
- **"Não vá" visível ao staff:** município K-B/N0–N1/sem rede aparece com a contraindicação citada quando alguém tenta agendá-lo (aviso com override registrado — não bloqueio duro).
- **Visão "Giros" em `/campanha/atividades/giros`:** compositor simples — escolher TI, ver âncora sugerida (maior estoque comprometido), satélites do mesmo TI e 1 semente de expansão (P12), gerar as `activity` em rascunho com `deputyPresent`.
- **J-B na prática:** criar atividade a partir de pedido registra `origin: 'pedido_broker'` + contra-oferta sugerida em texto (coordenador/vídeo/parada em giro).
- **"Não vá" visível ao staff:** município de classe `sem_base`/`marginal` ou sem rede aparece com a contraindicação citada (aviso, nunca bloqueio).
## Decisões travadas
- **Planejador compõe `actionPlan`s existentes; não cria entidade "giro" persistida na v1** — giro = agrupamento por TI+datas dos planos gerados. **Rejeitado:** collection `tour` nova (migration + access + UI por um agrupamento derivável; revisitar com gatilho).
- **Planejador compõe `activity`s existentes; não cria entidade "giro" persistida na v1** — giro = agrupamento por TI+datas das atividades geradas. **Rejeitado:** collection `tour` nova (migration + access + UI por um agrupamento derivável; revisitar com gatilho).
- **Checklist ✓/— em vez de score numérico.** Falsa precisão é o erro documentado (Hersh — §6.4); a disciplina das 5 condições é o valor. **Rejeitado:** score composto 0–100.
- **Aviso com override registrado, não bloqueio.** "A geografia serve à política" (T5-contraindicação); override grava `allocationDecision` (patternId `J-B`/`nao-va`) com motivo. **Rejeitado:** hard-block de municípios N0/N1.
- **Presença marcada por `deputyPresent`, não por kind novo** (ver Revisão 2026-07-27, achado 1).
- **Aviso sem gravação na v1.** "A geografia serve à política" (T5-contraindicação), mas gravar o override exige o primeiro write path de `allocationDecision` — adiado para o **E11** com gatilho. **Rejeitado:** hard-block de municípios de classe baixa; e construir o write path aqui, fora do appetite.
- **Sub-rota, não tab nova.** As tabs de `/campanha/atividades` são filtros de status traduzidos em `buildActivityListWhere`; um planejador não é um where-clause. `/campanha/atividades/giros` alcançada por botão "Planejar giro", sem entrada nova no sidebar. **Rejeitado:** 5ª tab "Giros" (abriria exceção no contrato tab→where); rota de 1º nível `/campanha/giros` (mais uma entrada num sidebar com 9).
- **Cenário fixo `central`** na conta de headroom, como o card do E8 já faz. **Rejeitado:** seletor de cenário no planejador.
- **i18n e naming:** `visitEligibility`, `tourComposer`, `calendarPhase` (`construcao|consolidacao|ativacao`), `origin` (C12); labels pt-BR.
## Questões em aberto
- **Kind novo `visita`/`giro` no enum de `actionPlan.kind`?** Opções: kinds novos | usar kinds existentes + flag. **Recomendação:** adicionar `visita` ao enum (kinds atuais são caminhada/comício etc.; visita de construção é reunião de lideranças, não comício) — entra na migration de C12 se este plano for aprovado antes dela rodar; senão migration própria mínima.
- **Janela política manual: campo onde?** **Recomendação:** nota curta no próprio rascunho do plano (campo `description` existente) — não criar campo estruturado para algo conversacional.
## Abordagem proposta
```mermaid
@ -52,46 +79,50 @@ flowchart LR
Elig["visitEligibility<br/>(5 condições sobre E8/rede)"]
Phase["calendarPhase (data)"]
Cands["lista candidatas a visita<br/>(J-A ordenadas)"]
Comp["tourComposer<br/>(âncora + satélites TI + semente)"]
Plans["actionPlan em rascunho<br/>(origin, kind=visita)"]
Dec["allocationDecision<br/>(override não-vá / J-B)"]
Comp["tourComposer<br/>(âncora + satélites do TI + semente)"]
Plans["activity em rascunho<br/>(origin, deputyPresent)"]
Elig --> Cands --> Comp --> Plans
Phase --> Cands
Comp -.aviso não-vá.-> Dec
```
Componentes:
- **`src/utilities/visitEligibility.ts`**: as 5 condições + fase, puro sobre derivados E8/bundle (lideranças ativas via `MunicipalityLeadershipsPanel` data, contiguidade via `bahiaTerritories.ts`).
- **`src/components/campaign/TourComposer*.tsx`**: visão "Giros" na página de planos (tab nova ao lado de Próximos/Todos); gera rascunhos via action existente de criação de plano (estendida com `origin`).
- **Detalhe do município:** card compacto "Elegibilidade para visita" (checklist) com CTA "agendar em giro".
- **Sem migration própria** (assumindo kind `visita` na migration C12; senão `pnpm migrate:create add_action_plan_visit_kind`).
- **`src/lib/visitPlannerAnchors.ts`**: `CALENDAR_PHASE_ANCHORS` num único objeto versionado, no precedente de `territorialClassAnchors.ts` — cortes **ilustrativos**, recalibração é diff de uma linha.
- **`src/utilities/visitEligibility.ts`**: as 5 condições + fase, puro sobre um input explícito (espelha `TerritorialClassInput`), então detalhe e lista alimentam o mesmo avaliador de fontes diferentes.
- **`src/utilities/visitPlannerData.ts`** (`server-only`): `loadVisitCandidates` com uma leitura própria de `municipality` (`overrideAccess: false`) — **não** `loadMunicipalityScope`, cujo select é superset compartilhado do dashboard/mapa/lista e não carrega `politicalTrend`/`stateDeputies`; alargá-lo cobraria de todos por dois campos que só o planejador usa. Contagem de lideranças em **uma** query com Map, no precedente de `leadershipCount` em `organizationData.ts`.
- **`src/components/campaign/tour/*`**: compositor na sub-rota; gera rascunhos por uma action em transação única.
- **Detalhe do município:** card compacto "Elegibilidade para visita" (checklist) com CTA "Planejar giro".
- **Sem migration** — nenhuma mudança de schema.
## Cortes da v1
Decididos nesta sessão, com o gatilho de cada um:
- **Override "não vá" gravando `allocationDecision`** → **E11** (que precisa do write path de qualquer forma).
- **Níveis N0–N4 no gatilho do "não vá"** → **E14**; até lá, classe E10 + rede vazia.
- **Padrões J como sugestão automática** → E11 fase 2.
- **Entidade `tour` persistida** (com resultado do giro) → 3º giro real composto e time pedindo visão consolidada pós-giro.
## Dependências
- Duras: **E8** (volume/headroom), **C12** (`origin`; `allocationDecision` para overrides). Suaves: **E16** dossiê ([dossie-municipio.md](dossie-municipio.md) — preparação da visita, link por município do giro), **E12** (contiguidade/agrupamento TI — usa `bahiaTerritories.ts` direto se E12 não estiver pronto), **A6** dobradinha (janela política ganha dado real pós-15/08), E14 (níveis alimentam o "não vá").
- Reusa: `/campanha/planos` inteiro (C3), `actionPlan` actions, `bahiaTerritories.ts`.
- Duras: **E8 ✓** (volume/headroom), **C12 ✓** (`origin`). Suaves: **E16 ✓** dossiê ([dossie-municipio.md](dossie-municipio.md) — preparação da visita, link por município do giro), **E12 ✓** (rollup de TI), **E4R ✓** (semeou `stateDeputies` e `politicalTrend`, que sustentam a condição de janela), **A6** dobradinha (janela política ganha dado real pós-15/08), **E14** (níveis alimentam o "não vá" quando existirem).
- Reusa: `/campanha/atividades` inteiro (C3), actions de `activity`, `bahiaTerritories.ts`, `municipalityCatalog.ts`.
## Não escopo
- Roteirização fina/otimização de deslocamento (humano decide a ordem); sincronização com calendário externo (Google/ics — fora deste ciclo); agenda do majoritário; padrões J como sugestões automáticas (E11 fase 2).
Roteirização fina/otimização de deslocamento (humano decide a ordem); sincronização com calendário externo (Google/ics); agenda do majoritário; padrões J como sugestões automáticas (E11 fase 2); registro de override em `allocationDecision` (E11); base de eventos municipais para janela política.
## Rabbit holes
- **Virar otimizador de rota.** Contiguidade de TI + ordenação é o teto; qualquer "distância por estrada" explode o item.
- **Virar otimizador de rota.** Agrupamento por TI + ordenação é o teto; qualquer "distância por estrada" explode o item — e o dado de adjacência não existe em runtime (achado 4).
- **Compositor virar wizard de 6 passos.** Escolher TI → revisar 4–6 municípios sugeridos → gerar rascunhos. Três interações.
- **Janela política estruturada** (datas de festas/feiras por município). Catálogo inexistente; campo livre + conhecimento do assessor. Não construir base de eventos municipais.
## Adiado com gatilho
- **Entidade `tour` persistida** (com resultado do giro). Gatilho: 3º giro real composto e time pedindo visão consolidada pós-giro.
- **Padrões J no motor (sugestão automática "município maduro sem visita").** Gatilho: E11 fase 2.
- **Janela política estruturada** (datas de festas/feiras por município). Catálogo inexistente; proxy derivado + campo livre + conhecimento do assessor. Não construir base de eventos municipais.
## Referências
- `docs/roadmap.md` (Inteligência de campanha, E13) · [plano-mestre](inteligencia-campanha.md)
- `docs/research/relatorio-entrevista-persona-campanha.md` §6.7 (elegibilidade, fases, não-vá, J-A/J-B/J-C), Rodada 6 J1–J4
- `src/collections/ActionPlan.ts` (kinds/status/access), `src/app/(campaign)/campanha/(app)/planos/` (superfície C3)
- `src/utilities/municipalityPageData.ts`, `src/lib/bahiaTerritories.ts`
- `docs/research/relatorio-entrevista-persona-campanha.md` §6.7 (elegibilidade, fases, não-vá, J-A/J-B/J-C), Rodada 6 J1–J4 · [CUSTOMER.md](../CUSTOMER.md) (restrição "perna", pedido O6)
- `src/collections/Activity.ts` (kinds/status/access, `deputyPresent`), `src/app/(campaign)/campanha/(app)/atividades/` (superfície C3)
- `src/utilities/municipalityGoalAccount.ts`, `src/utilities/municipalityPotential.ts`, `src/utilities/municipalityTerritorialClass.ts`, `src/lib/bahiaTerritories.ts`
- `PRODUCT.md`/`DESIGN.md` — âncoras da superfície nova
- AGENTS.md — transações, access, naming

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,39 @@
'use server'
import { createTourDraftActivities } from '@/app/(campaign)/campanha/actions/activity'
import { parseTourDraftFormData } from '@/utilities/activityFormData'
import { buildActivityListHref } from '@/utilities/activityUi'
import {
runCampaignRedirectFormAction,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
import {
TOUR_EMPTY_MESSAGE,
TOUR_MAX_STOPS_MESSAGE,
TOUR_OUT_OF_SCOPE_MESSAGE,
TOUR_STAFF_ONLY_MESSAGE,
} from '@/utilities/visitPlannerViews'
const TOUR_SAFE_MESSAGES = [
TOUR_EMPTY_MESSAGE,
TOUR_MAX_STOPS_MESSAGE,
TOUR_OUT_OF_SCOPE_MESSAGE,
TOUR_STAFF_ONLY_MESSAGE,
] as const
/**
* Success lands on the Atividades list filtered to drafts: the giro only exists
* as its stops, so the place where the coordination edits and schedules them is
* the honest destination — not a confirmation screen with nothing to do on it.
*/
export const createTourDraftsFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> =>
runCampaignRedirectFormAction({
execute: async () => createTourDraftActivities(parseTourDraftFormData(formData)),
redirectTo: () => buildActivityListHref({ page: 1, tab: 'rascunhos' }, 1),
safeMessages: TOUR_SAFE_MESSAGES,
genericMessage:
'Não foi possível gerar os rascunhos do giro. Verifique se algum título já existe e tente novamente.',
})

View file

@ -0,0 +1,172 @@
import config from '@payload-config'
import { MapPinnedIcon } from 'lucide-react'
import type { Metadata } from 'next'
import Link from 'next/link'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { CalendarPhaseNote } from '@/components/campaign/tour/CalendarPhaseNote'
import { TourComposerForm, type TourStopOption } from '@/components/campaign/tour/TourComposerForm'
import { TourRegionPicker } from '@/components/campaign/tour/TourRegionPicker'
import { Button } from '@/components/ui/button'
import { formatElectionNumber } from '@/lib/electionFormat'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { formatBahiaDayLabel } from '@/utilities/campaignTime'
import { buildMunicipalityDetailTabHref } from '@/utilities/municipalityDetailTabUi'
import { visitConditionLabels } from '@/utilities/visitEligibility'
import { loadVisitCandidates, loadVisitPlannerRegions } from '@/utilities/visitPlannerData'
import { buildTourComposerHref, parseTourComposerParams } from '@/utilities/visitPlannerUrl'
import {
composeTourSuggestion,
resolveTourStopRole,
tourStopRoleActivityKind,
tourSuggestionSlugs,
type VisitCandidateViewModel,
} from '@/utilities/visitPlannerViews'
import { createTourDraftsFormAction } from './formActions'
export const metadata: Metadata = {
title: 'Planejar giro | Campanha',
robots: { index: false, follow: false },
}
type TourComposerPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
/** "Giro Sisal 27/07" — the date is what tells two giros in the same TI apart. */
const defaultTourName = (region: string, now: Date): string =>
`Giro ${region} ${formatBahiaDayLabel(now)}`
const toStopOption = (
candidate: VisitCandidateViewModel,
role: TourStopOption['role'],
suggested: boolean,
): TourStopOption => {
return {
municipalityID: candidate.id,
slug: candidate.slug,
name: candidate.name,
role,
suggested,
kind: tourStopRoleActivityKind[role],
metCount: candidate.eligibility.metCount,
conditionCount: candidate.eligibility.conditions.length,
unmetConditionLabels: candidate.eligibility.conditions
.filter((condition) => !condition.met)
.map((condition) => visitConditionLabels[condition.id]),
deficitLabel: formatElectionNumber(Math.max(candidate.coverage.deficit, 0)),
contraindicationReason: candidate.eligibility.contraindication?.reason ?? null,
dossierHref: buildMunicipalityDetailTabHref(candidate.slug, 'dossie', {}),
}
}
/**
* E13 — the composer. It answers one question ("which municípios does the
* candidate visit next, and in what order of importance") in three interactions:
* pick the território, review the proposed âncora + satélites + semente, generate
* the drafts.
*
* There is no `tour` entity behind it: the giro is its stops, each a draft
* `activity` with `deputyPresent` — which is what the Atividades list already
* knows how to schedule, assign and record.
*
* Staff-only, like every intelligence surface: a `leader` never sees estimates,
* goals or the field ceiling this composition is built from.
*/
export default async function TourComposerPage({ searchParams }: TourComposerPageProps) {
const rawSearchParams = await searchParams
const { region } = parseTourComposerParams(rawSearchParams)
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
if (!user) return null
if (!isCampaignStaff(user)) redirect('/campanha')
const now = new Date()
const [regions, bundle] = await Promise.all([
loadVisitPlannerRegions(payload, user),
region ? loadVisitCandidates(payload, user, { region, now }) : null,
])
const candidates = bundle?.groups.flatMap((group) => group.candidates) ?? []
const suggestion = composeTourSuggestion(candidates)
// The proposal reads as a giro — âncora, satélites, semente — and the rest of
// the território follows in queue order. A município the suggestion left out is
// still offered as a satellite, which is what adding a stop means.
const stops: TourStopOption[] = [
...tourSuggestionSlugs(suggestion).flatMap((slug) => {
const candidate = candidates.find((entry) => entry.slug === slug)
const role = resolveTourStopRole(slug, suggestion)
return candidate && role ? [toStopOption(candidate, role, true)] : []
}),
...candidates
.filter((candidate) => resolveTourStopRole(candidate.slug, suggestion) === null)
.map((candidate) => toStopOption(candidate, 'satelite', false)),
]
return (
<CampaignPageShell>
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-semibold tracking-tight">Planejar giro</h1>
<p className="max-w-prose text-muted-foreground">
Um giro por território: o ato onde o voto comprometido já está, as paradas com rede para
receber, e uma semente de expansão para o giro não repetir só a base. A proposta é
sugestão — você edita antes de gerar.
</p>
</div>
<Button asChild variant="outline" className="min-h-11">
<Link href="/campanha/atividades">Voltar para Atividades</Link>
</Button>
</header>
{bundle ? <CalendarPhaseNote phase={bundle.phase} /> : null}
{regions.length > 0 ? (
<TourRegionPicker
regions={regions.map((option) => ({
...option,
href: buildTourComposerHref({ region: option.region }),
}))}
selectedRegion={region}
clearHref={buildTourComposerHref({ region: null })}
/>
) : null}
{regions.length === 0 ? (
<CampaignListEmptyState
icon={MapPinnedIcon}
title="Você ainda não acompanha nenhum município"
description="A composição do giro sai da sua carteira. Peça ao Coordenador Geral para vincular seus municípios."
/>
) : region === null ? (
<CampaignListEmptyState
icon={MapPinnedIcon}
title="Escolha um território para começar"
description="A composição do giro compara os municípios entre si dentro do mesmo território — é assim que 'encaixe em giro' faz sentido."
/>
) : stops.length === 0 ? (
<CampaignListEmptyState
icon={MapPinnedIcon}
title={`Nenhum município candidato em ${region}`}
description="Nenhum município deste território está no seu escopo com dados suficientes para compor um giro. Cadastre lideranças ou compromissos de voto e volte aqui."
>
<Button asChild variant="outline" className="min-h-11">
<Link href="/campanha/municipios">Ver municípios</Link>
</Button>
</CampaignListEmptyState>
) : (
<TourComposerForm
region={region}
stops={stops}
defaultTourName={defaultTourName(region, now)}
formAction={createTourDraftsFormAction}
/>
)}
</CampaignPageShell>
)
}

View file

@ -3,7 +3,7 @@ import {
CampaignListResults,
} from '@/components/campaign/shared/CampaignListPending'
import config from '@payload-config'
import { PlusIcon, SearchXIcon } from 'lucide-react'
import { MapPinnedIcon, PlusIcon, SearchXIcon } from 'lucide-react'
import Link from 'next/link'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
@ -25,6 +25,7 @@ import { toActivityListViewModel } from '@/utilities/activityViewModels'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadMunicipalityOptions } from '@/utilities/campaignRelationOptions'
import { TOUR_COMPOSER_PATH } from '@/utilities/visitPlannerUrl'
type ActivityListPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
@ -59,12 +60,22 @@ export default async function ActivityListPage({ searchParams }: ActivityListPag
</p>
</div>
{canCreate ? (
<Button asChild className="min-h-11">
<Link href="/campanha/atividades/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova atividade
</Link>
</Button>
<div className="flex flex-wrap gap-2">
{/* E13: the planner generates several drafts at once, so it sits next
to "Nova atividade" rather than replacing it. */}
<Button asChild variant="outline" className="min-h-11">
<Link href={TOUR_COMPOSER_PATH}>
<MapPinnedIcon data-icon="inline-start" aria-hidden="true" />
Planejar giro
</Link>
</Button>
<Button asChild className="min-h-11">
<Link href="/campanha/atividades/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova atividade
</Link>
</Button>
</div>
) : null}
</header>

View file

@ -8,6 +8,7 @@ import { MunicipalityPledgesPanel } from '@/components/campaign/municipality/Mun
import { MunicipalityStrategyCard } from '@/components/campaign/municipality/MunicipalityStrategyCard'
import { MunicipalityUpdateFeed } from '@/components/campaign/municipality/MunicipalityUpdateFeed'
import { MunicipalityUpdateForm } from '@/components/campaign/municipality/MunicipalityUpdateForm'
import { MunicipalityVisitEligibilityCard } from '@/components/campaign/municipality/MunicipalityVisitEligibilityCard'
import { MunicipalityZoneNeighborhoodsCard } from '@/components/campaign/municipality/MunicipalityZoneNeighborhoodsCard'
import { CampaignListPagination } from '@/components/campaign/shared/CampaignListPagination'
import type { VoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
@ -29,6 +30,7 @@ import {
parseMunicipalityUpdateFeedParams,
} from '@/utilities/municipalityUpdatePageData'
import type { loadAdvisorSummaries } from '@/utilities/municipalityViewModels'
import { loadMunicipalityVisitEligibility } from '@/utilities/visitPlannerData'
import { loadMunicipalityPledges } from '@/utilities/votePledgeData'
import {
aggregateMunicipalityPledgesFromRows,
@ -38,6 +40,7 @@ import {
} from '@/utilities/votePledgeViews'
import type { getPayload } from 'payload'
import { Suspense } from 'react'
import { declareVotesFormAction, estimateVotesFormAction } from './pledgeFormActions'
import { createMunicipalityUpdateFormAction } from './updateFormActions'
@ -84,6 +87,18 @@ export const OverviewTab = async ({
pledgeCoverage={pledgeCoverage}
pledgeAggregate={pledgeAggregate}
/>
{/* Two round trips deep (território scope, then its aggregates), against
one for the goal account — so it streams instead of holding the tab. */}
<Suspense
fallback={
<div
aria-hidden="true"
className="h-64 animate-pulse rounded-xl border bg-muted/40"
/>
}
>
<VisitEligibilityCard payloadUser={{ payload, user }} slug={view.slug} />
</Suspense>
</>
) : null}
<MunicipalityPledgesPanel pledges={pledges} estimateFormAction={estimateVotesFormAction} />
@ -123,6 +138,25 @@ const GoalAccountCard = async ({
)
}
/**
* E13 — the checklist that authorizes a day of the candidate's agenda, right
* after the goal account it reads its headroom from. The loader is scoped to
* the município's own identity territory: "encaixe em giro" is a statement
* about the neighbours, so it cannot be answered for one município alone.
*/
const VisitEligibilityCard = async ({
payloadUser: { payload, user },
slug,
}: {
payloadUser: PayloadUser
slug: string
}) => {
const { candidate, phase } = await loadMunicipalityVisitEligibility(payload, user, slug)
if (!candidate) return null
return <MunicipalityVisitEligibilityCard candidate={candidate} phase={phase} />
}
export const DossierTab = async ({
view,
advisorSummaries,

View file

@ -11,11 +11,19 @@ import {
type ActivityUpdateInput,
} from '@/lib/schemas/activity'
import type { CampaignUser } from '@/payload-types'
import type { ParsedTourDraftFormData } from '@/utilities/activityFormData'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
import { hookFilledCreateData } from '@/utilities/hookFilledData'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
import {
MAX_TOUR_STOPS,
TOUR_EMPTY_MESSAGE,
TOUR_MAX_STOPS_MESSAGE,
TOUR_OUT_OF_SCOPE_MESSAGE,
TOUR_STAFF_ONLY_MESSAGE,
} from '@/utilities/visitPlannerViews'
const MAX_ACTIVITY_UPDATE_BODY_LENGTH = 4000
const MAX_ACTIVITY_RESULT_SUMMARY_LENGTH = 6000
@ -63,6 +71,86 @@ export const createActivityRecord = async (
)
}
/**
* E13 — one giro becomes N draft activities in ONE transaction: a giro that
* half-exists is worse than none, because the coordination would work from a
* route with a hole in it. There is no `tour` entity: the giro IS its stops,
* derivable from `deputyPresent` + território + date window (the plan's own
* decision — persisting it needs a third real giro to justify the schema).
*
* Every stop goes through the same `activityCreateSchema` and the same
* `overrideAccess: false` create as the manual form, so an advisor cannot seed a
* município outside their portfolio by composing a giro over it.
*/
export const createTourDraftActivitiesRecord = async (
payload: Payload,
actor: CampaignUser,
{ tourName, note, stops }: ParsedTourDraftFormData,
) => {
if (stops.length === 0) throw new Error(TOUR_EMPTY_MESSAGE)
if (stops.length > MAX_TOUR_STOPS) throw new Error(TOUR_MAX_STOPS_MESSAGE)
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadCampaignActor(payload, actor, req)
if (!isCampaignStaff(currentActor)) throw new Error(TOUR_STAFF_ONLY_MESSAGE)
// `activity` create access is a plain staff boolean — Payload cannot express
// a per-município constraint on create — so a batch assembled from a scoped
// composition is checked against that same scope here, fail-closed. Without
// it, an advisor could seed drafts across the whole state by posting ids
// the composer never offered them.
const municipalityIDs = [...new Set(stops.map((stop) => stop.municipality))]
const readable = await payload.find({
collection: 'municipality',
where: { id: { in: municipalityIDs } },
depth: 0,
limit: 0,
pagination: false,
select: { name: true },
user: currentActor,
overrideAccess: false,
req,
})
if (readable.docs.length !== municipalityIDs.length) {
throw new Error(TOUR_OUT_OF_SCOPE_MESSAGE)
}
// The names come from the same read that authorized the stops, so a stop's
// title always names the município it was actually created in, and the
// giro reads as one set in the Atividades list.
const nameByID = new Map(readable.docs.map((doc) => [doc.id, doc.name]))
const created = []
for (const stop of stops) {
const data = activityCreateSchema.parse({
...stop,
title: `${tourName} — ${nameByID.get(stop.municipality)}`,
status: 'rascunho',
// The whole point of the planner: these drafts are the candidate's agenda.
deputyPresent: true,
...(note ? { description: note } : {}),
})
created.push(
await payload.create({
collection: 'activity',
data: hookFilledCreateData<'activity'>(data),
depth: 0,
user: currentActor,
overrideAccess: false,
req,
}),
)
}
return created
},
{ beginFailureMessage: 'Não foi possível iniciar a criação dos rascunhos do giro.' },
)
}
const updateActivityRecord = async (
payload: Payload,
actor: CampaignUser,
@ -297,6 +385,11 @@ export const appendActivityUpdate = async (activityId: number, body: string) =>
return appendActivityUpdateRecord(payload, actor, activityId, body)
}
export const createTourDraftActivities = async (input: ParsedTourDraftFormData) => {
const { payload, actor } = await getCampaignActionContext()
return createTourDraftActivitiesRecord(payload, actor, input)
}
export const registerActivityResultAction = async (
activityId: number,
resultSummary: string,

View file

@ -0,0 +1,132 @@
import { ArrowRightIcon, CheckIcon, MinusIcon } from 'lucide-react'
import Link from 'next/link'
import { CampaignInfoHint } from '@/components/campaign/shared/CampaignInfoHint'
import { CalendarPhaseNote } from '@/components/campaign/tour/CalendarPhaseNote'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { CAMPAIGN_CONCEPTS_PATH, campaignConceptHref } from '@/lib/campaignIntelligenceConcepts'
import type { CalendarPhase } from '@/lib/visitPlannerAnchors'
import {
visitConditionLabels,
visitContraindicationLabels,
type VisitCondition,
} from '@/utilities/visitEligibility'
import { buildTourComposerHref } from '@/utilities/visitPlannerUrl'
import type { VisitCandidateViewModel } from '@/utilities/visitPlannerViews'
/**
* One condition of the checklist. The reason is ALWAYS visible text, never a
* `title` attribute or a tooltip — E10's rule, for the same reason: a bare ✓/—
* reads as a verdict, and a verdict nobody can audit is what makes the mesa
* stop trusting the tool.
*/
const ConditionRow = ({ condition }: { condition: VisitCondition }) => (
<li className="flex items-start gap-2.5">
<span
aria-hidden="true"
className={
condition.met
? 'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary'
: 'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground'
}
>
{condition.met ? <CheckIcon className="size-3.5" /> : <MinusIcon className="size-3.5" />}
</span>
<span className="flex flex-col gap-0.5">
<span className="text-sm font-medium">
{visitConditionLabels[condition.id]}
<span className="sr-only">{condition.met ? ': atendida' : ': não atendida'}</span>
</span>
<span className="text-xs text-muted-foreground">{condition.detail}</span>
</span>
</li>
)
/**
* E13 — "vale a pena levar o candidato aqui?" as five conditions, never a
* score (research report §6.4: a composite 0–100 buys false confidence that
* the underlying data does not support).
*
* The count in the header is a count, not a grade: it says how many of the five
* are clear, and every row below says which and why. A contraindication is an
* ADVISORY with the counter-offer next to it — the coordination overrides it
* whenever politics outweighs the numbers ("a geografia serve à política").
*/
export const MunicipalityVisitEligibilityCard = ({
candidate,
phase,
}: {
candidate: VisitCandidateViewModel
phase: CalendarPhase
}) => {
const { eligibility } = candidate
const contraindication = eligibility.contraindication
return (
<section
aria-labelledby="municipality-visit-eligibility-title"
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="municipality-visit-eligibility-title" className="text-base font-medium">
Elegibilidade para visita
</h2>
<CampaignInfoHint label="Sobre a elegibilidade para visita">
<div className="flex flex-col gap-2">
<p>
A visita vale o que a rede local converte dela — por isso a leitura é uma{' '}
<strong>checklist de cinco condições</strong>, e não uma nota. Exigir as cinco é o
critério; o número no topo só diz quantas estão claras.
</p>
<p>
O aviso de contraindicação nunca bloqueia: a coordenação decide contra ele quando a
política pede.
</p>
<Link
href={campaignConceptHref('elegibilidade-para-visita')}
className="font-medium text-primary underline underline-offset-4"
>
Como cada condição é avaliada
</Link>
<Link
href={CAMPAIGN_CONCEPTS_PATH}
className="font-medium text-primary underline underline-offset-4"
>
Todos os conceitos
</Link>
</div>
</CampaignInfoHint>
<span className="ml-auto text-sm text-muted-foreground tabular-nums">
{eligibility.metCount} de {eligibility.conditions.length} condições
</span>
</div>
<CalendarPhaseNote phase={phase} />
<ul className="flex flex-col gap-3">
{eligibility.conditions.map((condition) => (
<ConditionRow key={condition.id} condition={condition} />
))}
</ul>
{contraindication ? (
<Alert variant={contraindication.id === 'perdida' ? 'destructive' : 'pending'}>
<AlertTitle>{visitContraindicationLabels[contraindication.id]}</AlertTitle>
<AlertDescription className="flex flex-col gap-1">
<span>{contraindication.reason}</span>
<span className="font-medium">{contraindication.counterOffer}</span>
</AlertDescription>
</Alert>
) : null}
<Button asChild variant="outline" className="min-h-11 w-fit">
<Link href={buildTourComposerHref({ region: candidate.region })}>
Planejar giro em {candidate.region}
<ArrowRightIcon aria-hidden="true" />
<span className="sr-only">, a partir de {candidate.name}</span>
</Link>
</Button>
</section>
)
}

View file

@ -0,0 +1,19 @@
import { Badge } from '@/components/ui/Badge'
import type { CalendarPhase } from '@/lib/visitPlannerAnchors'
import { calendarPhaseLabels, calendarPhaseVisitProduct } from '@/utilities/visitEligibility'
/**
* E13 — the phase never travels without what it changes. The same date can make
* a visit worth a night of organizing or a night of votes, so both surfaces that
* judge a visit (the município card and the composer) say which phase it is AND
* what the visit is supposed to produce in it.
*/
export const CalendarPhaseNote = ({ phase }: { phase: CalendarPhase }) => (
<div className="flex flex-col gap-1 rounded-lg bg-muted/40 px-3 py-2">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-muted-foreground">Fase do calendário</span>
<Badge variant="outline">{calendarPhaseLabels[phase]}</Badge>
</div>
<p className="text-xs text-muted-foreground">{calendarPhaseVisitProduct[phase]}</p>
</div>
)

View file

@ -0,0 +1,278 @@
'use client'
import { ExternalLinkIcon } from 'lucide-react'
import Link from 'next/link'
import { useActionState, useMemo, useState } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/Checkbox'
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import {
activityKindLabels,
activityOriginLabels,
activityOrigins,
type ActivityKind,
type ActivityOrigin,
} from '@/lib/schemas/activity'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { fieldError } from '@/utilities/campaignFormFields'
import type { TourStopRole } from '@/utilities/visitPlannerViews'
import {
MAX_TOUR_NAME_LENGTH,
MAX_TOUR_STOPS,
tourStopRoleDescriptions,
tourStopRoleLabels,
} from '@/utilities/visitPlannerViews'
/** The composition came from the numbers; the coordination changes it per stop. */
const DEFAULT_STOP_ORIGIN: ActivityOrigin = 'dado'
/**
* One candidate stop, already reduced to what the decision needs. Numbers arrive
* formatted and the dossier href arrives built: the canonical serializers drag
* the município catalog with them, and this is a client component (the B14
* lesson — 21 kB of First Load JS for one link).
*/
export type TourStopOption = {
municipalityID: number
slug: string
name: string
role: TourStopRole
suggested: boolean
/** Activity kind the stop generates, derived from its role. */
kind: ActivityKind
metCount: number
conditionCount: number
unmetConditionLabels: string[]
deficitLabel: string
contraindicationReason: string | null
dossierHref: string
}
const StopRow = ({
stop,
checked,
origin,
onToggle,
onOriginChange,
}: {
stop: TourStopOption
checked: boolean
origin: ActivityOrigin
onToggle: (checked: boolean) => void
onOriginChange: (origin: ActivityOrigin) => void
}) => {
const checkboxId = `tour-stop-${stop.slug}`
const originId = `tour-origin-${stop.slug}`
return (
<li className="flex flex-col gap-3 p-4 data-[checked=false]:bg-muted/20" data-checked={checked}>
<div className="flex items-start gap-3">
<Checkbox
id={checkboxId}
checked={checked}
onCheckedChange={(next) => onToggle(next === true)}
className="mt-1.5"
/>
<div className="flex min-w-0 flex-col gap-1.5">
<div className="flex flex-wrap items-center gap-2">
<label htmlFor={checkboxId} className="text-sm font-medium">
{stop.name}
</label>
<Badge variant={stop.role === 'ancora' ? 'default' : 'outline'}>
{tourStopRoleLabels[stop.role]}
</Badge>
<span className="text-xs text-muted-foreground tabular-nums">
{stop.metCount} de {stop.conditionCount} condições
</span>
</div>
<p className="text-xs text-muted-foreground">{tourStopRoleDescriptions[stop.role]}</p>
<p className="text-xs text-muted-foreground">
Faltam <span className="tabular-nums">{stop.deficitLabel}</span> votos para a meta.
{stop.unmetConditionLabels.length > 0
? ` Ainda não atende: ${stop.unmetConditionLabels.join(', ')}.`
: ' Atende as cinco condições.'}
</p>
{stop.contraindicationReason ? (
<p className="text-xs text-estimate-pending-foreground">
{stop.contraindicationReason}
</p>
) : null}
<div className="flex flex-wrap items-center gap-3">
<Link
href={stop.dossierHref}
className="text-xs font-medium text-primary underline underline-offset-4"
>
Ver dossiê
<ExternalLinkIcon aria-hidden="true" className="ml-1 inline size-3" />
</Link>
<span className="text-xs text-muted-foreground">
Gera: {activityKindLabels[stop.kind]}
</span>
</div>
</div>
</div>
{checked ? (
<Field className="sm:max-w-xs sm:pl-7">
<FieldLabel htmlFor={originId}>Origem desta parada</FieldLabel>
<NativeSelect
id={originId}
value={origin}
onChange={(event) => onOriginChange(event.target.value as ActivityOrigin)}
className="min-h-11 w-full"
>
{activityOrigins.map((value) => (
<NativeSelectOption key={value} value={value}>
{activityOriginLabels[value]}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
) : null}
</li>
)
}
/**
* Interactions 2 and 3 of the composer: review the proposed composition
* (âncora + satélites + semente, all pre-selected) and generate the drafts.
*
* It is a SUGGESTION and it says so — the numbers rank, the coordination
* decides, and nothing is written until this form is submitted (P25: no
* automatic allocation without a human in the loop).
*/
export const TourComposerForm = ({
region,
stops,
defaultTourName,
formAction,
}: {
region: string
stops: TourStopOption[]
defaultTourName: string
formAction: (
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
}) => {
const [state, submitAction, isPending] = useActionState(formAction, {})
const [selectedSlugs, setSelectedSlugs] = useState<string[]>(() =>
stops.filter((stop) => stop.suggested).map((stop) => stop.slug),
)
const [origins, setOrigins] = useState<Record<string, ActivityOrigin>>({})
const selectedSet = useMemo(() => new Set(selectedSlugs), [selectedSlugs])
const stopsJson = useMemo(
() =>
JSON.stringify(
stops
.filter((stop) => selectedSet.has(stop.slug))
.map((stop) => ({
municipality: stop.municipalityID,
kind: stop.kind,
origin: origins[stop.slug] ?? DEFAULT_STOP_ORIGIN,
})),
),
[stops, selectedSet, origins],
)
const overCap = selectedSlugs.length > MAX_TOUR_STOPS
const canSubmit = selectedSlugs.length > 0 && !overCap
const toggleStop = (slug: string, checked: boolean) =>
setSelectedSlugs((current) =>
checked ? [...current, slug] : current.filter((entry) => entry !== slug),
)
return (
<form action={submitAction} className="flex flex-col gap-5">
<input type="hidden" name="stopsJson" value={stopsJson} />
<div className="rounded-xl border">
<div className="flex flex-wrap items-baseline justify-between gap-2 border-b p-4">
<h2 className="text-base font-medium">Paradas propostas em {region}</h2>
<span className="text-sm text-muted-foreground tabular-nums">
{selectedSlugs.length} selecionada{selectedSlugs.length === 1 ? '' : 's'}
</span>
</div>
<ul className="flex list-none flex-col divide-y pl-0 [&>li]:mt-0">
{stops.map((stop) => (
<StopRow
key={stop.slug}
stop={stop}
checked={selectedSet.has(stop.slug)}
origin={origins[stop.slug] ?? DEFAULT_STOP_ORIGIN}
onToggle={(checked) => toggleStop(stop.slug, checked)}
onOriginChange={(origin) =>
setOrigins((current) => ({ ...current, [stop.slug]: origin }))
}
/>
))}
</ul>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="tour-name">Nome do giro</FieldLabel>
<Input
id="tour-name"
name="tourName"
defaultValue={defaultTourName}
maxLength={MAX_TOUR_NAME_LENGTH}
required
className="min-h-11"
/>
<FieldDescription>
Cada rascunho recebe este nome mais o município — assim o giro se lê como um conjunto na
lista de Atividades.
</FieldDescription>
{fieldError(state.fieldErrors, 'tourName') ? (
<FieldError>{fieldError(state.fieldErrors, 'tourName')}</FieldError>
) : null}
</Field>
<Field>
<FieldLabel htmlFor="tour-note">Nota de janela política</FieldLabel>
<Textarea id="tour-note" name="note" rows={4} maxLength={4000} />
<FieldDescription>
Vai na descrição de cada rascunho: com quem falar, o que já foi combinado, o que não
pode ser dito.
</FieldDescription>
</Field>
</div>
{fieldError(state.fieldErrors, 'stopsJson') ? (
<Alert variant="destructive">
<AlertDescription>{fieldError(state.fieldErrors, 'stopsJson')}</AlertDescription>
</Alert>
) : null}
{state.message ? (
<Alert variant="destructive">
<AlertTitle>Nada foi gravado</AlertTitle>
<AlertDescription>{state.message}</AlertDescription>
</Alert>
) : null}
<div className="flex flex-col gap-2">
<Button type="submit" disabled={isPending || !canSubmit} className="min-h-11 w-fit">
{isPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
Gerar {selectedSlugs.length} rascunho{selectedSlugs.length === 1 ? '' : 's'}
</Button>
<p className="text-xs text-muted-foreground" aria-live="polite">
{selectedSlugs.length === 0
? 'Selecione ao menos uma parada.'
: overCap
? `Um giro é um dia ou dois: escolha no máximo ${MAX_TOUR_STOPS} paradas.`
: 'Os rascunhos entram sem data, com presença do candidato marcada. A data e o horário você define em cada atividade.'}
</p>
</div>
</form>
)
}

View file

@ -0,0 +1,72 @@
'use client'
import { useRouter } from 'next/navigation'
import { useTransition } from 'react'
import { Field, FieldDescription, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Spinner } from '@/components/ui/Spinner'
/**
* Each option arrives with its href already serialized by the RSC: the canonical
* serializer imports the identity-territory tables, and pulling those into the
* client bundle for one query param is exactly what B14 measured and rejected.
*/
type TourRegionOption = {
region: string
municipalityCount: number
href: string
}
/**
* Interaction 1 of 3: which identity territory the giro travels in. The list is
* the actor's own scope, so an advisor is never offered a território where they
* have nothing to open — and the count says why each option is there.
*/
export const TourRegionPicker = ({
regions,
selectedRegion,
clearHref,
}: {
regions: TourRegionOption[]
selectedRegion: string | null
clearHref: string
}) => {
const router = useRouter()
const [isPending, startTransition] = useTransition()
return (
<Field className="max-w-md">
<FieldLabel htmlFor="tour-region">Território de Identidade</FieldLabel>
<NativeSelect
id="tour-region"
value={selectedRegion ?? ''}
disabled={isPending}
onChange={(event) => {
const { value } = event.target
const href = regions.find((option) => option.region === value)?.href ?? clearHref
// The result renders below the picker, so the viewport stays put.
startTransition(() => router.push(href, { scroll: false }))
}}
className="min-h-11 w-full"
>
<NativeSelectOption value="">Selecione um território</NativeSelectOption>
{regions.map((option) => (
<NativeSelectOption key={option.region} value={option.region}>
{option.region} ({option.municipalityCount})
</NativeSelectOption>
))}
</NativeSelect>
<FieldDescription>
{isPending ? (
<span className="flex items-center gap-2">
<Spinner aria-hidden="true" />
Montando a proposta de giro…
</span>
) : (
'Um giro acontece dentro de um território. O número é quantos municípios dele você acompanha.'
)}
</FieldDescription>
</Field>
)
}

View file

@ -31,6 +31,8 @@ export type CampaignConceptId =
| 'cobertura-da-meta'
| 'captura-regional'
| 'benchmark-intra-ti'
| 'elegibilidade-para-visita'
| 'fase-do-calendario'
export type CampaignConceptCategoryId = 'base' | 'diagnostico' | 'meta'
@ -182,6 +184,35 @@ export const campaignIntelligenceConcepts: ReadonlyArray<CampaignIntelligenceCon
whereItAppears:
'Card "Conta da cadeira" e capa do dossiê, no detalhe do município, coluna "Classe" da lista de municípios (com filtro e ordenação), e coluna "Classe" em `/campanha/territorios`.',
},
{
id: 'elegibilidade-para-visita',
categoryID: 'diagnostico',
title: 'Elegibilidade para visita',
oneLiner:
'Cinco condições que dizem se vale gastar um dia da agenda do candidato no município.',
formula:
'Volume: válidos projetados acima da mediana dos 435 municípios. Espaço para crescer: déficit de meta positivo, ou voto do campo sem captura acima da mediana. Rede de recepção: ao menos um assessor responsável E ao menos uma liderança ou um compromisso. Janela política: dobradinha estadual vinculada, ou tendência política registrada como favorável ou neutra. Encaixe em giro: ao menos um outro município do mesmo Território de Identidade com liderança ou compromisso — parada possível na mesma viagem.',
example:
'Um município grande, com déficit de meta, assessor e três lideranças, mas sem dobradinha e com tendência não registrada, fecha quatro de cinco: a condição que falta é a janela, e ela é acionável (registrar a conjuntura ou casar uma dobradinha).',
whyItMatters:
'A visita rende o que a rede local converte dela — sem quem receba, o dia do candidato vira foto. Por isso a leitura é uma checklist, e nunca uma nota de 0 a 100: um número composto esconde qual condição falta e cria confiança que o dado não sustenta. Exigir as cinco é o critério; a contraindicação ("não recomendado agora") é aviso com contra-oferta, jamais bloqueio — a coordenação decide contra ela quando a política pede. Os cortes são ilustrativos até o backtest calibrá-los.',
whereItAppears:
'Card "Elegibilidade para visita" no detalhe do município e a lista de candidatas no compositor de giros (`/campanha/atividades/giros`).',
},
{
id: 'fase-do-calendario',
categoryID: 'diagnostico',
title: 'Fase do calendário',
oneLiner: 'Em que etapa da campanha estamos — e, por consequência, para que serve uma visita.',
formula:
'Construção até 15/08 (véspera da abertura legal da propaganda), Consolidação de 16/08 a 27/09, Ativação de 28/09 até a votação em 04/10. A virada acontece à meia-noite no fuso da Bahia.',
example:
'A mesma reunião com lideranças é o produto certo em agosto e o produto errado na última semana, quando o dia rende mais em ato e mobilização de quem já está com a campanha.',
whyItMatters:
'A fase muda o produto da visita, não apenas a urgência: em julho ela existe para montar rede, em setembro para fechar compromissos e casar dobradinha, na última semana para mobilizar quem já decidiu. Sem esse rótulo, a agenda repete em outubro o formato que fazia sentido em julho. As duas datas de virada são âncoras reais (abertura da propaganda e véspera da eleição); o corte exato entre consolidação e ativação é decisão da coordenação.',
whereItAppears:
'Card "Elegibilidade para visita" no detalhe do município e o cabeçalho do compositor de giros.',
},
{
id: 'quantis-do-mapa',
categoryID: 'diagnostico',

View file

@ -22,6 +22,7 @@ export const activityKinds = [
'digital',
'outro',
] as const
export type ActivityKind = (typeof activityKinds)[number]
export const activityStatuses = [
'rascunho',
@ -40,7 +41,7 @@ export const activityOriginLabels: Record<ActivityOrigin, string> = {
obrigacao_politica: 'Obrigação política',
}
export const activityKindLabels: Record<(typeof activityKinds)[number], string> = {
export const activityKindLabels: Record<ActivityKind, string> = {
caminhada: 'Caminhada',
comicio: 'Comício',
carreata: 'Carreata',
@ -169,3 +170,17 @@ export const activityUpdateSchema = activityFieldsSchema
export type ActivityCreateInput = z.input<typeof activityCreateSchema>
export type ActivityUpdateInput = z.input<typeof activityUpdateSchema>
/**
* E13 — one stop of a giro, as the composer submits it: which município, what
* happens there, where the decision came from. The title is deliberately NOT
* part of it: it is composed on the server from the município's own name, so a
* client cannot label a draft after a município it did not pick.
*/
const tourStopDraftSchema = activityFieldsSchema
.pick({ municipality: true, kind: true })
.extend({ origin: z.enum(activityOrigins) })
export const tourStopDraftsSchema = z.array(tourStopDraftSchema)
export type TourStopDraft = z.infer<typeof tourStopDraftSchema>

View file

@ -0,0 +1,29 @@
/**
* E13 — the dates that split the campaign calendar into phases, in one
* client-safe module, on the precedent of `territorialClassAnchors.ts`: the
* planner's copy and the eligibility evaluator both read from here, so moving
* a phase is a one-line diff in one file.
*
* Cuts are ILLUSTRATIVE of the 2026 calendar the research report describes
* (construção jul–ago, consolidação set, ativação na última semana), anchored
* on two real dates — the legal start of the campaign and the vote — but the
* exact turn of "consolidação → ativação" is a judgement call the coordination
* makes, not a statute. Recalibration is E15's job, same as E10's anchors.
*/
export const CALENDAR_PHASES = ['construcao', 'consolidacao', 'ativacao'] as const
export type CalendarPhase = (typeof CALENDAR_PHASES)[number]
/**
* Bahia civil dates as `aaaa-mm-dd`, compared lexicographically against
* `formatBahiaCivilDate` — never as `Date` instances, so the phase turns at
* midnight in Bahia and not in UTC.
*/
export const CALENDAR_PHASE_ANCHORS = {
/** Legal start of the campaign (Lei 9.504/97, art. 36): propaganda opens. */
consolidationStart: '2026-08-16',
/** Last week before the vote — the phase where only mobilization pays. */
activationStart: '2026-09-28',
/** Election day (1º turno, 2026). */
electionDay: '2026-10-04',
} as const

View file

@ -7,6 +7,7 @@ import {
nullableRelationshipFormValue,
optionalFormText,
repeatedRelationshipFormValues,
requiredFormText,
requiredRelationshipFormValue,
} from '@/lib/formData'
import {
@ -15,11 +16,14 @@ import {
activityOrigins,
activityStatuses,
MAX_ACTIVITY_DEMAND_DRAFTS,
tourStopDraftsSchema,
type ActivityCreateInput,
type ActivityDemandDraft,
type ActivityUpdateInput,
type TourStopDraft,
} from '@/lib/schemas/activity'
import { parseBahiaDateTimeInput } from '@/utilities/campaignTime'
import { MAX_TOUR_NAME_LENGTH, TOUR_EMPTY_MESSAGE } from '@/utilities/visitPlannerViews'
type ParsedActivityTask = {
title: string
@ -138,6 +142,40 @@ export const parseActivityCreateFormData = (formData: FormData): ParsedActivityC
}
}
/**
* E13 — the composer submits its stops as one bounded JSON field, the precedent
* `tasksJson`/`demandsJson` already set: the alternative is per-stop field names
* whose correlation has to be reconstructed from `FormData` ordering.
*
* The stop shape itself is a zod schema picked from the activity fields, so this
* boundary only has to decide the ONE thing zod cannot: which message the mesa
* sees. Titles are not parsed here — the action composes them from the município
* names it reads for the scope check.
*/
export type ParsedTourDraftFormData = {
tourName: string
note: string | undefined
stops: TourStopDraft[]
}
export const parseTourDraftFormData = (formData: FormData): ParsedTourDraftFormData => {
const tourName = requiredFormText(formData, 'tourName')
if (tourName.length > MAX_TOUR_NAME_LENGTH) {
throw new FormDataBoundaryError('tourName', 'Nome do giro muito longo.')
}
const parsed = tourStopDraftsSchema.safeParse(boundedJsonFormValue(formData, 'stopsJson', 20_000))
if (!parsed.success) {
throw new FormDataBoundaryError(
'stopsJson',
'Paradas do giro inválidas. Atualize a página e monte o giro novamente.',
)
}
if (parsed.data.length === 0) throw new FormDataBoundaryError('stopsJson', TOUR_EMPTY_MESSAGE)
return { tourName, note: optionalFormText(formData, 'note'), stops: parsed.data }
}
export type ParsedActivityUpdateFormData = ActivityUpdateInput & {
demands: ActivityDemandDraft[]
}

View file

@ -1,6 +1,6 @@
import type { Where } from 'payload'
import { activityKinds, activityStatuses } from '@/lib/schemas/activity'
import { activityKinds, activityStatuses, type ActivityKind } from '@/lib/schemas/activity'
import {
buildListHref,
firstValue,
@ -14,7 +14,6 @@ export const activityPageSize = 25
export const activityTabs = ['proximos', 'todos', 'realizados', 'rascunhos'] as const
export type ActivityTab = (typeof activityTabs)[number]
type ActivityKind = (typeof activityKinds)[number]
export type ActivityStatus = (typeof activityStatuses)[number]
export const activityTabLabels: Record<ActivityTab, string> = {

View file

@ -60,6 +60,19 @@ const zonedCivilToInstant = (civil: CivilDateTime): Date => {
throw new Error('Não foi possível calcular o intervalo semanal no fuso da Bahia.')
}
const pad = (value: number): string => String(value).padStart(2, '0')
/**
* `aaaa-mm-dd` of an instant in Bahia civil time. Fixed width, so the result
* is lexicographically comparable against a date anchor written the same way —
* which is how the calendar phase (E13) decides its cut without importing a
* date library, and why the day turns at midnight in Bahia rather than in UTC.
*/
export const formatBahiaCivilDate = (date: Date): string => {
const { year, month, day } = getZonedParts(date)
return `${year}-${pad(month)}-${pad(day)}`
}
const bahiaDateTimeInputPattern = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/
/**
@ -85,7 +98,6 @@ export const parseBahiaDateTimeInput = (value: string): string | null => {
/** Formats a UTC ISO instant as a `datetime-local` input value in Bahia civil time. */
export const formatIsoAsBahiaDateTimeInput = (iso: string): string => {
const { year, month, day, hour, minute } = getZonedParts(new Date(iso))
const pad = (value: number): string => String(value).padStart(2, '0')
return `${year}-${pad(month)}-${pad(day)}T${pad(hour)}:${pad(minute)}`
}
@ -103,6 +115,15 @@ const bahiaDateTimeDisplayFormatter = new Intl.DateTimeFormat('pt-BR', {
export const formatBahiaDateTimeLabel = (iso: string): string =>
bahiaDateTimeDisplayFormatter.format(new Date(iso)).replace(', ', ' às ')
const bahiaDateDisplayFormatter = new Intl.DateTimeFormat('pt-BR', {
timeZone: BAHIA_TIME_ZONE,
day: '2-digit',
month: '2-digit',
})
/** Formats an instant as `dd/mm` in Bahia civil time — the day the staff names things by. */
export const formatBahiaDayLabel = (date: Date): string => bahiaDateDisplayFormatter.format(date)
/**
* Latest of two ISO timestamps, ignoring nulls. String comparison is only
* sound because every writer here produces fixed-width UTC — Payload's own

View file

@ -9,6 +9,7 @@ import {
ELECTION_YEAR_2022,
HISTORICAL_SERIES_YEARS,
} from '@/lib/electionResults'
import { medianOf } from '@/lib/median'
import { municipalityCatalog } from '@/lib/municipalityCatalog'
import type { VoteEstimateScenario } from '@/lib/voteEstimate'
import type { CampaignGoal } from '@/payload-types'
@ -167,6 +168,56 @@ const pessimisticHaircut = (margin: number | null | undefined): number =>
export const ownVotes2022 = (baseline: MunicipalityFederalBaseline): number =>
votesInYear(baseline, ELECTION_YEAR_2022)
/**
* "Campo não capturado": projected field ceiling minus his own vote, floored at
* 0. Lives here rather than next to its first consumer because two features
* read it and a second definition would drift — E10 measures a município's
* remaining field against the catalog median to tell expansão from marginal,
* and E13's headroom condition asks the same question for one visit.
*/
export const uncapturedFieldVotes = (baseline: MunicipalityFederalBaseline): number =>
Math.max(0, projectedFieldCeiling(baseline) - ownVotes2022(baseline))
type CatalogMedians = { uncapturedFieldVotes: number; projectedValidVotes: number }
let catalogMedians: CatalogMedians | null = null
/**
* Catalog-wide medians, computed once per process (the artifact is immutable).
* Both cuts are RELATIVE on purpose: a hardcoded "20.000 votos é grande" ages
* with every election, a median does not.
*
* Slugs with no measurable denominator are excluded — a município whose
* majoritarian cell is missing would report a zero ceiling and drag the median
* down until every weak município cleared it.
*/
const getCatalogMedians = (): CatalogMedians => {
if (catalogMedians) return catalogMedians
const uncaptured: number[] = []
const projected: number[] = []
for (const entry of municipalityCatalog) {
const baseline = getMunicipalityFederalBaseline(entry.slug)
if (projectedFieldCeiling(baseline) > 0) uncaptured.push(uncapturedFieldVotes(baseline))
const votes = projectedValidVotes(baseline)
if (votes > 0) projected.push(votes)
}
catalogMedians = {
uncapturedFieldVotes: medianOf(uncaptured) ?? 0,
projectedValidVotes: medianOf(projected) ?? 0,
}
return catalogMedians
}
/** Median uncaptured field votes across the catalog — E10's "big field" cut. */
export const catalogMedianUncapturedFieldVotes = (): number =>
getCatalogMedians().uncapturedFieldVotes
/** Median projected 2026 valid votes across the catalog — E13's "volume" cut. */
export const catalogMedianProjectedValidVotes = (): number =>
getCatalogMedians().projectedValidVotes
/** Suggested goal for one município across the three scenarios. */
export type SuggestedGoalByScenario = Record<VoteEstimateScenario, number>

View file

@ -1,19 +1,17 @@
import {
federalBaselineMunicipalitySlugs,
getMunicipalityFederalBaseline,
getStatewideFederalTotals,
type MunicipalityFederalBaseline,
} from '@/lib/bahiaElectionAggregates'
import { ELECTION_YEAR_2022 } from '@/lib/electionResults'
import { medianOf } from '@/lib/median'
import { computeVoteRankByYear } from '@/lib/municipalityVoteRank'
import { TERRITORIAL_CLASS_ANCHORS } from '@/lib/territorialClassAnchors'
import { territorialClassSortWeight } from '@/lib/territorialClassSortWeight'
import {
captureRate,
catalogMedianUncapturedFieldVotes,
fieldCeiling,
ownVotes2022,
projectedFieldCeiling,
uncapturedFieldVotes,
} from '@/utilities/municipalityPotential'
/**
@ -136,12 +134,7 @@ export const classifyMunicipalityTerritory = (
return { ...shared, class: 'manutencao', factors: [dominance, ownShareFactor, ...captureFactor] }
}
/** Uncaptured field votes: projected 2022 field ceiling minus his own vote. */
const fieldHeadroomOf = (baseline: MunicipalityFederalBaseline): number =>
Math.max(0, projectedFieldCeiling(baseline) - ownVotes2022(baseline))
type CatalogContext = {
medianFieldHeadroom: number
coreBlockSlugs: ReadonlySet<string>
}
@ -149,23 +142,15 @@ let catalogContext: CatalogContext | null = null
/**
* Catalog-wide reference values, computed once per process (the artifact is
* immutable): the median field headroom — a relative cut, so no magic vote
* count ages — and the set of municípios that, sorted by his own vote,
* accumulate `coreCumulativeShare` of his statewide total.
* immutable): the set of municípios that, sorted by his own vote, accumulate
* `coreCumulativeShare` of his statewide total. The "big field" cut it used to
* compute here now lives with the rest of the E8 potential math
* (`catalogMedianUncapturedFieldVotes`), because E13's visit eligibility reads
* the same median.
*/
const getCatalogContext = (): CatalogContext => {
if (catalogContext) return catalogContext
// Slugs whose majoritarian cell is missing would drag the median to zero and
// make every weak município read as "expansão".
const medianFieldHeadroom =
medianOf(
federalBaselineMunicipalitySlugs()
.map(getMunicipalityFederalBaseline)
.filter((baseline) => projectedFieldCeiling(baseline) > 0)
.map(fieldHeadroomOf),
) ?? 0
// A11 already ranks every slug by own vote, descending, and memoizes it —
// walking that map is the same order this used to re-sort for itself.
const coreVoteTarget =
@ -179,7 +164,7 @@ const getCatalogContext = (): CatalogContext => {
accumulated += entry.votes
}
catalogContext = { medianFieldHeadroom, coreBlockSlugs }
catalogContext = { coreBlockSlugs }
return catalogContext
}
@ -194,15 +179,15 @@ export const computeMunicipalityTerritorialClass = (
const baseline = getMunicipalityFederalBaseline(slug)
const totals = getStatewideFederalTotals(TERRITORIAL_CLASS_YEAR)
const { medianFieldHeadroom, coreBlockSlugs } = getCatalogContext()
const { coreBlockSlugs } = getCatalogContext()
const classification = classifyMunicipalityTerritory({
ownVotes: ownVotes2022(baseline),
validVotes: baseline.validVotesByYear[String(TERRITORIAL_CLASS_YEAR)] ?? 0,
stateOwnVotes: totals.ownVotes,
stateValidVotes: totals.validVotes,
fieldHeadroom: fieldHeadroomOf(baseline),
fieldHeadroomCut: medianFieldHeadroom,
fieldHeadroom: uncapturedFieldVotes(baseline),
fieldHeadroomCut: catalogMedianUncapturedFieldVotes(),
captureRate: captureRate(baseline),
inCoreBlock: coreBlockSlugs.has(slug),
})
@ -232,7 +217,7 @@ export const computeAggregateTerritorialClass = (
if (slugs.length === 0) return UNCLASSIFIED
const totals = getStatewideFederalTotals(TERRITORIAL_CLASS_YEAR)
const { medianFieldHeadroom, coreBlockSlugs } = getCatalogContext()
const { coreBlockSlugs } = getCatalogContext()
let ownVotes = 0
let validVotes = 0
@ -244,7 +229,7 @@ export const computeAggregateTerritorialClass = (
const baseline = getMunicipalityFederalBaseline(slug)
ownVotes += ownVotes2022(baseline)
validVotes += baseline.validVotesByYear[String(TERRITORIAL_CLASS_YEAR)] ?? 0
fieldHeadroom += fieldHeadroomOf(baseline)
fieldHeadroom += uncapturedFieldVotes(baseline)
fieldCeiling2022 += fieldCeiling(baseline)
inCoreBlock ||= coreBlockSlugs.has(slug)
}
@ -255,7 +240,7 @@ export const computeAggregateTerritorialClass = (
stateOwnVotes: totals.ownVotes,
stateValidVotes: totals.validVotes,
fieldHeadroom,
fieldHeadroomCut: medianFieldHeadroom * slugs.length,
fieldHeadroomCut: catalogMedianUncapturedFieldVotes() * slugs.length,
captureRate: fieldCeiling2022 > 0 ? ownVotes / fieldCeiling2022 : null,
inCoreBlock,
})

View file

@ -0,0 +1,286 @@
import { formatElectionNumber } from '@/lib/electionFormat'
import type { PoliticalTrendStatusValue } from '@/lib/schemas/municipality'
import { CALENDAR_PHASE_ANCHORS, type CalendarPhase } from '@/lib/visitPlannerAnchors'
import { formatBahiaCivilDate } from '@/utilities/campaignTime'
import type { MunicipalityTerritorialClass } from '@/utilities/municipalityTerritorialClass'
/**
* E13 "planejador de presença" — is this município worth a day of the
* candidate's agenda? The answer is a **checklist of five conditions**, never a
* score: the research report (§6.7) is explicit that a visit is worth what the
* local network converts from it, and §6.4 documents that a composite 0–100
* buys false confidence. The discipline of requiring all five IS the value, so
* the caller renders ✓/— with the reason next to each one and no total.
*
* Pure over an explicit input (same shape discipline as `TerritorialClassInput`)
* so the município detail card and the tour composer feed one evaluator from
* different reads instead of drifting into two definitions of "elegível".
*/
export const VISIT_CONDITIONS = ['volume', 'headroom', 'rede', 'janela', 'encaixe'] as const
export type VisitConditionId = (typeof VISIT_CONDITIONS)[number]
export type VisitCondition = {
id: VisitConditionId
met: boolean
/** Always rendered as visible text — never a `title` attribute (E10 precedent). */
detail: string
}
/**
* 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.
*/
export const VISIT_CONTRAINDICATIONS = ['perdida', 'sem_rede', 'no_teto'] as const
export type VisitContraindicationId = (typeof VISIT_CONTRAINDICATIONS)[number]
type VisitContraindication = {
id: VisitContraindicationId
reason: string
/** The intermediate offer to make instead of a visit ("mande o coordenador"). */
counterOffer: string
}
export type VisitEligibility = {
conditions: VisitCondition[]
metCount: number
contraindication: VisitContraindication | null
}
export type VisitEligibilityInput = {
/** E8 projected 2026 valid votes — the size of the room. */
projectedValidVotes: number
/** Catalog median of the above: "grande" is relative, so no vote count ages. */
projectedValidVotesCut: number
/** E8 goal coverage deficit (`central`): positive means the goal is uncovered. */
coverageDeficit: number
/** Projected field ceiling minus his own vote, floored at 0 (E8/E10). */
uncapturedFieldVotes: number
/** Catalog median of the above — same relative-cut discipline. */
uncapturedFieldVotesCut: number
advisorCount: number
leadershipCount: number
pledgeCount: number
/** Dobradinhas linked to the município (`stateDeputies`, seeded by E4R). */
linkedStateDeputyCount: number
politicalTrend: PoliticalTrendStatusValue | null
territorialClass: MunicipalityTerritorialClass
/**
* Other municípios of the SAME identity territory, inside the reader's
* access scope, that already have somewhere to stop (a liderança or a
* pledge). "Encaixe em giro" means exactly this in v1 — there is no
* adjacency graph at runtime, and building one is the route-optimizer rabbit
* hole the plan forbids.
*/
territoryStopPeerCount: number
}
const votes = (value: number): string => formatElectionNumber(Math.round(value))
const volumeCondition = (input: VisitEligibilityInput): VisitCondition => {
const met = input.projectedValidVotes >= input.projectedValidVotesCut
return {
id: 'volume',
met,
detail: `${votes(input.projectedValidVotes)} votos válidos projetados, ${met ? 'acima' : 'abaixo'} da mediana do estado (${votes(input.projectedValidVotesCut)})`,
}
}
const headroomCondition = (input: VisitEligibilityInput): VisitCondition => {
const goalUncovered = input.coverageDeficit > 0
const fieldLeft = input.uncapturedFieldVotes >= input.uncapturedFieldVotesCut
if (goalUncovered) {
return {
id: 'headroom',
met: true,
detail: `Faltam ${votes(input.coverageDeficit)} votos para a meta`,
}
}
if (fieldLeft) {
return {
id: 'headroom',
met: true,
detail: `Meta coberta, mas ainda há ${votes(input.uncapturedFieldVotes)} votos de campo não capturados`,
}
}
return {
id: 'headroom',
met: false,
detail: `Meta coberta e pouco campo a capturar (${votes(input.uncapturedFieldVotes)} votos, abaixo da mediana)`,
}
}
const redeCondition = (input: VisitEligibilityInput): VisitCondition => {
const hasAdvisor = input.advisorCount >= 1
const hasField = input.leadershipCount >= 1 || input.pledgeCount >= 1
const fieldLabel =
input.leadershipCount >= 1
? `${input.leadershipCount} liderança${input.leadershipCount > 1 ? 's' : ''}`
: `${input.pledgeCount} compromisso${input.pledgeCount > 1 ? 's' : ''}`
if (hasAdvisor && hasField) {
return { id: 'rede', met: true, detail: `Assessor responsável e ${fieldLabel} para receber` }
}
if (!hasAdvisor && !hasField) {
return {
id: 'rede',
met: false,
detail: 'Sem assessor responsável e sem rede local registrada',
}
}
return {
id: 'rede',
met: false,
detail: hasAdvisor
? 'Assessor responsável, mas nenhuma liderança ou compromisso para receber'
: `${fieldLabel} no município, mas sem assessor responsável`,
}
}
const janelaCondition = (input: VisitEligibilityInput): VisitCondition => {
if (input.linkedStateDeputyCount >= 1) {
const count = input.linkedStateDeputyCount
return {
id: 'janela',
met: true,
detail: `${count} dobradinha${count > 1 ? 's' : ''} vinculada${count > 1 ? 's' : ''} para casar a agenda`,
}
}
if (input.politicalTrend === 'favoravel' || input.politicalTrend === 'neutra') {
return {
id: 'janela',
met: true,
detail:
input.politicalTrend === 'favoravel'
? 'Tendência política favorável'
: 'Tendência política neutra, sem impedimento registrado',
}
}
return {
id: 'janela',
met: false,
detail:
input.politicalTrend === 'desfavoravel'
? 'Tendência política desfavorável e nenhuma dobradinha vinculada'
: 'Tendência política não registrada e nenhuma dobradinha vinculada',
}
}
const encaixeCondition = (input: VisitEligibilityInput): VisitCondition => {
const count = input.territoryStopPeerCount
const met = count >= 1
return {
id: 'encaixe',
met,
detail: met
? `${count} outro${count > 1 ? 's' : ''} município${count > 1 ? 's' : ''} do mesmo território com parada possível`
: 'Nenhum outro município do mesmo território tem liderança ou compromisso — a viagem renderia uma parada só',
}
}
const resolveContraindication = (
input: VisitEligibilityInput,
conditions: ReadonlyArray<VisitCondition>,
): VisitContraindication | null => {
const hasNetwork = input.leadershipCount >= 1 || input.pledgeCount >= 1
const weakClass = input.territorialClass === 'sem_base' || input.territorialClass === 'marginal'
// Reading order of the mesa: the hardest "não vá" first (nothing to win AND
// nobody to convert), then the fixable one, then the one that is really a
// redirection of effort. Levels N0–N4 replace the class test when E14 ships.
if (weakClass && !hasNetwork) {
return {
id: 'perdida',
reason:
input.territorialClass === 'sem_base'
? 'Sem base eleitoral medida em 2022 e sem rede local: a visita não tem quem converta.'
: 'Classe marginal (pouca base e pouco campo a capturar) e sem rede local: a visita não tem quem converta.',
counterOffer: 'Mande o coordenador de território antes; visita só depois de haver rede.',
}
}
if (!hasNetwork) {
return {
id: 'sem_rede',
reason: 'Nenhuma liderança ou compromisso registrado: não há quem receba e mobilize.',
counterOffer:
'Ofereça uma reunião com o coordenador ou um vídeo, e cadastre a rede primeiro.',
}
}
const headroom = conditions.find((condition) => condition.id === 'headroom')
if (headroom && !headroom.met) {
return {
id: 'no_teto',
reason:
'Meta já coberta e pouco campo não capturado: o dia do candidato rende mais em outro município.',
counterOffer: 'Encaixe como parada curta num giro, ou troque por um município com déficit.',
}
}
return null
}
export const evaluateVisitEligibility = (input: VisitEligibilityInput): VisitEligibility => {
const conditions: VisitCondition[] = [
volumeCondition(input),
headroomCondition(input),
redeCondition(input),
janelaCondition(input),
encaixeCondition(input),
]
return {
conditions,
metCount: conditions.filter((condition) => condition.met).length,
contraindication: resolveContraindication(input, conditions),
}
}
/**
* The calendar changes the PRODUCT of a visit, not only its urgency (research
* report §6.7): in July a visit exists to build the network, in the last week
* it exists to move people who are already with you.
*/
export const resolveCalendarPhase = (now: Date): CalendarPhase => {
const today = formatBahiaCivilDate(now)
if (today >= CALENDAR_PHASE_ANCHORS.activationStart) return 'ativacao'
if (today >= CALENDAR_PHASE_ANCHORS.consolidationStart) return 'consolidacao'
return 'construcao'
}
export const calendarPhaseLabels: Record<CalendarPhase, string> = {
construcao: 'Construção',
consolidacao: 'Consolidação',
ativacao: 'Ativação',
}
/** What a visit is FOR in each phase — the copy the composer suggests per stop. */
export const calendarPhaseVisitProduct: Record<CalendarPhase, string> = {
construcao: 'Reunião com lideranças: montar e ativar a rede local.',
consolidacao: 'Fechar compromissos de voto e casar a dobradinha estadual.',
ativacao: 'Mobilizar quem já está com a gente: ato, caminhada, boca de urna.',
}
/**
* Headlines say what to do, not how bad it is: "não vá" is staff vocabulary for
* a deprioritization, and the same município is never told it was written off.
*/
export const visitContraindicationLabels: Record<VisitContraindicationId, string> = {
perdida: 'Não recomendado agora',
sem_rede: 'Falta rede para receber',
no_teto: 'Rende mais em outro município',
}
export const visitConditionLabels: Record<VisitConditionId, string> = {
volume: 'Volume eleitoral',
headroom: 'Espaço para crescer',
rede: 'Rede de recepção',
janela: 'Janela política',
encaixe: 'Encaixe em giro',
}

View file

@ -0,0 +1,304 @@
import 'server-only'
import type { Payload, Where } from 'payload'
import { getMunicipalityFederalBaseline } from '@/lib/bahiaElectionAggregates'
import type { BahiaIdentityTerritory } from '@/lib/bahiaTerritories'
import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
import type { CalendarPhase } from '@/lib/visitPlannerAnchors'
import { DEFAULT_VOTE_ESTIMATE_SCENARIO } from '@/lib/voteEstimate'
import type { CampaignUser, Municipality } from '@/payload-types'
import { computeGoalCoverage } from '@/utilities/goalCoverage'
import { loadStatewideSuggestedGoals } from '@/utilities/municipalityGoalAccount'
import {
catalogMedianProjectedValidVotes,
catalogMedianUncapturedFieldVotes,
uncapturedFieldVotes,
} from '@/utilities/municipalityPotential'
import { computeMunicipalityTerritorialClass } from '@/utilities/municipalityTerritorialClass'
import { relationshipId, uniqueRelationshipIds } from '@/utilities/relationship'
import { evaluateVisitEligibility, resolveCalendarPhase } from '@/utilities/visitEligibility'
import {
compareVisitCandidates,
type VisitCandidateGroupViewModel,
type VisitCandidateViewModel,
type VisitPlannerBundle,
} from '@/utilities/visitPlannerViews'
import { aggregatePledgesByMunicipality } from '@/utilities/votePledgeData'
import {
emptyMunicipalityPledgeAggregate,
type MunicipalityPledgeAggregate,
} from '@/utilities/votePledgeViews'
/**
* E13 loader — the municípios in the actor's scope, each with its five visit
* conditions evaluated, grouped by identity territory (the unit a giro travels
* in).
*
* It reads `municipality` itself instead of going through
* `loadMunicipalityScope`: that select is a superset shared by the dashboard,
* the map and the list, and it carries neither `politicalTrend` nor
* `stateDeputies` — the two fields the "janela política" condition needs.
* Widening the shared select would charge every one of those surfaces for two
* fields only the planner reads.
*/
type PlannerMunicipalityDoc = Pick<
Municipality,
| 'id'
| 'name'
| 'slug'
| 'region'
| 'advisors'
| 'priority'
| 'expectedVotes'
| 'politicalTrend'
| 'stateDeputies'
>
/**
* 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.
*/
const countLeadershipsByMunicipality = async (
payload: Payload,
municipalityIDs: ReadonlyArray<number>,
): Promise<Map<number, number>> => {
const counts = new Map<number, number>()
if (municipalityIDs.length === 0) return counts
const inScope = new Set(municipalityIDs)
const leaderships = await payload.find({
collection: 'leadership',
where: { municipalities: { in: [...municipalityIDs] } },
depth: 0,
limit: 0,
pagination: false,
select: { municipalities: true },
overrideAccess: true,
})
for (const leadership of leaderships.docs) {
for (const municipality of leadership.municipalities ?? []) {
const id = relationshipId(municipality)
if (id !== null && inScope.has(id)) counts.set(id, (counts.get(id) ?? 0) + 1)
}
}
return counts
}
/**
* "Encaixe em giro" in v1 = another município of the SAME identity territory,
* in this actor's scope, that already has somewhere to stop. There is no
* adjacency graph at runtime (`bahiaTerritories.ts` carries membership only),
* and building one is the route-optimizer rabbit hole the plan forbids — so
* the group the loader already assembled IS the answer, at no extra cost.
*/
const countStopPeersByRegion = (
entries: ReadonlyArray<{ region: BahiaIdentityTerritory; hasStop: boolean }>,
): Map<BahiaIdentityTerritory, number> => {
const counts = new Map<BahiaIdentityTerritory, number>()
for (const entry of entries) {
if (!entry.hasStop) continue
counts.set(entry.region, (counts.get(entry.region) ?? 0) + 1)
}
return counts
}
type VisitPlannerRegionOption = {
region: BahiaIdentityTerritory
municipalityCount: number
}
/**
* The identity territories the actor can actually compose a giro in, with how
* many municípios of theirs live in each. One cheap `slug`-only read: offering
* all 27 TIs to an advisor with a six-município portfolio would be offering
* twenty-one dead ends, and evaluating the full eligibility of the state just to
* build a picker would cost the whole planner load.
*/
export const loadVisitPlannerRegions = async (
payload: Payload,
user: CampaignUser,
): Promise<VisitPlannerRegionOption[]> => {
const result = await payload.find({
collection: 'municipality',
depth: 0,
limit: 0,
pagination: false,
select: { slug: true },
user,
overrideAccess: false,
})
const counts = new Map<BahiaIdentityTerritory, number>()
for (const municipality of result.docs) {
const region = getMunicipalityCatalogEntry(municipality.slug)?.region
if (region) counts.set(region, (counts.get(region) ?? 0) + 1)
}
return [...counts.entries()]
.map(([region, municipalityCount]) => ({ region, municipalityCount }))
.sort((left, right) => left.region.localeCompare(right.region, 'pt-BR'))
}
export type VisitPlannerFilter = {
/** Restrict to one identity territory — the composer always does. */
region?: BahiaIdentityTerritory
/** Injected so the calendar phase is testable and stable within a request. */
now?: Date
}
export const loadVisitCandidates = async (
payload: Payload,
user: CampaignUser,
{ region, now = new Date() }: VisitPlannerFilter = {},
): Promise<VisitPlannerBundle> => {
const where: Where = region ? { region: { equals: region } } : {}
const result = await payload.find({
collection: 'municipality',
where,
depth: 0,
limit: 0,
pagination: false,
select: {
name: true,
slug: true,
region: true,
advisors: true,
priority: true,
expectedVotes: true,
politicalTrend: true,
stateDeputies: true,
},
user,
overrideAccess: false,
})
const municipalities = result.docs as PlannerMunicipalityDoc[]
const municipalityIDs = municipalities.map((municipality) => municipality.id)
const [pledgeAggregates, leadershipCounts, { suggestedGoalBySlug, potentialBySlug }] =
await Promise.all([
aggregatePledgesByMunicipality(payload, municipalityIDs),
countLeadershipsByMunicipality(payload, municipalityIDs),
loadStatewideSuggestedGoals(payload, user),
])
// First pass: everything that does not depend on the município's neighbours.
const partials = municipalities.flatMap((municipality) => {
// A município outside the catalog has no derived potential to reason about;
// it can only exist as leftover data, so it is not a visit candidate.
const catalogEntry = getMunicipalityCatalogEntry(municipality.slug)
const suggestedGoalByScenario = suggestedGoalBySlug.get(municipality.slug)
const potential = potentialBySlug.get(municipality.slug)
if (!catalogEntry || !suggestedGoalByScenario || !potential) return []
const pledgeAggregate: MunicipalityPledgeAggregate =
pledgeAggregates.get(municipality.id) ?? emptyMunicipalityPledgeAggregate
const leadershipCount = leadershipCounts.get(municipality.id) ?? 0
return [
{
municipality,
region: catalogEntry.region,
leadershipCount,
pledgeAggregate,
// Somewhere for the candidate to actually stop: a liderança to receive
// him or a pledge already recorded. Read twice below — as this
// município's own gate, and as a neighbour's contribution to the giro.
hasStop: leadershipCount >= 1 || pledgeAggregate.pledgeCount >= 1,
uncapturedFieldVotes: uncapturedFieldVotes(
getMunicipalityFederalBaseline(municipality.slug),
),
potential,
coverage: computeGoalCoverage(
municipality.expectedVotes,
suggestedGoalByScenario,
pledgeAggregate,
DEFAULT_VOTE_ESTIMATE_SCENARIO,
),
},
]
})
const stopPeersByRegion = countStopPeersByRegion(partials)
const groups = new Map<BahiaIdentityTerritory, VisitCandidateViewModel[]>()
for (const partial of partials) {
const { municipality, pledgeAggregate, leadershipCount, coverage } = partial
const territorialClass = computeMunicipalityTerritorialClass(municipality.slug).class
// A município never counts as its own tour peer.
const territoryStopPeerCount =
(stopPeersByRegion.get(partial.region) ?? 0) - (partial.hasStop ? 1 : 0)
const advisorCount = uniqueRelationshipIds(municipality.advisors).length
const candidate: VisitCandidateViewModel = {
id: municipality.id,
slug: municipality.slug,
name: municipality.name,
region: partial.region,
priority: municipality.priority ?? null,
eligibility: evaluateVisitEligibility({
projectedValidVotes: partial.potential.projectedValidVotes,
projectedValidVotesCut: catalogMedianProjectedValidVotes(),
coverageDeficit: coverage.deficit,
uncapturedFieldVotes: partial.uncapturedFieldVotes,
uncapturedFieldVotesCut: catalogMedianUncapturedFieldVotes(),
advisorCount,
leadershipCount,
pledgeCount: pledgeAggregate.pledgeCount,
linkedStateDeputyCount: uniqueRelationshipIds(municipality.stateDeputies).length,
politicalTrend: municipality.politicalTrend?.status ?? null,
territorialClass,
territoryStopPeerCount,
}),
coverage,
territorialClass,
leadershipCount,
pledgeCount: pledgeAggregate.pledgeCount,
advisorCount,
}
const bucket = groups.get(partial.region)
if (bucket) bucket.push(candidate)
else groups.set(partial.region, [candidate])
}
const groupViews: VisitCandidateGroupViewModel[] = [...groups.entries()]
.map(([groupRegion, candidates]) => ({
region: groupRegion,
candidates: candidates.sort(compareVisitCandidates),
}))
.sort((left, right) => left.region.localeCompare(right.region, 'pt-BR'))
return { phase: resolveCalendarPhase(now), groups: groupViews }
}
/**
* One município's eligibility for the detail-page card. It runs the SAME loader
* scoped to the município's territory rather than a second evaluator, because
* the "encaixe em giro" condition is a statement about the neighbours — asking
* it for one município in isolation is not possible, and asking it twice is how
* a detail page and a list start disagreeing.
*/
export const loadMunicipalityVisitEligibility = async (
payload: Payload,
user: CampaignUser,
slug: string,
now: Date = new Date(),
): Promise<{ candidate: VisitCandidateViewModel | null; phase: CalendarPhase }> => {
// Off-catalog slugs are never candidates, so bail before the read: without the
// território there is nothing to scope the query to, and it would scan the state.
const catalogEntry = getMunicipalityCatalogEntry(slug)
if (!catalogEntry) return { candidate: null, phase: resolveCalendarPhase(now) }
const { phase, groups } = await loadVisitCandidates(payload, user, {
region: catalogEntry.region,
now,
})
const candidate = groups.flatMap((group) => group.candidates).find((entry) => entry.slug === slug)
return { candidate: candidate ?? null, phase }
}

View file

@ -0,0 +1,26 @@
/**
* E13 tour-composer URL contract — one region param, parsed and serialized in
* one place so the detail card's CTA, the Atividades button and the page itself
* cannot spell it three ways.
*
* Client-safe by construction: it only touches the identity-territory name
* table. The B14 lesson applies to serializers too — a client component that
* imports a serializer which imports the catalog drags the catalog into the
* bundle, which is why nothing heavier lives here.
*/
import { isBahiaIdentityTerritory, type BahiaIdentityTerritory } from '@/lib/bahiaTerritories'
import { firstValue, type RawSearchParams } from '@/utilities/campaignListUrl'
export const TOUR_COMPOSER_PATH = '/campanha/atividades/giros'
export type TourComposerState = {
region: BahiaIdentityTerritory | null
}
export const parseTourComposerParams = (params: RawSearchParams): TourComposerState => {
const raw = firstValue(params.region)?.trim()
return { region: raw && isBahiaIdentityTerritory(raw) ? raw : null }
}
export const buildTourComposerHref = ({ region }: TourComposerState): string =>
region ? `${TOUR_COMPOSER_PATH}?region=${encodeURIComponent(region)}` : TOUR_COMPOSER_PATH

View file

@ -0,0 +1,184 @@
import type { BahiaIdentityTerritory } from '@/lib/bahiaTerritories'
import type { ActivityKind } from '@/lib/schemas/activity'
import type { CalendarPhase } from '@/lib/visitPlannerAnchors'
import type { MunicipalityGoalCoverage } from '@/utilities/goalCoverage'
import type { MunicipalityTerritorialClass } from '@/utilities/municipalityTerritorialClass'
import type { VisitEligibility } from '@/utilities/visitEligibility'
/**
* E13 view models and tour composition — the client-safe contract side of
* `visitPlannerData.ts` (which owns the Payload reads and is `server-only`),
* same split as `votePledgeViews` vs `votePledgeData`.
*/
export type VisitCandidateViewModel = {
id: number
slug: string
name: string
region: BahiaIdentityTerritory
priority: 'alta' | 'normal' | null
eligibility: VisitEligibility
/** Fixed to the `central` scenario, like the E8 card on the detail page. */
coverage: MunicipalityGoalCoverage
territorialClass: MunicipalityTerritorialClass
leadershipCount: number
pledgeCount: number
advisorCount: number
}
export type VisitCandidateGroupViewModel = {
region: BahiaIdentityTerritory
candidates: VisitCandidateViewModel[]
}
export type VisitPlannerBundle = {
phase: CalendarPhase
groups: VisitCandidateGroupViewModel[]
}
/**
* Ordering of the queue: how many of the five conditions a município clears
* first, then how many votes are still missing from its goal. Deliberately not
* a weighted sum of the two — the checklist is a gate, the deficit is the
* tiebreak inside a gate level.
*/
export const compareVisitCandidates = (
left: VisitCandidateViewModel,
right: VisitCandidateViewModel,
): number =>
right.eligibility.metCount - left.eligibility.metCount ||
right.coverage.deficit - left.coverage.deficit ||
left.name.localeCompare(right.name, 'pt-BR')
/** Somewhere the candidate could actually be received. */
const hasNetwork = (candidate: VisitCandidateViewModel): boolean =>
candidate.leadershipCount >= 1 || candidate.pledgeCount >= 1
/** A giro is a day or two: an anchor, a few satellites, one bet. */
export const TOUR_MAX_SATELLITES = 3
export type TourSuggestion = {
/** Município with the largest committed stock — the act that justifies the trip. */
anchorSlug: string | null
satelliteSlugs: string[]
/** One expansão município: P12's "semente" — a bet, not a repeat. */
seedSlug: string | null
}
/**
* J-C composition: âncora + satélites + 1 semente, inside ONE identity
* territory. The anchor is where the committed vote already is (the act has an
* audience), the satellites are the next best stops that have someone to
* receive, and the seed is deliberately a município he is weak in — without it
* every giro repeats the base and the campaign never expands (P12). The seed
* still needs a network: a bet on a place with nobody to receive the candidate
* is not a bet, it is a wasted day (research report §6.7).
*
* The result is a SUGGESTION: the composer renders it pre-selected and the
* coordination edits it before generating anything.
*/
export const composeTourSuggestion = (
candidates: ReadonlyArray<VisitCandidateViewModel>,
): TourSuggestion => {
const receivable = candidates.filter(hasNetwork)
const anchor = receivable.reduce<VisitCandidateViewModel | null>((best, candidate) => {
if (!best) return candidate
if (candidate.coverage.committed > best.coverage.committed) return candidate
if (candidate.coverage.committed < best.coverage.committed) return best
return candidate.coverage.goal > best.coverage.goal ? candidate : best
}, null)
// The seed is reserved BEFORE the satellites are filled. Picking it from the
// leftovers meant a territory with four good stops produced a giro that only
// repeated the base — the satellites had eaten every slot.
const seed =
receivable
.filter(
(candidate) => candidate.territorialClass === 'expansao' && candidate.slug !== anchor?.slug,
)
.sort((left, right) => right.coverage.deficit - left.coverage.deficit)[0] ?? null
const satellites = receivable
.filter((candidate) => candidate.slug !== anchor?.slug && candidate.slug !== seed?.slug)
.sort(compareVisitCandidates)
.slice(0, TOUR_MAX_SATELLITES)
return {
anchorSlug: anchor?.slug ?? null,
satelliteSlugs: satellites.map((candidate) => candidate.slug),
seedSlug: seed?.slug ?? null,
}
}
/** Every slug the suggestion pre-selects, in tour reading order. */
export const tourSuggestionSlugs = (suggestion: TourSuggestion): string[] => [
...(suggestion.anchorSlug ? [suggestion.anchorSlug] : []),
...suggestion.satelliteSlugs,
...(suggestion.seedSlug ? [suggestion.seedSlug] : []),
]
export type TourStopRole = 'ancora' | 'satelite' | 'semente'
export const tourStopRoleLabels: Record<TourStopRole, string> = {
ancora: 'Âncora',
satelite: 'Satélite',
semente: 'Semente',
}
/**
* What each role is for, in the words the coordination uses — rendered next to
* the stop so the composition is auditable instead of magic.
*/
export const tourStopRoleDescriptions: Record<TourStopRole, string> = {
ancora: 'Maior estoque de votos comprometidos do território: é o ato que justifica a viagem.',
satelite: 'Parada com rede para receber, no caminho do mesmo território.',
semente: 'Município de expansão: aposta de crescimento, para o giro não repetir só a base.',
}
export const resolveTourStopRole = (
slug: string,
suggestion: TourSuggestion,
): TourStopRole | null => {
if (slug === suggestion.anchorSlug) return 'ancora'
if (slug === suggestion.seedSlug) return 'semente'
return suggestion.satelliteSlugs.includes(slug) ? 'satelite' : null
}
/**
* The activity kind each role generates. There is no `visita` kind and E13 does
* not add one: `activity.deputyPresent` already IS the candidate-presence
* marker, so every stop keeps the real kind of what happens there — an act at
* the anchor, a support meeting at a stop that is being opened.
*/
export const tourStopRoleActivityKind: Record<TourStopRole, ActivityKind> = {
ancora: 'comicio',
satelite: 'reuniao_apoio',
semente: 'reuniao_apoio',
}
/**
* A giro is a day or two of the candidate's agenda. The cap is not a UI detail:
* it is what keeps one submit from writing an unbounded batch of drafts.
*/
export const MAX_TOUR_STOPS = 8
/**
* The giro's name is a prefix of every stop's title, so the client's `maxLength`
* and the server's boundary check have to be the same number. It is well under
* the activity title bound (160) plus the longest município name, which is why
* the composed title needs no second check.
*/
export const MAX_TOUR_NAME_LENGTH = 90
/**
* The failures the composer is allowed to show verbatim. They live here, next to
* the cap they describe, because the action that throws them is a `'use server'`
* module (which may only export async functions) and the form action that lists
* them as safe must not spell them a second time.
*/
export const TOUR_EMPTY_MESSAGE = 'Selecione ao menos uma parada do giro.'
export const TOUR_STAFF_ONLY_MESSAGE = 'Apenas a equipe da campanha pode planejar um giro.'
export const TOUR_OUT_OF_SCOPE_MESSAGE =
'Algum município do giro saiu do seu escopo. Atualize a página e monte o giro novamente.'
export const TOUR_MAX_STOPS_MESSAGE = `Um giro aceita no máximo ${MAX_TOUR_STOPS} paradas.`

View file

@ -8,10 +8,13 @@ import {
} from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import { createActivityRecord } from '@/app/(campaign)/campanha/actions/activity'
import {
createActivityRecord,
createTourDraftActivitiesRecord,
} from '@/app/(campaign)/campanha/actions/activity'
import { activityCreateSchema, activityUpdateSchema } from '@/lib/schemas/activity'
import config from '@/payload.config'
import { parseActivityCreateFormData } from '@/utilities/activityFormData'
import { parseActivityCreateFormData, parseTourDraftFormData } from '@/utilities/activityFormData'
import {
canCreateActivity,
canReadActivity,
@ -428,3 +431,145 @@ describe('activity domain', () => {
).rejects.toThrow()
})
})
const tourFormData = (
stops: unknown,
{ tourName = 'Giro Sisal 27/07', note }: { tourName?: string; note?: string } = {},
): FormData => {
const formData = new FormData()
formData.set('tourName', tourName)
if (note !== undefined) formData.set('note', note)
formData.set('stopsJson', JSON.stringify(stops))
return formData
}
describe('tour composer (E13)', () => {
it('parses the giro name, the shared note and one stop per município', () => {
expect(
parseTourDraftFormData(
tourFormData(
[
{ municipality: 7, kind: 'comicio', origin: 'dado' },
{ municipality: 9, kind: 'reuniao_apoio', origin: 'pedido_broker' },
],
{ note: 'Falar com o vereador antes.' },
),
),
).toEqual({
tourName: 'Giro Sisal 27/07',
note: 'Falar com o vereador antes.',
stops: [
{ municipality: 7, kind: 'comicio', origin: 'dado' },
{ municipality: 9, kind: 'reuniao_apoio', origin: 'pedido_broker' },
],
})
})
it('refuses a composition it cannot trust', () => {
expect(() => parseTourDraftFormData(tourFormData([]))).toThrow(/ao menos uma parada/i)
expect(() =>
parseTourDraftFormData(tourFormData([{ municipality: 7, kind: 'visita', origin: 'dado' }])),
).toThrow(/Paradas do giro inválidas/i)
expect(() =>
parseTourDraftFormData(tourFormData([{ municipality: 0, kind: 'comicio', origin: 'dado' }])),
).toThrow(/Paradas do giro inválidas/i)
expect(() =>
parseTourDraftFormData(
tourFormData([{ municipality: 7, kind: 'comicio', origin: 'inventada' }]),
),
).toThrow(/Paradas do giro inválidas/i)
expect(() =>
parseTourDraftFormData(
tourFormData([{ municipality: 7, kind: 'comicio', origin: 'dado' }], {
tourName: 'G'.repeat(120),
}),
),
).toThrow(/muito longo/i)
})
it('writes every stop of a giro as a draft in one transaction, titled after the município', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const [anchor, satellite] = await Promise.all([
fixtures.getMunicipality(),
fixtures.getMunicipality(),
])
const tourName = fixtures.value('Giro do teste')
const created = await createTourDraftActivitiesRecord(payload, coordinator, {
tourName,
note: 'Falar com o vereador antes.',
stops: [
{ municipality: anchor.id, kind: 'comicio', origin: 'dado' },
{ municipality: satellite.id, kind: 'reuniao_apoio', origin: 'dado' },
],
})
created.forEach((activity) => fixtures.own('activity', activity.id))
expect(created).toHaveLength(2)
// The title names the município as the DATABASE spells it — the client only
// ever sent the id, which is also the only thing that granted the write.
expect(created.map((activity) => activity.title)).toEqual([
`${tourName} — ${anchor.name}`,
`${tourName} — ${satellite.name}`,
])
expect(
created.every((activity) => activity.description === 'Falar com o vereador antes.'),
).toBe(true)
// Drafts without a date, flagged as the candidate's own agenda: that flag is
// what makes a giro derivable without a `tour` entity.
expect(created.every((activity) => activity.status === 'rascunho')).toBe(true)
expect(created.every((activity) => activity.deputyPresent === true)).toBe(true)
expect(created.every((activity) => activity.startAt === null)).toBe(true)
})
it('rolls back the whole giro when one stop is outside the advisor portfolio', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const [inPortfolio, outOfPortfolio] = await Promise.all([
fixtures.getMunicipality(),
fixtures.getMunicipality(),
])
await fixtures.assignMunicipalityAdvisors(inPortfolio, [advisor])
const tourName = fixtures.value('Giro fora de escopo')
await expect(
createTourDraftActivitiesRecord(payload, advisor, {
tourName,
note: undefined,
stops: [
{ municipality: inPortfolio.id, kind: 'comicio', origin: 'dado' },
{ municipality: outOfPortfolio.id, kind: 'reuniao_apoio', origin: 'dado' },
],
}),
).rejects.toThrow(/fora do seu escopo|saiu do seu escopo/i)
const leftovers = await payload.find({
collection: 'activity',
where: { title: { contains: tourName } },
depth: 0,
pagination: false,
overrideAccess: true,
})
leftovers.docs.forEach((activity) => fixtures.own('activity', activity.id))
expect(leftovers.docs).toHaveLength(0)
})
it('caps a giro so one submit cannot write an unbounded batch', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
await expect(
createTourDraftActivitiesRecord(payload, coordinator, {
tourName: fixtures.value('Giro grande demais'),
note: undefined,
stops: Array.from({ length: 9 }, () => ({
municipality: municipality.id,
kind: 'reuniao_apoio' as const,
origin: 'dado' as const,
})),
}),
).rejects.toThrow(/no máximo 8 paradas/i)
})
})

View file

@ -0,0 +1,169 @@
// @vitest-environment node
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import config from '@/payload.config'
import { loadMunicipalityVisitEligibility, loadVisitCandidates } from '@/utilities/visitPlannerData'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
/**
* E13's loader is the one place where visit eligibility meets real rows: the
* access scope, the per-município leadership count (one query, never N), and
* the two fields the planner has its OWN `municipality` select for —
* `politicalTrend` and `stateDeputies`, which nothing in
* `loadMunicipalityScope` carries.
*
* The peer count and the ordering are pure and pinned in
* `visitEligibility.unit.spec.ts` / `visitPlannerViews.unit.spec.ts`; what an
* advisor's portfolio contains cannot be, because it is a `where` clause.
*/
describe('loadVisitCandidates', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('never hands an advisor a município outside the portfolio they administer', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const administered = await fixtures.getMunicipality()
const foreign = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(administered, [advisor.id])
const contact = await fixtures.createContact()
const leadership = await fixtures.createLeadership({
contact: contact.id,
municipalities: [foreign.id],
})
await fixtures.createVotePledge({
leadership: leadership.id,
municipality: foreign.id,
declaredVotes: 900,
})
const bundle = await loadVisitCandidates(payload, advisor)
const slugs = bundle.groups.flatMap((group) =>
group.candidates.map((candidate) => candidate.slug),
)
expect(slugs).toEqual([administered.slug])
// Every group is one identity territory, and every candidate belongs to it.
for (const group of bundle.groups) {
for (const candidate of group.candidates) expect(candidate.region).toBe(group.region)
}
})
it('counts one liderança linked to several municípios in each of them', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const first = await fixtures.getMunicipality()
const second = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(first, [advisor.id])
await fixtures.assignMunicipalityAdvisors(second, [advisor.id])
const shared = await fixtures.createContact()
await fixtures.createLeadership({
contact: shared.id,
municipalities: [first.id, second.id],
})
const extra = await fixtures.createContact()
await fixtures.createLeadership({ contact: extra.id, municipalities: [first.id] })
const bundle = await loadVisitCandidates(payload, advisor)
const bySlug = new Map(
bundle.groups
.flatMap((group) => group.candidates)
.map((candidate) => [candidate.slug, candidate]),
)
expect(bySlug.get(first.slug)?.leadershipCount).toBe(2)
expect(bySlug.get(second.slug)?.leadershipCount).toBe(1)
})
it('reads the janela política from politicalTrend and stateDeputies', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const municipality = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(municipality, [advisor.id])
fixtures.touchMunicipality(municipality)
const janelaOf = async () => {
const bundle = await loadVisitCandidates(payload, advisor)
const candidate = bundle.groups[0]?.candidates[0]
const janela = candidate?.eligibility.conditions.find(
(condition) => condition.id === 'janela',
)
if (!janela) throw new Error('Condição de janela política ausente')
return janela
}
await payload.update({
collection: 'municipality',
id: municipality.id,
data: { politicalTrend: { status: 'desfavoravel' } },
})
expect((await janelaOf()).met).toBe(false)
await payload.update({
collection: 'municipality',
id: municipality.id,
data: { politicalTrend: { status: 'favoravel' } },
})
expect((await janelaOf()).met).toBe(true)
// A linked dobradinha carries the window even against an adverse trend.
const deputy = await fixtures.createStateDeputy()
await payload.update({
collection: 'municipality',
id: municipality.id,
data: { politicalTrend: { status: 'desfavoravel' }, stateDeputies: [deputy.id] },
})
const janela = await janelaOf()
expect(janela.met).toBe(true)
expect(janela.detail).toContain('dobradinha')
})
it('never counts a município as its own tour peer, and carries the pledge stock', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const municipality = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(municipality, [advisor.id])
const contact = await fixtures.createContact()
const leadership = await fixtures.createLeadership({
contact: contact.id,
municipalities: [municipality.id],
})
await fixtures.createVotePledge({
leadership: leadership.id,
municipality: municipality.id,
declaredVotes: 150,
estimatedVotes: { pessimistic: null, central: 220, optimistic: null },
})
const { candidate } = await loadMunicipalityVisitEligibility(
payload,
advisor,
municipality.slug,
)
expect(candidate?.pledgeCount).toBe(1)
expect(candidate?.coverage.committed).toBe(220)
// The portfolio holds this município alone: it has a stop, but no peer to
// pair with — so "encaixe em giro" must read as unmet, not as self-met.
const encaixe = candidate?.eligibility.conditions.find(
(condition) => condition.id === 'encaixe',
)
expect(encaixe?.met).toBe(false)
})
})

View file

@ -28,6 +28,21 @@ import {
const sourceRoot = resolve(process.cwd(), 'src')
const localImportPattern = /(?:import|export)\s+(?:[^'"]*?\s+from\s+)?['"]([^'"]+)['"]/g
/**
* The client graphs overlap heavily — the ui kit sits in almost every one — so
* each file is read and scanned once for the whole suite instead of once per
* entry that reaches it.
*/
const sourceCache = new Map<string, string>()
const readSource = (file: string): string => {
const cached = sourceCache.get(file)
if (cached !== undefined) return cached
const source = readFileSync(file, 'utf8')
sourceCache.set(file, source)
return source
}
const resolveLocalModule = (importer: string, specifier: string): string | null => {
const base = specifier.startsWith('@/')
? resolve(sourceRoot, specifier.slice(2))
@ -47,16 +62,32 @@ const resolveLocalModule = (importer: string, specifier: string): string | null
return null
}
const runtimeDependencyCache = new Map<string, string[]>()
/** Local modules a file pulls in for VALUES — `import type` never reaches the client. */
const runtimeDependenciesOf = (file: string): string[] => {
const cached = runtimeDependencyCache.get(file)
if (cached) return cached
const dependencies: string[] = []
for (const match of readSource(file).matchAll(localImportPattern)) {
if (/^import\s+type\b/.test(match[0])) continue
const dependency = resolveLocalModule(file, match[1]!)
// A `'use server'` module is a network boundary, not a bundled import.
if (dependency && !/^['"]use server['"]/.test(readSource(dependency))) {
dependencies.push(dependency)
}
}
runtimeDependencyCache.set(file, dependencies)
return dependencies
}
const collectLocalDependencyGraph = (entry: string, visited = new Set<string>()): Set<string> => {
if (visited.has(entry)) return visited
visited.add(entry)
const source = readFileSync(entry, 'utf8')
for (const match of source.matchAll(localImportPattern)) {
if (/^import\s+type\b/.test(match[0])) continue
const dependency = resolveLocalModule(entry, match[1]!)
if (dependency && !/^['"]use server['"]/.test(readFileSync(dependency, 'utf8'))) {
collectLocalDependencyGraph(dependency, visited)
}
for (const dependency of runtimeDependenciesOf(entry)) {
collectLocalDependencyGraph(dependency, visited)
}
return visited
}
@ -208,16 +239,18 @@ describe('campaign client module boundaries', () => {
const clientEntries = readdirSync(sourceRoot, { recursive: true, withFileTypes: true })
.filter((entry) => entry.isFile() && ['.ts', '.tsx'].includes(extname(entry.name)))
.map((entry) => resolve(entry.parentPath, entry.name))
.filter((file) => /^['"]use client['"]/.test(readFileSync(file, 'utf8')))
.filter((file) => /^['"]use client['"]/.test(readSource(file)))
const reachable = new Set<string>()
for (const entry of clientEntries) {
for (const dependency of collectLocalDependencyGraph(entry)) {
const source = readFileSync(dependency, 'utf8')
expect(dependency).not.toBe(resolve(sourceRoot, 'lib/formData.ts'))
expect(source, `${dependency} entered the client graph`).not.toMatch(
/(?:from\s+['"]node:|require\(['"]node:|\bBuffer\b)/,
)
}
for (const dependency of collectLocalDependencyGraph(entry)) reachable.add(dependency)
}
for (const dependency of reachable) {
expect(dependency).not.toBe(resolve(sourceRoot, 'lib/formData.ts'))
expect(readSource(dependency), `${dependency} entered the client graph`).not.toMatch(
/(?:from\s+['"]node:|require\(['"]node:|\bBuffer\b)/,
)
}
})
})

View file

@ -0,0 +1,349 @@
import { describe, expect, it } from 'vitest'
import { CALENDAR_PHASE_ANCHORS, CALENDAR_PHASES } from '@/lib/visitPlannerAnchors'
import type { MunicipalityGoalCoverage } from '@/utilities/goalCoverage'
import type { MunicipalityTerritorialClass } from '@/utilities/municipalityTerritorialClass'
import {
calendarPhaseLabels,
calendarPhaseVisitProduct,
evaluateVisitEligibility,
resolveCalendarPhase,
VISIT_CONDITIONS,
VISIT_CONTRAINDICATIONS,
visitConditionLabels,
visitContraindicationLabels,
type VisitConditionId,
type VisitEligibilityInput,
} from '@/utilities/visitEligibility'
import {
compareVisitCandidates,
composeTourSuggestion,
resolveTourStopRole,
TOUR_MAX_SATELLITES,
tourSuggestionSlugs,
type VisitCandidateViewModel,
} from '@/utilities/visitPlannerViews'
/** An eligible município: all five conditions met, no contraindication. */
const input = (overrides: Partial<VisitEligibilityInput> = {}): VisitEligibilityInput => ({
projectedValidVotes: 20_000,
projectedValidVotesCut: 10_000,
coverageDeficit: 500,
uncapturedFieldVotes: 4_000,
uncapturedFieldVotesCut: 3_000,
advisorCount: 1,
leadershipCount: 2,
pledgeCount: 3,
linkedStateDeputyCount: 1,
politicalTrend: 'favoravel',
territorialClass: 'manutencao',
territoryStopPeerCount: 4,
...overrides,
})
const conditionById = (
result: ReturnType<typeof evaluateVisitEligibility>,
id: VisitConditionId,
) => {
const condition = result.conditions.find((entry) => entry.id === id)
if (!condition) throw new Error(`Condição ausente: ${id}`)
return condition
}
describe('evaluateVisitEligibility', () => {
it('returns the five conditions, in reading order, with a visible reason each', () => {
const result = evaluateVisitEligibility(input())
expect(result.conditions.map((condition) => condition.id)).toEqual([...VISIT_CONDITIONS])
expect(result.metCount).toBe(5)
expect(result.contraindication).toBeNull()
// The reason is what the UI renders — never an empty string behind a ✓.
for (const condition of result.conditions) expect(condition.detail.length).toBeGreaterThan(0)
})
it('reads volume against the catalog median, not an absolute vote count', () => {
expect(conditionById(evaluateVisitEligibility(input()), 'volume').met).toBe(true)
// At the cut is enough; a hair below is not.
expect(
conditionById(evaluateVisitEligibility(input({ projectedValidVotes: 10_000 })), 'volume').met,
).toBe(true)
expect(
conditionById(evaluateVisitEligibility(input({ projectedValidVotes: 9_999 })), 'volume').met,
).toBe(false)
})
it('accepts headroom from an uncovered goal OR from uncaptured field votes', () => {
const goalCovered = input({ coverageDeficit: -200 })
expect(conditionById(evaluateVisitEligibility(goalCovered), 'headroom').met).toBe(true)
const noRoom = input({ coverageDeficit: -200, uncapturedFieldVotes: 100 })
const headroom = conditionById(evaluateVisitEligibility(noRoom), 'headroom')
expect(headroom.met).toBe(false)
expect(headroom.detail).toContain('Meta coberta')
})
it('requires BOTH an advisor and someone on the ground for rede', () => {
expect(conditionById(evaluateVisitEligibility(input({ advisorCount: 0 })), 'rede').met).toBe(
false,
)
expect(
conditionById(evaluateVisitEligibility(input({ leadershipCount: 0, pledgeCount: 0 })), 'rede')
.met,
).toBe(false)
// A pledge with no leadership still counts as someone to receive.
expect(conditionById(evaluateVisitEligibility(input({ leadershipCount: 0 })), 'rede').met).toBe(
true,
)
})
it('treats an unrecorded political trend as unknown, not as a green light', () => {
const unknown = input({ linkedStateDeputyCount: 0, politicalTrend: null })
const janela = conditionById(evaluateVisitEligibility(unknown), 'janela')
expect(janela.met).toBe(false)
expect(janela.detail).toContain('não registrada')
// A linked dobradinha carries the window on its own, even with a bad trend.
expect(
conditionById(evaluateVisitEligibility(input({ politicalTrend: 'desfavoravel' })), 'janela')
.met,
).toBe(true)
expect(
conditionById(
evaluateVisitEligibility(
input({ linkedStateDeputyCount: 0, politicalTrend: 'desfavoravel' }),
),
'janela',
).met,
).toBe(false)
})
it('reads encaixe as another stop in the same identity territory', () => {
const alone = evaluateVisitEligibility(input({ territoryStopPeerCount: 0 }))
expect(conditionById(alone, 'encaixe').met).toBe(false)
expect(conditionById(alone, 'encaixe').detail).toContain('uma parada só')
expect(alone.metCount).toBe(4)
})
it('contraindicates "perdida" only when a weak class meets an empty network', () => {
const weakWithNetwork = evaluateVisitEligibility(input({ territorialClass: 'sem_base' }))
expect(weakWithNetwork.contraindication).toBeNull()
const weakAlone = evaluateVisitEligibility(
input({ territorialClass: 'sem_base', leadershipCount: 0, pledgeCount: 0 }),
)
expect(weakAlone.contraindication?.id).toBe('perdida')
expect(weakAlone.contraindication?.counterOffer.length).toBeGreaterThan(0)
const marginalAlone = evaluateVisitEligibility(
input({ territorialClass: 'marginal', leadershipCount: 0, pledgeCount: 0 }),
)
expect(marginalAlone.contraindication?.id).toBe('perdida')
})
it('contraindicates "sem_rede" when the class is fine but nobody can receive', () => {
const result = evaluateVisitEligibility(input({ leadershipCount: 0, pledgeCount: 0 }))
expect(result.contraindication?.id).toBe('sem_rede')
expect(result.contraindication?.counterOffer).toContain('coordenador')
})
it('contraindicates "no_teto" when the goal is covered and the field is exhausted', () => {
const result = evaluateVisitEligibility(
input({ coverageDeficit: -1_000, uncapturedFieldVotes: 0 }),
)
expect(result.contraindication?.id).toBe('no_teto')
})
})
describe('resolveCalendarPhase', () => {
/** Noon in Bahia (UTC−3) on a civil date, so the instant is unambiguous. */
const bahiaNoon = (civilDate: string): Date => new Date(`${civilDate}T15:00:00.000Z`)
it('labels the phases of the 2026 calendar', () => {
expect(resolveCalendarPhase(bahiaNoon('2026-07-27'))).toBe('construcao')
expect(resolveCalendarPhase(bahiaNoon('2026-09-01'))).toBe('consolidacao')
expect(resolveCalendarPhase(bahiaNoon(CALENDAR_PHASE_ANCHORS.electionDay))).toBe('ativacao')
})
it('turns the phase at midnight in Bahia, not in UTC', () => {
// 03:00 UTC on the anchor day is still the previous day in Bahia (UTC−3).
expect(resolveCalendarPhase(new Date('2026-08-16T02:59:00.000Z'))).toBe('construcao')
expect(resolveCalendarPhase(new Date('2026-08-16T03:00:00.000Z'))).toBe('consolidacao')
expect(resolveCalendarPhase(new Date('2026-09-28T02:59:00.000Z'))).toBe('consolidacao')
expect(resolveCalendarPhase(new Date('2026-09-28T03:00:00.000Z'))).toBe('ativacao')
})
})
describe('visit planner copy', () => {
it('names every calendar phase and every condition', () => {
for (const phase of CALENDAR_PHASES) {
expect(calendarPhaseLabels[phase].length).toBeGreaterThan(0)
expect(calendarPhaseVisitProduct[phase].length).toBeGreaterThan(0)
}
for (const condition of VISIT_CONDITIONS) {
expect(visitConditionLabels[condition].length).toBeGreaterThan(0)
}
})
it('names every contraindication', () => {
for (const contraindication of VISIT_CONTRAINDICATIONS) {
expect(visitContraindicationLabels[contraindication].length).toBeGreaterThan(0)
}
})
})
type CandidateOverrides = {
slug: string
name?: string
metConditions?: number
committed?: number
deficit?: number
goal?: number
leadershipCount?: number
pledgeCount?: number
territorialClass?: MunicipalityTerritorialClass
}
/**
* `metCount` is produced by the evaluator, never hand-set: the comparator has
* to keep agreeing with the checklist. `metConditions` picks how many of the
* five to satisfy, from the cheapest knobs.
*/
const candidate = ({
slug,
name = slug,
metConditions = 5,
committed = 100,
deficit = 100,
goal = 200,
leadershipCount = 2,
pledgeCount = 2,
territorialClass = 'manutencao',
}: CandidateOverrides): VisitCandidateViewModel => {
const coverage: MunicipalityGoalCoverage = {
goal,
committed,
coverageRatio: goal > 0 ? committed / goal : null,
deficit,
}
return {
id: slug.length,
slug,
name,
region: 'Metropolitano de Salvador',
priority: null,
eligibility: evaluateVisitEligibility({
projectedValidVotes: metConditions >= 1 ? 20_000 : 1_000,
projectedValidVotesCut: 10_000,
coverageDeficit: metConditions >= 2 ? 500 : -500,
uncapturedFieldVotes: metConditions >= 2 ? 5_000 : 0,
uncapturedFieldVotesCut: 3_000,
advisorCount: metConditions >= 3 ? 1 : 0,
leadershipCount,
pledgeCount,
linkedStateDeputyCount: metConditions >= 4 ? 1 : 0,
politicalTrend: metConditions >= 4 ? 'favoravel' : null,
territorialClass,
territoryStopPeerCount: metConditions >= 5 ? 3 : 0,
}),
coverage,
territorialClass,
leadershipCount,
pledgeCount,
advisorCount: metConditions >= 3 ? 1 : 0,
}
}
describe('compareVisitCandidates', () => {
it('orders by conditions met first, then by the votes still missing', () => {
const ordered = [
candidate({ slug: 'baixa', metConditions: 2, deficit: 9_000 }),
candidate({ slug: 'alta-deficit-baixo', metConditions: 5, deficit: 10 }),
candidate({ slug: 'alta-deficit-alto', metConditions: 5, deficit: 5_000 }),
].sort(compareVisitCandidates)
// A big deficit never buys a place ahead of a município that clears more
// conditions — the checklist is a gate, the deficit only breaks ties in it.
expect(ordered.map((entry) => entry.slug)).toEqual([
'alta-deficit-alto',
'alta-deficit-baixo',
'baixa',
])
})
it('falls back to the pt-BR name so the order is stable', () => {
const ordered = [
candidate({ slug: 'b', name: 'Óbidos' }),
candidate({ slug: 'a', name: 'Ibicaraí' }),
].sort(compareVisitCandidates)
expect(ordered.map((entry) => entry.name)).toEqual(['Ibicaraí', 'Óbidos'])
})
})
describe('composeTourSuggestion', () => {
it('anchors on the largest committed stock, not on the most conditions met', () => {
const suggestion = composeTourSuggestion([
candidate({ slug: 'estoque', committed: 5_000, metConditions: 3 }),
candidate({ slug: 'checklist', committed: 100, metConditions: 5 }),
])
expect(suggestion.anchorSlug).toBe('estoque')
expect(suggestion.satelliteSlugs).toEqual(['checklist'])
})
it('caps the satellites and keeps them in queue order', () => {
const suggestion = composeTourSuggestion([
candidate({ slug: 'ancora', committed: 9_000 }),
candidate({ slug: 's1', deficit: 4_000 }),
candidate({ slug: 's2', deficit: 3_000 }),
candidate({ slug: 's3', deficit: 2_000 }),
candidate({ slug: 's4', deficit: 1_000 }),
])
expect(suggestion.satelliteSlugs).toHaveLength(TOUR_MAX_SATELLITES)
expect(suggestion.satelliteSlugs).toEqual(['s1', 's2', 's3'])
})
it('never proposes a stop with nobody to receive the candidate', () => {
const suggestion = composeTourSuggestion([
candidate({ slug: 'com-rede', committed: 500 }),
candidate({ slug: 'sem-rede', committed: 9_000, leadershipCount: 0, pledgeCount: 0 }),
])
expect(suggestion.anchorSlug).toBe('com-rede')
expect(suggestion.satelliteSlugs).toEqual([])
})
it('reserves the seed before filling the satellites, so a small giro still bets', () => {
// Four receivable municípios and three satellite slots: picking the seed
// from the leftovers would leave none, and the giro would only repeat the base.
const suggestion = composeTourSuggestion([
candidate({ slug: 'ancora', committed: 9_000, deficit: 900 }),
candidate({ slug: 'satelite', committed: 400, deficit: 700 }),
candidate({ slug: 'semente', territorialClass: 'expansao', deficit: 8_000 }),
candidate({ slug: 'semente-menor', territorialClass: 'expansao', deficit: 500 }),
])
expect(suggestion.seedSlug).toBe('semente')
expect(suggestion.satelliteSlugs).not.toContain('semente')
expect(tourSuggestionSlugs(suggestion)).toEqual([
'ancora',
'satelite',
'semente-menor',
'semente',
])
expect(resolveTourStopRole('ancora', suggestion)).toBe('ancora')
expect(resolveTourStopRole('semente', suggestion)).toBe('semente')
expect(resolveTourStopRole('satelite', suggestion)).toBe('satelite')
expect(resolveTourStopRole('fora', suggestion)).toBeNull()
})
it('returns an empty suggestion when the territory has nowhere to stop', () => {
const suggestion = composeTourSuggestion([
candidate({ slug: 'vazio', leadershipCount: 0, pledgeCount: 0 }),
])
expect(suggestion).toEqual({ anchorSlug: null, satelliteSlugs: [], seedSlug: null })
expect(tourSuggestionSlugs(suggestion)).toEqual([])
})
})