diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index bbdd9615..e65c39eb 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -7,6 +7,7 @@ alwaysApply: false ## Status +- **E13 entregue em código (2026-07-27):** planejador de presença/giros. Núcleo decisório: `visitEligibility.ts` (puro, input explícito espelhando `TerritorialClassInput`) resolve as **5 condições ✓/—** (volume, headroom, rede, janela, encaixe) e as 3 contraindicações (`perdida`/`sem_rede`/`no_teto`, sempre aviso com contra-oferta, nunca bloqueio); `visitPlannerAnchors.ts` guarda as datas das fases (`construcao|consolidacao|ativacao`, cortes ilustrativos no precedente de `territorialClassAnchors.ts`, comparação por data civil da Bahia via `formatBahiaCivilDate` novo em `campaignTime.ts`); `visitPlannerData.ts` (`server-only`) agrupa candidatas por TI. Superfícies: card "Elegibilidade para visita" no `OverviewTab` do detalhe do município e compositor em `/campanha/atividades/giros` (TI → âncora/satélites/semente → gerar rascunhos), alcançado por botão "Planejar giro" na página de Atividades, sem entrada nova no sidebar. **A auditoria pré-implementação derrubou duas premissas do plano de 24/07:** `kind: 'visita'` nunca entrou na migration do C12 e **não entra** — `activity.deputyPresent` já É o marcador de presença do candidato (o giro é derivável por `deputyPresent` + TI + janela de datas), e criar o enum custaria migration por um kind sobreposto a `reuniao_apoio`/`ato`; e `allocationDecision` não tem write path em `src/` (só o int spec), então o "não vá" fica textual e gravar o override virou gatilho do **E11**, que precisa desse write path de qualquer forma. Terceira correção: "satélites contíguos" era impossível — `bahiaTerritories.ts` só tem pertencimento, adjacência de polígono existe apenas nos scripts de build, então **encaixe = mesmo TI**, dito com essas palavras na UI (construir adjacência é o rabbit hole "otimizador de rota" que o plano já proibia). **Duas decisões de implementação que valem por si:** (1) o teto-do-campo-não-capturado e as medianas do catálogo saíram do classificador do **E10** para `municipalityPotential.ts` (`uncapturedFieldVotes`, `catalogMedianUncapturedFieldVotes`, `catalogMedianProjectedValidVotes`, memoizadas) — sem isso o card "Elegibilidade" e a coluna "Classe" seriam duas contas do mesmo número na mesma tela, e ninguém saberia qual estava certa; (2) `createTourDraftActivitiesRecord` relê os ids das paradas com `overrideAccess: false` **dentro** da transação e aborta se algum não voltar, porque o `create` de `activity` é um booleano de staff e Payload não expressa restrição por município no create — buraco achado por int test, sem o qual um assessor semeava rascunhos no estado inteiro postando ids que o compositor nunca lhe ofereceu. Também: `loadMunicipalityVisitEligibility` roda o **mesmo** loader recortado no TI em vez de um segundo avaliador (encaixe é afirmação sobre vizinhos — não é avaliável para um município sozinho, e dois avaliadores começariam a discordar); `composeTourSuggestion` prioriza âncora → semente → satélites (a primeira versão deixava os satélites consumirem as vagas e a semente de expansão nunca aparecia); caps `TOUR_MAX_SATELLITES` 3 e `MAX_TOUR_STOPS` 8, verificados no cliente e revalidados na action; `TourRegionPicker` monta href com `usePathname` + `encodeURIComponent` em vez do serializador canônico (lição do **B14**: arrastaria `bahiaTerritories` + `municipalityCatalog` para o bundle). Dois verbetes E18 (`elegibilidade-para-visita`, `fase-do-calendario`). Suítes **fundidas, não acrescentadas** (arquivos separados empurraram unit e int para timeout): views do planejador em `tests/unit/visitEligibility.unit.spec.ts`, compositor em `tests/int/campaignActivity.int.spec.ts`; `CALENDAR_PHASES`/`VISIT_CONDITIONS`/`VISIT_CONTRAINDICATIONS` são exportadas e varridas lá para provar completude dos rótulos (um `const` usado só como tipo é warning de ESLint, e a varredura é a razão honesta de ser público). First Load JS: `/campanha/atividades/giros` 8,59 kB / 226 kB; detalhe do município 7,3 kB / 232 kB. Sem migration, sem collection, sem `Consent`, sem entidade `tour` persistida. Gate verde e bare (tsc, lint, format:check, knip com o P3 pré-existente, check:cycles, 429 unit+int, build local); Aikido **0 findings**. **e2e não serviu de veredito:** `campaignMunicipalities.e2e.spec.ts` falha em árvore stashada também, em teste diferente a cada rodada — o flake já ledgerizado. **Adiados com gatilho:** override "não vá" gravando `allocationDecision` → **E11**; 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 → 3º giro real composto. Plano as-built: [`planejador-de-giros.md`](../../../docs/plans/planejador-de-giros.md). - **B42 entregue em código (2026-07-27):** polimento mobile de `/campanha/municipios`. **Escopo ampliado na sessão (decisão do usuário): Votos estimados entrou** junto com Tendência e Assessores, o que fez o item passar de 2 para **3 controles com a mesma casca** — critério de extração do repo — e **inverteu a questão em aberto do plano** (que recomendava branch local e extração só no `/simplify`): nasceu `src/components/campaign/shared/CampaignCellEditOverlay.tsx`, Popover em `md+` / Drawer abaixo. A casca carrega **só o container**: cada controle segue dono do seu auto-save, e como o `handleOpenChange` de cada um passa direto para `onOpenChange`, fechar o Drawer grava exatamente como dispensar o Popover (é o que preserva o flush da justificativa do **B24 ✓**). O **Sinal (B26 ✓) não migrou, e o motivo que registrei primeiro estava errado** — não é que "a casca não expressa isso sem virar genérica": o `
` não precisa envolver o `DrawerHeader` (título não é campo) e submit fora do form é HTML padrão via `form=""`. Falta só um **slot `footer`** (+ `formId` do chamador), débito com gatilho. Ele segue sendo o precedente visual (com "Fechar" no lugar de "Cancelar", porque aqui não há o que cancelar) e agora **consome** a classe de trigger da casca e os três atributos ARIA que lhe faltavam. **`useIsMobile` foi descartado por auditoria**: cards (`md:hidden`) e tabela (`hidden md:block`) são árvores irmãs, então `variant` por call site basta — media query trocaria o container depois da hidratação. **Navegação do card = link esticado, zero JS** (`` no `

` com `after:absolute after:inset-0` sobre `
`), então `MunicipalityList` continua Server Component; o botão "Abrir município" saiu — o plano previa testar "tap no título navega" quando o título **ainda não era link**, e a navegação nasceu junto. **Quem é `relative` é o trigger, não a célula:** a primeira versão posicionou os seis `
` do `
` e com eles os rótulos e o padding de cada célula, o que fez metade do card não editar nem navegar (P0 do critique, corrigido movendo `relative` para a classe compartilhada do trigger; o e2e passou a tocar num rótulo `dt` dentro da grade, exatamente a área que o bug engolia). **Dois props `layout` morreram** com a paridade pedida (`TerritorialClassReadout` = badge + `sr-only` do porquê; `MunicipalityListGoalCoverageCell` = sempre `compact`). `autoFocusScenario` suprimido no `sheet` para o teclado virtual não subir antes de o número ser lido; `municipalityName` é **exigido** onde há Drawer e **opcional** no Popover (união discriminada nos props de Votos estimados; o 3º call site, `MunicipalityGoalAccountCard`, é Popover sob o título do próprio município e não passa). A primeira versão o **proibia** no Popover (`?: never`) e criou assimetria na mesma linha da tabela — Tendência/Assessores nomeando o município no rótulo e Votos estimados não; resolvido para o lado de incluir porque a célula do nome é ``, **não** ``, então nada supre o contexto quando o foco entra num trigger (custo: 0,22 KiB gzip em 25 linhas). Sem migration/collection/action/`Consent`. **Critique + `/simplify` (mesma sessão), três defeitos de comportamento além do P0 acima:** (1) o Drawer focava o primeiro tabbable e subia o teclado virtual sobre a folha antes de ela ser lida — `initialFocus` no `DrawerTitle` (`tabIndex={-1}`), e o `preventContentAutoFocus` que devia mitigar isso era **inerte no branch `sheet`**, um prop que mentia → renomeado `preventPopoverAutoFocus`, comportamento do Drawer incondicional e pinado no spec; (2) `aria-label` **substitui** o conteúdo do trigger, então as quatro células anunciavam o verbo e engoliam o valor que o pill/avatar/número mostra → forma do Sinal ("Editar tendência em X — Favorável"), o que de passagem tirou `municipalityName` de payload RSC morto no desktop (27,9 KiB crus / 2,9 KiB gzip em 435 linhas); (3) fechar **commita** o rascunho e desmonta o `Alert`/`aria-live` que carregariam o erro, então falha na saída revertia o badge em silêncio → `toast.error` só quando já fechado, nos quatro controles. Mais: alvos de toque a 44px abaixo de `md` (inputs de votos, chip de assessor, linhas do `Command`), `CampaignHoverTooltip` na Classe do card (`sr-only` sozinho deixava o veredito inalcançável no toque — fecha o "Adiado com gatilho" do plano) e a frase da Cobertura em `sr-only` (`title` não é anunciado nem alcançado por toque). **Reuso: `LeadershipListSupportStatusControl` (B32 ✓) era o branch Popover da casca redigitado à mão** (classe de trigger byte-a-byte inclusive) → 4º call site, ~25 linhas a menos, nenhum prop novo; a string de trigger virou `campaignCellEditTriggerClassName`; `sheetBodyClassName` era o único prop de classe que substituía em vez de compor, o que obrigava o chamador de assessores a re-espalhar `px-4`. **Medido e não pago:** cada Drawer fechado ainda executa o corpo de `DrawerContent`, cujo `cn()` roda `tailwind-merge` sobre 4.068 caracteres (6,2 µs contra 0,8 µs do Popover que substituiu = +0,40 ms em 25 linhas, +7 ms no caminho de 435) — é troca, não adição; o fix é `{everOpened && …}` (não `{open && …}`, senão perde `data-starting-style`). Testes: 6 casos jsdom (3 controles × 2 variants; stubs de `ResizeObserver`/`scrollIntoView` que o `cmdk` exige) + e2e em viewport 390×844 (abrir Tendência não navega; toque num rótulo da grade abre o município). **Nada foi confirmado em device real:** a coleta de evidência visual falhou por falta de automação de browser na sessão, então alvos de toque estão checados por classe, não medidos. Gate tsc/lint/format/knip(P3)/cycles/**592 unit**/**420 int**/build (`/campanha/municipios` 13,4 kB / 320 kB); Aikido 0 findings. Falhas rotativas da suíte cheia reproduzidas **piores na árvore limpa** (4×3, mesmo teste de demanda na mesma linha) sob contenção de outra sessão Playwright — mesmo padrão de ambiente do B27 ✓/B32 ✓. **Cuidado operacional descoberto aqui: `git stash` é compartilhado entre worktrees** — stashar para medir baseline com sessões concorrentes traz o WIP alheio no `pop` (revertido com `git stash store `); prefira patch local. Plano: [`polimento-mobile-lista-municipios.md`](../../../docs/plans/polimento-mobile-lista-municipios.md). - **B41 entregue em código (2026-07-27):** scroll horizontal + coluna Município sticky em `/campanha/municipios` (`md+`). Espelha **B21 ✓** `TerritoryList`: `containerClassName="overflow-x-auto"`, coluna `name` com `sticky left-0 z-20`/`z-[5]` + `min-w-56 bg-background`; wrapper `overflow-visible` mantido. Cards mobile inalterados (**B42**). Sem migration/action/Consent. Critique: P1 `min-w`×`max-w` corrigido; edge fade Adiado. Gate tsc/lint/format/knip(P3)/cycles/540 unit/413 int/build; Aikido 0 findings. Plano: [`scroll-horizontal-lista-municipios.md`](../../../docs/plans/scroll-horizontal-lista-municipios.md). - **C8 F4b entregue em código (2026-07-27):** o guard `campaign formActions convention` em `codebaseConventions.unit.spec.ts` só via `…/formActions.ts`, então os irmãos (`lifecycleFormActions`, `resultFormActions`, `updateFormActions` ×2, `pledgeFormActions`, `municipalityStaffFormActions`, `taskActions`) ficavam sem verificação. Filtro ampliado para basename `/^\w*[Ff]ormActions\.ts$/` + `taskActions.ts`; 6 ladders migradas para `runCampaignFormAction`/`runCampaignRedirectFormAction`; `taskActions.ts` allowlisted (contrato posicional para `useOptimistic`). Docstring do wrapper precisada: exceção de atividades = só `atividades/formActions.ts`. Sem migration/UI. Gate: tsc/lint/format/knip (P3 pré-existente)/cycles/unit 540/build; int relacionado (activity + votePledge) verde — suíte int cheia flocou com `too many clients` no Postgres compartilhado. Aikido Opengrep exit 2 pré-existente, 0 findings nos arquivos editados. Plano: [`escala-dry-pos-c6.md`](../../../docs/plans/escala-dry-pos-c6.md). diff --git a/AGENTS.md b/AGENTS.md index e98dbb43..87660d20 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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) diff --git a/docs/plans/feedback-pendente-compositor-giro.md b/docs/plans/feedback-pendente-compositor-giro.md new file mode 100644 index 00000000..0b7fce2a --- /dev/null +++ b/docs/plans/feedback-pendente-compositor-giro.md @@ -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 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
(page.tsx)"] --> picker["TourRegionPicker
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 ``. +- 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 diff --git a/docs/plans/planejador-de-giros.md b/docs/plans/planejador-de-giros.md index 4f827954..60aac991 100644 --- a/docs/plans/planejador-de-giros.md +++ b/docs/plans/planejador-de-giros.md @@ -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 `" — "` 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
(5 condições sobre E8/rede)"] Phase["calendarPhase (data)"] Cands["lista candidatas a visita
(J-A ordenadas)"] - Comp["tourComposer
(âncora + satélites TI + semente)"] - Plans["actionPlan em rascunho
(origin, kind=visita)"] - Dec["allocationDecision
(override não-vá / J-B)"] + Comp["tourComposer
(âncora + satélites do TI + semente)"] + Plans["activity em rascunho
(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 diff --git a/docs/roadmap.md b/docs/roadmap.md index c37cdd0a..5ec134aa 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-27 (**B42 ✓** polimento mobile da lista de municípios — Drawer em Tendência/Assessores/Votos estimados pela casca `CampaignCellEditOverlay`, card compacto e clicável por link esticado; a triage do fechamento não abriu ID novo: **F4** entrou no **B32+** (região `aria-live` que desmonta com o overlay) e o débito de bundle do catálogo no **B33+** deixou de ser condicional (gatilho disparado por B41 ✓ e B42 ✓) · [plano](plans/polimento-mobile-lista-municipios.md); **B41 ✓** scroll horizontal + sticky Município · [plano](plans/scroll-horizontal-lista-municipios.md); **B32+** novo fill-in de engenharia registrado no fechamento do B32 — `MunicipalityListExpectedVotesControl`/`MunicipalityListTrendControl`/`LeadershipListSupportStatusControl` são o 3º call site da mesma máquina de auto-save por debounce (estado/refs/save/schedule/flush quase idênticos), gatilho que os três planos B24/B27/B32 já haviam registrado; auditoria primeiro, extração condicional (precedente B33+) — [plano](plans/escala-dry-pos-b32.md); **B32 ✓** editar Status de apoio na célula da lista de lideranças — `LeadershipListSupportStatusControl` (Popover + `NativeSelect`, sempre montado, sem toggle "Editar" de tabela inteira) grava por `POST /campanha/liderancas/support-status` (auto-save ~150 ms, sem `revalidatePath`), reusando `isSameOriginRequest`/`campaignJsonMutationRoute.ts` já extraídos pelo B24 ✓/B27 ✓; fecha o "Adiado com gatilho" do B29 — [plano](plans/autosave-status-lista-liderancas.md); **B33+ ✓** `useCampaignListFilterNavigation` — o scaffold de navegação/debounce sai dos três filtros de lista para um hook em `shared/` (281 linhas removidas contra 75 em `src`); a auditoria derrubou a premissa do plano de que os três domínios teriam assinaturas de canonicalização diferentes — `buildStateDeputyListHref`/`buildTerritoryListHref`/`buildMunicipalityListHref` **já canonicalizam internamente**, então o `parse(...)` explícito de dobradinhas/territórios era trabalho redundante e um único callback `toHref` cobre os três; a entrega achou **dois** bugs de comportamento, não de estrutura: tocar um filtro no meio da digitação **descartava** a busca pendente em dobradinhas e territórios (unificado na semântica de munícipios por `navigateWithSearch`), e o `setTimeout` do debounce capturava o `state` do render que o agendou, então um sort clicado dentro da janela de 1s era **revertido** um segundo depois — munícipios escapava por acidente, pelo `key=` da página que remonta e mata o timer (corrigido com `stateRef`, com caso de spec verificado vermelho nas três listas sem o fix); o `/simplify` ainda achou o fallback `useCampaignListPending() ?? useTransition()` copiado em 5 lugares, com o hook novo como 6º → `useCampaignListTransition`, e um `aria-live` que fazia o resumo de filtros de munícipios ser a **terceira** região viva da mesma página; `shouldUpdateMunicipalitySearchUrl` orfanou e foi deletado; `tests/unit/campaignListFilterNavigation.unit.spec.ts` (jsdom + router mockado + fake timers, 8 casos × 3 listas) é a primeira cobertura desses componentes — [plano](plans/escala-dry-pos-b33.md); **C8 F4b ✓** guard de `formActions` ampliado para irmãos `*FormActions.ts` + `taskActions.ts`; 6 ladders migradas; `taskActions.ts` allowlisted · [plano](plans/escala-dry-pos-c6.md); **Build resiliente ao global `metadata` ausente ✓** — `resolveSiteMetadata` em `src/utilities/seo.ts`; fallback `NEXT_PUBLIC_SITE_URL` e omit canonical/OG quando não há URL; 4 rotas + layout; [plano](plans/build-resiliente-metadata-ausente.md); **B8+ ✓** escala e DRY pós-B8 F2 — a política de escala de chão das malhas commitadas virou código nomeado (`scripts/lib/topology.mjs`, tolerância de Visvalingam em m² e dígitos de quantização derivados do bbox de cada topologia), e com ela a malha das ZE caiu de 480 para **30,2 pontos/feature** (72.310 → 7.687 bytes) sem que `bahia-municipalities` mudasse um byte; a auditoria corrigiu a alavanca do plano (quantização sozinha quase não corta — o peso absoluto manda) e **rejeitou** um item do F4 por medição (`city`/`zoneNumber` de `MunicipalityZoneNeighborhoodEntry` são evidência de proveniência hasheada, não código morto); `MAX_POINTS_PER_FEATURE = 60` é o novo tripwire, `featureMapKey` passou a ser exercido sobre features reais das três malhas (417 `codarea`, 27 `code`, 19 `slug`), o mapa **degrada** em vez de morrer quando o chunk das zonas falha, e a ressalva de aproximação saiu do gate de comparação · [plano](plans/escala-dry-pos-b8f2.md); **`users.roles` ✓** — `admin`\|`editor` em `users`, migration `20260727_032523_add_users_roles`, editor publica `post`/`tag`/`media`; unificação com `campaignUser` rejeitada · [plano](plans/roles-admin-payload.md); 2026-07-26 (**fill-in** remover lista "Municípios por zona eleitoral" do Início — pós-B8 F2 ✓ os polígonos tornam a lista textual redundante; dropar UI + `zoneBreakdown` do bundle; ressalva de aproximação fica na malha · [plano](plans/remover-lista-zonas-mapa-inicio.md); **B8 F2 ✓** polígonos das 19 ZE de Salvador — artefato `bahia-municipality-zones.topo.json` (72 KB) por dissolve bairro→zona do `BA_bairros_CD2022` do IBGE contra a RA 02/2017 do TRE-BA, com curadoria explícita e um passe de maior fronteira compartilhada validado; o coroplético passou a ser keyed por **map key** (`slug` na zona, `ibgeCode` no resto) e o polígono municipal de Salvador ficou como base sem interação por baixo, porque duas malhas simplificadas independentemente não fecham na mesma costa; `competitiveRank` segue por cidade (o artefato TSE só tem `federalRankByIbgeCode`) com int pinando o fato; desambiguar a ZE no card "Onde estou" do B14 ✓ virou débito com gatilho, não dependência — [plano](plans/poligonos-pracas-zona.md); **B8+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B8 F2, cujos relatórios chegaram depois do merge — a malha de 19 zonas ficou com a mesma contagem de vértices da malha dos 417 municípios (as duas constantes de simplificação são relativas ao bbox e foram copiadas verbatim, o que vale ~3,9 m de grade em Salvador contra ~103 m no estado), a regra da map key está em três formas sem nenhum teste ligando artefato → `featureMapKey` → catálogo, uma falha de chunk nas zonas passou a derrubar o mapa inteiro, a ressalva de aproximação desaparece no modo comparação, e `getTerritoryFeature` perdeu o último consumidor de produção neste commit · [plano](plans/escala-dry-pos-b8f2.md); **B39–B42** registrados: sessão "Lembrar de mim" + login biométrico WebAuthn + scroll horizontal/sticky na lista de municípios + polimento mobile da mesma lista — [B39](plans/lembrar-de-mim-sessao-campanha.md), [B40](plans/login-biometrico-campanha.md), [B41](plans/scroll-horizontal-lista-municipios.md), [B42](plans/polimento-mobile-lista-municipios.md); **E12 ✓** camada TI — rollups MAUP, colunas Cobertura/Captura/Classe em `/campanha/territorios`, benchmark intra-TI no `MunicipalityGoalAccountCard`; [plano](plans/camada-territorios-identidade.md); **B36 ✓** coluna "Lideranças" em `/campanha/dobradinhas` — chips + Popover+Command + delta reuse da mutação B31; `LeadershipStateDeputyRelationCell` compartilhada (`direction`/`measureOverflow`); columns factory (não Context); casca `dobradinhas/formActions.ts`; `loadLeadershipOptions` + `leaderships[]` no row; [plano](plans/chips-liderancas-lista-dobradinhas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` (`UserPlusIcon`) na coluna `actions` ao lado do contato do B28 ✓; Popover two-step reusa `createCampaignInvite`/`autopreenchimento`; disable por `normalizeBrazilianPhone`; [plano](plans/convite-whatsapp-lista-liderancas.md); **B31 ✓** coluna de dobradinhas na lista de lideranças entregue — chips clicáveis → `/campanha/dobradinhas/` em `/campanha/liderancas`, edição por Popover+`Command` (mecânica do **B27 ✓**) com escrita por **server action direta do cliente** (`useTransition`, padrão do B19 ✓) em vez do endpoint JSON do B27, porque aqui há derivados a revalidar (`leadershipCount`, a ficha do deputado); `setLeadershipStateDeputyMembership(Record)` em transação + lock `leadership-state-deputies:{id}` + `revalidatePath`; helper puro `nextStateDeputyIdsAfterMembership` espelha `nextAdvisorIdsAfterMembership`; catálogo entregue uma vez por tabela via `leadershipColumns(stateDeputyOptions)` (columns factory, mesmo precedente de `municipalityListColumns`) em vez de Context; é o **2º call site** do padrão Popover+Command+chips — extração para `shared/` continua esperando o 3º (B34/B36/B37, que agora consomem a mutação/lock em vez de correr para desenhá-los); [plano](plans/dobradinhas-lista-liderancas.md); **fill-in ✓** limitar pan/zoom do mapa à Bahia — `BahiaMap` com `maxBounds`/`maxBoundsViscosity: 1` sobre `BAHIA_BOUNDS.pad(0.1)` e `minZoom` via `getBoundsZoom` com o mesmo padding 16px do `fitBounds`, recalculado no `ResizeObserver` existente; único arquivo; [plano](plans/limitar-mapa-a-bahia.md); (**B14 ✓** município mais próximo entregue — card cliente "Onde estou" no Início staff resolvendo **point-in-polygon** sobre o chunk de geometria que o mapa do dashboard já carrega (a premissa "polígonos explodem o bundle" do plano de 24/07 caiu na medição: 41 KB gzip já estão no browser, então o artefato de centroides e seu script saíram de escopo), com fallback nomeando onde o assessor está e o município mais próximo **da carteira** dentro de 150 km; a posição nunca sai do aparelho (sem action, sem `Consent`); First Load JS de `/campanha` 265 → 268 kB depois de mover o href das zonas de Salvador para o servidor (`zoneCityHrefs`) — importar `buildMunicipalityListHref` no cliente custava **21 kB** por um link, porque arrasta `bahiaTerritories` + `municipalityCatalog`; [plano](plans/municipio-mais-proximo.md); ~~**B38**~~ ✓ sidebar recolhido em tablet entregue — offcanvas + trigger md+ + cookie/matchMedia; [plano](plans/sidebar-recolhido-tablet.md); **B33+** novo fill-in de engenharia registrado no fechamento do B33 — `StateDeputyFilters` é o 3º call site do scaffold de navegação/debounce que `TerritoryFilters`/`MunicipalityFilters` já tinham, achado do `/simplify` rodada 2, [plano](plans/escala-dry-pos-b33.md); **B28 ✓** e-mail + celular na lista de lideranças entregue — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp, no padrão de `/campanha/assessores` (B19 ✓); `shared/CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`, `loadLeadershipDetail` deduplicado; [plano](plans/email-celular-lista-liderancas.md); **B24 ✓** auto-save + justificativa no popover de Tendência de `/campanha/municipios` — sem botão "Salvar", `POST /campanha/municipios/political-trend`, [plano](plans/autosave-tendencia-lista-municipios.md); **B13 ✓** escala relativa no mapa do Início entregue — quantis como novo default, padrão próprio/LQ nas âncoras do E10 e posição no município a partir do **artefato TSE v3** (`federalRankByIbgeCode`), mais bolhas opcionais por válidos projetados coloridas pela classe; `computeAggregateTerritorialClass` nasce aqui e o **E12** deve herdá-lo em vez de escrever um rollup próprio; `TERRITORIAL_CLASS_ANCHORS` mudou de casa para `src/lib/territorialClassAnchors.ts`; [plano](plans/escala-relativa-mapa.md); **B13+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B13 — teste de concordância entre as duas escritas do LQ (mapa cliente × classificador server-only, a rede de segurança do **E15**), escopo geográfico declarado no readout (posição é da cidade inteira, classe é do escopo do usuário), uma varredura da fatia TSE por ano no script de agregados em vez de três, e a tupla no `competitiveRankByYear` que corta ~metade dos ~63,6 KB somados ao payload RSC · [plano](plans/escala-dry-pos-b13.md); **débito de teste** afinado no ledger ([TECH-DEBT.md](TECH-DEBT.md), P3 → P2): `campaignMunicipalities`/`campaign-pwa` são flaky com 2 workers **em `main`** também — clique no checkbox de consentimento antes da hidratação e regex de fixture em que "Conde" casa com "Condeúba"; **B27 ✓** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` entregue — a checkbox list + botão "Salvar assessores" virou chips removíveis + `Command` com gravação por delta, `nextAdvisorIdsAfterMembership` movido para `src/lib/`, endpoint `POST /campanha/municipios/advisors` espelho do `expected-votes/`, [plano](plans/combobox-assessores-lista-municipios.md); **B23 ✓** tooltip de conteúdo nas células das listas entregue — a mecânica genérica (`cellTooltip`, `CampaignCellTooltip`, `CampaignHoverTooltip` em `shared/`) já tinha saído de graça no E10 ✓; este item fechou `openOnTouch`/`disabled` no primitivo (mais um terceiro comportamento do `/simplify`: `content` falsy renderiza o trigger sem wrapper) e os dois consumidores de `/campanha/municipios` (nomes de assessores + justificativa da tendência), [plano](plans/tooltip-celulas-listas.md); **B22 ✓** explicação por coluna no header das listas entregue — `description?: ReactNode` em `CampaignTableHead`/`MunicipalitySortableHead` (rename de `tooltip`, sublinhado pontilhado), `campaignConceptOneLiner` como fonte única para colunas cobertas pelo E18 ✓, `municipalityColumnDescriptions` cobrindo as 11 colunas de `/campanha/municipios`; sem migration/action, [plano](plans/explicacao-colunas-header-listas.md); 2026-07-25: **E10 ✓** classificação territorial relativa entregue — Reduto/Expansão/Manutenção/Marginal/Sem base sobre o artefato TSE, coluna+sort+filtro na lista, badge no card do E8 e na capa do dossiê, e o seam genérico `cellTooltip` da `CampaignTable` (núcleo do B23) com `CampaignHoverTooltip` promovido para `shared/`; **B13** liberado; **E10+** novo fill-in de engenharia com os quatro débitos do `/simplify` do E10 — a lista lendo `municipality` duas vezes por request (o que **dispara o "Adiado com gatilho" do A11**: `classe` é o 4º path derivado no loader), memo de processo dos potenciais por município, `TooltipProvider` único no layout e os multi-filtros do mobile saindo das definitions · [plano](plans/escala-dry-pos-e10.md); ~~**C8 F4b**~~ ✓ (entregue 2026-07-27); **B26 ✓** registrar sinal na célula "Último sinal" — [plano](plans/registrar-sinal-lista-municipios.md); **C13 ✓** "Plano de Ação" → **"Atividade"** renomeado em toda a vertical — collection `activity`, rota `/campanha/atividades`, sidebar "Atividades", migration data-preserving escrita à mão (4 tabelas, 3 enums, 2 colunas, 39 índices, 14 FKs, 2 sequences + os 8 índices fósseis `%plaza%` da remodelagem) e guard de vocabulário table-driven varrendo `src`+`tests`+`scripts`; [plano](plans/renomear-plano-acao-para-atividade.md); **B37** chips editáveis de municípios na lista de dobradinhas — coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`municipalityCount`) por chips com link (município) / território (`MapPinIcon`) e o mesmo Popover+combobox do **B34** (busca por município/TI/ZE, lote de território/zona), mas escrevendo do lado invertido (`municipality.stateDeputies`, sem piso mínimo, lock por município em vez de por dobradinha, sem `assertMunicipalitiesWithinScope` porque `canUpdateMunicipality` já escopa o assessor); reaproveita/promove a mesma lib do B19/B34 (quem chegar primeiro faz a renomeação); mesmo conflito registrado com o pedido literal (toggle "Editar" de tabela inteira) que B28/B31/B32/B34/B36 já resolveram na direção do Popover por célula — decisão marcada como _proposta, a validar com produto_; sem migration, [plano](plans/chips-municipios-lista-dobradinhas.md); **B36** coluna "Lideranças" em `/campanha/dobradinhas` com a mesma UI/mecânica da coluna "Municípios" de `/campanha/assessores` (B19 ✓) — chips + Popover + `Command` + auto-save por delta + "Ver mais…" (clamp de 3 linhas por `ResizeObserver`, necessário aqui porque um deputado popular pode acumular muito mais lideranças do que uma liderança acumula dobradinhas); pedido explícito de não duplicar lógica — reusa **sem alteração** a mutação/schema/lock que o **B31** desenha para o lado inverso da mesma relação `leadership.stateDeputies`, promovendo a célula desse item para um componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`) em vez de um segundo componente-irmão; corrida de extração com o B31 (quem entrar primeiro paga a peça compartilhada); sem migration, [plano](plans/chips-liderancas-lista-dobradinhas.md); **B34** chips editáveis de municípios na lista de lideranças — coluna "Municípios" de `/campanha/liderancas` ganha chips com link (município) / território (`MapPinIcon`), edição por **Popover disparado por célula** com chips removíveis + combobox `Command` (busca por município/TI/ZE, lote de território/zona), gravação **por delta** direto em `leadership.municipalities` com lock `leadership-municipalities:{id}` (piso de 1 e teto de 30 respeitados) e `revalidatePath` (`liderancas`, `liderancas/[id]`, `municipios/[slug]` dos tocados); promove a lógica pura de `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros, sem portar a UI com `ResizeObserver`; **conflito registrado com o pedido literal** (pede um toggle "Editar" de tabela inteira, que B28/B31/B32 já travaram como anti-goal nesta mesma tabela — este plano segue o precedente e marca a decisão como _proposta, a validar com produto_); sem migration, [plano](plans/chips-municipios-lista-liderancas.md); ~~**B33**~~ ✓ ordenação e filtro no header da lista de dobradinhas (entregue 2026-07-26) — `?sort=`/`?dir=` por Nome/Partido + Popover multi-seleção de Partido com "Sem partido", consumindo o chrome compartilhado do **B21 ✓** (a corrida de ordem de chegada com B29 foi encerrada); paridade mobile (`StateDeputyFilters`) entrou por decisão desta sessão; ordem dos nulos de `party` pinada por int test; contagens derivadas (`municipalityCount`/`leadershipCount`) seguem sem sort/filtro, como travado, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` na coluna `actions` (entregue 2026-07-26), [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); ~~**B28**~~ ✓ e-mail + celular na lista de `/campanha/liderancas` (entregue 2026-07-26) — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp como em `/campanha/assessores` (B19 ✓); `CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`; edição in-list adiada; sem migration, [plano](plans/email-celular-lista-liderancas.md); ~~**B27**~~ ✓ combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/combobox-assessores-lista-municipios.md); ~~**B26**~~ ✓ registrar sinal na célula "Último sinal" (entregue 2026-07-25), [plano](plans/registrar-sinal-lista-municipios.md); **fill-in** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); glossário/KPI strips intactos; B16 ✓ liberou o nome ao remover "Assessoria", [plano](plans/renomear-coluna-cobertura-meta.md); ~~**B25**~~ ✓ link do território na lista de municípios (entregue 2026-07-26) — coluna "Território" + card mobile → `/campanha/territorios#ti-`; `territoryAnchor.ts` + `TerritoryLink`; seam `rowId` em `CampaignTable`; [plano](plans/link-territorio-lista-municipios.md); ~~**B24**~~ ✓ auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/autosave-tendencia-lista-municipios.md); **fill-in** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtro por Salvador cobre as zonas; drop também do filtro/sort/`?kind=` na URL da lista, schema `kind` intacto, [plano](plans/remover-coluna-tipo-municipios.md); ~~**B23**~~ ✓ tooltip de conteúdo nas células das listas (entregue 2026-07-26), [plano](plans/tooltip-celulas-listas.md); **fill-in** opções selecionadas no topo nos Popovers de filtro do header — helper puro reutilizável pelas demais listas, [plano](plans/selecionados-no-topo-filtros-header.md); ~~**B22**~~ ✓ explicação por coluna no header das listas (entregue 2026-07-26), [plano](plans/explicacao-colunas-header-listas.md); **B21** página própria dos Territórios de Identidade — rota `/campanha/territorios` com overview + tabela no sistema de listas (sort/filtro no header), sem migration, [plano](plans/pagina-territorios-identidade.md); **B20 ✓** Municípios prioritários no Início entregue 2026-07-25 — atalho ranqueado por déficit + markup sem bullets, [plano](plans/municipios-prioritarios-inicio.md); **Pass 2 ✓** consolidação de engenharia — listas genéricas/B16+ absorvido/B17-B18 triviais/E10 limpo/C8 F4 ✓/D3 consent WhatsApp; **B16 ✓**; **E16 ✓**; **E9 ✓**; **E18 ✓**; **B19 ✓**; **E8 ✓**; C12 ✓; B18; B17; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14 ✓; janela 1: smoke, R6)Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc). +Atualizado em: 2026-07-27 (**fill-in** dimir o resultado no compositor de giro — a troca de TI em `/campanha/atividades/giros` sinaliza só no seletor; a triage do fechamento do E13 mediu que as peças de pending não são acopladas a lista e o `/simplify` as havia dispensado por isso · [plano](plans/feedback-pendente-compositor-giro.md); **E13 ✓** planejador de presença/giros — elegibilidade de visita em **5 condições ✓/—** (volume, headroom, rede, janela, encaixe) no detalhe do município, fase do calendário (`construcao|consolidacao|ativacao`, âncoras ilustrativas em `src/lib/visitPlannerAnchors.ts`) e compositor em `/campanha/atividades/giros` gerando `activity` em rascunho com `deputyPresent` numa transação; **duas premissas do plano caíram na auditoria** — `kind: 'visita'` nunca entrou na migration do C12 (e não entra: `deputyPresent` já É o marcador de presença) e `allocationDecision` não tem write path em `src/` (o "não vá" é aviso textual; gravar o override vira gatilho do **E11**); "encaixe em giro" = **mesmo TI**, porque não existe grafo de contiguidade em runtime; o teto-do-campo-não-capturado e as medianas do catálogo saíram do classificador do E10 para `municipalityPotential.ts` para que card e coluna "Classe" não pudessem divergir; a action relê os ids das paradas com `overrideAccess: false` **dentro** da transação (o `create` de `activity` é booleano de staff — sem isso um assessor semeava o estado inteiro), buraco achado por int test; sem migration, sem collection, sem `Consent`, sem entidade `tour` · [plano](plans/planejador-de-giros.md); **B42 ✓** polimento mobile da lista de municípios — Drawer em Tendência/Assessores/Votos estimados pela casca `CampaignCellEditOverlay`, card compacto e clicável por link esticado; a triage do fechamento não abriu ID novo: **F4** entrou no **B32+** (região `aria-live` que desmonta com o overlay) e o débito de bundle do catálogo no **B33+** deixou de ser condicional (gatilho disparado por B41 ✓ e B42 ✓) · [plano](plans/polimento-mobile-lista-municipios.md); **B41 ✓** scroll horizontal + sticky Município · [plano](plans/scroll-horizontal-lista-municipios.md); **B32+** novo fill-in de engenharia registrado no fechamento do B32 — `MunicipalityListExpectedVotesControl`/`MunicipalityListTrendControl`/`LeadershipListSupportStatusControl` são o 3º call site da mesma máquina de auto-save por debounce (estado/refs/save/schedule/flush quase idênticos), gatilho que os três planos B24/B27/B32 já haviam registrado; auditoria primeiro, extração condicional (precedente B33+) — [plano](plans/escala-dry-pos-b32.md); **B32 ✓** editar Status de apoio na célula da lista de lideranças — `LeadershipListSupportStatusControl` (Popover + `NativeSelect`, sempre montado, sem toggle "Editar" de tabela inteira) grava por `POST /campanha/liderancas/support-status` (auto-save ~150 ms, sem `revalidatePath`), reusando `isSameOriginRequest`/`campaignJsonMutationRoute.ts` já extraídos pelo B24 ✓/B27 ✓; fecha o "Adiado com gatilho" do B29 — [plano](plans/autosave-status-lista-liderancas.md); **B33+ ✓** `useCampaignListFilterNavigation` — o scaffold de navegação/debounce sai dos três filtros de lista para um hook em `shared/` (281 linhas removidas contra 75 em `src`); a auditoria derrubou a premissa do plano de que os três domínios teriam assinaturas de canonicalização diferentes — `buildStateDeputyListHref`/`buildTerritoryListHref`/`buildMunicipalityListHref` **já canonicalizam internamente**, então o `parse(...)` explícito de dobradinhas/territórios era trabalho redundante e um único callback `toHref` cobre os três; a entrega achou **dois** bugs de comportamento, não de estrutura: tocar um filtro no meio da digitação **descartava** a busca pendente em dobradinhas e territórios (unificado na semântica de munícipios por `navigateWithSearch`), e o `setTimeout` do debounce capturava o `state` do render que o agendou, então um sort clicado dentro da janela de 1s era **revertido** um segundo depois — munícipios escapava por acidente, pelo `key=` da página que remonta e mata o timer (corrigido com `stateRef`, com caso de spec verificado vermelho nas três listas sem o fix); o `/simplify` ainda achou o fallback `useCampaignListPending() ?? useTransition()` copiado em 5 lugares, com o hook novo como 6º → `useCampaignListTransition`, e um `aria-live` que fazia o resumo de filtros de munícipios ser a **terceira** região viva da mesma página; `shouldUpdateMunicipalitySearchUrl` orfanou e foi deletado; `tests/unit/campaignListFilterNavigation.unit.spec.ts` (jsdom + router mockado + fake timers, 8 casos × 3 listas) é a primeira cobertura desses componentes — [plano](plans/escala-dry-pos-b33.md); **C8 F4b ✓** guard de `formActions` ampliado para irmãos `*FormActions.ts` + `taskActions.ts`; 6 ladders migradas; `taskActions.ts` allowlisted · [plano](plans/escala-dry-pos-c6.md); **Build resiliente ao global `metadata` ausente ✓** — `resolveSiteMetadata` em `src/utilities/seo.ts`; fallback `NEXT_PUBLIC_SITE_URL` e omit canonical/OG quando não há URL; 4 rotas + layout; [plano](plans/build-resiliente-metadata-ausente.md); **B8+ ✓** escala e DRY pós-B8 F2 — a política de escala de chão das malhas commitadas virou código nomeado (`scripts/lib/topology.mjs`, tolerância de Visvalingam em m² e dígitos de quantização derivados do bbox de cada topologia), e com ela a malha das ZE caiu de 480 para **30,2 pontos/feature** (72.310 → 7.687 bytes) sem que `bahia-municipalities` mudasse um byte; a auditoria corrigiu a alavanca do plano (quantização sozinha quase não corta — o peso absoluto manda) e **rejeitou** um item do F4 por medição (`city`/`zoneNumber` de `MunicipalityZoneNeighborhoodEntry` são evidência de proveniência hasheada, não código morto); `MAX_POINTS_PER_FEATURE = 60` é o novo tripwire, `featureMapKey` passou a ser exercido sobre features reais das três malhas (417 `codarea`, 27 `code`, 19 `slug`), o mapa **degrada** em vez de morrer quando o chunk das zonas falha, e a ressalva de aproximação saiu do gate de comparação · [plano](plans/escala-dry-pos-b8f2.md); **`users.roles` ✓** — `admin`\|`editor` em `users`, migration `20260727_032523_add_users_roles`, editor publica `post`/`tag`/`media`; unificação com `campaignUser` rejeitada · [plano](plans/roles-admin-payload.md); 2026-07-26 (**fill-in** remover lista "Municípios por zona eleitoral" do Início — pós-B8 F2 ✓ os polígonos tornam a lista textual redundante; dropar UI + `zoneBreakdown` do bundle; ressalva de aproximação fica na malha · [plano](plans/remover-lista-zonas-mapa-inicio.md); **B8 F2 ✓** polígonos das 19 ZE de Salvador — artefato `bahia-municipality-zones.topo.json` (72 KB) por dissolve bairro→zona do `BA_bairros_CD2022` do IBGE contra a RA 02/2017 do TRE-BA, com curadoria explícita e um passe de maior fronteira compartilhada validado; o coroplético passou a ser keyed por **map key** (`slug` na zona, `ibgeCode` no resto) e o polígono municipal de Salvador ficou como base sem interação por baixo, porque duas malhas simplificadas independentemente não fecham na mesma costa; `competitiveRank` segue por cidade (o artefato TSE só tem `federalRankByIbgeCode`) com int pinando o fato; desambiguar a ZE no card "Onde estou" do B14 ✓ virou débito com gatilho, não dependência — [plano](plans/poligonos-pracas-zona.md); **B8+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B8 F2, cujos relatórios chegaram depois do merge — a malha de 19 zonas ficou com a mesma contagem de vértices da malha dos 417 municípios (as duas constantes de simplificação são relativas ao bbox e foram copiadas verbatim, o que vale ~3,9 m de grade em Salvador contra ~103 m no estado), a regra da map key está em três formas sem nenhum teste ligando artefato → `featureMapKey` → catálogo, uma falha de chunk nas zonas passou a derrubar o mapa inteiro, a ressalva de aproximação desaparece no modo comparação, e `getTerritoryFeature` perdeu o último consumidor de produção neste commit · [plano](plans/escala-dry-pos-b8f2.md); **B39–B42** registrados: sessão "Lembrar de mim" + login biométrico WebAuthn + scroll horizontal/sticky na lista de municípios + polimento mobile da mesma lista — [B39](plans/lembrar-de-mim-sessao-campanha.md), [B40](plans/login-biometrico-campanha.md), [B41](plans/scroll-horizontal-lista-municipios.md), [B42](plans/polimento-mobile-lista-municipios.md); **E12 ✓** camada TI — rollups MAUP, colunas Cobertura/Captura/Classe em `/campanha/territorios`, benchmark intra-TI no `MunicipalityGoalAccountCard`; [plano](plans/camada-territorios-identidade.md); **B36 ✓** coluna "Lideranças" em `/campanha/dobradinhas` — chips + Popover+Command + delta reuse da mutação B31; `LeadershipStateDeputyRelationCell` compartilhada (`direction`/`measureOverflow`); columns factory (não Context); casca `dobradinhas/formActions.ts`; `loadLeadershipOptions` + `leaderships[]` no row; [plano](plans/chips-liderancas-lista-dobradinhas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` (`UserPlusIcon`) na coluna `actions` ao lado do contato do B28 ✓; Popover two-step reusa `createCampaignInvite`/`autopreenchimento`; disable por `normalizeBrazilianPhone`; [plano](plans/convite-whatsapp-lista-liderancas.md); **B31 ✓** coluna de dobradinhas na lista de lideranças entregue — chips clicáveis → `/campanha/dobradinhas/` em `/campanha/liderancas`, edição por Popover+`Command` (mecânica do **B27 ✓**) com escrita por **server action direta do cliente** (`useTransition`, padrão do B19 ✓) em vez do endpoint JSON do B27, porque aqui há derivados a revalidar (`leadershipCount`, a ficha do deputado); `setLeadershipStateDeputyMembership(Record)` em transação + lock `leadership-state-deputies:{id}` + `revalidatePath`; helper puro `nextStateDeputyIdsAfterMembership` espelha `nextAdvisorIdsAfterMembership`; catálogo entregue uma vez por tabela via `leadershipColumns(stateDeputyOptions)` (columns factory, mesmo precedente de `municipalityListColumns`) em vez de Context; é o **2º call site** do padrão Popover+Command+chips — extração para `shared/` continua esperando o 3º (B34/B36/B37, que agora consomem a mutação/lock em vez de correr para desenhá-los); [plano](plans/dobradinhas-lista-liderancas.md); **fill-in ✓** limitar pan/zoom do mapa à Bahia — `BahiaMap` com `maxBounds`/`maxBoundsViscosity: 1` sobre `BAHIA_BOUNDS.pad(0.1)` e `minZoom` via `getBoundsZoom` com o mesmo padding 16px do `fitBounds`, recalculado no `ResizeObserver` existente; único arquivo; [plano](plans/limitar-mapa-a-bahia.md); (**B14 ✓** município mais próximo entregue — card cliente "Onde estou" no Início staff resolvendo **point-in-polygon** sobre o chunk de geometria que o mapa do dashboard já carrega (a premissa "polígonos explodem o bundle" do plano de 24/07 caiu na medição: 41 KB gzip já estão no browser, então o artefato de centroides e seu script saíram de escopo), com fallback nomeando onde o assessor está e o município mais próximo **da carteira** dentro de 150 km; a posição nunca sai do aparelho (sem action, sem `Consent`); First Load JS de `/campanha` 265 → 268 kB depois de mover o href das zonas de Salvador para o servidor (`zoneCityHrefs`) — importar `buildMunicipalityListHref` no cliente custava **21 kB** por um link, porque arrasta `bahiaTerritories` + `municipalityCatalog`; [plano](plans/municipio-mais-proximo.md); ~~**B38**~~ ✓ sidebar recolhido em tablet entregue — offcanvas + trigger md+ + cookie/matchMedia; [plano](plans/sidebar-recolhido-tablet.md); **B33+** novo fill-in de engenharia registrado no fechamento do B33 — `StateDeputyFilters` é o 3º call site do scaffold de navegação/debounce que `TerritoryFilters`/`MunicipalityFilters` já tinham, achado do `/simplify` rodada 2, [plano](plans/escala-dry-pos-b33.md); **B28 ✓** e-mail + celular na lista de lideranças entregue — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp, no padrão de `/campanha/assessores` (B19 ✓); `shared/CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`, `loadLeadershipDetail` deduplicado; [plano](plans/email-celular-lista-liderancas.md); **B24 ✓** auto-save + justificativa no popover de Tendência de `/campanha/municipios` — sem botão "Salvar", `POST /campanha/municipios/political-trend`, [plano](plans/autosave-tendencia-lista-municipios.md); **B13 ✓** escala relativa no mapa do Início entregue — quantis como novo default, padrão próprio/LQ nas âncoras do E10 e posição no município a partir do **artefato TSE v3** (`federalRankByIbgeCode`), mais bolhas opcionais por válidos projetados coloridas pela classe; `computeAggregateTerritorialClass` nasce aqui e o **E12** deve herdá-lo em vez de escrever um rollup próprio; `TERRITORIAL_CLASS_ANCHORS` mudou de casa para `src/lib/territorialClassAnchors.ts`; [plano](plans/escala-relativa-mapa.md); **B13+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B13 — teste de concordância entre as duas escritas do LQ (mapa cliente × classificador server-only, a rede de segurança do **E15**), escopo geográfico declarado no readout (posição é da cidade inteira, classe é do escopo do usuário), uma varredura da fatia TSE por ano no script de agregados em vez de três, e a tupla no `competitiveRankByYear` que corta ~metade dos ~63,6 KB somados ao payload RSC · [plano](plans/escala-dry-pos-b13.md); **débito de teste** afinado no ledger ([TECH-DEBT.md](TECH-DEBT.md), P3 → P2): `campaignMunicipalities`/`campaign-pwa` são flaky com 2 workers **em `main`** também — clique no checkbox de consentimento antes da hidratação e regex de fixture em que "Conde" casa com "Condeúba"; **B27 ✓** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` entregue — a checkbox list + botão "Salvar assessores" virou chips removíveis + `Command` com gravação por delta, `nextAdvisorIdsAfterMembership` movido para `src/lib/`, endpoint `POST /campanha/municipios/advisors` espelho do `expected-votes/`, [plano](plans/combobox-assessores-lista-municipios.md); **B23 ✓** tooltip de conteúdo nas células das listas entregue — a mecânica genérica (`cellTooltip`, `CampaignCellTooltip`, `CampaignHoverTooltip` em `shared/`) já tinha saído de graça no E10 ✓; este item fechou `openOnTouch`/`disabled` no primitivo (mais um terceiro comportamento do `/simplify`: `content` falsy renderiza o trigger sem wrapper) e os dois consumidores de `/campanha/municipios` (nomes de assessores + justificativa da tendência), [plano](plans/tooltip-celulas-listas.md); **B22 ✓** explicação por coluna no header das listas entregue — `description?: ReactNode` em `CampaignTableHead`/`MunicipalitySortableHead` (rename de `tooltip`, sublinhado pontilhado), `campaignConceptOneLiner` como fonte única para colunas cobertas pelo E18 ✓, `municipalityColumnDescriptions` cobrindo as 11 colunas de `/campanha/municipios`; sem migration/action, [plano](plans/explicacao-colunas-header-listas.md); 2026-07-25: **E10 ✓** classificação territorial relativa entregue — Reduto/Expansão/Manutenção/Marginal/Sem base sobre o artefato TSE, coluna+sort+filtro na lista, badge no card do E8 e na capa do dossiê, e o seam genérico `cellTooltip` da `CampaignTable` (núcleo do B23) com `CampaignHoverTooltip` promovido para `shared/`; **B13** liberado; **E10+** novo fill-in de engenharia com os quatro débitos do `/simplify` do E10 — a lista lendo `municipality` duas vezes por request (o que **dispara o "Adiado com gatilho" do A11**: `classe` é o 4º path derivado no loader), memo de processo dos potenciais por município, `TooltipProvider` único no layout e os multi-filtros do mobile saindo das definitions · [plano](plans/escala-dry-pos-e10.md); ~~**C8 F4b**~~ ✓ (entregue 2026-07-27); **B26 ✓** registrar sinal na célula "Último sinal" — [plano](plans/registrar-sinal-lista-municipios.md); **C13 ✓** "Plano de Ação" → **"Atividade"** renomeado em toda a vertical — collection `activity`, rota `/campanha/atividades`, sidebar "Atividades", migration data-preserving escrita à mão (4 tabelas, 3 enums, 2 colunas, 39 índices, 14 FKs, 2 sequences + os 8 índices fósseis `%plaza%` da remodelagem) e guard de vocabulário table-driven varrendo `src`+`tests`+`scripts`; [plano](plans/renomear-plano-acao-para-atividade.md); **B37** chips editáveis de municípios na lista de dobradinhas — coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`municipalityCount`) por chips com link (município) / território (`MapPinIcon`) e o mesmo Popover+combobox do **B34** (busca por município/TI/ZE, lote de território/zona), mas escrevendo do lado invertido (`municipality.stateDeputies`, sem piso mínimo, lock por município em vez de por dobradinha, sem `assertMunicipalitiesWithinScope` porque `canUpdateMunicipality` já escopa o assessor); reaproveita/promove a mesma lib do B19/B34 (quem chegar primeiro faz a renomeação); mesmo conflito registrado com o pedido literal (toggle "Editar" de tabela inteira) que B28/B31/B32/B34/B36 já resolveram na direção do Popover por célula — decisão marcada como _proposta, a validar com produto_; sem migration, [plano](plans/chips-municipios-lista-dobradinhas.md); **B36** coluna "Lideranças" em `/campanha/dobradinhas` com a mesma UI/mecânica da coluna "Municípios" de `/campanha/assessores` (B19 ✓) — chips + Popover + `Command` + auto-save por delta + "Ver mais…" (clamp de 3 linhas por `ResizeObserver`, necessário aqui porque um deputado popular pode acumular muito mais lideranças do que uma liderança acumula dobradinhas); pedido explícito de não duplicar lógica — reusa **sem alteração** a mutação/schema/lock que o **B31** desenha para o lado inverso da mesma relação `leadership.stateDeputies`, promovendo a célula desse item para um componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`) em vez de um segundo componente-irmão; corrida de extração com o B31 (quem entrar primeiro paga a peça compartilhada); sem migration, [plano](plans/chips-liderancas-lista-dobradinhas.md); **B34** chips editáveis de municípios na lista de lideranças — coluna "Municípios" de `/campanha/liderancas` ganha chips com link (município) / território (`MapPinIcon`), edição por **Popover disparado por célula** com chips removíveis + combobox `Command` (busca por município/TI/ZE, lote de território/zona), gravação **por delta** direto em `leadership.municipalities` com lock `leadership-municipalities:{id}` (piso de 1 e teto de 30 respeitados) e `revalidatePath` (`liderancas`, `liderancas/[id]`, `municipios/[slug]` dos tocados); promove a lógica pura de `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros, sem portar a UI com `ResizeObserver`; **conflito registrado com o pedido literal** (pede um toggle "Editar" de tabela inteira, que B28/B31/B32 já travaram como anti-goal nesta mesma tabela — este plano segue o precedente e marca a decisão como _proposta, a validar com produto_); sem migration, [plano](plans/chips-municipios-lista-liderancas.md); ~~**B33**~~ ✓ ordenação e filtro no header da lista de dobradinhas (entregue 2026-07-26) — `?sort=`/`?dir=` por Nome/Partido + Popover multi-seleção de Partido com "Sem partido", consumindo o chrome compartilhado do **B21 ✓** (a corrida de ordem de chegada com B29 foi encerrada); paridade mobile (`StateDeputyFilters`) entrou por decisão desta sessão; ordem dos nulos de `party` pinada por int test; contagens derivadas (`municipalityCount`/`leadershipCount`) seguem sem sort/filtro, como travado, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` na coluna `actions` (entregue 2026-07-26), [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); ~~**B28**~~ ✓ e-mail + celular na lista de `/campanha/liderancas` (entregue 2026-07-26) — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp como em `/campanha/assessores` (B19 ✓); `CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`; edição in-list adiada; sem migration, [plano](plans/email-celular-lista-liderancas.md); ~~**B27**~~ ✓ combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/combobox-assessores-lista-municipios.md); ~~**B26**~~ ✓ registrar sinal na célula "Último sinal" (entregue 2026-07-25), [plano](plans/registrar-sinal-lista-municipios.md); **fill-in** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); glossário/KPI strips intactos; B16 ✓ liberou o nome ao remover "Assessoria", [plano](plans/renomear-coluna-cobertura-meta.md); ~~**B25**~~ ✓ link do território na lista de municípios (entregue 2026-07-26) — coluna "Território" + card mobile → `/campanha/territorios#ti-`; `territoryAnchor.ts` + `TerritoryLink`; seam `rowId` em `CampaignTable`; [plano](plans/link-territorio-lista-municipios.md); ~~**B24**~~ ✓ auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/autosave-tendencia-lista-municipios.md); **fill-in** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtro por Salvador cobre as zonas; drop também do filtro/sort/`?kind=` na URL da lista, schema `kind` intacto, [plano](plans/remover-coluna-tipo-municipios.md); ~~**B23**~~ ✓ tooltip de conteúdo nas células das listas (entregue 2026-07-26), [plano](plans/tooltip-celulas-listas.md); **fill-in** opções selecionadas no topo nos Popovers de filtro do header — helper puro reutilizável pelas demais listas, [plano](plans/selecionados-no-topo-filtros-header.md); ~~**B22**~~ ✓ explicação por coluna no header das listas (entregue 2026-07-26), [plano](plans/explicacao-colunas-header-listas.md); **B21** página própria dos Territórios de Identidade — rota `/campanha/territorios` com overview + tabela no sistema de listas (sort/filtro no header), sem migration, [plano](plans/pagina-territorios-identidade.md); **B20 ✓** Municípios prioritários no Início entregue 2026-07-25 — atalho ranqueado por déficit + markup sem bullets, [plano](plans/municipios-prioritarios-inicio.md); **Pass 2 ✓** consolidação de engenharia — listas genéricas/B16+ absorvido/B17-B18 triviais/E10 limpo/C8 F4 ✓/D3 consent WhatsApp; **B16 ✓**; **E16 ✓**; **E9 ✓**; **E18 ✓**; **B19 ✓**; **E8 ✓**; C12 ✓; B18; B17; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14 ✓; janela 1: smoke, R6)Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc). ## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026) @@ -131,6 +131,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re - **Renomear coluna Cobertura da meta → Cobertura** em `/campanha/municipios` — pedido de produto (2026-07-25): header curto na fila (padrão A11 `2022`); definição no hover (**B22**) / glossário (**E18 ✓**). Só lista (header + card mobile + label de sort `deficit`); strips/card/conceitos mantêm o nome longo da métrica. Soft: **B16 ✓** (saiu "Assessoria", liberou o nome) · ~0,25d · Impeccable B · cortável · [plano](plans/renomear-coluna-cobertura-meta.md) - **Remover coluna Tipo** em `/campanha/municipios` — pedido de produto (2026-07-25): a coluna "Tipo" (Município × Zona eleitoral) não informa a coordenação (ambos tratados iguais); filtrar ZE = filtrar Salvador (19 slugs). Remove coluna + filtro/sort UI + param `?kind=` do contrato URL da lista; **schema/catálogo `kind` intacto** (mapa, B8, dossiê) · ~0,25–0,5d · Impeccable B · soft: B15 ✓/B16 ✓ (header); B17 ganha uma coluna a menos · cortável · [plano](plans/remover-coluna-tipo-municipios.md) - **Remover lista "Municípios por zona eleitoral" do Início** — pedido de produto (2026-07-26): com **B8 F2 ✓** cada ZE de Salvador tem polígono próprio no mapa; a lista de 19 linhas sob o painel (`zoneBreakdown`) é densidade morta. Remove o bloco em `MunicipalityMapPanel` + o campo do bundle; mantém a ressalva de aproximação atrelada à malha — **B8+** F3 ✓ já a moveu para fora do gate da lista, condicionada a `bundle.hasZoneMunicipalities` (campo próprio, que sobrevive ao drop de `zoneBreakdown`), então resta apagar o `
    ` + o campo do bundle · ~0,25d · Impeccable B · dura: B8 F2 ✓ · cortável · [plano](plans/remover-lista-zonas-mapa-inicio.md) +- **Dimir o resultado no compositor de giro** em `/campanha/atividades/giros` — trocar de Território de Identidade recompõe a proposta no servidor, mas hoje só o seletor sinaliza (spinner na própria descrição): a proposta do território anterior fica nítida e clicável, e o leitor de tela não recebe evento nenhum, contra o que o `engineering-standards.mdc` exige ("a região de RESULTADOS dima, não só o controle"). O `/simplify` do **E13 ✓** passou por isto como _"too involved — the composer isn't a list"_; a auditoria do fechamento mediu o contrário — `CampaignListPendingBoundary` é um Provider sobre `children`, `CampaignListResults` é uma `div` com `data-pending`/`aria-busy`/`aria-live` e `useCampaignListTransition` já cai num transition local, então são três fios a ligar, não uma abstração a portar · ~0,25d · Impeccable B · dura: E13 ✓ · cortável · [plano](plans/feedback-pendente-compositor-giro.md) - **Ícone de prioridade na lista** em `/campanha/municipios` — trocar Badge “Prioritária” por ícone Flag + tooltip “Município prioritário” (hover) na coluna do nome · ~0,25d · Impeccable B · [plano](plans/icone-prioridade-lista-municipios.md) - **Selecionados no topo nos filtros do header** em `/campanha/municipios` — nos Popovers de filtro (B16 ✓) as opções já marcadas sobem para o topo (ordem congelada enquanto o Popover está aberto; divisor de hairline), para desmarcar sem caçar a checkbox entre 27 territórios / 435 municípios; a regra sai como **helper puro em `src/lib/`** (`orderFilterOptionsSelectedFirst`), consumido pelo Popover desktop e pelo select mobile e pronto para **B21**/E12 — sem extrair o head genérico (gatilho do B21 segue sendo o 3º call site; **B22** promove o tooltip pelo mesmo critério de "peça compartilhada sem head genérico") · ~0,25–0,5d · Impeccable B · soft: B16 ✓ / B21 / B22 · [plano](plans/selecionados-no-topo-filtros-header.md) - **Cenário junto aos filtros** em `/campanha/municipios` — mover `VoteEstimateScenarioField` para a fileira/barra slim de `MunicipalityFilters` (com **B16**, a barra deixa de carregar os selects de coluna); overview só consome o contexto · ~0,25–0,5d · Impeccable B · soft: B16 · [plano](plans/cenario-junto-filtros-municipios.md) diff --git a/src/app/(campaign)/campanha/(app)/atividades/giros/formActions.ts b/src/app/(campaign)/campanha/(app)/atividades/giros/formActions.ts new file mode 100644 index 00000000..bcc666b2 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/atividades/giros/formActions.ts @@ -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 => + 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.', + }) diff --git a/src/app/(campaign)/campanha/(app)/atividades/giros/page.tsx b/src/app/(campaign)/campanha/(app)/atividades/giros/page.tsx new file mode 100644 index 00000000..e49f5245 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/atividades/giros/page.tsx @@ -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> +} + +/** "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 ( + +
    +
    +

    Planejar giro

    +

    + 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. +

    +
    + +
    + + {bundle ? : null} + + {regions.length > 0 ? ( + ({ + ...option, + href: buildTourComposerHref({ region: option.region }), + }))} + selectedRegion={region} + clearHref={buildTourComposerHref({ region: null })} + /> + ) : null} + + {regions.length === 0 ? ( + + ) : region === null ? ( + + ) : stops.length === 0 ? ( + + + + ) : ( + + )} +
    + ) +} diff --git a/src/app/(campaign)/campanha/(app)/atividades/page.tsx b/src/app/(campaign)/campanha/(app)/atividades/page.tsx index ce126151..9eddcdcd 100644 --- a/src/app/(campaign)/campanha/(app)/atividades/page.tsx +++ b/src/app/(campaign)/campanha/(app)/atividades/page.tsx @@ -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> @@ -59,12 +60,22 @@ export default async function ActivityListPage({ searchParams }: ActivityListPag

{canCreate ? ( - +
+ {/* E13: the planner generates several drafts at once, so it sits next + to "Nova atividade" rather than replacing it. */} + + +
) : null} diff --git a/src/app/(campaign)/campanha/(app)/municipios/[slug]/MunicipalityDetailTabs.tsx b/src/app/(campaign)/campanha/(app)/municipios/[slug]/MunicipalityDetailTabs.tsx index 8ce25e0c..53b287c3 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/[slug]/MunicipalityDetailTabs.tsx +++ b/src/app/(campaign)/campanha/(app)/municipios/[slug]/MunicipalityDetailTabs.tsx @@ -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. */} + ) : null} @@ -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 +} + export const DossierTab = async ({ view, advisorSummaries, diff --git a/src/app/(campaign)/campanha/actions/activity.ts b/src/app/(campaign)/campanha/actions/activity.ts index d052b21a..8ac8b4a8 100644 --- a/src/app/(campaign)/campanha/actions/activity.ts +++ b/src/app/(campaign)/campanha/actions/activity.ts @@ -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, diff --git a/src/components/campaign/municipality/MunicipalityVisitEligibilityCard.tsx b/src/components/campaign/municipality/MunicipalityVisitEligibilityCard.tsx new file mode 100644 index 00000000..59b9fbe1 --- /dev/null +++ b/src/components/campaign/municipality/MunicipalityVisitEligibilityCard.tsx @@ -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 }) => ( +
  • + + + + {visitConditionLabels[condition.id]} + {condition.met ? ': atendida' : ': não atendida'} + + {condition.detail} + +
  • +) + +/** + * 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 ( +
    +
    +

    + Elegibilidade para visita +

    + +
    +

    + A visita vale o que a rede local converte dela — por isso a leitura é uma{' '} + checklist de cinco condições, e não uma nota. Exigir as cinco é o + critério; o número no topo só diz quantas estão claras. +

    +

    + O aviso de contraindicação nunca bloqueia: a coordenação decide contra ele quando a + política pede. +

    + + Como cada condição é avaliada + + + Todos os conceitos + +
    +
    + + {eligibility.metCount} de {eligibility.conditions.length} condições + +
    + + + +
      + {eligibility.conditions.map((condition) => ( + + ))} +
    + + {contraindication ? ( + + {visitContraindicationLabels[contraindication.id]} + + {contraindication.reason} + {contraindication.counterOffer} + + + ) : null} + + +
    + ) +} diff --git a/src/components/campaign/tour/CalendarPhaseNote.tsx b/src/components/campaign/tour/CalendarPhaseNote.tsx new file mode 100644 index 00000000..675fc521 --- /dev/null +++ b/src/components/campaign/tour/CalendarPhaseNote.tsx @@ -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 }) => ( +
    +
    + Fase do calendário + {calendarPhaseLabels[phase]} +
    +

    {calendarPhaseVisitProduct[phase]}

    +
    +) diff --git a/src/components/campaign/tour/TourComposerForm.tsx b/src/components/campaign/tour/TourComposerForm.tsx new file mode 100644 index 00000000..871974f6 --- /dev/null +++ b/src/components/campaign/tour/TourComposerForm.tsx @@ -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 ( +
  • +
    + onToggle(next === true)} + className="mt-1.5" + /> +
    +
    + + + {tourStopRoleLabels[stop.role]} + + + {stop.metCount} de {stop.conditionCount} condições + +
    +

    {tourStopRoleDescriptions[stop.role]}

    +

    + Faltam {stop.deficitLabel} votos para a meta. + {stop.unmetConditionLabels.length > 0 + ? ` Ainda não atende: ${stop.unmetConditionLabels.join(', ')}.` + : ' Atende as cinco condições.'} +

    + {stop.contraindicationReason ? ( +

    + {stop.contraindicationReason} +

    + ) : null} +
    + + Ver dossiê +
    +
    +
    + + {checked ? ( + + Origem desta parada + onOriginChange(event.target.value as ActivityOrigin)} + className="min-h-11 w-full" + > + {activityOrigins.map((value) => ( + + {activityOriginLabels[value]} + + ))} + + + ) : null} +
  • + ) +} + +/** + * 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 +}) => { + const [state, submitAction, isPending] = useActionState(formAction, {}) + const [selectedSlugs, setSelectedSlugs] = useState(() => + stops.filter((stop) => stop.suggested).map((stop) => stop.slug), + ) + const [origins, setOrigins] = useState>({}) + + 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 ( + + + +
    +
    +

    Paradas propostas em {region}

    + + {selectedSlugs.length} selecionada{selectedSlugs.length === 1 ? '' : 's'} + +
    +
      + {stops.map((stop) => ( + toggleStop(stop.slug, checked)} + onOriginChange={(origin) => + setOrigins((current) => ({ ...current, [stop.slug]: origin })) + } + /> + ))} +
    +
    + +
    + + Nome do giro + + + Cada rascunho recebe este nome mais o município — assim o giro se lê como um conjunto na + lista de Atividades. + + {fieldError(state.fieldErrors, 'tourName') ? ( + {fieldError(state.fieldErrors, 'tourName')} + ) : null} + + + Nota de janela política +