Merge branch 'cursor/55aaebf7'
This commit is contained in:
commit
b839b924fb
30 changed files with 2764 additions and 100 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
111
docs/plans/feedback-pendente-compositor-giro.md
Normal file
111
docs/plans/feedback-pendente-compositor-giro.md
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
# Dimir o resultado no compositor de giro (feedback pendente)
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-27
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins abertos)
|
||||
Impeccable: B — encaixe em `/campanha/atividades/giros` (rota entregue no **E13 ✓**); nenhuma superfície nova
|
||||
Appetite: ~0,25 dia eng; três pontos de edição, sem migration/action/`Consent`
|
||||
Responsável: —
|
||||
|
||||
## Design (Impeccable)
|
||||
|
||||
Âncoras: `PRODUCT.md` (clareza sob pressão) / `DESIGN.md` (register `product`, Field Desk) · tema `data-theme='campaign'` · precedente `engineering-standards.mdc` § Loading feedback e as 10 listas de `/campanha` que já usam `CampaignListPendingBoundary` + `CampaignListResults`.
|
||||
|
||||
Na implementação (`implement-roadmap-item`): craft compacto → critique → polish. Sem redesign.
|
||||
|
||||
Brief compacto:
|
||||
|
||||
- **Persona / contexto:** o Assessor troca o Território de Identidade no seletor e o compositor abaixo é recomposto pelo servidor (âncora, satélites, semente, fila). Enquanto a navegação corre, a proposta antiga continua nítida na tela — nada diz que ela é do território anterior.
|
||||
- **Job principal:** ao trocar de território, saber que a proposta visível está sendo substituída.
|
||||
- **Estratégia de cor:** Restrained — o idioma já existe (`opacity-60` + `aria-busy` + `aria-live` do `CampaignListResults`); nada de spinner novo nem skeleton próprio.
|
||||
- **Anti-goals:** transformar o compositor numa lista (ele não é); segundo idioma de pending; `loading.tsx` para a rota (a troca de território é uma transição, não uma entrada).
|
||||
|
||||
## Dados → decisão → apresentação
|
||||
|
||||
Dados: N/A — nenhuma métrica, série ou escala muda. O item é feedback de transição sobre a composição que o **E13 ✓** já entrega.
|
||||
|
||||
## Contexto
|
||||
|
||||
O `/simplify` do **E13** (2026-07-27, três revisores) marcou este achado como **"too involved — the composer isn't a list structure"** e o deixou passar. A auditoria de fechamento (`capture-review-debts`, mesma data) mediu o contrário: as peças de pending **não são acopladas a lista nenhuma** —
|
||||
|
||||
- `CampaignListPendingBoundary` (`src/components/campaign/shared/CampaignListPending.tsx:30`) é um `Context.Provider` em volta de `children`, sem uma linha sobre tabelas;
|
||||
- `CampaignListResults` (`:107`) é uma `div` que aplica `data-pending` → `opacity-60`, `aria-busy` e a região `aria-live` "Atualizando resultados…";
|
||||
- `useCampaignListTransition` (`:45`) **já** cai num `useTransition` local quando não há boundary acima.
|
||||
|
||||
Ou seja, o que falta é ligar três fios, não portar uma abstração. Hoje `TourRegionPicker` (`src/components/campaign/tour/TourRegionPicker.tsx:35`) instancia `useTransition()` **à mão** e gasta o `isPending` só no seu próprio `FieldDescription` (spinner + "Montando a proposta de giro…"), com o select desabilitado. É exatamente o padrão que o `engineering-standards.mdc` nomeia como defeito: _"a região de RESULTADOS dima, não só o controle"_.
|
||||
|
||||
Dois efeitos concretos, além da inconsistência com as dez listas vizinhas:
|
||||
|
||||
1. **A proposta obsoleta continua legível e clicável** durante a transição — inclusive as checkboxes de parada e o campo de nome do giro, cujo default (`Giro <TI> dd/mm`) é do território que está saindo.
|
||||
2. **Leitor de tela perde o evento.** As listas anunciam "Atualizando resultados…" numa região `aria-live` que envolve o resultado; aqui o único sinal vive na descrição do próprio select, que o usuário de teclado acabou de sair.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Trocar de território em `/campanha/atividades/giros` dima a região de resultado (proposta do giro / empty state), não apenas o seletor.
|
||||
- `TourRegionPicker` consome `useCampaignListTransition` em vez de instanciar o seu próprio `useTransition` — mesma peça que os outros controles navegantes de `/campanha`.
|
||||
- A transição anuncia-se por `aria-live` uma vez (a do `CampaignListResults`), sem criar uma segunda região viva na página.
|
||||
- Guardrails: sem migration, sem collection, sem server action, sem `Consent`; contrato de URL (`?region=`) intacto; o comportamento do formulário (`useActionState`, caps, validação) intacto.
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Reusar `CampaignListPendingBoundary`/`CampaignListResults` em vez de um par próprio para o compositor.** As duas peças não sabem o que é uma lista e o hook já tem fallback local; um par paralelo seria o segundo idioma de pending em `/campanha`. (2026-07-27, medição do fechamento do E13.) **Rejeitado:** componentes `TourComposerPending*` próprios (duplica `opacity`/`aria-busy`/`aria-live` por zero ganho); renomear as peças para `CampaignPending*` neste fill-in (toca 10 rotas por cosmética — só se um 3º domínio não-lista aparecer).
|
||||
- **A fronteira do "resultado" é tudo o que depende de `?region=`:** o `CalendarPhaseNote`, os empty states de "escolha um território"/"nenhum candidato" e o `TourComposerForm`. O header, o seletor e o botão "Voltar para Atividades" ficam fora. **Rejeitado:** dimir a página inteira (o controle que o usuário está operando não deve desaparecer sob ele).
|
||||
- **Nada de `loading.tsx` na rota.** A navegação é `router.push` dentro de uma transição, então o segmento não remonta; um `loading.tsx` só apareceria na entrada direta, que já é rápida. **Rejeitado:** skeleton do compositor (a proposta anterior dimada é mais informativa que um esqueleto).
|
||||
- **i18n e naming** (AGENTS.md): identificadores em inglês; a copy visível reusa "Atualizando resultados…" já existente, sem nova string.
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **A copy `aria-live` "Atualizando resultados…" serve para uma proposta de giro?** **Opções:** A) reusar como está | B) prop de mensagem no `CampaignListResults` para dizer "Recompondo o giro…". **Recomendação:** **A** — "resultados" descreve honestamente o que muda, e um prop novo abriria a porta para dez variações de copy nas listas; revisitar no critique se soar errado em voz alta.
|
||||
- **O spinner atual do `FieldDescription` fica ou sai?** **Opções:** A) fica (controle + resultado sinalizam) | B) sai, deixando só o dim. **Recomendação:** **A** — é o feedback imediato no ponto do gesto; o dim é o que informa o escopo. Custa duas linhas manter.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
boundary["CampaignListPendingBoundary<br/>(page.tsx)"] --> picker["TourRegionPicker<br/>useCampaignListTransition"]
|
||||
boundary --> results["CampaignListResults"]
|
||||
results --> phase["CalendarPhaseNote"]
|
||||
results --> empty["CampaignListEmptyState"]
|
||||
results --> form["TourComposerForm"]
|
||||
```
|
||||
|
||||
Componentes:
|
||||
|
||||
- **`src/app/(campaign)/campanha/(app)/atividades/giros/page.tsx`**: envolve o seletor + o bloco de resultado num `CampaignListPendingBoundary` (o provider é client, o conteúdo continua RSC como nas dez listas) e o bloco condicional (`CalendarPhaseNote` + a escada de empty states + `TourComposerForm`) num `CampaignListResults`.
|
||||
- **`src/components/campaign/tour/TourRegionPicker.tsx`** (`:35`): `useTransition()` → `useCampaignListTransition()`; nada mais muda (o `href` já vem serializado pelo RSC).
|
||||
- **Teste:** caso jsdom em `tests/unit/` no molde de `campaignListFilterNavigation.unit.spec.ts` (router mockado) provando que a troca de território marca `aria-busy` na região de resultado; o `data-pending` é o mesmo atributo que as listas já pinam.
|
||||
- **Migration:** nenhuma.
|
||||
|
||||
Depth check: nenhum módulo novo — três edições em dois arquivos que já donam a superfície.
|
||||
|
||||
## Dependências
|
||||
|
||||
- Dura: **E13 ✓** (a rota existe). Suave: **B33+ ✓** (`useCampaignListTransition` nasceu ali como fallback compartilhado).
|
||||
- Reusa: `CampaignListPending.tsx` inteiro.
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Feedback de submissão do formulário (gerar rascunhos) — já coberto pelo `useActionState` + `Spinner` do `TourComposerForm`.
|
||||
- Pending nas demais superfícies do E13 (card "Elegibilidade para visita" no detalhe) — já streama atrás de `<Suspense>`.
|
||||
- Renomear a família `CampaignList*` para algo neutro — gatilho no plano do **B32+** se um 3º consumidor não-lista aparecer.
|
||||
|
||||
## Rabbit holes
|
||||
|
||||
- **"Já que estou aqui, transformo o compositor numa lista canônica."** Se alguém "só completar": nasce paginação, filtros e footer numa tela de três interações. **Mitigação:** o item toca duas linhas de import e dois wrappers.
|
||||
- **"Generalizo o pending para toda a `/campanha`."** Vira o rename de 10 rotas que esta decisão explicitamente adiou. **Mitigação:** boundary = a rota de giros.
|
||||
|
||||
## Adiado com gatilho
|
||||
|
||||
- **Renomear `CampaignList*` → `CampaignPending*`.** Revisitar quando um **3º** consumidor não-lista usar as peças (o compositor é o 2º, contando o wizard de import como parcial).
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (Fill-ins abertos)
|
||||
- [planejador-de-giros.md](planejador-de-giros.md) — **E13 ✓**, o pai do achado
|
||||
- `src/components/campaign/shared/CampaignListPending.tsx` (`:30` boundary, `:45` hook, `:107` results)
|
||||
- `src/components/campaign/tour/TourRegionPicker.tsx` (`:35`)
|
||||
- `src/app/(campaign)/campanha/(app)/atividades/giros/page.tsx`
|
||||
- `src/app/(campaign)/campanha/(app)/municipios/page.tsx` — precedente de montagem boundary + results
|
||||
- `tests/unit/campaignListFilterNavigation.unit.spec.ts` — molde do teste
|
||||
- `.cursor/rules/engineering-standards.mdc` — § Loading feedback ("a região de RESULTADOS dima")
|
||||
- `PRODUCT.md` / `DESIGN.md` — Field Desk
|
||||
|
|
@ -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,54 @@ 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.
|
||||
|
||||
Um débito de engenharia ficou registrado aqui em vez de virar ID, na triage do fechamento (2026-07-27):
|
||||
|
||||
- **Um loader batelado da conta da cadeira.** O planejador compõe `loadStatewideSuggestedGoals` + `computeGoalCoverage` diretamente, como faz `loadMunicipalityGoalCoverageBundle` (E8) — são as mesmas primitivas, não um segundo orquestrador, e não há laço de `await` por município. Mas os dois divergem de propósito no dado ausente: a lista **preenche** meta zero para um slug fora do catálogo (a linha existe e tem de aparecer) e o planejador **descarta** o município (não se propõe visita sem base para raciocinar). Um revisor do `/simplify` leu isso como definição conflitante; é intencional, e por isso está dito aqui. Revisitar quando: um **3º** consumidor compor essas primitivas fora de `municipalityGoalAccount.ts` — aí a política de dado ausente passa a ser argumento do loader, em vez de convenção repetida em três lugares.
|
||||
|
||||
## 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
|
|
@ -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.',
|
||||
})
|
||||
172
src/app/(campaign)/campanha/(app)/atividades/giros/page.tsx
Normal file
172
src/app/(campaign)/campanha/(app)/atividades/giros/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
19
src/components/campaign/tour/CalendarPhaseNote.tsx
Normal file
19
src/components/campaign/tour/CalendarPhaseNote.tsx
Normal 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>
|
||||
)
|
||||
278
src/components/campaign/tour/TourComposerForm.tsx
Normal file
278
src/components/campaign/tour/TourComposerForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
72
src/components/campaign/tour/TourRegionPicker.tsx
Normal file
72
src/components/campaign/tour/TourRegionPicker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
29
src/lib/visitPlannerAnchors.ts
Normal file
29
src/lib/visitPlannerAnchors.ts
Normal 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
|
||||
|
|
@ -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[]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
286
src/utilities/visitEligibility.ts
Normal file
286
src/utilities/visitEligibility.ts
Normal 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',
|
||||
}
|
||||
304
src/utilities/visitPlannerData.ts
Normal file
304
src/utilities/visitPlannerData.ts
Normal 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 }
|
||||
}
|
||||
26
src/utilities/visitPlannerUrl.ts
Normal file
26
src/utilities/visitPlannerUrl.ts
Normal 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
|
||||
184
src/utilities/visitPlannerViews.ts
Normal file
184
src/utilities/visitPlannerViews.ts
Normal 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.`
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
169
tests/int/visitPlannerData.int.spec.ts
Normal file
169
tests/int/visitPlannerData.int.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)/,
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
349
tests/unit/visitEligibility.unit.spec.ts
Normal file
349
tests/unit/visitEligibility.unit.spec.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue