From c7001b75f0895e30939cd55f885fe4bb8be44e01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Mon, 27 Jul 2026 21:16:03 -0300 Subject: [PATCH 1/4] E14: engagement levels N0-N4 and the first allocationDecision writer. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `municipality.engagementLevel` (staff-only, no default) records what the campaign decided to invest in a município, next to the E8/E10 metrics that describe what it knows. Moving it is a reallocation decision, so only unrestricted staff may, every move carries a rationale plus the signals that would reverse it, and the write files an `allocationDecision` in the same transaction under an advisory lock taken before the current level is read — otherwise the snapshot's `from` is a guess. The enum gains `movimento`: a level move is neither `aceita` nor `descarta`. No backfill. Null is "Sem nível", and filtering by it is the triage queue. That is also why the list sorts `nivel` in memory rather than in the query: Postgres puts NULLs first on DESC, so a native sort would open "N4 primeiro" on a page of municípios nobody has decided about yet. The filter stays native. The hysteresis rules from research §6.8 are pure and client-safe, so the Popover explains a block before submitting and the server re-runs them anyway; a blocked move answers 409 with typed violations and the override is recorded with exactly the rules it contradicted. --- .cursor/rules/projects/nucleos-eleitorais.mdc | 1 + AGENTS.md | 3 + docs/plans/niveis-de-envolvimento.md | 77 +- docs/roadmap.md | 12 +- .../municipios/engagement-level/route.ts | 68 + .../municipios/engagement-level/types.ts | 29 + .../campanha/(app)/municipios/page.tsx | 3 + .../campanha/actions/municipality.ts | 107 + src/collections/AllocationDecision.ts | 4 + src/collections/Municipality.ts | 76 +- .../municipality/MunicipalityFilters.tsx | 12 + .../municipality/MunicipalityHeaderFilter.tsx | 1 + .../municipality/MunicipalityLevelBadge.tsx | 67 + .../municipality/MunicipalityList.tsx | 68 + .../MunicipalityListLevelControl.tsx | 332 + .../municipality/MunicipalityStrategyCard.tsx | 18 + src/lib/campaignIntelligenceConcepts.ts | 16 + src/lib/engagementLevel.ts | 173 + src/lib/schemas/municipality.ts | 21 + ...752_add_municipality_engagement_level.json | 12001 ++++++++++++++++ ...61752_add_municipality_engagement_level.ts | 26 + src/migrations/index.ts | 6 + src/payload-types.ts | 17 +- src/utilities/access/municipalities.ts | 20 +- src/utilities/campaignAccess.ts | 1 + src/utilities/municipalityLabels.ts | 4 +- src/utilities/municipalityListFilters.ts | 52 +- src/utilities/municipalityListUrl.ts | 59 + src/utilities/municipalityPageData.ts | 12 + src/utilities/municipalityViewModels.ts | 18 + tests/e2e/setup.e2e.spec.ts | 1 + tests/helpers/campaignFixtures.ts | 2 + ...ignMunicipalityEngagementLevel.int.spec.ts | 262 + tests/int/municipalityPageData.int.spec.ts | 45 + tests/unit/campaignComponents.unit.spec.ts | 30 +- tests/unit/engagementLevel.unit.spec.ts | 100 + tests/unit/municipalityList.unit.spec.ts | 78 + 37 files changed, 13782 insertions(+), 40 deletions(-) create mode 100644 src/app/(campaign)/campanha/(app)/municipios/engagement-level/route.ts create mode 100644 src/app/(campaign)/campanha/(app)/municipios/engagement-level/types.ts create mode 100644 src/components/campaign/municipality/MunicipalityLevelBadge.tsx create mode 100644 src/components/campaign/municipality/MunicipalityListLevelControl.tsx create mode 100644 src/lib/engagementLevel.ts create mode 100644 src/migrations/20260727_161752_add_municipality_engagement_level.json create mode 100644 src/migrations/20260727_161752_add_municipality_engagement_level.ts create mode 100644 tests/int/campaignMunicipalityEngagementLevel.int.spec.ts create mode 100644 tests/unit/engagementLevel.unit.spec.ts diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index e65c39eb..7e6a90e1 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -7,6 +7,7 @@ alwaysApply: false ## Status +- **E14 entregue em código (2026-07-27):** níveis de envolvimento N0–N4 por município, e o **primeiro escritor de produção do `allocationDecision`** (C12 ✓ existia desde 2026-07-24 com access e testes, sem nenhum caller). Migration `20260727_161752_add_municipality_engagement_level`: `engagementLevel` (select `n0…n4`, indexado, **sem default**), `levelNote` (textarea 2000), `levelChangedAt` (date indexada, `readOnly`, escrita por `canSetCampaignSystemField`) e o valor `movimento` no enum de `allocationDecision.outcome` — mover de nível não é `aceita` nem `descarta`, e fingir que era teria envenenado a triagem do E11. **Sem backfill:** o plano pedia default N2; carimbar N2 em 435 municípios afirmaria uma decisão que ninguém tomou, então nulo renderiza "Sem nível" e o filtro por ele **é** a fila de triagem inicial. Mover é decisão de realocação → `canManageMunicipalityEngagementLevel` (`isCampaignUnrestricted`: coordinator/candidate), leitura no bloco staff normal (`canReadCampaignStaffField`); o assessor lê e propõe por sinal. `setMunicipalityEngagementLevelRecord` segura `municipality-engagement-level:{id}` **antes** de ler o nível atual — sem o lock o `from` do snapshot é chute sob concorrência — e grava os dois documentos na mesma transação, com `snapshot = { from, to, reversalSignals, triangulatedShock, violations, overridden, previousLevelChangedAt }`: só os valores sobre os quais a decisão foi tomada, nunca o documento. Histerese do relatório §6.8 em `src/lib/engagementLevel.ts` (puro, client-safe): salto de 2 níveis só com choque triangulado, 3 semanas de proteção pós-promoção (só contra rebaixamento) e **um movimento por mês**, derivável só de `levelChangedAt` — nenhuma query extra. **A rota tem três estados, não dois:** `blocked` (409) devolve as violações tipadas em vez de virar "não foi possível salvar", porque um movimento barrado é uma decisão que a coordenação pode tomar mesmo assim — o Popover mantém o rascunho e oferece o override, que entra no snapshot com exatamente as regras que contrariou. **Submit explícito, não auto-save** (exceção que `campanha-edit-where-you-see` prevê para flows com nota/confirmação; efeito colateral: o controle não vira o 4º call site da máquina de debounce que o **B32+** vai auditar). Lista: 11ª coluna staff com badge outline numerado, filtro `?level=` **nativo** (ao contrário de `classe` (E10), o campo é armazenado e entra no `where`; "sem nível" não cabe num `in` e vira ramo `or` com `{ exists: false }`) e sort `nivel` **em memória**: `n0…n4` até ordena lexicograficamente, mas o Postgres põe NULL primeiro no DESC e sem backfill "N4 primeiro" abriria com uma página de "Sem nível" — `sortByNullableValue` afunda o nulo nas duas direções, como `sem_base` em `classe`, ao custo de carregar o escopo inteiro (o que o sort padrão `deficit` já faz). Verbete `nivel-de-envolvimento` no E18. **Dois achados:** com `overrideAccess: false` o Payload **descarta o campo em silêncio** quando o ator não pode escrevê-lo, em vez de rejeitar o update — o int do assessor prova que o nível **não mudou**, não que a chamada lançou; e o banco de dev local estava fora do histórico de migrations (`allocation_decision.outcome` era `varchar`, sem o tipo `enum_allocation_decision_outcome` que o `teqo_test`, construído só por migrations, tinha) — reparado à mão no `teqo`, nada a fazer em produção. `/campanha/municipios` em 17,7 kB / 322 kB; suítes 64 unit (604) e 53 int (427); Aikido 0 findings. Plano as-built: [`niveis-de-envolvimento.md`](../../../docs/plans/niveis-de-envolvimento.md). - **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). diff --git a/AGENTS.md b/AGENTS.md index 87660d20..d558977b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,6 +72,8 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap. **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):** **E14** Níveis de envolvimento N0–N4 — `municipality.engagementLevel` (staff-only select `n0…n4`) records **what the campaign decided to invest** in a município, next to the E8/E10 metrics that describe what it knows. It is also the **first production writer of `allocationDecision`** (C12 shipped 2026-07-24 with access and tests but no caller). Migration `20260727_161752_add_municipality_engagement_level` adds `engagementLevel` (indexed, **no default**), `levelNote` (textarea 2000) and `levelChangedAt` (indexed date, `readOnly`, stamped by a `beforeChange` hook only when the level actually changes — and cleared when the level is), plus the value `movimento` on the `allocationDecision.outcome` enum, because a level move is neither `aceita` nor `descarta` and pretending otherwise would poison E11's triage. **No backfill:** the field is born null on all 435 municípios — stamping N2 would assert a decision nobody made — so null renders "Sem nível" and filtering by it **is** the triage queue. Moving a município is a reallocation decision, so writes are `isCampaignUnrestricted` only (`canManageMunicipalityEngagementLevel` in `utilities/access/municipalities.ts`); the advisor reads it and proposes through a signal. `setMunicipalityEngagementLevelRecord` takes the advisory lock `municipality-engagement-level:{id}` **before** reading the current level (otherwise the snapshot's `from` is a guess under concurrency) and writes both documents in one transaction, with `snapshot = { from, to, reversalSignals, triangulatedShock, violations, overridden, previousLevelChangedAt }` — the values the decision was made on, never the document. The hysteresis rules from research §6.8 live in the pure, client-safe `src/lib/engagementLevel.ts` (two-level jump needs a triangulated shock; three-week protection window after a promotion, against demotion only; one move per calendar month, derivable from `levelChangedAt` alone — no extra query), so the Popover shows the reason before submitting and the server re-runs them anyway. **The route has three states, not two:** `blocked` (409) returns the typed violations instead of collapsing into "could not save", because a blocked move is a decision the coordination may still take — the Popover keeps the draft and offers the override, which is recorded with exactly the rules it contradicted. **Explicit submit, not auto-save** — the exception `campanha-edit-where-you-see` already carves out for flows needing a note/confirmation, which also keeps this control out of the debounce machine **B32+** will audit. On the list: an 11th staff column with a numbered outline badge, plus filter `?level=`, which — unlike E10's derived `classe` — is **native**, because the field is stored and reaches the `where`; "sem nível" cannot ride inside an `in` and becomes an `or` branch with `{ exists: false }`. **The sort is not native**, though `n0…n4` does order lexicographically: Postgres puts NULLs FIRST on DESC, so with no backfill "N4 primeiro" would open on a page of "Sem nível". `nivel` joins `applyDerivedMunicipalitySort` and reuses `sortByNullableValue`, which sinks the absent level in both directions — the same treatment `sem_base` gets in `classe`. New E18 concept `nivel-de-envolvimento`. **Worth knowing:** with `overrideAccess: false` Payload **silently drops** a field the actor cannot write instead of rejecting the update, so the advisor int test asserts the level did **not** change rather than expecting a throw. Plano: [`docs/plans/niveis-de-envolvimento.md`](docs/plans/niveis-de-envolvimento.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). **Recently resolved (2026-07-26):** **B8 F2** Polígonos dos Municípios-zona de Salvador (ZE 1–19) — Salvador used to be **one** polygon carrying the whole city's votes, so the choropleth could not answer "which piece of Salvador is weak, or mine"; the only per-zone view was the textual `zoneBreakdown`. Two halves shipped. **(1) The mesh:** a new committed artifact `src/lib/geometries/bahia-municipality-zones.topo.json` (72,310 bytes, ~20 KB gzip, 19 features keyed by `municipalitySlug`) built by `pnpm build:municipality-zone-geometries` — see "Campaign map geometries" for the source, the parser and the three curated reconciliation tables. The interesting part is not the dissolve, it is that **the curation lives in the script, declared line by line with its evidence, and the build fails closed**: an orphan polygon or a TRE neighbourhood with no polygon aborts it, so "170/170 → 19/19" is a tested invariant. Periperi, which the resolution splits mid-street between two ZE, goes **whole** to ZE 17 — the neighbourhood is the smallest geometry available and cutting a polygon from a prose description of a street was the rabbit hole the plan already refused. The 5 polygons the resolution never names are assigned by **longest shared boundary**, and that rule was _validated_ against the 4 it names under another label before being trusted. **(2) The key:** the choropleth is no longer keyed by `codarea` but by a **map key** (`mapKeyForMunicipality`: slug for `kind === 'zona'`, `ibgeCode` otherwise) — see the "Map" bullet in the Município model for the contract, the non-interactive Salvador base and the per-city rank limitation. Because each map key now maps to exactly one catalog entry, the rollups stopped accumulating with `+=` and `computeAggregateTerritorialClass` gave way to `computeMunicipalityTerritorialClass`; `resolveMunicipalityMapNavigation` became 1:1, and with it the `kind: 'zones'` branch, its "toque de novo" readout copy and the panel's scroll-anchor all died. `buildMunicipalitiesByIbgeCode` survives untouched: B14's list href is per city on purpose. **Two measurements, so nobody re-derives them:** Salvador's municipal polygon is **~690 km²** against **~324 km²** for the 19 zones summed — the difference is the bay water the municipal limit encloses and a neighbourhood mesh never draws, which is why the int test asserts a **land**-area range instead of a ratio against the município (the first version demanded ≥95% and was comparing land to land+water); and centroid-inside-another-zone is **not** a valid overlap check here, because ZE 16 is concave and wraps ZE 14, so 16's centroid legitimately falls in 14 — non-overlap is guaranteed structurally at build time plus the area sum. New e2e `campaignZoneMap.e2e.spec.ts` pins 436 paths / 435 interactive (the one non-interactive path is Salvador's base) and that a zone opens `/campanha/municipios/salvador-ze-N`. No migration, no collection, no server action, no `Consent`. **Open debts with triggers (in the plan, not silent):** the B14 "Onde estou" card still routes Salvador to the filtered list even though resolving the exact ZE is now possible, and per-ZE competitive rank needs a new dimension in the TSE artifact. Plano: [`docs/plans/poligonos-pracas-zona.md`](docs/plans/poligonos-pracas-zona.md). @@ -159,6 +161,7 @@ The internal `/campanha` area is gated by its own authentication, deliberately k - **Município definition:** 435 seeded rows — one per Bahia municipality (416), except Salvador (19 zone municipalities, ZE 1–19); Camaçari is a single whole municipality (ZE 170/171 aggregated). Canonical catalog: `src/lib/municipalityCatalog.ts` (`tests/fixtures/municipality-catalog.snapshot.json`). Seeded by migration `20260723_200000_remodel_municipalities` (+ reconcile `20260723_202000_reconcile_municipality_remodel`). Geography is admin-only; staff edit strategy fields and the coordinator assigns `advisors`. - **Collections (admin group `Campanha`):** `municipality`, `leadership`, `votePledge`, `organization`, `stateDeputy` (dobradinhas), `campaignDemand`, `allocationDecision`, `municipalityUpdate`, `campaignInvite`, `supporter`, `activity`. **Global** (same admin group): `campaignGoals` (E8 "conta da cadeira" — `stateGoal` sets the optimistic suggested-goal scenario, `margin` the pessimistic haircut; `read` = admin/staff, `update` = admin/`isCampaignUnrestricted`; no `afterChange` hook — nothing caches it). +- **Engagement level (E14):** `municipality.engagementLevel` (`n0…n4`, staff-only, no default) is the campaign's declared investment in the município — N0 monitorar … N4 investimento pleno. Only `isCampaignUnrestricted` actors move it, every move needs a rationale plus the signals that would reverse it, and the move writes an `allocationDecision` (`outcome: 'movimento'`) in the same transaction under an advisory lock. Hysteresis rules are pure (`src/lib/engagementLevel.ts`) and shared by client and server; the override is recorded with the rules it contradicted. Null means "Sem nível" — the triage queue, not a missing value to backfill. - **Roles:** `coordinator` ("Coordenador Geral") and `candidate` ("Candidato", unrestricted visibility via `isCampaignUnrestricted`) see/manage everything staff sees; `advisor` ("Assessor") is scoped to `municipality.advisors`; `leader` ("Liderança") is **lockdown** — home is the supporter contact tool only (`LeaderContactsPanel`, `source: lideranca`), reads only supporters they created (`createdBy`), no municipalities/leaderships/pledges/demands/plans/election data. - **Vote pledge asymmetry:** staff declare `declaredVotes` and record `estimatedVotes` (pessimistic/central/optimistic); leaders never see estimates. Aggregates use `estimated[S] ?? declared`, default `central` (`src/utilities/votePledgeData.ts`). - **Demandas:** staff-only create/read (`canCreateCampaignDemand`); optional `leadership` provenance selected by staff. Escalated decisions: coordinator **or** candidate. diff --git a/docs/plans/niveis-de-envolvimento.md b/docs/plans/niveis-de-envolvimento.md index 8f7f3a6a..05a5da4c 100644 --- a/docs/plans/niveis-de-envolvimento.md +++ b/docs/plans/niveis-de-envolvimento.md @@ -1,12 +1,19 @@ # E14 — Níveis de envolvimento N0–N4 por município -Status: rascunho -Atualizado em: 2026-07-24 (refs sincronizadas pós-remodelagem Municípios + hardening) +Status: entregue (2026-07-27) +Atualizado em: 2026-07-27 (entregue: migration `20260727_161752_add_municipality_engagement_level`, action transacional com lock gravando `municipality` + `allocationDecision`, rota JSON com terceiro estado `blocked`, Popover de submit explícito, coluna/sort/filtro na lista, verbete E18 — as-built no fim do arquivo) Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Inteligência de campanha", E14; plano-mestre [inteligencia-campanha.md](inteligencia-campanha.md)) -Impeccable: B — campo novo no detalhe/lista do município (Popover B9) + badge staff; sem rota nova -Appetite: ~1 dia eng; migration pequena (campos em `municipality`) +Impeccable: B — campo novo no detalhe/lista do município (Popover) + badge staff; rota nova só de mutação JSON (`POST /campanha/municipios/engagement-level`) +Appetite: ~1,25 dia eng; migration pequena (3 campos em `municipality` + um valor no enum de `allocationDecision.outcome`) Responsável: — +## Dados → decisão → apresentação + +- **Vou apresentar dados?** Sim — uma classificação decidida por humano (não derivada), em **badge categórico** de 5 estados + "sem nível", na célula da lista e no card de estratégia do detalhe. +- **Que decisão ela serve?** Onde a campanha coloca presença, rede e agenda nas próximas semanas — e o registro de por que mudou. +- **Forma escolhida:** pill **outline numerada** ("N3"), rótulo longo e motivo no `cellTooltip` (seam do B23 ✓). A linha já carrega prioridade (vermelho), classe (5 variantes) e tendência (3): o nível não pode competir por cor. +- **Anti-goals de dado:** escala de cor de "importância" (vira ranking público — anti-goal 11); qualquer visibilidade para `leader`; gráfico ou série do nível. + ## Design (Impeccable) Âncoras: `PRODUCT.md` (princípios 3 e 5) / `DESIGN.md` (register `product`) · controles B9 (`MunicipalityList*Control`). @@ -25,24 +32,22 @@ Relatório §6.8: a escala N0 Monitorar → N1 Presença de mandato → N2 Rede ## Objetivos -- `municipality.engagementLevel` (`n0…n4`, default `n2`), `levelNote` (motivo corrente), `levelChangedAt` (derivado) — todos staff-only (access nega `leader` na leitura, mesmo mecanismo de `supportStatus`). +- `municipality.engagementLevel` (`n0…n4`, **sem default** — ver decisões), `levelNote` (motivo corrente), `levelChangedAt` (derivado) — todos staff-only na leitura (`canReadCampaignStaffField`, o mesmo mecanismo de `priority`/`politicalTrend`) e **unrestricted** na escrita. - Mudança de nível SEMPRE grava `allocationDecision` (patternId `nivel`, snapshot com nível anterior/novo, motivo, sinais de reversão) — histórico auditável sem versionar `municipality`. -- Validações de movimento no server action (pulo de 2 níveis exige flag "choque triangulado" com nota; aviso de janela de proteção) — avisos bloqueiam por default com override explícito do coordenador (registrado). -- `priority` legada: mapeada (`alta`→N3 sugerido) e aposentada da UI; campo permanece no schema até limpeza futura (sem migração destrutiva agora). -- Consumidores: badge staff no detalhe/lista; E9 usa nível na ordenação (N0/N1 fora do topo do déficit); metas E8 leem nível para "meta mínima". +- Validações de movimento no server action (pulo de 2 níveis exige flag "choque triangulado" com nota; aviso de janela de proteção) — avisos bloqueiam por default com override explícito do coordenador (registrado no snapshot). +- Ordenação e filtro por nível na lista de municípios (chave `nivel`, param repetível) — o campo é armazenado, então o filtro é `where` do Payload; o sort roda em memória por causa dos nulos (ver as-built). ## Decisões travadas -- **Nível é staff-only fail-closed** (field access como `supportStatus`; nunca em view models de liderança). Vocabulário duplo é requisito de produto. **Rejeitado:** badge público "prioridade" (vaza o mapa de defesa). +- **Nível é staff-only fail-closed** (field access; nunca em view models de liderança). Vocabulário duplo é requisito de produto. **Rejeitado:** badge público "prioridade" (vaza o mapa de defesa). - **Histórico via `allocationDecision`, não versions em `municipality`** (um mecanismo só para decisões — C12). **Rejeitado:** versions em municipality (custo alto, ruído); array `levelHistory` no doc (cresce sem access próprio). +- **`outcome` ganha um terceiro valor (`movimento`)** em vez de o movimento de nível se disfarçar de `aceita`. O enum nasceu binário (`aceita|descarta`) para a triagem de sugestões do E11, com `alternativeReading` obrigatória no descarte; um movimento de nível não é nenhum dos dois. **Rejeitado:** gravar sempre `aceita` (o campo viraria ruído semântico no primeiro consumidor da collection); usar `municipalityUpdate` kind `sinal` (misturaria decisão de alocação com sinal de campo e contaminaria `lastUpdateAt`/frescor). +- **Só `coordinator`/`candidate` movem o nível** (é decisão de realocação — rito §6.8); o assessor propõe por sinal. O access de `allocationDecision` já permite assessor no escopo dele, então a restrição vive na field access do campo + no `reloadUnrestrictedActor` da action. **Rejeitado:** assessor com aprovação (workflow multi-etapa é rabbit hole declarado abaixo). +- **Sem backfill: o campo nasce nulo, não `n2`.** Carimbar N2 em 435 municípios afirmaria uma decisão que ninguém tomou, e o rito §6.8 é registro ex-ante com motivo. "Sem nível" é um estado honesto, e filtrar por ele **é** a fila de triagem inicial. **Rejeitado:** default N2 no schema (o plano original) — reintroduz o binário que o item existe para substituir, agora com 435 falsos positivos. _(assumido — validar com produto)_ +- **Submit explícito, não auto-save.** É a exceção que a regra `campanha-edit-where-you-see` já prevê ("flows que precisam de nota/confirmação"): o movimento exige motivo E sinais de reversão, e pode precisar de override. Efeito colateral bom: o controle não vira o 4º call site da máquina de debounce que o **B32+** ainda vai extrair. **Rejeitado:** auto-save por debounce como B24 ✓/B27 ✓/B32 ✓. - **Rebaixar meta ≠ rebaixar município** (K-C): nível e meta são eixos independentes; a UI não acopla os dois automaticamente. **Rejeitado:** auto-derivar nível da cobertura (vira gaming imediato — G4). - **i18n e naming:** `engagementLevel` (`n0|n1|n2|n3|n4`), `levelNote`, `levelChangedAt`; labels pt-BR ("N1 · Presença de mandato" etc.). -## Questões em aberto - -- **Default N2 para todas ou derivar sugestão inicial dos dados (E8/E10)?** Opções: N2 fixo | sugestão computada com aceite em lote. **Recomendação:** N2 fixo + tela de revisão em lote ordenada por sugestão — decisão continua humana, custo de setup ~1h de reunião. _(validar com produto)_ -- **Quem pode mudar nível?** Opções: só `coordinator` | `advisor` nos seus municípios com aprovação. **Recomendação:** só `coordinator` (é decisão de realocação — rito §6.8); assessor propõe via nota/sinal. - ## Abordagem proposta ```mermaid @@ -51,41 +56,63 @@ flowchart LR Action["setMunicipalityEngagementLevel
(valida movimento)"] Municipality["municipality.engagementLevel"] Dec["allocationDecision
(motivo + reversão + snapshot)"] - Consumers["E9 fila · E8 metas · E11 motor"] + ListUrl["Lista: sort e filtro por nível"] UI --> Action --> Municipality Action --> Dec - Municipality --> Consumers + Municipality --> ListUrl ``` -Componentes: +Componentes (caminhos conferidos contra o repo em 2026-07-27): -- **`src/collections/Municipality.ts`**: 3 campos novos com access staff (padrão `politicalTrend`); `priority` sai da UI. -- **`src/utilities/municipalityStaffFormActions.ts`** (ou action irmã): `setMunicipalityEngagementLevel` transacional (municipality + allocationDecision com `req`), validações de histerese puras em `src/lib/engagementLevel.ts` (unit-testável). -- **UI:** `MunicipalityListLevelControl` no padrão dos `MunicipalityList*Control` (B9); badge no header do detalhe. -- **Migration:** `pnpm migrate:create add_municipality_engagement_level`. +- **`src/collections/Municipality.ts`**: 3 campos novos; leitura `canReadCampaignStaffField`, escrita `canManageMunicipalityEngagementLevel` (novo, em `src/utilities/access/municipalities.ts`, no molde de `canAssignMunicipalityAdvisors`); hook `beforeChange` carimbando `levelChangedAt`, espelhando `derivePoliticalTrendAudit`. +- **`src/collections/AllocationDecision.ts`**: terceiro valor em `outcome`. +- **`src/app/(campaign)/campanha/actions/municipality.ts`**: `setMunicipalityEngagementLevel[Record]` transacional (municipality + allocationDecision com `req`), com lock `municipality-engagement-level:{id}` via `acquireTextAdvisoryLocks` (`src/utilities/postgresTransactionLocks.ts`) — o "nível anterior" do snapshot precisa ser verdade sob concorrência. +- **`src/lib/engagementLevel.ts`** (novo, puro/client-safe): rótulos, peso ordinal e as violações de histerese (unit-testável). +- **Rota:** `POST /campanha/municipios/engagement-level` (`route.ts` + `types.ts`), espelhando `municipios/political-trend/`. +- **UI:** `src/components/campaign/municipality/MunicipalityListLevelControl.tsx` (Popover com submit explícito); badge no bloco staff de `MunicipalityStrategyCard.tsx`, ao lado de prioridade/tendência. +- **URL:** chave de sort `nivel` e filtro multi estático em `municipalityListUrl.ts` / `municipalityListFilters.ts`; coluna descrita em `municipalityLabels.ts`; verbete no `campaignIntelligenceConcepts.ts` (E18). +- **Migration:** `pnpm migrate:create add_municipality_engagement_level`. `ALTER TYPE … ADD VALUE` roda dentro da transação da migration (PG 12+) desde que o valor novo **não seja usado na mesma migration** — não backfillar aqui. ## Dependências -- Duras: **C12** (`allocationDecision` existir). Suaves: E8 (metas por nível), E9 (ordenação), E11 (nível no snapshot dos gatilhos). -- Reusa: `municipalityStaffFormActions.ts`, `campaignAccess.ts`, padrão de field access de `supportStatus` (`Leadership.ts`). +- Duras: **C12 ✓** (`allocationDecision` existe; o E14 é seu primeiro escritor de produção). Suaves: E11 (nível no snapshot dos gatilhos), B23 ✓ (`cellTooltip`), B41 ✓ (scroll horizontal, que é o que faz caber a 12ª coluna). +- Reusa: `actions/municipality.ts`, `municipalityStaffEditMessages.ts`, `campaignJsonMutationRoute.ts` + `isSameOriginRequest`, `campaignAccess.ts`, `withPayloadTransaction`, `acquireTextAdvisoryLocks`. ## Não escopo +- **Aposentar `priority` da UI** — o plano original previa isso; a auditoria de 2026-07-27 mostrou 18 arquivos em `src` envolvidos (atalho do Início `dashboardPriorityMunicipalities.ts`, link `?priority=alta&coverage=sem_assessor` do E9, filtro de header, card/form de estratégia, dossiê) **e o seed da planilha `projectionSheetParse.ts`, que escreve o campo**. Vira fill-in próprio; `priority` fica intocado aqui. +- **Consumidores a jusante:** nível na ordenação do E9 e "meta mínima" N0/N1 no E8 — mudam semântica travada de `goalCoverage` (`meta = expectedVotes[cenário] ?? suggestedGoal`); item separado. - Sugerir promoções/rebaixamentos automaticamente (E11, padrões K-A/P5); relatório de movimentos (E15); redeployment de brokers (processo humano — relatório K3). ## Rabbit holes - **Workflow de aprovação multi-etapa.** Vira mini-Jira; o rito é reunião quinzenal + registro, não máquina de estados. **Mitigação:** action única com validações; nada de status "pendente de aprovação". - **Migrar `priority` destrutivamente agora.** Coluna fica; remoção em migração futura de limpeza pós-estabilização. +- **Calibrar as regras de histerese.** Os cortes (salto de 2 níveis, 3 semanas de proteção, 1 movimento por mês) são os do relatório §6.8 e são **ilustrativos** — calibração é **E15**, num objeto só. ## Adiado com gatilho - **Notificação de mudança de nível ao assessor do município.** Gatilho: D2 (sino) entregue. +## As-built (2026-07-27) + +O que ficou diferente do plano, e o que só se soube implementando: + +- **Migration `20260727_161752_add_municipality_engagement_level`** — `engagementLevel` (select indexado, sem default), `levelNote` (textarea 2000) e `levelChangedAt` (date indexada, `readOnly`, escrita por `canSetCampaignSystemField`), mais o valor `movimento` no enum de `allocationDecision.outcome`. O hook `deriveEngagementLevelAudit` carimba `levelChangedAt` só quando o nível muda **e o zera quando o nível é apagado**: uma data de "registrado em" sem nível não descreve nada, e as regras de histerese a leriam como histórico. +- **O `from` do snapshot é verdade sob concorrência** porque a action segura `municipality-engagement-level:{id}` antes de ler o nível atual. Os dois escritos (update do município + `allocationDecision`) vivem na mesma transação, e o snapshot guarda `{ from, to, reversalSignals, triangulatedShock, violations, overridden, previousLevelChangedAt }` — só os valores sobre os quais a decisão foi tomada, nunca o documento. +- **A rota tem três estados, não dois.** `blocked` (409) devolve as violações tipadas em vez de virar "não foi possível salvar": um movimento barrado pela histerese não é uma falha para repetir, é uma decisão que a coordenação pode tomar mesmo assim — então o Popover mantém o rascunho e oferece o override no lugar. As mesmas regras rodam no cliente (o módulo é puro) para o coordenador ver o motivo antes de submeter; o servidor as roda de novo de qualquer jeito. +- **O filtro é armazenado, então entra no `where`** — ao contrário de `classe` (E10), que é derivada e força o caminho `limit: 0`. "Sem nível" não cabe num `in`, então vira um ramo `or` com `{ engagementLevel: { exists: false } }`; selecionar N0 + sem nível (a fila de triagem) continua sendo uma consulta só. **O sort, não.** `n0…n4` ordena lexicograficamente na ordem ordinal, então parecia nativo — mas o Postgres põe NULL **primeiro** no DESC, e sem backfill "N4 primeiro" entregaria uma página inteira de "Sem nível". Ausência de decisão não é o topo da escada: `nivel` entrou em `applyDerivedMunicipalitySort` com `sortByNullableValue`, que joga nulo para o fim nas duas direções (mesma semântica de `sem_base` em `classe`). Custo: sortear por nível carrega o escopo inteiro — o mesmo que o sort padrão (`deficit`) já faz. Int test pina as duas direções. +- **Descoberto no int test:** com `overrideAccess: false`, o Payload **não** rejeita o update inteiro quando o ator não pode escrever o campo — ele descarta o campo em silêncio. O teste do assessor, então, prova que o nível **não mudou**, não que a chamada lançou. +- **Banco de desenvolvimento local estava fora do histórico de migrations:** `allocation_decision.outcome` era `varchar` e o tipo `enum_allocation_decision_outcome` não existia (o `teqo_test`, construído só por migrations, tinha ambos). Reparado à mão no `teqo` (criar o tipo + cast da coluna) antes de aplicar esta migration; nada a fazer em produção, que segue o histórico. +- **Custo:** `/campanha/municipios` fica em 17,7 kB / 322 kB de First Load JS. Suítes: 64 unit (604 testes) e 53 int (427). Nada novo no bundle do cliente: a revisão de performance conferiu contra o build de produção que todo módulo que a ilha importa já estava nos chunks da rota. +- **O que a revisão pós-entrega corrigiu:** (1) um `blocked` vindo do **servidor** — o caso que só existe sob concorrência, quando outra aba moveu o município — virava string no box de erro, sem o checkbox de override ao lado; agora as violações tipadas voltam ao mesmo lugar que as locais, deduplicadas por `id` e presas ao nível para o qual foram levantadas, que é a única forma de o terceiro estado da rota valer para quem mais precisa dele. (2) `formatEngagementLevelLabel` / `EMPTY_ENGAGEMENT_LEVEL_LABEL` mudaram de `municipalityLabels` para `lib/engagementLevel.ts`: importar um valor daquele módulo arrasta o catálogo de 435 entradas e o glossário de conceitos para dentro da ilha (o padrão de 21 kB do B14), hoje inócuo só porque o controle de tendência já mantém a cadeia viva. (3) `isEngagementLevel` saiu de três chamadas onde o tipo gerado pelo Payload já era a união fechada. +- **Débito conhecido (não é do E14 sozinho):** `levelNote` entrou em `municipalityListSelect`, e o sort padrão do staff (`deficit`) roda esse select sobre os 435 municípios do escopo para renderizar 25 linhas — no teto de 2000 caracteres são ~870 KB lidos por request. Hoje custa zero (o campo nasce nulo em todos), e `politicalTrend.note` tem exatamente a mesma forma e já estava lá: o conserto é dos dois juntos (ler as notas só para a fatia da página), não de um. +- **Fora do escopo, confirmado na entrega:** `priority` continua intocado (18 arquivos + o seed `projectionSheetParse.ts` que escreve o campo), e nem o E8 nem o E9 leem o nível. + ## Referências - `docs/roadmap.md` (Inteligência de campanha, E14) · [plano-mestre](inteligencia-campanha.md) (G7) - `docs/research/relatorio-entrevista-persona-campanha.md` §6.8 (escala, movimento, rito, custo político), K1–K4 -- `src/collections/Municipality.ts` (priority/politicalTrend/access), `src/collections/Leadership.ts` (padrão supportStatus) -- `src/utilities/municipalityStaffFormActions.ts`, `src/components/campaign/MunicipalityListTrendControl.tsx` (padrão de controle B9) +- `src/collections/Municipality.ts` (priority/politicalTrend/access), `src/collections/AllocationDecision.ts` (C12) +- `src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts`, `src/components/campaign/municipality/MunicipalityListTrendControl.tsx` (padrão de controle na célula) - AGENTS.md — field access fail-closed, transações, migrations diff --git a/docs/roadmap.md b/docs/roadmap.md index 5ec134aa..74db92b0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -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). +Atualizado em: 2026-07-27 (**E14 ✓** níveis de envolvimento N0–N4 — `municipality.engagementLevel` staff-only (sem default: nulo é "sem nível", e filtrar por ele é a fila de triagem), motivo e sinais de reversão obrigatórios, e o **primeiro escritor de produção do `allocationDecision`** (C12 ✓), gravado na mesma transação sob lock porque o "nível anterior" do snapshot precisa ser verdade; enum de `outcome` estendido com `movimento`; histerese do §6.8 (salto duplo só com choque triangulado, 3 semanas de proteção, um movimento por mês) num módulo puro que roda no cliente **e** no servidor, com a rota respondendo `blocked` (409) com as violações tipadas e o override registrado com as regras que contrariou; submit explícito em vez de auto-save (exceção prevista pela regra, e o controle não vira o 4º call site do B32+); coluna + filtro nativo (`?level=`) e sort em memória na lista (o Postgres põe NULL primeiro no DESC, e sem backfill isso entregaria "N4 primeiro" cheio de "Sem nível") + verbete no E18 · [plano](plans/niveis-de-envolvimento.md); **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) @@ -60,6 +60,8 @@ Textos provisórios de Consent + `/privacidade` auto-provisionados ([onda-0.md]( - **B8 F2 (2026-07-26)** — polígonos das 19 ZE de Salvador e re-keying do coroplético. Salvador era **um** polígono carregando os votos da cidade, então o mapa não respondia "qual pedaço de Salvador está fraco / é meu". Novo artefato commitado `bahia-municipality-zones.topo.json` (72 KB, ~20 KB gzip, 19 features) gerado por `pnpm build:municipality-zone-geometries` a partir do `BA_bairros_CD2022` do IBGE (`shpjs` como devDependency — o recorte só existe em SHP), dissolvendo bairro→zona pela **RA 02/2017 do TRE-BA**: 170/170 polígonos atribuídos, 19/19 zonas, curadoria em 3 tabelas explícitas no script (aliases, bairro sem polígono próprio, bairro que a resolução parte) mais um passe de **maior fronteira compartilhada** para os 5 que a resolução nunca nomeia — regra validada contra os 4 que ela nomeia sob outro rótulo. A chave do mapa virou **map key** (`slug` na zona, `ibgeCode` no resto): bundle, rollups, navegação e classe territorial re-keyed, e o ramo `kind: 'zones'` do readout morreu. O polígono municipal de Salvador ficou desenhado por baixo como **base sem dado e sem pointer**, porque duas malhas simplificadas independentemente não fecham na mesma costa. `competitiveRank` continua por cidade (o artefato só tem `federalRankByIbgeCode`, o que a legenda já dizia — int pina que as 19 dividem uma posição). Sem migration, sem collection, sem action. Plano: [poligonos-pracas-zona.md](plans/poligonos-pracas-zona.md); débitos do `/simplify` (relatórios chegaram depois do merge): [escala-dry-pos-b8f2.md](plans/escala-dry-pos-b8f2.md). +- **E14 (2026-07-27)** — níveis de envolvimento N0–N4 e o primeiro escritor de produção do `allocationDecision` (C12 ✓). Três campos staff-only em `municipality` (`engagementLevel` sem default, `levelNote`, `levelChangedAt` derivado por hook) na migration `20260727_161752_add_municipality_engagement_level`, que também acrescenta `movimento` ao enum de `outcome` — um movimento de nível não é "aceita" nem "descarta". **Sem backfill:** o campo nasce nulo em 435 municípios, porque carimbar N2 afirmaria uma decisão que ninguém tomou; "sem nível" é o estado honesto e filtrar por ele **é** a fila de triagem. Mover é decisão de realocação, então só `coordinator`/`candidate` (field access própria + `reloadUnrestrictedActor`); o assessor lê e propõe por sinal. A action grava o município e a decisão na **mesma transação**, sob o lock `municipality-engagement-level:{id}` — sem ele o "nível anterior" do snapshot é chute. A histerese do relatório §6.8 (salto de 2 níveis só com choque triangulado, 3 semanas de proteção pós-promoção, um movimento por mês, este derivado só de `levelChangedAt`) vive num módulo puro e client-safe, então o Popover mostra o motivo antes de submeter e o servidor revalida; a rota responde `blocked` (409) com as violações tipadas, e o override entra no snapshot com exatamente as regras que contrariou. **Submit explícito, não auto-save** — a exceção que `campanha-edit-where-you-see` prevê para flows com nota/confirmação, e que mantém o controle fora do 4º call site do **B32+**. Na lista: 11ª coluna staff com badge outline numerado, filtro `?level=` **nativo** (o campo é armazenado; "sem nível" vira `exists: false` num ramo `or`) e sort `nivel` **em memória** — o Postgres põe NULL primeiro no DESC, então o sort nativo entregaria "N4 primeiro" com uma página de "Sem nível"; `sortByNullableValue` manda o nulo para o fim nas duas direções, como `sem_base` em `classe` —, mais o verbete no `/campanha/conceitos` (E18). Plano: [niveis-de-envolvimento.md](plans/niveis-de-envolvimento.md). + ## Próximos — Campanha (`/campanha`) ### Programa Inteligência de campanha (E8–E16, B13, C12 · adjacentes A11/E17/E18) @@ -70,7 +72,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re | ------- | -------------------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------- | --------- | -------------------- | | ~~E10~~ | ~~Classificação territorial relativa~~ | [detalhes](plans/classificacao-territorial-relativa.md) | **✓ (entregue 2026-07-25)** Reduto/Expansão/Manutenção/Marginal por LQ + importância + teto do campo; coluna, sort e filtro na lista | B | ~1,25d | 3 | E8 ✓ | | ~~B13~~ | ~~Escala relativa no mapa~~ | [detalhes](plans/escala-relativa-mapa.md) | **✓ (entregue 2026-07-26)** Quantis (default) + LQ nas âncoras do E10 + posição no município (artefato v3); bolhas por válidos projetados coloridas pela classe | B | ~3d | 3 | E8 ✓, E10 ✓ | -| E14 | Níveis de envolvimento N0–N4 | [detalhes](plans/niveis-de-envolvimento.md) | `engagementLevel` com histerese e sinais de reversão; staff-only (vocabulário duplo) | B | ~1d | 3 | C12 | +| ~~E14~~ | ~~Níveis de envolvimento N0–N4~~ | [detalhes](plans/niveis-de-envolvimento.md) | **✓ (entregue 2026-07-27)** `engagementLevel` staff-only com histerese e sinais de reversão; movimento grava `allocationDecision` na mesma transação | B | ~1,25d | 3 | C12 ✓ | | E11 | Motor de sugestões v1 | [detalhes](plans/motor-de-sugestoes.md) | 8 padrões (P1/P2/P3/P5/P6/P7/K-A/K-B), menu com estatuto, aceitar/descartar → `allocationDecision`, triagem 1–5 | C | ~3d | 3 | E8 ✓, E9 ✓, E10, C12 | | ~~E12~~ | ~~Camada TI~~ | [detalhes](plans/camada-territorios-identidade.md) | **✓ (entregue 2026-07-26)** Rollups MAUP + cobertura/classe/captura em `/campanha/territorios`; benchmark intra-TI no card Conta da cadeira; gatilhos T = E11 fase 2 | B | ~1,5d | 3 | E8 ✓ | | E13 | Planejador de presença/giros | [detalhes](plans/planejador-de-giros.md) | Elegibilidade (5 condições ✓/—), fases construção/consolidação/ativação, compositor de giro, visita pedida×justificada | C | ~1,5d | 3 | E8 ✓, C12 | @@ -211,7 +213,7 @@ flowchart TD E16i["E16 ✓ Dossiê do município"] E10i["E10 ✓ Classificação relativa"] B13i["B13 ✓ Escala relativa no mapa"] - E14i["E14 Níveis N0–N4"] + E14i["E14 ✓ Níveis N0–N4"] E11i["E11 Motor de sugestões v1"] E12i["E12 Camada TI"] E13i["E13 Planejador de giros"] @@ -337,7 +339,7 @@ Paralelizáveis agora: **B13** (E10 ✓ entregou as classes; consome `computeMun **Janela 2 — 05/08 → 16/08 (pré-propaganda):** C2 dados reais assim que o jurídico liberar; ~~**E9** fila de alocação~~ (entregue 2026-07-24, antecipada da janela 2); ~~**E16** dossiê do município~~ (entregue 2026-07-25, antecipado da janela 2); D2 se sobrar folga. -**Janela 3 — 16/08 → set:** A6 dobradinha (pós-TSE 15/08); ~~**E10** classificação relativa~~ (entregue 2026-07-25) → **B13** escala relativa no mapa; **E14** níveis N0–N4; **E11** motor de sugestões v1; **E12** camada TI; **E13** planejador de giros; ~~**B8 F2** polígonos das zonas de Salvador~~ (entregue 2026-07-26); débitos sobreviventes. Se houver folga **e** os deltas do ZAP não estiverem sendo absorvidos pelo fluxo sede-digita (C12): começar **D3**. +**Janela 3 — 16/08 → set:** A6 dobradinha (pós-TSE 15/08); ~~**E10** classificação relativa~~ (entregue 2026-07-25) → **B13** escala relativa no mapa; ~~**E14** níveis N0–N4~~ (entregue 2026-07-27); **E11** motor de sugestões v1; **E12** camada TI; **E13** planejador de giros; ~~**B8 F2** polígonos das zonas de Salvador~~ (entregue 2026-07-26); débitos sobreviventes. Se houver folga **e** os deltas do ZAP não estiverem sendo absorvidos pelo fluxo sede-digita (C12): começar **D3**. **Janela 4 — set → 04/10:** C5 GOTV _(validar)_, congelamento ~20/09 (só bugfix/dados). **D4** envio 1:1 e **D5** inbox→rascunhos só se D3 estiver estável **antes** do congelamento (senão adiar pós-04/10). **E15** backtest pós-eleição (após 04/10). @@ -345,7 +347,7 @@ Paralelizáveis agora: **B13** (E10 ✓ entregou as classes; consome `computeMun **Não cortáveis:** Onda 0 (jurídico/Consent); ~~**E4R** seed da planilha~~ (entregue 2026-07-24 — ainda aplicar em produção após smoke); ~~**E8** conta da cadeira~~ (entregue 2026-07-24); ~~**C12** registro-fundação~~ (entregue 2026-07-24); C2 dados reais; assimetria declarado×estimado (relação de campo); ~~**E9** fila de alocação~~ (entregue 2026-07-24 — era o mínimo de "inteligência, não planilha"); ~~**B19** gerenciar assessores~~ (entregue 2026-07-24 — CG/candidato não entram em `/admin`); **B39** lembrar de mim / sessão longa (default de 2 h derruba sessão no onboarding/campo — atrito diário de adoção). -**Cortes seguros** (se o prazo apertar, nesta ordem): **D5** inbox→rascunhos (manter registro manual + `wa.me`); **D4** envio bridge (manter `wa.me`); **D3** fundação do canal (atalho `wa.me` continua); **E12** camada TI (rollup manual por lista; **E17** já dá a leitura regional básica no Início); **E13** planejador de giros (rebaixado na fila de corte em 2026-07-24: "perna"/agenda é a restrição dominante nomeada em campo — cortar só depois de E12; agenda segue manual com J-A/J-B como guia); **E15** backtest (pós-eleição por definição — cortar = perder o aprendizado 2030); **E11** motor v1 (manter a fila E9 ✓ sem sugestões); **B13** símbolo proporcional (manter quantis/LQ como escala); **E14** níveis (manter `priority` alta/normal); ~~**B8 F2** polígonos~~ (entregue 2026-07-26 — o mapa não é mais agregado em Salvador); **D2** push (adiar); **A6**; ~~**B14** município mais próximo~~ (entregue 2026-07-26); ~~**B38** sidebar recolhido em tablet~~ (entregue 2026-07-26); **B40** login biométrico (senha + B39 continuam — cortar custa só o atalho de digital/Face ID; o mais caro e o primeiro a cair desta leva B39–B42); **B42** polimento mobile da lista de municípios (Popover e card longos continuam usáveis — cortar custa conforto de teclado e densidade no celular); ~~**B41** scroll horizontal + sticky~~ (entregue 2026-07-27) (a tabela continua legível com zoom/pan da página — cortar custa o overflow em tablet; cai **depois** de B42 porque o bug de layout em mesa é mais caro que o polish mobile); **B18** filtros salvos (lista + filtros manuais continuam; Visitados cobrem “voltar”); **B21** página dos Territórios de Identidade (o painel do E17 ✓ no Início continua entregando a leitura regional) — **B25** cai junto por definição (sem destino, sem link); **B17** seletor de colunas (tabela completa continua; scroll horizontal); ~~**B22** explicação no header~~ (entregue 2026-07-26); ~~**B23** tooltip nas células~~ (entregue 2026-07-26); ~~**B24** auto-save + justificativa na Tendência~~ (entregue 2026-07-26); **B27** combobox + chips nos Assessores (a lista de checkboxes com "Salvar" continua atribuindo — cortar custa a mesma coerência do B24 mais o tempo do CG procurando nomes numa coluna durante o onboarding; corte junto com B24, já que dividem o endpoint); **B28** e-mail + celular na lista de lideranças (a ficha `/liderancas/[id]` continua mostrando o contato — cortar custa o gesto de copiar/abrir ZAP na varredura; quick win barato de cortar e de fazer); ~~**B30** convite de completar cadastro por WhatsApp na lista~~ (entregue 2026-07-26); **B31** coluna de dobradinhas nas lideranças (o `RelationMultiSelect` da ficha interna continua vinculando — cortar custa o preenchimento em lote do onboarding, uma ficha aberta por liderança, e mantém o vínculo invisível na varredura; corte junto com B27, já que dividem a peça de célula, e antes de B29 só se a mesa já tiver preenchido as dobradinhas à mão); ~~**B32** editar Status de apoio na célula~~ (entregue 2026-07-26); **B34** chips editáveis de municípios na lista de lideranças (o texto simples `row.municipalityNames.join(', ')` continua legível e a ficha `/liderancas/[id]` continua vinculando município via formulário — cortar custa o link direto ao município, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha; corte junto com B31, já que dividem a mesma peça de célula editável, e antes de B29 pelo mesmo motivo de coerência); **B37** chips editáveis de municípios na lista de dobradinhas (o número `municipalityCount` continua legível e a ficha de município continua vinculando dobradinha pelo `MunicipalityStrategyForm` — cortar custa o link direto, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha do município; corte junto com B34, já que dividem a mesma lib e a mesma decisão de container); ~~**B36** coluna de lideranças na lista de dobradinhas~~ (entregue 2026-07-26; a contagem `leadershipCount` era o fallback e a ficha `/liderancas/[id]` continua vinculando dobradinha via formulário — cortar custa o link direto à liderança, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha; corte junto com B31, já que dividem a mesma mutação/schema/lock); ~~**B33** sort + filtro no header das dobradinhas~~ (entregue 2026-07-26); **B29** sort + filtro no header das lideranças (a busca por nome continua funcionando e o escopo do assessor já reduz a lista — cortar custa o recorte por status/setor/município no onboarding da rede e mantém o header rico como cópia única de municípios; por ser o item mais caro desta faixa (~1,5–2 dias com a extração do head), cai **antes** do B26, que é o que sustenta o gate de adoção); **B26** registrar sinal na célula (o formulário completo em `/[slug]?tab=updates` continua sendo o caminho — cortar custa o laço da fila do E9 ✓ e o registro em campo pelo celular, que é justamente onde o gate de adoção se decide; por isso cai **depois** de B22/B23/B24); ~~**C13** renomear Plano de Ação → Atividade~~ (entregue 2026-07-25); **B20** prioritários no Início (lista `?priority=alta` continua; só perde o atalho — mas a seção atual mente, então cortar = conviver com o bug); fill-in **renomear coluna Cobertura** (a lista continua legível com o rótulo longo — corte = header mais largo); fill-in **remover coluna Tipo** (a lista continua completa; só mantém densidade morta — corte = conviver com a coluna); fill-in **remover lista de zonas do Início** (o mapa B8 F2 ✓ continua abrindo cada ZE — corte = conviver com 19 linhas redundantes sob o painel); ~~**B16** filtros no header~~ (entregue 2026-07-25); ~~**B15** sort por coluna~~ (entregue 2026-07-24); ~~**A11**/**E17**~~ (entregues 2026-07-24); ~~**E18** documentação de conceitos~~ (entregue 2026-07-24); ~~**E16** dossiê~~ (entregue 2026-07-25); demais débitos/fill-ins. +**Cortes seguros** (se o prazo apertar, nesta ordem): **D5** inbox→rascunhos (manter registro manual + `wa.me`); **D4** envio bridge (manter `wa.me`); **D3** fundação do canal (atalho `wa.me` continua); **E12** camada TI (rollup manual por lista; **E17** já dá a leitura regional básica no Início); **E13** planejador de giros (rebaixado na fila de corte em 2026-07-24: "perna"/agenda é a restrição dominante nomeada em campo — cortar só depois de E12; agenda segue manual com J-A/J-B como guia); **E15** backtest (pós-eleição por definição — cortar = perder o aprendizado 2030); **E11** motor v1 (manter a fila E9 ✓ sem sugestões); **B13** símbolo proporcional (manter quantis/LQ como escala); ~~**E14** níveis~~ (entregue 2026-07-27 — `priority` continua, intocado); ~~**B8 F2** polígonos~~ (entregue 2026-07-26 — o mapa não é mais agregado em Salvador); **D2** push (adiar); **A6**; ~~**B14** município mais próximo~~ (entregue 2026-07-26); ~~**B38** sidebar recolhido em tablet~~ (entregue 2026-07-26); **B40** login biométrico (senha + B39 continuam — cortar custa só o atalho de digital/Face ID; o mais caro e o primeiro a cair desta leva B39–B42); **B42** polimento mobile da lista de municípios (Popover e card longos continuam usáveis — cortar custa conforto de teclado e densidade no celular); ~~**B41** scroll horizontal + sticky~~ (entregue 2026-07-27) (a tabela continua legível com zoom/pan da página — cortar custa o overflow em tablet; cai **depois** de B42 porque o bug de layout em mesa é mais caro que o polish mobile); **B18** filtros salvos (lista + filtros manuais continuam; Visitados cobrem “voltar”); **B21** página dos Territórios de Identidade (o painel do E17 ✓ no Início continua entregando a leitura regional) — **B25** cai junto por definição (sem destino, sem link); **B17** seletor de colunas (tabela completa continua; scroll horizontal); ~~**B22** explicação no header~~ (entregue 2026-07-26); ~~**B23** tooltip nas células~~ (entregue 2026-07-26); ~~**B24** auto-save + justificativa na Tendência~~ (entregue 2026-07-26); **B27** combobox + chips nos Assessores (a lista de checkboxes com "Salvar" continua atribuindo — cortar custa a mesma coerência do B24 mais o tempo do CG procurando nomes numa coluna durante o onboarding; corte junto com B24, já que dividem o endpoint); **B28** e-mail + celular na lista de lideranças (a ficha `/liderancas/[id]` continua mostrando o contato — cortar custa o gesto de copiar/abrir ZAP na varredura; quick win barato de cortar e de fazer); ~~**B30** convite de completar cadastro por WhatsApp na lista~~ (entregue 2026-07-26); **B31** coluna de dobradinhas nas lideranças (o `RelationMultiSelect` da ficha interna continua vinculando — cortar custa o preenchimento em lote do onboarding, uma ficha aberta por liderança, e mantém o vínculo invisível na varredura; corte junto com B27, já que dividem a peça de célula, e antes de B29 só se a mesa já tiver preenchido as dobradinhas à mão); ~~**B32** editar Status de apoio na célula~~ (entregue 2026-07-26); **B34** chips editáveis de municípios na lista de lideranças (o texto simples `row.municipalityNames.join(', ')` continua legível e a ficha `/liderancas/[id]` continua vinculando município via formulário — cortar custa o link direto ao município, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha; corte junto com B31, já que dividem a mesma peça de célula editável, e antes de B29 pelo mesmo motivo de coerência); **B37** chips editáveis de municípios na lista de dobradinhas (o número `municipalityCount` continua legível e a ficha de município continua vinculando dobradinha pelo `MunicipalityStrategyForm` — cortar custa o link direto, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha do município; corte junto com B34, já que dividem a mesma lib e a mesma decisão de container); ~~**B36** coluna de lideranças na lista de dobradinhas~~ (entregue 2026-07-26; a contagem `leadershipCount` era o fallback e a ficha `/liderancas/[id]` continua vinculando dobradinha via formulário — cortar custa o link direto à liderança, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha; corte junto com B31, já que dividem a mesma mutação/schema/lock); ~~**B33** sort + filtro no header das dobradinhas~~ (entregue 2026-07-26); **B29** sort + filtro no header das lideranças (a busca por nome continua funcionando e o escopo do assessor já reduz a lista — cortar custa o recorte por status/setor/município no onboarding da rede e mantém o header rico como cópia única de municípios; por ser o item mais caro desta faixa (~1,5–2 dias com a extração do head), cai **antes** do B26, que é o que sustenta o gate de adoção); **B26** registrar sinal na célula (o formulário completo em `/[slug]?tab=updates` continua sendo o caminho — cortar custa o laço da fila do E9 ✓ e o registro em campo pelo celular, que é justamente onde o gate de adoção se decide; por isso cai **depois** de B22/B23/B24); ~~**C13** renomear Plano de Ação → Atividade~~ (entregue 2026-07-25); **B20** prioritários no Início (lista `?priority=alta` continua; só perde o atalho — mas a seção atual mente, então cortar = conviver com o bug); fill-in **renomear coluna Cobertura** (a lista continua legível com o rótulo longo — corte = header mais largo); fill-in **remover coluna Tipo** (a lista continua completa; só mantém densidade morta — corte = conviver com a coluna); fill-in **remover lista de zonas do Início** (o mapa B8 F2 ✓ continua abrindo cada ZE — corte = conviver com 19 linhas redundantes sob o painel); ~~**B16** filtros no header~~ (entregue 2026-07-25); ~~**B15** sort por coluna~~ (entregue 2026-07-24); ~~**A11**/**E17**~~ (entregues 2026-07-24); ~~**E18** documentação de conceitos~~ (entregue 2026-07-24); ~~**E16** dossiê~~ (entregue 2026-07-25); demais débitos/fill-ins. ## Bloqueadores atuais diff --git a/src/app/(campaign)/campanha/(app)/municipios/engagement-level/route.ts b/src/app/(campaign)/campanha/(app)/municipios/engagement-level/route.ts new file mode 100644 index 00000000..f1fff536 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/municipios/engagement-level/route.ts @@ -0,0 +1,68 @@ +import { NextResponse } from 'next/server' + +import { setMunicipalityEngagementLevel } from '@/app/(campaign)/campanha/actions/municipality' +import { EngagementLevelBlockedError } from '@/lib/engagementLevel' +import { + MUNICIPALITY_ENGAGEMENT_LEVEL_UNRESTRICTED_MESSAGE, + municipalityEngagementLevelSchema, +} from '@/lib/schemas/municipality' +import { positiveRelationshipId } from '@/lib/schemas/primitives' +import { + campaignJsonMutationErrorResponse, + parseCampaignJsonRequestBody, +} from '@/utilities/campaignJsonMutationRoute' +import { isSameOriginRequest } from '@/utilities/sameOriginRequest' + +import type { MunicipalityListEngagementLevelResponse } from './types' + +export type { MunicipalityListEngagementLevelResponse } from './types' + +export const dynamic = 'force-dynamic' + +/** JSON body uses `municipalityId`; the action schema uses `municipality`. */ +const bodySchema = municipalityEngagementLevelSchema + .omit({ municipality: true }) + .extend({ municipalityId: positiveRelationshipId }) + +export async function POST( + request: Request, +): Promise> { + if (!isSameOriginRequest(request)) { + return NextResponse.json({ status: 'error', message: 'Requisição inválida.' }, { status: 403 }) + } + + const parsed = await parseCampaignJsonRequestBody(request) + if (!parsed.ok) return parsed.response + + try { + const { municipalityId, ...movement } = bodySchema.parse(parsed.body) + const updated = await setMunicipalityEngagementLevel({ + municipality: municipalityId, + ...movement, + }) + + return NextResponse.json({ + status: 'success', + message: 'Nível de envolvimento registrado.', + savedLevel: { + level: movement.level, + note: updated.levelNote ?? null, + changedAt: updated.levelChangedAt ?? null, + }, + }) + } catch (error) { + if (error instanceof EngagementLevelBlockedError) { + return NextResponse.json( + { status: 'blocked', message: error.message, violations: error.violations }, + { status: 409 }, + ) + } + + return campaignJsonMutationErrorResponse(error, { + // Only the unrestricted-actor message: this action reloads the actor with + // its own guard, never through `getFreshStaffActor` (same as `advisors/`). + safeMessages: [MUNICIPALITY_ENGAGEMENT_LEVEL_UNRESTRICTED_MESSAGE], + genericMessage: 'Não foi possível registrar o nível. Verifique seu acesso e tente novamente.', + }) + } +} diff --git a/src/app/(campaign)/campanha/(app)/municipios/engagement-level/types.ts b/src/app/(campaign)/campanha/(app)/municipios/engagement-level/types.ts new file mode 100644 index 00000000..4bb44b04 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/municipios/engagement-level/types.ts @@ -0,0 +1,29 @@ +import type { EngagementLevel, EngagementLevelViolation } from '@/lib/engagementLevel' + +export type MunicipalityListSavedEngagementLevel = { + level: EngagementLevel + note: string | null + changedAt: string | null +} + +/** + * `blocked` is a third state on purpose: a movement held by the stability + * rules is not a failure the coordinator should retry, it is a decision they + * can take anyway — so the reasons travel back and the control offers the + * override in place. + */ +export type MunicipalityListEngagementLevelResponse = + | { + status: 'success' + message: string + savedLevel: MunicipalityListSavedEngagementLevel + } + | { + status: 'blocked' + message: string + violations: EngagementLevelViolation[] + } + | { + status: 'error' + message: string + } diff --git a/src/app/(campaign)/campanha/(app)/municipios/page.tsx b/src/app/(campaign)/campanha/(app)/municipios/page.tsx index c407be36..96ba764a 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/page.tsx +++ b/src/app/(campaign)/campanha/(app)/municipios/page.tsx @@ -18,6 +18,7 @@ import { isCampaignCoordinator, isCampaignLeader, isCampaignStaff, + isCampaignUnrestricted, } from '@/utilities/campaignAccess' import { getCampaignUser } from '@/utilities/campaignAuth' import { getCampaignScopeLabel } from '@/utilities/municipalityLabels' @@ -48,6 +49,7 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie if (isCampaignLeader(user)) redirect('/campanha') const isStaffView = isCampaignStaff(user) const isCoordinator = isCampaignCoordinator(user) + const canMoveEngagementLevel = isCampaignUnrestricted(user) const pageBundle = await loadMunicipalityListPageBundle(payload, user, rawSearchParams) const { @@ -122,6 +124,7 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie advisorNamesById={advisorNamesById} isStaffView={isStaffView} isCoordinator={isCoordinator} + canMoveEngagementLevel={canMoveEngagementLevel} advisorOptions={advisorOptions} columnFilterOptions={columnFilterOptions} signalFormAction={createMunicipalityListSignalFormAction} diff --git a/src/app/(campaign)/campanha/actions/municipality.ts b/src/app/(campaign)/campanha/actions/municipality.ts index 4b42616f..8df5d019 100644 --- a/src/app/(campaign)/campanha/actions/municipality.ts +++ b/src/app/(campaign)/campanha/actions/municipality.ts @@ -2,16 +2,24 @@ import type { Payload } from 'payload' +import { + ENGAGEMENT_LEVEL_PATTERN_ID, + EngagementLevelBlockedError, + getEngagementLevelViolations, +} from '@/lib/engagementLevel' import { nextAdvisorIdsAfterMembership } from '@/lib/municipalityAdvisorMembership' import { MUNICIPALITY_ADVISOR_MEMBERSHIP_UNRESTRICTED_MESSAGE, + MUNICIPALITY_ENGAGEMENT_LEVEL_UNRESTRICTED_MESSAGE, municipalityAdvisorMembershipSchema, municipalityAdvisorsAssignmentSchema, + municipalityEngagementLevelSchema, municipalityExpectedVotesSchema, municipalityPoliticalTrendSchema, municipalityStrategyUpdateSchema, type MunicipalityAdvisorMembershipInput, type MunicipalityAdvisorsAssignmentInput, + type MunicipalityEngagementLevelInput, type MunicipalityExpectedVotesInput, type MunicipalityPoliticalTrendInput, type MunicipalityStrategyUpdateInput, @@ -126,6 +134,105 @@ export const setMunicipalityExpectedVotes = async (input: MunicipalityExpectedVo return setMunicipalityExpectedVotesRecord(payload, actor, input) } +/** + * E14 — moving a município up or down the N0–N4 ladder. Two writes in one + * transaction: the município carries the current level, and `allocationDecision` + * carries the movement forever (C12's first production writer), so the history + * survives the next movement instead of being overwritten by it. + * + * The lock is what makes the recorded "nível anterior" true: without it two + * coordinators moving the same município would each read the same `from` and + * write two decisions claiming to start from it. + */ +export const setMunicipalityEngagementLevelRecord = async ( + payload: Payload, + actor: CampaignUser, + input: MunicipalityEngagementLevelInput, +) => { + const { municipality, level, note, reversalSignals, triangulatedShock, override } = + municipalityEngagementLevelSchema.parse(input) + + return withPayloadTransaction( + payload, + async ({ req }) => { + const currentActor = await reloadUnrestrictedActor( + payload, + actor, + MUNICIPALITY_ENGAGEMENT_LEVEL_UNRESTRICTED_MESSAGE, + req, + ) + + await acquireTextAdvisoryLocks(payload, req, [ + `municipality-engagement-level:${municipality}`, + ]) + + const current = await payload.findByID({ + collection: 'municipality', + id: municipality, + depth: 0, + select: { engagementLevel: true, levelChangedAt: true }, + user: currentActor, + overrideAccess: false, + req, + }) + + const from = current.engagementLevel ?? null + const violations = getEngagementLevelViolations({ + from, + to: level, + levelChangedAt: current.levelChangedAt ?? null, + now: new Date(), + triangulatedShock, + }) + + if (violations.length > 0 && !override) throw new EngagementLevelBlockedError(violations) + + const updated = await payload.update({ + collection: 'municipality', + id: municipality, + data: { engagementLevel: level, levelNote: note }, + depth: 0, + user: currentActor, + overrideAccess: false, + req, + }) + + await payload.create({ + collection: 'allocationDecision', + data: { + municipality, + patternId: ENGAGEMENT_LEVEL_PATTERN_ID, + outcome: 'movimento', + rationale: note, + // Only the values the decision was taken on — no document dumps + // (the collection caps the serialized snapshot at 16 KB). + snapshot: { + from, + to: level, + reversalSignals, + triangulatedShock, + violations: violations.map((violation) => violation.id), + overridden: violations.length > 0, + previousLevelChangedAt: current.levelChangedAt ?? null, + }, + }, + depth: 0, + user: currentActor, + overrideAccess: false, + req, + }) + + return updated + }, + { beginFailureMessage: 'Não foi possível iniciar o movimento de nível.' }, + ) +} + +export const setMunicipalityEngagementLevel = async (input: MunicipalityEngagementLevelInput) => { + const { payload, actor } = await getCampaignActionContext() + return setMunicipalityEngagementLevelRecord(payload, actor, input) +} + /** Advisor assignment is unrestricted staff (coordinator + candidate); the hook validates eligibility. */ export const assignMunicipalityAdvisorsRecord = async ( payload: Payload, diff --git a/src/collections/AllocationDecision.ts b/src/collections/AllocationDecision.ts index 35115157..6d1218de 100644 --- a/src/collections/AllocationDecision.ts +++ b/src/collections/AllocationDecision.ts @@ -99,9 +99,13 @@ export const AllocationDecision: CollectionConfig = { type: 'select', label: 'Decisão', required: true, + // `aceita`/`descarta` is the triage of a SUGGESTED pattern (E11). A level + // movement (E14) is neither — it is a decision taken outright — so it + // gets its own value instead of masquerading as an acceptance. options: [ { label: 'Aceita', value: 'aceita' }, { label: 'Descarta', value: 'descarta' }, + { label: 'Movimento', value: 'movimento' }, ], }, { diff --git a/src/collections/Municipality.ts b/src/collections/Municipality.ts index 0c5c3366..1efae5ce 100644 --- a/src/collections/Municipality.ts +++ b/src/collections/Municipality.ts @@ -5,6 +5,11 @@ import type { } from 'payload' import { APIError } from 'payload' +import { + ENGAGEMENT_LEVEL_TEXT_MAX_LENGTH, + engagementLevels, + formatEngagementLevelLabel, +} from '@/lib/engagementLevel' import { politicalTrendStatuses } from '@/lib/schemas/municipality' import { getVoteEstimateOrderViolation, @@ -17,6 +22,7 @@ import { canDeleteMunicipality, canManageCampaignStaffField, canManageMunicipalityAdvisors, + canManageMunicipalityEngagementLevel, canReadCampaignStaffField, canReadMunicipality, canSetCampaignSystemField, @@ -106,6 +112,22 @@ const validateMunicipalityAdvisors: CollectionBeforeValidateHook = async ({ data return data } +/** + * E14 — `levelChangedAt` is the clock the movement rules read (protection + * window, one movement per month), so it is derived here rather than trusted + * from the caller: an admin editing the level in `/admin` has to start the + * same clock the server action does. + */ +const deriveEngagementLevelAudit: CollectionBeforeChangeHook = ({ data, originalDoc }) => { + if (!data || data.engagementLevel === undefined) return data + if (data.engagementLevel === (originalDoc?.engagementLevel ?? null)) return data + + // Clearing the level clears its clock too: "registrado em" with no level is + // a date that describes nothing, and the rules would read it as history. + data.levelChangedAt = data.engagementLevel ? new Date().toISOString() : null + return data +} + const trendSnapshot = (value: unknown) => { const trend = (value ?? {}) as { status?: unknown; note?: unknown } return `${trend.status ?? ''}\u0000${typeof trend.note === 'string' ? trend.note.trim() : ''}` @@ -150,7 +172,7 @@ export const Municipality: CollectionConfig = { }, hooks: { beforeValidate: [validateMunicipalityAdvisors, validateExpectedVotes], - beforeChange: [derivePoliticalTrendAudit], + beforeChange: [derivePoliticalTrendAudit, deriveEngagementLevelAudit], }, fields: [ { @@ -302,6 +324,58 @@ export const Municipality: CollectionConfig = { { label: 'Normal', value: 'normal' }, ], }, + { + name: 'engagementLevel', + type: 'select', + label: 'Nível de envolvimento', + index: true, + // No default on purpose: stamping N2 on all 435 municípios would assert a + // decision nobody took. Absent means "ainda não decidido", and filtering + // for it is the triage queue. + access: { + read: canReadCampaignStaffField, + create: canManageMunicipalityEngagementLevel, + update: canManageMunicipalityEngagementLevel, + }, + admin: { + description: + 'Quanto a campanha investe neste município (N0 a N4). Movimento é decisão da coordenação e fica registrado em Decisões de alocação.', + }, + // Single source: the ladder in `lib/engagementLevel` + the shared label table. + options: engagementLevels.map((value) => ({ + label: formatEngagementLevelLabel(value), + value, + })), + }, + { + name: 'levelNote', + type: 'textarea', + label: 'Motivo do nível', + maxLength: ENGAGEMENT_LEVEL_TEXT_MAX_LENGTH, + access: { + read: canReadCampaignStaffField, + create: canManageMunicipalityEngagementLevel, + update: canManageMunicipalityEngagementLevel, + }, + admin: { + description: 'Motivo corrente do nível — o histórico vive em Decisões de alocação.', + }, + }, + { + name: 'levelChangedAt', + type: 'date', + label: 'Nível registrado em', + index: true, + admin: { + readOnly: true, + description: 'Derivado da última mudança de nível.', + }, + access: { + read: canReadCampaignStaffField, + create: canSetCampaignSystemField, + update: canSetCampaignSystemField, + }, + }, { name: 'expectedVotes', type: 'group', diff --git a/src/components/campaign/municipality/MunicipalityFilters.tsx b/src/components/campaign/municipality/MunicipalityFilters.tsx index 3a667a28..90c563a2 100644 --- a/src/components/campaign/municipality/MunicipalityFilters.tsx +++ b/src/components/campaign/municipality/MunicipalityFilters.tsx @@ -190,6 +190,18 @@ export const MunicipalityFilters = ({ } /> ) : null} + {showStaffFilters ? ( + + navigateWithSearch(toggleMunicipalityMultiFilterValue(state, 'level', value)) + } + /> + ) : null} {showStaffFilters && advisorFilterOptions.length ? ( diff --git a/src/components/campaign/municipality/MunicipalityLevelBadge.tsx b/src/components/campaign/municipality/MunicipalityLevelBadge.tsx new file mode 100644 index 00000000..8b25171e --- /dev/null +++ b/src/components/campaign/municipality/MunicipalityLevelBadge.tsx @@ -0,0 +1,67 @@ +import { Badge } from '@/components/ui/Badge' +import { + EMPTY_ENGAGEMENT_LEVEL_LABEL, + formatEngagementLevelLabel, + type EngagementLevel, +} from '@/lib/engagementLevel' + +/** + * Deliberately NOT a colour ramp. A five-step scale of importance painted + * across a staff list is the "mapa de onde você não vai defender" the research + * warns about (anti-goal 11), and this row already spends red on priority, + * amber on cold signals and five fills on the E10 class. The ordinal + * information is in the numeral; the pill is a hairline. Absence of a level is + * not a sixth step, so it reads as flat muted text instead. + */ +const ENGAGEMENT_LEVEL_BADGE_VARIANT = 'outline' as const +const EMPTY_ENGAGEMENT_LEVEL_BADGE_VARIANT = 'ghost' as const + +type MunicipalityLevelBadgeProps = { + level: EngagementLevel | null + /** The rationale behind the current level, when the caller has it. */ + note?: string | null + /** + * In the table the numeral carries it and the long name lives in the + * column's `cellTooltip`; elsewhere there is room to spell it out. + */ + layout: 'table' | 'card' +} + +/** + * E14 — the read-only face of the ladder, for staff who cannot move it. + * + * The long name (and the motivo, when given) is always TEXT next to the pill — + * `sr-only` in the table, visible in the card — because the B23 `cellTooltip` + * contract is that the tooltip only repeats what the cell already says: Radix + * mounts tooltip content on open, and the table adds no tab stops. Same shape + * as `TerritorialClassReadout` in the neighbouring column. + */ +export const MunicipalityLevelBadge = ({ level, note, layout }: MunicipalityLevelBadgeProps) => { + if (!level) { + return ( + + {EMPTY_ENGAGEMENT_LEVEL_LABEL} + + ) + } + + const label = formatEngagementLevelLabel(level) + + // The card spells the name out in the pill itself, so only the motivo is + // left to say; the table shows the numeral and owes the reader both. + if (layout === 'card') { + return ( + <> + {label} + {note ? {note} : null} + + ) + } + + return ( + <> + {level.toUpperCase()} + {[label, note].filter(Boolean).join(' — ')} + + ) +} diff --git a/src/components/campaign/municipality/MunicipalityList.tsx b/src/components/campaign/municipality/MunicipalityList.tsx index bf66c64a..d35bf572 100644 --- a/src/components/campaign/municipality/MunicipalityList.tsx +++ b/src/components/campaign/municipality/MunicipalityList.tsx @@ -7,9 +7,11 @@ import { MissingAdvisorBadge, MunicipalityAdvisorAvatarStack, } from '@/components/campaign/municipality/MunicipalityAdvisorAvatarStack' +import { MunicipalityLevelBadge } from '@/components/campaign/municipality/MunicipalityLevelBadge' import { MunicipalityListAdvisorsControl } from '@/components/campaign/municipality/MunicipalityListAdvisorsControl' import { MunicipalityListExpectedVotesControl } from '@/components/campaign/municipality/MunicipalityListExpectedVotesControl' import { MunicipalityListGoalCoverageCell } from '@/components/campaign/municipality/MunicipalityListGoalCoverageCell' +import { MunicipalityListLevelControl } from '@/components/campaign/municipality/MunicipalityListLevelControl' import { MunicipalityListSignalControl } from '@/components/campaign/municipality/MunicipalityListSignalControl' import { MunicipalityListTrendControl } from '@/components/campaign/municipality/MunicipalityListTrendControl' import { MunicipalitySortableHead } from '@/components/campaign/municipality/MunicipalitySortableHead' @@ -32,6 +34,7 @@ import { formatPlacementOrdinal, formatVoteSharePercent, } from '@/lib/electionFormat' +import { formatEngagementLevelLabel } from '@/lib/engagementLevel' import { cn } from '@/lib/utils' import type { CampaignFormActionState } from '@/utilities/campaignFormActionError' import { @@ -85,6 +88,12 @@ export type MunicipalityListProps = { advisorNamesById: ReadonlyMap isStaffView: boolean isCoordinator: boolean + /** + * E14 — moving the ladder is unrestricted staff (coordinator + candidate), + * a wider set than `isCoordinator`, so it travels as its own prop instead of + * quietly hiding the control from the candidate. + */ + canMoveEngagementLevel: boolean advisorOptions: EligibleAdvisorOption[] columnFilterOptions: MunicipalityColumnFilterOptions signalFormAction: MunicipalityStaffFormAction @@ -239,6 +248,7 @@ const municipalityListColumns = ({ state, isStaffView, isCoordinator, + canMoveEngagementLevel, columnFilterOptions, advisorNamesById, advisorOptions, @@ -347,6 +357,44 @@ const municipalityListColumns = ({ ? null : formatTerritorialClassWhy(municipality.territorialClassFactors), }, + { + // Right after "Classe": the pair reads diagnosis then decision. + id: 'level', + head: ( + + Nível + + ), + cell: (municipality) => + canMoveEngagementLevel ? ( + + ) : ( + + ), + // The editable cell wraps its own trigger in a tooltip; declaring it + // here too would double it up on the same gesture (as in `advisors`). + cellTooltip: (municipality) => + canMoveEngagementLevel || !municipality.engagementLevel + ? null + : [formatEngagementLevelLabel(municipality.engagementLevel), municipality.levelNote] + .filter(Boolean) + .join(' — '), + }, { id: 'advisors', head: ( @@ -595,6 +643,26 @@ export const MunicipalityList = (props: MunicipalityListProps) => { />
+
+
Nível
+
+ {props.canMoveEngagementLevel ? ( + + ) : ( + + )} +
+
Tendência
diff --git a/src/components/campaign/municipality/MunicipalityListLevelControl.tsx b/src/components/campaign/municipality/MunicipalityListLevelControl.tsx new file mode 100644 index 00000000..15a3145e --- /dev/null +++ b/src/components/campaign/municipality/MunicipalityListLevelControl.tsx @@ -0,0 +1,332 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' + +import type { + MunicipalityListEngagementLevelResponse, + MunicipalityListSavedEngagementLevel, +} from '@/app/(campaign)/campanha/(app)/municipios/engagement-level/types' +import { MunicipalityLevelBadge } from '@/components/campaign/municipality/MunicipalityLevelBadge' +import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip' +import { Alert, AlertDescription } from '@/components/ui/Alert' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/Checkbox' +import { Field, FieldContent, FieldLabel } from '@/components/ui/field' +import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover' +import { Spinner } from '@/components/ui/Spinner' +import { Textarea } from '@/components/ui/textarea' +import { + EMPTY_ENGAGEMENT_LEVEL_LABEL, + ENGAGEMENT_LEVEL_TEXT_MAX_LENGTH, + engagementLevelRank, + engagementLevels, + formatEngagementLevelLabel, + getEngagementLevelViolations, + isEngagementLevel, + type EngagementLevel, + type EngagementLevelViolation, +} from '@/lib/engagementLevel' +import { cn } from '@/lib/utils' + +const ENGAGEMENT_LEVEL_ENDPOINT = '/campanha/municipios/engagement-level' +const SAVE_ERROR_MESSAGE = 'Não foi possível registrar o nível. Tente novamente.' + +type MunicipalityListLevelControlProps = { + municipalityID: number + municipalityName: string + level: EngagementLevel | null + levelNote: string | null + levelChangedAt: string | null +} + +/** + * E14 — unlike the trend and estimate popovers next to it, this one submits + * explicitly: a movement carries a motivo AND the signals that would reverse + * it, and it may need an override. Auto-saving half of that would file an + * incomplete decision under the coordinator's name. + */ +export const MunicipalityListLevelControl = ({ + municipalityID, + municipalityName, + level, + levelNote, + levelChangedAt, +}: MunicipalityListLevelControlProps) => { + const [open, setOpen] = useState(false) + const [saved, setSaved] = useState( + level ? { level, note: levelNote, changedAt: levelChangedAt } : null, + ) + // Seeded by `resetDraft` on every open; while closed the popover body — and + // with it the select — is not mounted. + const [draftLevel, setDraftLevel] = useState('') + const [note, setNote] = useState('') + const [reversalSignals, setReversalSignals] = useState('') + const [triangulatedShock, setTriangulatedShock] = useState(false) + const [override, setOverride] = useState(false) + const [isPending, setIsPending] = useState(false) + const [errorMessage, setErrorMessage] = useState(null) + // Violations only the server could know (it re-reads the município under the + // lock), kept with the level they were raised for so a changed draft drops them. + const [serverBlock, setServerBlock] = useState<{ + level: EngagementLevel + violations: EngagementLevelViolation[] + } | null>(null) + const abortRef = useRef(null) + const lastPropsRef = useRef({ level, levelNote, levelChangedAt }) + + // Adopt the server value when it changes from outside (navigation / refresh). + useEffect(() => { + const last = lastPropsRef.current + if ( + last.level === level && + last.levelNote === levelNote && + last.levelChangedAt === levelChangedAt + ) + return + lastPropsRef.current = { level, levelNote, levelChangedAt } + setSaved(level ? { level, note: levelNote, changedAt: levelChangedAt } : null) + }, [level, levelNote, levelChangedAt]) + + useEffect(() => () => abortRef.current?.abort(), []) + + const currentLevel = saved?.level ?? null + const currentNote = saved?.note ?? null + const isMovement = draftLevel !== '' && draftLevel !== currentLevel + + // The rules are pure and client-safe, so the coordinator sees why a movement + // is held before submitting it — the server re-runs them either way, and what + // it raises on top (a concurrent move this tab has not seen) joins the list + // rather than turning into an error string with no override next to it. + const localViolations = isMovement + ? getEngagementLevelViolations({ + from: currentLevel, + to: draftLevel, + levelChangedAt: saved?.changedAt ?? null, + now: new Date(), + triangulatedShock, + }) + : [] + const violations = + serverBlock && serverBlock.level === draftLevel + ? [ + ...localViolations, + ...serverBlock.violations.filter( + (violation) => !localViolations.some((local) => local.id === violation.id), + ), + ] + : localViolations + + const isJump = + isMovement && + currentLevel !== null && + Math.abs(engagementLevelRank[draftLevel] - engagementLevelRank[currentLevel]) > 1 + + const resetDraft = () => { + setDraftLevel(currentLevel ?? '') + setNote('') + setReversalSignals('') + setTriangulatedShock(false) + setOverride(false) + setErrorMessage(null) + setServerBlock(null) + } + + const handleOpenChange = (nextOpen: boolean) => { + if (nextOpen) resetDraft() + setOpen(nextOpen) + } + + const submit = async () => { + if (!isMovement || isPending) return + + const controller = new AbortController() + abortRef.current = controller + setIsPending(true) + setErrorMessage(null) + setServerBlock(null) + + try { + const response = await fetch(ENGAGEMENT_LEVEL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + credentials: 'same-origin', + signal: controller.signal, + body: JSON.stringify({ + municipalityId: municipalityID, + level: draftLevel, + note, + reversalSignals, + triangulatedShock, + override, + }), + }) + + const payload = (await response.json()) as MunicipalityListEngagementLevelResponse + + if (payload.status === 'success') { + setSaved(payload.savedLevel) + setOpen(false) + return + } + + // A blocked movement keeps the popover open with the draft intact: the + // next click is "override", not "type it all again". + if (payload.status === 'blocked') { + setServerBlock({ level: draftLevel, violations: payload.violations }) + return + } + + setErrorMessage(payload.message) + } catch { + setErrorMessage(SAVE_ERROR_MESSAGE) + } finally { + setIsPending(false) + } + } + + const canSubmit = isMovement && note.trim().length > 0 && reversalSignals.trim().length > 0 + const fieldId = (suffix: string) => `municipality-list-level-${suffix}-${municipalityID}` + + return ( + + +

{formatEngagementLevelLabel(currentLevel)}

+ {currentNote ?

{currentNote}

: null} +
+ ) : null + } + align="start" + openOnTouch={false} + disabled={open} + > + + + + + event.preventDefault()} + > +
+ + Nível de envolvimento + + setDraftLevel(isEngagementLevel(event.target.value) ? event.target.value : '') + } + className="min-h-11 w-full" + > + {EMPTY_ENGAGEMENT_LEVEL_LABEL} + {engagementLevels.map((option) => ( + + {formatEngagementLevelLabel(option)} + + ))} + + + + Motivo +