From b3283ee06194b9aafaa5e5a1329966693ba09d37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Sun, 26 Jul 2026 14:05:36 -0300 Subject: [PATCH] =?UTF-8?q?B24:=20auto-save=20e=20justificativa=20no=20pop?= =?UTF-8?q?over=20de=20Tend=C3=AAncia.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove o botão Salvar da lista de municípios; grava via POST political-trend sem revalidar a lista, com flush coalescido e feedback na célula. --- .cursor/rules/projects/nucleos-eleitorais.mdc | 1 + .../suggest-next-roadmap-items/SKILL.md | 73 +++-- AGENTS.md | 2 + .../autosave-tendencia-lista-municipios.md | 28 +- docs/roadmap.md | 12 +- .../campanha/(app)/municipios/page.tsx | 6 +- .../(app)/municipios/political-trend/route.ts | 57 ++++ .../(app)/municipios/political-trend/types.ts | 17 ++ .../municipality/MunicipalityList.tsx | 17 +- .../MunicipalityListTrendControl.tsx | 280 +++++++++++++++--- tests/e2e/campaignMunicipalities.e2e.spec.ts | 54 ++++ tests/e2e/setup.e2e.spec.ts | 6 +- tests/unit/campaignComponents.unit.spec.ts | 8 +- 13 files changed, 457 insertions(+), 104 deletions(-) create mode 100644 src/app/(campaign)/campanha/(app)/municipios/political-trend/route.ts create mode 100644 src/app/(campaign)/campanha/(app)/municipios/political-trend/types.ts diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index 7b128bd0..d6be9682 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -7,6 +7,7 @@ alwaysApply: false ## Status +- **B24 entregue em código (2026-07-26):** auto-save + justificativa editável no popover de Tendência de `/campanha/municipios`. Tirou o botão "Salvar" (anti-goal do princípio 4) e o `trendNote` hidden; `MunicipalityListTrendControl` agora espelha a máquina de `MunicipalityListExpectedVotesControl` — draft/display/committed + AbortController + geração, status ~150 ms / nota 600 ms (timer cancelável), flush no blur e no fechamento do Popover, Badge otimista, `Textarea` "Justificativa" (`maxLength` 2000), `Alert` + live region `sr-only`, tooltip B23 lendo a nota exibida. Endpoint `POST /campanha/municipios/political-trend` (mesmo shell CSRF/JSON do B27: `isSameOriginRequest` + `campaignJsonMutationRoute`) chama `setMunicipalityPoliticalTrend` **sem** `revalidatePath` — faceta/`?trend=` reconciliam na próxima navegação. Lista/page deixam de passar `trendFormAction`; `/editar` mantém o form action multi-campo. Sem migration. Gate verde (tsc/lint/format/knip/check:cycles/479 unit/393 int/build); e2e B24 + prewarm da rota; Aikido 0 findings. Plano: [`autosave-tendencia-lista-municipios.md`](../../../docs/plans/autosave-tendencia-lista-municipios.md). - **B13 entregue em código (2026-07-26):** escala relativa no mapa do Início. O coropleto era quase monocromático porque 0–100% dos válidos comprime uma disputa cujo topo estadual é 4,71% — três escalas **relativas ao próprio candidato**, em **classes discretas** (nunca rampa contínua): **quantis** (novo default), **padrão próprio (LQ)** e **posição no município**, ao lado de "% dos válidos" (B11) e "Total (votos)". **Auditoria do plano antes de codar** achou três defasagens, corrigidas em [`escala-relativa-mapa.md`](../../../docs/plans/escala-relativa-mapa.md) § Revisão na entrega: caminhos pós-Pass 2 W2 (`components/campaign/map/`), o nome inexistente `computeDfTerritorialClass` (é `computeMunicipalityTerritorialClass`) e a premissa "o mapa acompanha a lista" (desde a remodelagem o mapa só existe no Início) — resolvida como **quantis sobre o escopo de acesso do usuário**, decisão do usuário nesta sessão. Resolvi também a **contradição interna do plano** sobre LQ: os Objetivos pediam diverging em torno de 1 e as Decisões travadas reservavam diverging ao compare — ficou com as Decisões travadas, LQ como 5 classes nomeadas nas âncoras do E10, porque vermelho × azul significa "Solla × adversário" e não pode passar a significar duas coisas. **Artefato v3 (decisão cara do usuário: construir).** "Posição no município" não existia nem no artefato nem em query reaproveitável, e computá-la em request seria varrer ~100 mil linhas por ano para um dado imutável. Foi para o artefato commitado (regra 3 da escada de cache, como o E8 na v2): `federalRankByIbgeCode: Record>` — colocação entre **todos** os candidatos com votos > 0 na geografia, 2014/2018/2022, alimentada por `loadFederalVotesByCityZoneAndCandidate` (irmão CLI-only de `loadCampoFederalVotesByCityZone`). Chaveado por `ibgeCode` e não por slug porque é o que o mapa desenha (as 19 zonas de Salvador somam a cidade inteira); rank por slug entra quando dossiê/E11 pedirem. 534 → 623 KB, dentro do orçamento de 700 KB. Rank fica indisponível em 2026 (dado TSE), mesmo precedente do compare; LQ em 2026 reusa o padrão estadual de 2022. diff --git a/.cursor/skills/suggest-next-roadmap-items/SKILL.md b/.cursor/skills/suggest-next-roadmap-items/SKILL.md index d2652dab..0515dfbc 100644 --- a/.cursor/skills/suggest-next-roadmap-items/SKILL.md +++ b/.cursor/skills/suggest-next-roadmap-items/SKILL.md @@ -2,23 +2,24 @@ name: suggest-next-roadmap-items description: >- Lê docs/roadmap.md (e planos/notebook/calendário necessários), filtra o que - já foi entregue e o que ainda está bloqueado por dependência, e sugere 3–5 - itens candidatos a serem o próximo a implementar no Teqo. Usar quando o - usuário perguntar "o que fazer agora", "próximo item do roadmap", "o que - priorizar", "sugere o próximo", "what's next", "qual item implementar", ou - pedir recomendações de priorização a partir do roadmap. + já foi entregue e o que ainda está bloqueado por dependência, e indica o + próximo item a implementar no Teqo mais o lote que pode correr em paralelo + com ele. Usar quando o usuário perguntar "o que fazer agora", "próximo item + do roadmap", "o que priorizar", "sugere o próximo", "what's next", "qual + item implementar", "o que dá para tocar em paralelo", ou pedir + recomendações de priorização a partir do roadmap. --- # Sugerir próximos itens do roadmap -Esta skill **só recomenda** — não implementa, não edita o roadmap e não cria planos. O deliverable é uma shortlist ranqueada com racional, para o usuário escolher. Depois disso, o fluxo natural é `implement-roadmap-item` no ID escolhido (ou `roadmap-item` se a sugestão revelar um gap de registro). +Esta skill **só recomenda** — não implementa, não edita o roadmap e não cria planos. O deliverable é **uma recomendação principal + um lote paralelo**: os outros itens que podem ser tocados ao mesmo tempo, cada um em sua própria branch/worktree, sem brigar com o principal nem entre si. Não são alternativas ao principal; são trabalho simultâneo. Depois disso, o fluxo natural é `implement-roadmap-item` por ID (um por sessão/worktree), ou `roadmap-item` se a sugestão revelar um gap de registro. **Divisão com as skills irmãs:** | Skill | Papel | | ---------------------------- | ------------------------------------------------------ | | `compile-roadmap` | Enxugar roadmap: resumir feito, destacar abertos | -| `suggest-next-roadmap-items` | **Escolher candidatos** entre o que o roadmap já lista | +| `suggest-next-roadmap-items` | **Escolher o próximo + o lote paralelo** entre o que o roadmap já lista | | `roadmap-item` | Registrar ideia nova / criar plano | | `implement-roadmap-item` | Auditar plano + implementar o ID escolhido | | `rebase-on-main` | Após simplify: fetch + rebase em main + conflitos | @@ -34,7 +35,8 @@ Esta skill **só recomenda** — não implementa, não edita o roadmap e não cr - [ ] 3. Verificar dependências duras e bloqueios externos - [ ] 4. Pontuar candidatos (calendário × valor × prontidão × risco) - [ ] 5. Ler planos só dos top candidatos (progressive disclosure) -- [ ] 6. Entregar shortlist 3–5 + próximo passo; parar +- [ ] 6. Checar colisões e montar o lote paralelo +- [ ] 7. Entregar principal + lote paralelo + próximo passo; parar ``` ## Passo 1 — Ler as fontes canônicas @@ -99,13 +101,14 @@ Atribua a cada **Pronto** / **Quase** uma nota 1–5 (soma ponderada mental; nã 1. Não sugerir como #1 um fill-in DRY se houver feature **Pronta** da janela vigente que desbloqueie operação (ex. atuais: E8, C12, E9; B8 F2; D2). 2. Preferir **uma fatia** de um programa (um ID: E8, C12, …) a "fazer o programa de inteligência inteiro" — diga qual plano (`docs/plans/.md`), e respeite a ordem do grafo (E8 antes de E9/E10/E12; C12 antes de E11/E14/E15). -3. Se várias cadeias DRY competem (C10, C11, E6, E7, A7, B5, O0+, …), escolha no máximo **1–2** na shortlist, priorizando as Fases que o próprio roadmap marca como "mais valiosas" em Cortes seguros. +3. Se várias cadeias DRY competem (C10, C11, E6, E7, A7, B5, O0+, …), escolha no máximo **1–2** no total (principal + lote), priorizando as Fases que o próprio roadmap marca como "mais valiosas" em Cortes seguros. 4. Itens _(proposto — validar com produto)_ (ex.: C5) só entram se o usuário pedir exploração de produto; senão liste em "Depois / validar". 5. Trabalho externo (lote jurídico) **nunca** substitui um candidato de engenharia — reporte em seção própria. +6. O #1 é o de maior valor/urgência de calendário, **nunca** o "mais isolado". Isolamento decide quem entra no lote paralelo (Passo 6), não quem é o principal. ## Passo 5 — Aprofundar só o top -Pegue os **3–5** melhores do Passo 4. Para cada um: +Pegue os **4–6** melhores do Passo 4 (o #1 mais os pré-candidatos ao lote). Para cada um: 1. Abra o plano linkado (`docs/plans/...`) — leia cabeçalho, Status, Dependências, Não escopo, e o tamanho das fases (1 parágrafo basta). Para itens do programa de inteligência, a tabela do programa já traz classe Impeccable/appetite/depende-de — só abra o plano do candidato final. 2. Se houver design-ref na tabela do roadmap, cite o nome do par em `docs/design-refs/latest/` (não precisa abrir o HTML). @@ -113,7 +116,33 @@ Pegue os **3–5** melhores do Passo 4. Para cada um: Se dois candidatos forem quase equivalentes, prefira o de menor risco de migration / o que o calendário pune mais se atrasar. -## Passo 6 — Entregar e parar +## Passo 6 — Checar colisões e montar o lote paralelo + +Premissa: cada item roda na **sua própria branch/worktree** e fecha com `close-delivery` (rebase em `main` antes do merge). Logo, paralelizável não é "cabe no mesmo dia" — é "os dois podem viver em worktrees separadas e reencontrar `main` sem conflito que exija reescrita". + +Um candidato entra no lote quando passa **nos três testes**: + +1. **Independência** — não depende (nem suave) do principal nem de outro item do lote, e o principal não depende dele. Dependência suave em ambos os sentidos → fora do lote, vira "depois de X". +2. **Superfície disjunta** — não disputa nenhuma superfície serializadora com o principal nem com outro item já no lote (tabela abaixo). +3. **Fechamento independente** — pode ser mesclado sozinho e entregar valor sem esperar o irmão (nada de "só faz sentido junto"). + +**Superfícies serializadoras do Teqo (no máximo 1 item por lote em cada linha):** + +| Superfície | Por que serializa | +| --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `src/migrations/` (SQL + `.json` snapshot + `index.ts`) e o `src/payload-types.ts` que a segue | A cadeia de snapshots é linear: dois itens com migration não se resolvem por merge textual, um tem que refazer | +| Artefato TSE (`src/lib/electionAggregates/`, `build:election-aggregates`, orçamento de bytes) | Arquivo gerado grande + teste de tamanho; dois geradores em paralelo colidem inteiros | +| Seams do sistema de listas (`CampaignTable`, `campaignListUrl`, shells de `campaign/shared`) | Quem **altera** a seam serializa; quem só **consome** pode ir junto | +| Um mesmo loader/rota de domínio (ex.: `municipalityPageData.ts` + colunas da lista) | Precedente real: E9/E10/B13 empilharam no mesmo arquivo | +| Mesma collection/global no schema (mesmo sem migration nova) | Access, zod, view models e form mudam juntos | + +**Não** serializam (append trivial, resolve no rebase): `docs/roadmap.md`, `docs/plans/`, `campaignIntelligenceConcepts.ts` (E18), `nav.ts`, novos arquivos em domínios distintos. Só avise a ordem de merge. + +Monte assim: fixe o principal, percorra os pré-candidatos do Passo 5 em ordem de score e inclua os que passam nos três testes. **Lote de 2–4 itens** (3–5 no total com o principal). Se um candidato falha só pelo teste 2 contra o principal, liste-o como "serializar depois de \" em vez de descartar. Prefira lotes de trilhas distintas (campo × dados × site público) — menos atrito de rebase e menos troca de contexto. + +Se **nada** for paralelizável, diga isso explicitamente e nomeie o que destrava paralelismo (normalmente: o principal ser o único com migration). + +## Passo 7 — Entregar e parar Formato obrigatório da resposta (conciso; sem reescrever o roadmap): @@ -133,11 +162,17 @@ Formato obrigatório da resposta (conciso; sem reescrever o roadmap): - Impeccable: A|B|C|D - Próximo passo: skill `implement-roadmap-item` neste ID -## Alternativas (2–4) +## Em paralelo com ele (2–4) -| # | ID | Por que agora | Por que não #1 | Plano | -| --- | --- | ------------- | -------------- | ----- | -| 2 | … | … | … | … | +Cada um em sua própria worktree; nenhum depende do principal nem dos outros. + +| # | ID | Por que agora | Por que não colide | Plano | +| --- | --- | ------------- | ------------------ | ----- | +| 2 | … | … | … | … | + +- Migration no lote: só \ (os demais são sem schema) — ou "nenhum". +- Serializar depois: \ — colide com \ em \. +- Ordem de merge sugerida: … (só se importar) ## Deixados de fora (amostra) @@ -152,8 +187,10 @@ Quer que eu rode `implement-roadmap-item` em algum destes? - Implementar código, abrir PR, ou marcar item como entregue - Editar `docs/roadmap.md` / `docs/plans/` (se achar inconsistência material, **reporte** e ofereça `roadmap-item` / correção via `implement-roadmap-item` Passo 7) -- Inventar IDs ou itens que não estão no roadmap -- Shortlist > 5 candidatos "principais" (alternativas contam no total 3–5) +- Inventar IDs ou itens que não estão no roadmap — inclusive para encher o lote paralelo +- Mais de 5 itens no total (principal + lote) +- Apresentar o lote como "alternativas ao principal" ou como um plano de execução sequencial +- Colocar no lote item que colide em superfície serializadora (Passo 6) — ele vai para "serializar depois" - Auditoria afirmação-a-afirmação do plano (isso é `implement-roadmap-item`) -Se o usuário já disser "faz o X" / "implementa o segundo", pare esta skill e invoque `implement-roadmap-item` no ID escolhido. +Se o usuário já disser "faz o X" / "implementa o segundo", pare esta skill e invoque `implement-roadmap-item` no ID escolhido (um por sessão/worktree). diff --git a/AGENTS.md b/AGENTS.md index d9f1b4fc..4b50a916 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,6 +70,8 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap. **Recently resolved (2026-07-25): Engineering Consolidation Pass 2** (tracker: [`docs/IMPROVE-CODE-QUALITY-PLAN.md`](docs/IMPROVE-CODE-QUALITY-PLAN.md) § Pass 2; architecture: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md); agent map: `.cursor/rules/codebase-map.mdc`). Highlights: **W1 sistema de listas** — colunas como dado em `CampaignTable` (seams B17/B18), URL state compartilhado (`campaignListUrl` + módulos por domínio; o god module `municipalityUi.ts` virou `municipalityListUrl`/`municipalityListFilters`/`municipalityLabels`/`municipalitySignal`), shells (`CampaignListFooter`, `CampaignListEmptyState`), 8 superfícies migradas (municípios, lideranças, organizações, dobradinhas, demandas, apoiadores, assessores, atividades-shells), B16+ absorvido (`useOptimistic`, hrefs por serializador canônico, facet por slugs, facets no mesmo `Promise.all`); **W2** — fronteiras: `slug`/`phone`/`wordStartFilter`/`voteEstimate` movidos para `lib/`, 21 loaders `server-only`, `campaignRoles.ts` client-safe (sidebar sem o barrel de access), `votePledgeViews` (contract) vs `votePledgeData` (loaders), ciclo supporter quebrado, `components/campaign` em 15 subpastas de domínio; decisão D1: sem `src/domains/`, sem ports-and-adapters; **W4a** — `electionInsights.ts` (941 linhas, 2 exports vivos) deletado; sobreviventes em `electionFormat.ts`/`voteTrend.ts`; E10 nasce limpo; **W4b** — knip `exports`/`types`/`enumMembers` em **ERROR** no CI (0 findings; kit ui sem peças mortas); **W4c** — tabs das páginas de detalhe extraídas (`MunicipalityDetailTabs`, `ActivityOverviewTab`) + factory `detailTabUi`; **W4d** — `runCampaignFormAction` fecha o C8 F4 (13 ações em 7 arquivos; exceções documentadas); **W4e** — tipos de fonte única; **W4f** — copy "Praça"→"Município" varrida (apoiadores desambiguado: cidade do contato = "Cidade"); **D3** — consent do WhatsApp por chave estável (`whatsapp-inscricao`, fail-closed). Suites: 46 unit / 48 int / 7 e2e. +**Recently resolved (2026-07-26):** **B24** Auto-save + justificativa no popover de Tendência — `MunicipalityListTrendControl` grava sozinho via `POST /campanha/municipios/political-trend` (status ~150 ms, nota 600 ms, flush blur/fechamento), `Textarea` de justificativa no Popover, sem `revalidatePath` da lista; `/editar` mantém submit explícito. Plano: [`docs/plans/autosave-tendencia-lista-municipios.md`](docs/plans/autosave-tendencia-lista-municipios.md). + **Recently resolved (2026-07-26):** **B13** Escala relativa no mapa dos Municípios — the dashboard choropleth was nearly monochrome because 0–100% of valid votes compresses a race whose statewide ceiling is 4.71%. It now offers three scales **relative to the candidate himself**, all as **discrete classes**: **quantis** (the new default), **padrão próprio (LQ)** and **posição no município**, next to the existing "% dos válidos" (B11) and "Total (votos)". Pure math in `src/lib/mapScaleClasses.ts` (new file — `bahiaMapStyle.ts` already owned path styling); `resolvePathStyle` gained a `fillByKey` path that **replaces** the continuous ramp when present, where a missing key is "no data", which is not a value of zero. **LQ is 5 named classes cut on the E10 anchors, not diverging** — the source plan asked for both and contradicted itself; red×blue keeps meaning "Solla × adversário" and stays exclusive to the compare mode. `TERRITORIAL_CLASS_ANCHORS` / `AT_STANDARD_LQ` moved to `src/lib/territorialClassAnchors.ts` because the map panel is a client component and importing the classifier there would drag the ~600 KB TSE artifact into the browser bundle; numbers only, so recalibration (**E15**) stays a one-line diff in one file. Quantiles are computed over the **user's access scope** (435 for coordinator/candidate, the portfolio for an advisor) and **degrade to 3 classes below 10 municípios**, with the legend stating the effective count. "Posição no município" existed nowhere, so it went into the **committed artifact (v3)** rather than a request-time query — see "Election baseline data" above. New in E10: `computeAggregateTerritorialClass(slugs)` classes a GROUP as one territory by summing inputs and running the **same** `classifyMunicipalityTerritory`, never averaging per-slug classes (LQ is a ratio, and the mean of ratios is not the ratio of the sums) — **E12's TI rollup must inherit this helper instead of growing a second one**. The map loader fills `statewideShareByYear`, `territorialClassByCode`, `competitiveRankByYear` and `projectedValidVotesByCode`, all pure over the artifact: **no new query per request**. UI: discrete `MapScaleLegend` with real ranges tied to the selector via `aria-describedby`, a readout that always spells out the reason ("4ª de 5 faixas", "1,8× o padrão estadual do candidato", "12º entre 663 candidatos votados aqui"), and an opt-in proportional-symbol layer (area ∝ projected valid votes, E10 class colour, `interactive: false` so the bubble does not stack a second hover target on its own município) — 417 markers were **measured** before optimizing and did not drop the frame, so the top-K fallback was not built. Two new concepts in `/campanha/conceitos`. No migration, no collection, no server action; `/campanha`'s First Load JS is byte-identical (9.46 kB / 265 kB) because the map panel already ships in its own `ssr: false` chunk — that chunk itself grew, and the RSC payload grew ~64 KB uncompressed (~10 KB gzipped), dominated by `competitiveRankByYear`. **Known flake (pre-existing, reproduced on a clean tree):** `campaignMunicipalities.e2e.spec.ts` fails intermittently with 2 workers — the `LeaderContactsPanel` consent checkbox clicked before hydration, and a `getByRole` regex that matches both "Conde" and "Condeúba" when the fixture picks the former. Plano: [`docs/plans/escala-relativa-mapa.md`](docs/plans/escala-relativa-mapa.md). **Recently resolved (2026-07-25):** **C13** "Plano de Ação" → "Atividade" — the vertical speaks one word now, the one the mesa already uses. Collection `actionPlan` → `activity` (`src/collections/Activity.ts`, labels "Atividade"/"Atividades"), `campaignDemand.actionPlan` → `activity`, route `/campanha/planos` → `/campanha/atividades` (create at `/campanha/atividades/nova`), sidebar entry "Atividades", and field labels realigned to the entity ("Tipo de atividade", "Origem da atividade", "Resultado da atividade") — enum **values** (`kind`/`status`/`origin`) are unchanged. Schema moved by a **hand-written data-preserving migration** (`20260725_213000_rename_action_plan_to_activity`): 4 tables, 3 enums, 2 columns, 39 indexes (4 pkeys included — renaming a pkey index renames its constraint), 14 FKs and 2 sequences, every statement guarded on the source object existing so environments converge either way, with a symmetric `down()`. Payload's generator would have emitted DROP+CREATE (data loss) and cannot see the hand-written partial index `action_plan_upcoming_start_at_idx`, so the object list came from `pg_indexes`/`pg_constraint`/`pg_type`/`pg_sequences` and the `.json` snapshot was written by transforming its predecessor — validated by `pnpm migrate:create __probe` reporting no schema changes. The same migration aligned the **8 fossil `%plaza%` index names** the Município remodel left behind (it renamed the `plaza_id` columns but not their indexes, so the database and the snapshot had been disagreeing since 2026-07-23). Guard: the `codebaseConventions.unit.spec.ts` "banned campaign terminology" describe is now table-driven and sweeps `src` + `tests` + `scripts` (migrations stay out — frozen history), banning the `actionPlan` compounds, the pt-BR copy and the old route, but **not** bare `plan`/`plano` (that would collide with the white-label subscription plans of Fase 2); allowlist covers the spec itself, `scripts/generate-remodel-municipalities-migration.mjs` and the historical comment in `campaignMigrationReconciliation.int.spec.ts`. Plano: [`docs/plans/renomear-plano-acao-para-atividade.md`](docs/plans/renomear-plano-acao-para-atividade.md). diff --git a/docs/plans/autosave-tendencia-lista-municipios.md b/docs/plans/autosave-tendencia-lista-municipios.md index dfe44d1c..7969af15 100644 --- a/docs/plans/autosave-tendencia-lista-municipios.md +++ b/docs/plans/autosave-tendencia-lista-municipios.md @@ -1,8 +1,12 @@ # Auto-save e justificativa no popover de Tendência da lista de municípios -Status: rascunho -Atualizado em: 2026-07-25 +Status: entregue (2026-07-26) +Atualizado em: 2026-07-26 Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B24) + +**Revisão 2026-07-26 (auditoria pré-implementação):** `isSameOriginRequest` e o shell JSON (`parseCampaignJsonRequestBody` / `campaignJsonMutationErrorResponse`) já vivem em `src/utilities/` (extraídos pelo **B27 ✓**); o popover de Assessores sem "Salvar" já foi **B27 ✓** (sai de "Não escopo" como fill-in aberto); **B23 ✓** já pluga tooltip + `openOnTouch={false}`/`disabled={open}` — este item só troca a nota read-only do Popover pelo `Textarea`. Questões em aberto fechadas: (1) reconciliar sort/filtro só na próxima navegação; (2) sem contador de caracteres; (3) Assessores fora (já B27). + +**Entrega 2026-07-26:** `POST /campanha/municipios/political-trend` + `MunicipalityListTrendControl` reescrito (select ~150 ms / nota 600 ms, flush blur/fechamento, rollback + live region, sem "Salvar", sem `revalidatePath`); lista/page desacoplados de `trendFormAction` (`/editar` mantém o form action); e2e B24 + prewarm da rota; gate tsc/lint/format/knip/check:cycles/unit/int/build verde; Aikido 0 findings. Impeccable: B — encaixe em `MunicipalityListTrendControl` (tabela desktop + card mobile de `/campanha/municipios`); sem rota nova de UI Appetite: ~0,5 dia eng; 1 componente reescrito + 1 route handler JSON espelhando o de votos estimados; sem migration Responsável: — @@ -54,9 +58,11 @@ Duas coisas envelheceram desde o B9: ## Questões em aberto -- **Reconciliar sort/filtro depois de editar?** **Opções:** A) nunca (só na próxima navegação); B) `router.refresh()` ao fechar o popover; C) refresh só quando a lista **não** está filtrada/ordenada por tendência. **Recomendação:** A, alinhada ao precedente de votos estimados — e, se o time reclamar de defasagem em uso real, subir para C (que é barata e não remove a linha embaixo de quem está trabalhando). Não implementar B. -- **Contador de caracteres na justificativa?** **Opções:** A) nenhum; B) contador sempre; C) contador só a partir de ~90% dos 2000. **Recomendação:** A — 2000 caracteres é folga enorme para uma nota de conjuntura, e o `maxLength` do textarea já impede o estouro; C só se alguém realmente encostar no limite. -- **O popover de Assessores (mesmo B9) também tem "Salvar" — entra junto?** **Opções:** A) não, item separado; B) sim, no mesmo passe. **Recomendação:** A — é multi-seleção (semântica de commit em lote, precedente de delta + advisory lock no B19) e dobraria o appetite; registrado em _Não escopo_ como fill-in próprio. +_Fechadas na auditoria 2026-07-26:_ + +- **Reconciliar sort/filtro depois de editar?** → **A** (só na próxima navegação). Opção C fica em _Adiado com gatilho_. +- **Contador de caracteres na justificativa?** → **A** (nenhum; `maxLength={2000}`). +- **Popover de Assessores no mesmo passe?** → **A** — entregue como **B27 ✓** (não neste item). ## Abordagem proposta @@ -77,7 +83,7 @@ Componentes: - **`MunicipalityListTrendControl`** (`src/components/campaign/municipality/MunicipalityListTrendControl.tsx`): deixa de receber `formAction`/`municipalitySlug` e passa a espelhar a máquina de estado do controle de votos — `draft` / `displayStatus` / `committedRef` / `saveGenerationRef` / `abortRef` / `saveTimeoutRef`, `onOpenChange` com flush, `Alert` de erro, `

`. Some o `

`, o `Button` "Salvar" e o hidden `trendNote`; entra `Textarea` (`src/components/ui/textarea`) rotulada "Justificativa" com `maxLength={2000}`. - **`src/app/(campaign)/campanha/(app)/municipios/political-trend/route.ts` + `types.ts`** (novos): `POST` com guarda de mesma origem, `zod` (`municipalityId`, `status` nullable enum, `note` nullable ≤2000) reusando `politicalTrendStatuses` / `trimmedNullableText` de `src/lib/schemas/municipality.ts`, chamando `setMunicipalityPoliticalTrend` e devolvendo `{ status: 'success', savedTrend: { status, note } }`; erros por `mapCampaignFormActionError` + `municipalityStaffEditSafeMessages`, `401` em sessão expirada — cópia fiel do contrato de `expected-votes/route.ts`. -- **Guarda CSRF compartilhada** (`isSameOriginRequest`): hoje vive privada dentro de `expected-votes/route.ts`; com o segundo endpoint vira helper nomeado em `src/utilities/` (política de segurança vale nomear mesmo com 2 call sites — exceção explícita ao "3+ call sites" do `engineering-standards`). Não criar framework de rotas JSON de campanha. +- **Guarda CSRF + shell JSON compartilhados:** reusar `isSameOriginRequest` (`src/utilities/sameOriginRequest.ts`) e `parseCampaignJsonRequestBody` / `campaignJsonMutationErrorResponse` (`src/utilities/campaignJsonMutationRoute.ts`) — já extraídos pelo B27. Não criar framework de rotas JSON de campanha. - **`MunicipalityList`** (`src/components/campaign/municipality/MunicipalityList.tsx`): remove as props `trendFormAction`/`municipalitySlug` dos dois pontos de uso (coluna `trend` e card mobile). - **`municipios/page.tsx` + `municipalityStaffFormActions.ts`**: para de passar `setMunicipalityPoliticalTrendFormAction` para a lista. O form action **permanece** — `/[slug]/editar` continua usando (`MunicipalityStrategyForm`); só a lista deixa de consumi-lo. Rodar `pnpm exec knip` para confirmar que nada ficou órfão. - **Depth check:** nenhum `useAutosave` genérico nem `EditableCell` — dois controles com máquinas parecidas ainda são dois controles nomeados (ver _Adiado com gatilho_). @@ -87,15 +93,15 @@ Componentes: ## Dependências - **Nenhuma dura.** Reusa `setMunicipalityPoliticalTrend` (`src/app/(campaign)/campanha/actions/municipality.ts`), `municipalityPoliticalTrendSchema`, `politicalTrendLabels`/`politicalTrendBadgeVariant` (`src/utilities/municipalityLabels.ts`) e o padrão de endpoint de `expected-votes/`. -- **Suave:** **B23** ([tooltip-celulas-listas.md](tooltip-celulas-listas.md)) — só a ordem de chegada muda o encaixe (ver "Dados → decisão → apresentação"); nenhum bloqueia o outro, e ambos precisam de `disabled={open}` / `openOnTouch={false}` na célula para o tooltip não brigar com o Popover. **B9 ✓** (origem da célula e do adiado que este item fecha), **B16 ✓** (filtro `?trend=` no header, cuja defasagem é discutida acima), **E4R ✓** (semeou notas de tendência em massa a partir da planilha — são elas que a mesa vai querer corrigir na varredura). +- **Suave:** **B23 ✓** ([tooltip-celulas-listas.md](tooltip-celulas-listas.md)) — tooltip + convivência Popover já na célula; este item só troca a leitura read-only no Popover pelo `Textarea`. **B27 ✓** — irmão Assessores + helpers JSON compartilhados. **B9 ✓** (origem da célula e do adiado que este item fecha), **B16 ✓** (filtro `?trend=` no header, cuja defasagem é discutida acima), **E4R ✓** (semeou notas de tendência em massa a partir da planilha — são elas que a mesa vai querer corrigir na varredura). ## Não escopo -- **Popover de Assessores sem botão "Salvar"** → fill-in próprio (multi-seleção pede delta/lock como no **B19 ✓**). -- **Mostrar a justificativa no hover da célula** → **B23** ([tooltip-celulas-listas.md](tooltip-celulas-listas.md)). +- **Popover de Assessores sem botão "Salvar"** → **B27 ✓** (entregue 2026-07-26). +- **Mostrar a justificativa no hover da célula** → **B23 ✓**. - **Auto-save no formulário longo `/editar`** → continua submit explícito por decisão de produto (form multi-campo). - **Histórico/versionamento da tendência** → **C12 ✓** cobre registro ex-ante de decisões (`allocationDecision`), não conjuntura. -- **Revalidação/facets em tempo real da lista** → questão em aberto acima; se virar trabalho, cai no mesmo lugar do débito de revalidação do controle de votos. +- **Revalidação/facets em tempo real da lista** → adiado (opção C); se virar trabalho, cai no mesmo lugar do débito de revalidação do controle de votos. ## Rabbit holes @@ -106,7 +112,7 @@ Componentes: ## Adiado com gatilho -- **Hook/shell compartilhado de auto-save em popover de lista.** Revisitar quando: **3º** controle com a mesma máquina (candidatos: assessores na lista, prioridade, `budgetNotes`). +- **Hook/shell compartilhado de auto-save em popover de lista.** Revisitar quando: **3º** controle com a mesma máquina **debounce+draft** (ExpectedVotes + este = 2; Advisors/B27 é delta distinto — não conta). Candidatos: prioridade, `budgetNotes`. - **Reconciliação de sort/filtro pós-edição (opção C).** Revisitar quando: alguém relatar em uso real linha "presa" no filtro de tendência errado. - **Contador de caracteres da justificativa.** Revisitar quando: houver nota real perto de 2000 caracteres. diff --git a/docs/roadmap.md b/docs/roadmap.md index 0471af7d..490becba 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-26 (**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** novo fill-in — o guard de `formActions` só vê arquivos chamados exatamente `formActions.ts`, achado do `/simplify` do C13; **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 — leva B15 ✓/B16 ✓ para `/campanha/dobradinhas` (sort por Nome/Partido, filtro por Partido); consome o header rico compartilhado que **B29** extrai (ou extrai o dele mesmo, se chegar primeiro — mesma corrida de ordem de chegada de B21/B29) — Não escopo do B29 já nomeava esta entrada; contagens derivadas (`municipalityCount`/`leadershipCount`) ficam fora do sort v1, [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 — ação ao fim de cada linha de `/campanha/liderancas` reusando `createCampaignInvite`/`kind: 'autopreenchimento'` (mesmo fluxo do detalhe), disabled quando `contact.phone` não normaliza (`normalizeBrazilianPhone`); ícone distinto do WhatsApp de contato do B28 (soft, mesma linha); sem migration/action nova, [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` — colunas com copy-on-click + WhatsApp como em `/campanha/assessores` (B19 ✓); 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 — a célula da coluna "Território" vira link para a linha daquele TI em `/campanha/territorios` (âncora, sem rota de detalhe); dep dura B21, [plano](plans/link-territorio-lista-municipios.md); **B24** auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` — tira o botão "Salvar" que o princípio 4 do `PRODUCT.md` proíbe e fecha o adiado "nota no popover" do B9, [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) +Atualizado em: 2026-07-26 (**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** novo fill-in — o guard de `formActions` só vê arquivos chamados exatamente `formActions.ts`, achado do `/simplify` do C13; **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 — leva B15 ✓/B16 ✓ para `/campanha/dobradinhas` (sort por Nome/Partido, filtro por Partido); consome o header rico compartilhado que **B29** extrai (ou extrai o dele mesmo, se chegar primeiro — mesma corrida de ordem de chegada de B21/B29) — Não escopo do B29 já nomeava esta entrada; contagens derivadas (`municipalityCount`/`leadershipCount`) ficam fora do sort v1, [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 — ação ao fim de cada linha de `/campanha/liderancas` reusando `createCampaignInvite`/`kind: 'autopreenchimento'` (mesmo fluxo do detalhe), disabled quando `contact.phone` não normaliza (`normalizeBrazilianPhone`); ícone distinto do WhatsApp de contato do B28 (soft, mesma linha); sem migration/action nova, [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` — colunas com copy-on-click + WhatsApp como em `/campanha/assessores` (B19 ✓); 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 — a célula da coluna "Território" vira link para a linha daquele TI em `/campanha/territorios` (âncora, sem rota de detalhe); dep dura B21, [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) @@ -99,7 +99,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re - ~~**B16** filtros no header das colunas da lista de municípios~~ — **entregue 2026-07-24** (funil+Popover no `TableHead` ao lado do sort B15; barra slim busca+resumo+Limpar; mobile selects empilhados; optimistic + busca no Popover de TI) · [plano](plans/filtros-no-header-lista-municipios.md) - ~~**B22** explicação por coluna no header das listas~~ — **entregue 2026-07-26** (`description?: ReactNode` em `CampaignTableHead` e `MunicipalitySortableHead` — rename de `tooltip`, sublinhado pontilhado no label, mesmo `CampaignHoverTooltip` compartilhado já promovido pelo **E10 ✓**; `campaignConceptOneLiner` em `campaignIntelligenceConcepts.ts` como fonte única para as colunas cobertas pelo **E18 ✓**; `municipalityColumnDescriptions` cobre as 11 colunas de `/campanha/municipios`, staff e leader; sem migration/action) · [plano](plans/explicacao-colunas-header-listas.md) - ~~**B23** tooltip de conteúdo nas células das listas~~ — **entregue 2026-07-26** (a mecânica genérica — `cellTooltip?: (row) => ReactNode` em `CampaignTableColumn`, `CampaignCellTooltip`, `CampaignHoverTooltip` promovido para `shared/` — já tinha saído de graça no **E10 ✓**; este item fechou o que faltava: `openOnTouch`/`disabled` em `CampaignHoverTooltip` para conviver com Popover sem roubar o tap nem sobreviver atrás dele (mais um terceiro comportamento do `/simplify`: `content` falsy renderiza o trigger sem wrapper), `formatAdvisorNamesTooltip`, e os dois consumidores de `/campanha/municipios` — hover na célula "Assessores" mostra os nomes completos (avatares só levavam iniciais, ambíguas a partir do 3º), hover na célula "Tendência" mostra a justificativa (`politicalTrend.note`, semeada pelo **E4R ✓**, antes só um ``); em touch a informação passou a aparecer dentro do próprio Popover de edição (a nota da tendência vira leitura acima do select); sem migration) · [plano](plans/tooltip-celulas-listas.md) -- **B24** auto-save + justificativa no popover de Tendência da lista de municípios — pedido de produto (2026-07-25): o popover da coluna "Tendência" perde o botão **"Salvar"** (grava sozinho: status na hora, justificativa com debounce que **cancela o timer anterior**, flush no blur/fechamento) e ganha o campo para **editar a justificativa** (`politicalTrend.note`, hoje hidden input na lista e só editável em `/[slug]/editar`). Fecha duas dívidas datadas: o anti-goal literal do princípio 4 do `PRODUCT.md` ("Save buttons on Popovers and quick-edit cells") — a célula vizinha da mesma linha (votos estimados) já grava sozinha — e o "nota de tendência no popover, gatilho: feedback de uso" adiado no **B9 ✓**. Persistência por endpoint JSON próprio (`POST /campanha/municipios/political-trend`, espelho do `expected-votes/`), **sem revalidar** a lista de 435 linhas por gravação; `setMunicipalityPoliticalTrend` e o access ficam intactos; sem migration/collection/Consent · ~0,5d · Janela 1–2 · Impeccable B · soft: B23 (se vier antes, a nota read-only do Popover vira o textarea) · cortável · [plano](plans/autosave-tendencia-lista-municipios.md) +- ~~**B24** auto-save + justificativa no popover de Tendência da lista de municípios~~ — **entregue 2026-07-26** (`MunicipalityListTrendControl` sem "Salvar": status ~150 ms / justificativa 600 ms + flush blur/fechamento; `Textarea` no lugar do hidden/`

` read-only; endpoint `POST /campanha/municipios/political-trend` sem `revalidatePath`; fecha o adiado "nota no popover" do **B9 ✓** e o anti-goal do princípio 4; soft **B23 ✓**/**B27 ✓**) · [plano](plans/autosave-tendencia-lista-municipios.md) - **B25** link do território na lista de municípios — pedido de produto (2026-07-25): na coluna "Território" de `/campanha/municipios` o nome do TI vira link para **a página daquele território**. Hoje a célula é texto morto (`cell: (m) => m.region`) e a navegação é assimétrica: o painel do Início (**E17 ✓**) já linka TI → `/campanha/municipios?region=`, mas do município não se chega ao território. Destino = a página do **B21** ancorada na linha do TI (`/campanha/territorios#ti-` + `scroll-mt` e `target:` no precedente de `/campanha/conceitos`), **não** rota de detalhe `/campanha/territorios/` (Não escopo do B21, sem evidência nova: seria a linha do rollup + a lista filtrada, que já existem) nem `?region=` (tabela de uma linha destrói a comparação que é o job da tela). Âncora derivada por helper puro (`territoryAnchorId` sobre `slugify`), consumida pelas duas pontas e pinada por teste — TI não ganha slug persistido; sem migration/collection/action/query nova · ~0,25d · Janela 1–2 · Impeccable B · **dep dura: B21** · soft: B17 (coluna ocultável) · E12 é gatilho de revisitação da decisão "âncora vs rota de detalhe", não dependência · cortável · [plano](plans/link-territorio-lista-municipios.md) - ~~**B26** registrar sinal a partir da célula "Último sinal"~~ — **entregue 2026-07-25** (Popover desktop / Drawer mobile; `municipalityUpdate` `kind: 'sinal'`; submit explícito; `revalidateMunicipalityListPaths`; sem migration) · [plano](plans/registrar-sinal-lista-municipios.md) - ~~**B27** combobox + chips + auto-save no popover de Assessores da lista de municípios~~ — **entregue 2026-07-26** (chips removíveis + `Command` de busca acento-insensível + gravação **por delta**, sem botão "Salvar"; `nextAdvisorIdsAfterMembership` subiu para `src/lib/municipalityAdvisorMembership.ts`; endpoint `POST /campanha/municipios/advisors` espelha `expected-votes/`; `assignMunicipalityAdvisorsFormAction`/replace do array sobrevive só para `/editar`) · [plano](plans/combobox-assessores-lista-municipios.md) @@ -165,7 +165,7 @@ flowchart TD B21n["B21 Página dos
Territórios de Identidade"] B22n["B22 Explicação por coluna
no header das listas"] B23n["B23 Tooltip nas células
das listas"] - B24n["B24 Auto-save + justificativa
no popover de Tendência"] + B24n["B24 ✓ Auto-save + justificativa
no popover de Tendência"] B25n["B25 Link do território
na lista de municípios"] B26n["B26 ✓ Registrar sinal
na célula Último sinal"] B27n["B27 ✓ Combobox + chips
no popover de Assessores"] @@ -302,11 +302,11 @@ flowchart TD B8F2 -.ZE Salvador.-> B14n ``` -Paralelizáveis agora: **B13** (E10 ✓ entregou as classes; consome `computeMunicipalityTerritorialClass` como está), **E12** (E8 ✓), **E11** (E9 ✓/E10 ✓ entregues — livre), **B14** (atalho geo no Início; soft B8 F2 só para ZE Salvador), **B21** (página dos TIs; sem dep dura — E17 ✓/B15 ✓/B16 ✓ já entregues; E12 pousa nela depois; **B25** é o único item que espera B21 — o link do território na lista só existe depois do destino), **B17** (seletor de colunas; soft B15 ✓/B16 ✓ — pousa no header de coluna atual), **B18** (filtros salvos + submenu Municípios; soft B16 ✓/B17 — botão na barra), **B22** (explicação por coluna no header; sem dep dura — B15 ✓/B16 ✓/E18 ✓), **B23** (tooltip nas células; a mecânica genérica e a promoção do primitivo já saíram no E10 ✓ — restam os consumidores Assessores/Tendência e a convivência com o Popover), **B24** (auto-save + justificativa no popover de Tendência; sem dep dura — soft B23 só pela ordem em que a nota entra no Popover), **B26** (registrar sinal na célula "Último sinal"; sem dep dura — E9 ✓/C12 ✓ já entregues; soft B24/B23 só pela coerência de contrato na mesma linha), **B27** (combobox + chips + auto-save no popover de Assessores; sem dep dura — B19 ✓ já deu o delta+lock e o padrão de chips; soft B24 pelo endpoint e B23 pelo gatilho), **B28** (e-mail + celular na lista de lideranças; sem dep dura — B19 ✓ é só o padrão soft de copy/WhatsApp; `phone` já está no view model), **B29** (sort + filtro no header da lista de lideranças; sem dep dura — B15 ✓/B16 ✓ deram o contrato e o popover; soft B21 e B28 só pela ordem de chegada: quem entrar depois herda o head compartilhado / encaixa as colunas do outro), fill-ins (renomear coluna Cobertura → quick win de densidade no header; remover coluna Tipo — quick win de densidade; ícone de prioridade na lista; Cenário junto aos filtros — encaixa na barra slim de B16 ✓ —; selecionados no topo nos filtros do header — helper puro que B21 reusa —; O0+, RS+). **B30** (convite WhatsApp na lista de lideranças; sem dep dura — reusa integralmente a action/collection existentes; soft B28 só pelo ícone na mesma linha, qualquer um pode ir primeiro), **B31** (coluna de dobradinhas na lista de lideranças; sem dep dura — o dado já está no view model e o catálogo já tem loader; soft B27 pela peça de célula, que só vira extração compartilhada no 3º call site, e B28/B29/B30 pela ordem de chegada na mesma tabela), **B32** (editar Status de apoio na célula da lista de lideranças; sem dep dura — o campo já existe e já é opcional no schema de update; soft B24 só pela ordem de extração do guard CSRF compartilhado, e B29/B31 só pela convenção "sem modo Editar" e a mesma tabela), **B33** (sort + filtro no header da lista de dobradinhas; sem dep dura — B15 ✓/B16 ✓ deram o contrato; soft B29/B21 só pela ordem de chegada na mesma corrida de extração do chrome compartilhado, quem entrar primeiro paga a peça e o outro só consome), **B34** (chips editáveis de municípios na lista de lideranças; sem dep dura — B19 ✓ já deu o delta+lock, o padrão de chips e o loader do catálogo a promover; soft B27/B31 pela mesma peça de célula editável — quem chegar depois herda o combobox/Popover em vez de reinventar —, e B28/B30/B32 só pela mesma tabela; _decisão a validar com produto_: Popover por célula em vez do toggle "Editar" pedido literalmente, mesmo precedente do B31/B32), **B37** (chips editáveis de municípios na lista de dobradinhas; sem dep dura — B19 ✓/B34 já dão a lib a promover e o desenho do Popover+combobox; soft B34 só pela ordem de chegada em quem faz a promoção da lib, B31 pela mesma família de pedido em outra tabela, B33 pela mesma tabela; _mesma decisão a validar com produto_ do B31/B32/B34: Popover por célula, não toggle). **B36** (coluna de lideranças na lista de dobradinhas; sem dep dura — reusa integralmente a mutação/schema/lock que o B31 desenha para a mesma relação, do lado oposto; soft B31 pela corrida de extração da célula/action compartilhadas, B19 ✓ pelo padrão de origem, B33 pela mesma tabela), **C13** (renomear Plano de Ação → Atividade; sem dep dura — não toca nenhuma das listas em curso (B26–B37 são municípios/lideranças/dobradinhas), então roda em paralelo a elas; a única ordem que importa é **antes de E13**, que constrói dentro da vertical renomeada, e antes do congelamento ~20/09 por carregar migration; PR mecânico e isolado, não misturar com feature). **D3** só após smoke + folga e só se o fluxo sede-digita (C12 ✓) não absorver os deltas do ZAP (não compete com E9). +Paralelizáveis agora: **B13** (E10 ✓ entregou as classes; consome `computeMunicipalityTerritorialClass` como está), **E12** (E8 ✓), **E11** (E9 ✓/E10 ✓ entregues — livre), **B14** (atalho geo no Início; soft B8 F2 só para ZE Salvador), **B21** (página dos TIs; sem dep dura — E17 ✓/B15 ✓/B16 ✓ já entregues; E12 pousa nela depois; **B25** é o único item que espera B21 — o link do território na lista só existe depois do destino), **B17** (seletor de colunas; soft B15 ✓/B16 ✓ — pousa no header de coluna atual), **B18** (filtros salvos + submenu Municípios; soft B16 ✓/B17 — botão na barra), **B22** (explicação por coluna no header; sem dep dura — B15 ✓/B16 ✓/E18 ✓), **B23** (tooltip nas células; a mecânica genérica e a promoção do primitivo já saíram no E10 ✓ — restam os consumidores Assessores/Tendência e a convivência com o Popover), ~~**B24**~~ ✓ (auto-save + justificativa no popover de Tendência; entregue 2026-07-26), **B26** (registrar sinal na célula "Último sinal"; sem dep dura — E9 ✓/C12 ✓ já entregues; soft B24/B23 só pela coerência de contrato na mesma linha), **B27** (combobox + chips + auto-save no popover de Assessores; sem dep dura — B19 ✓ já deu o delta+lock e o padrão de chips; soft B24 pelo endpoint e B23 pelo gatilho), **B28** (e-mail + celular na lista de lideranças; sem dep dura — B19 ✓ é só o padrão soft de copy/WhatsApp; `phone` já está no view model), **B29** (sort + filtro no header da lista de lideranças; sem dep dura — B15 ✓/B16 ✓ deram o contrato e o popover; soft B21 e B28 só pela ordem de chegada: quem entrar depois herda o head compartilhado / encaixa as colunas do outro), fill-ins (renomear coluna Cobertura → quick win de densidade no header; remover coluna Tipo — quick win de densidade; ícone de prioridade na lista; Cenário junto aos filtros — encaixa na barra slim de B16 ✓ —; selecionados no topo nos filtros do header — helper puro que B21 reusa —; O0+, RS+). **B30** (convite WhatsApp na lista de lideranças; sem dep dura — reusa integralmente a action/collection existentes; soft B28 só pelo ícone na mesma linha, qualquer um pode ir primeiro), **B31** (coluna de dobradinhas na lista de lideranças; sem dep dura — o dado já está no view model e o catálogo já tem loader; soft B27 pela peça de célula, que só vira extração compartilhada no 3º call site, e B28/B29/B30 pela ordem de chegada na mesma tabela), **B32** (editar Status de apoio na célula da lista de lideranças; sem dep dura — o campo já existe e já é opcional no schema de update; soft B24 só pela ordem de extração do guard CSRF compartilhado, e B29/B31 só pela convenção "sem modo Editar" e a mesma tabela), **B33** (sort + filtro no header da lista de dobradinhas; sem dep dura — B15 ✓/B16 ✓ deram o contrato; soft B29/B21 só pela ordem de chegada na mesma corrida de extração do chrome compartilhado, quem entrar primeiro paga a peça e o outro só consome), **B34** (chips editáveis de municípios na lista de lideranças; sem dep dura — B19 ✓ já deu o delta+lock, o padrão de chips e o loader do catálogo a promover; soft B27/B31 pela mesma peça de célula editável — quem chegar depois herda o combobox/Popover em vez de reinventar —, e B28/B30/B32 só pela mesma tabela; _decisão a validar com produto_: Popover por célula em vez do toggle "Editar" pedido literalmente, mesmo precedente do B31/B32), **B37** (chips editáveis de municípios na lista de dobradinhas; sem dep dura — B19 ✓/B34 já dão a lib a promover e o desenho do Popover+combobox; soft B34 só pela ordem de chegada em quem faz a promoção da lib, B31 pela mesma família de pedido em outra tabela, B33 pela mesma tabela; _mesma decisão a validar com produto_ do B31/B32/B34: Popover por célula, não toggle). **B36** (coluna de lideranças na lista de dobradinhas; sem dep dura — reusa integralmente a mutação/schema/lock que o B31 desenha para a mesma relação, do lado oposto; soft B31 pela corrida de extração da célula/action compartilhadas, B19 ✓ pelo padrão de origem, B33 pela mesma tabela), **C13** (renomear Plano de Ação → Atividade; sem dep dura — não toca nenhuma das listas em curso (B26–B37 são municípios/lideranças/dobradinhas), então roda em paralelo a elas; a única ordem que importa é **antes de E13**, que constrói dentro da vertical renomeada, e antes do congelamento ~20/09 por carregar migration; PR mecânico e isolado, não misturar com feature). **D3** só após smoke + folga e só se o fluxo sede-digita (C12 ✓) não absorver os deltas do ZAP (não compete com E9). ### Sequência por janela (só pendentes) -**Janela 1 — agora → 05/08 (convenções):** smoke pós-deploy em produção + onboarding do time (Onda 0 §2/§4); **E4R ✓** em código (aplicar `pnpm db:seed:projecao` em produção após smoke); **E8 ✓** conta da cadeira entregue; **E17 ✓** tabela TI no Início; **B15 ✓** (sort por coluna) e **A11 ✓** (posição em votos + `sort=votos`; o default do staff passou a `deficit` no E9 ✓) entregues 2026-07-24; **E18 ✓** documentação de conceitos (`/campanha/conceitos`, v1 só E8) entregue 2026-07-24; **B19 ✓** gerenciar assessores (caminho do onboarding sem `/admin` — criar/ativar contas e carteiras) entregue 2026-07-24; **B16 ✓** filtros no header entregue 2026-07-25; **B20 ✓** Municípios prioritários no Início entregue 2026-07-25; **B21** (página dos Territórios de Identidade — a leitura regional sai do painel do Início e ganha tela própria com sort/filtro; útil já no onboarding para dividir carteiras) se folga — e, logo atrás dela, **B25** (link do território na célula da lista: quarto de dia, é o que faz a página nova ser encontrada de dentro do trabalho); **B22** (explicação por coluna no header — a tabela deixa de exigir o autor do E8/E9 ao lado para ser lida; quick win de onboarding, meio dia); **B23** (tooltip nas células — nomes dos assessores no hover da coluna, hoje só iniciais, e a justificativa da tendência, hoje invisível na lista; capacidade genérica da célula, meio dia, casa com B22 pelo mesmo primitivo); **B24** (auto-save + justificativa no popover de Tendência — a mesa corrige em massa as notas semeadas pelo E4R ✓ sem abrir 435 telas de edição; meio dia, casa com B23 pela mesma célula); **B26** (registrar sinal na própria célula "Último sinal" — fecha o laço do E9 ✓: hoje a fila diz onde está frio e obriga a sair dela para registrar qualquer coisa; ~0,75 dia, e é o item que mais aumenta a chance do gate de adoção abaixo, porque o registro passa a caber no gesto de varrer a lista, inclusive no celular); **B27** (combobox + chips + auto-save nos Assessores — é literalmente a tela em que o CG divide as carteiras no onboarding: hoje procura o nome numa coluna de checkboxes e confirma num botão; ~0,75 dia, casa com B24 pelo mesmo endpoint e com B23 pelo mesmo gatilho); **B28** (e-mail + celular na lista de lideranças — copy + WhatsApp como em assessores, sem abrir a ficha; ~0,5–0,75 dia, quick win de campo no onboarding da rede); **B30** (convidar para completar cadastro por WhatsApp na mesma lista — reusa o convite que já existe no detalhe, sem abrir a ficha; ~0,25–0,5 dia, o quick win mais barato desta leva porque não toca action/collection nenhuma; casa com B28 pela mesma linha, ícone distinto); **B31** (coluna de dobradinhas na mesma lista — é o preenchimento em lote que o onboarding exige: o E4R ✓ deixou dobradinha só no município e as fichas vazias, e completar uma a uma pelo detalhe é o gesto que trava a mesa; ~1 dia, casa com B27 pela mesma peça de célula — quem for primeiro define o padrão do chip+combobox in-list); **B32** (editar Status de apoio na célula da mesma lista — pedido de produto explícito para não repetir o toggle "Editar" do B19 ✓; fecha o adiado do próprio B29; ~0,4–0,5 dia, o quick win mais barato entre os popovers de célula desta leva, casa com B24 pelo guard CSRF compartilhado); **B34** (chips editáveis de municípios na mesma lista — a coluna "Municípios" ganha o mesmo tratamento da coluna homônima em Assessores (B19 ✓): chips com link + Popover de edição com combobox, em vez do texto morto atual; ~1–1,25 dia, casa com B27/B31 pela mesma peça de célula editável — quem entrar antes define o padrão do combobox+chip in-list que os outros herdam); **B37** (o mesmo tratamento de chips editáveis na coluna "Municípios" de `/campanha/dobradinhas` — hoje um número morto, `municipalityCount`; ~0,75–1 dia, herda a lib promovida pelo B34 se ele já tiver landado, senão promove ela mesma); **B36** (a coluna "Lideranças" da mesma lista ganha o mesmo tratamento — chips nomeados + Popover + busca + auto-save por delta, reusando sem alteração a mutação que o B31 desenha para o lado oposto da mesma relação; ~0,5–1,5 dia dependendo de quem entre B31/B36 chega primeiro na corrida de extração da célula compartilhada); **B33** (sort + filtro no header de `/campanha/dobradinhas` — pedido explícito de reuso: Nome/Partido no header e filtro por Partido, sem duplicar o chrome de `/campanha/municipios`; ~0,5–1,5 dia dependendo de quem chega primeiro na corrida de extração do chrome compartilhado com B29/B21; entra perto de B29 por reusar a mesma peça); **B29** (sort + filtro no header da mesma lista — é a tela onde o assessor recebe a carteira e precisa recortar "quem ainda está a abordar aqui" / "quem ainda não tem acesso ao app" antes de convidar; ~1,5–2 dias porque paga também a extração do head compartilhado, então entra depois dos quick wins — mas antes se B21 for adiado, para o head nascer compartilhado); **B17** (seletor de colunas) / **B18** (filtros salvos + atalho sidebar) se folga de UX da lista no onboarding; fill-in **renomear coluna Cobertura** (header curto na fila — quarto de dia; glossário/KPI intactos); fill-in **remover coluna Tipo** (densidade morta na fila — quarto/meio dia; Salvador via slugs cobre ZE); fill-in **selecionados no topo nos filtros do header** (desmarcar sem caçar checkbox; helper puro que B21 herda — quarto de dia); **C13 ✓** (renomeado "Plano de Ação" → "Atividade" em toda a vertical, incluindo a migration data-preserving — entregue 2026-07-25); gate de adoção: sinal Little Hire — ≥1 update espontâneo até 30/07, cobrança da tabela dispensada (planilhas já em `docs/sheets/`) — acompanhamento em [IMPROVE-APP-PLAN.md](IMPROVE-APP-PLAN.md); **R6** critique/polish; **E9 ✓** entregue no mesmo dia; **B14** (atalho geo) se sobrar folga de campo no onboarding; Onda 0 jurídica em paralelo (externa). +**Janela 1 — agora → 05/08 (convenções):** smoke pós-deploy em produção + onboarding do time (Onda 0 §2/§4); **E4R ✓** em código (aplicar `pnpm db:seed:projecao` em produção após smoke); **E8 ✓** conta da cadeira entregue; **E17 ✓** tabela TI no Início; **B15 ✓** (sort por coluna) e **A11 ✓** (posição em votos + `sort=votos`; o default do staff passou a `deficit` no E9 ✓) entregues 2026-07-24; **E18 ✓** documentação de conceitos (`/campanha/conceitos`, v1 só E8) entregue 2026-07-24; **B19 ✓** gerenciar assessores (caminho do onboarding sem `/admin` — criar/ativar contas e carteiras) entregue 2026-07-24; **B16 ✓** filtros no header entregue 2026-07-25; **B20 ✓** Municípios prioritários no Início entregue 2026-07-25; **B21** (página dos Territórios de Identidade — a leitura regional sai do painel do Início e ganha tela própria com sort/filtro; útil já no onboarding para dividir carteiras) se folga — e, logo atrás dela, **B25** (link do território na célula da lista: quarto de dia, é o que faz a página nova ser encontrada de dentro do trabalho); **B22** (explicação por coluna no header — a tabela deixa de exigir o autor do E8/E9 ao lado para ser lida; quick win de onboarding, meio dia); **B23** (tooltip nas células — nomes dos assessores no hover da coluna, hoje só iniciais, e a justificativa da tendência, hoje invisível na lista; capacidade genérica da célula, meio dia, casa com B22 pelo mesmo primitivo); ~~**B24**~~ ✓ (auto-save + justificativa no popover de Tendência — entregue 2026-07-26; a mesa corrige em massa as notas semeadas pelo E4R ✓ sem abrir 435 telas de edição); **B26** (registrar sinal na própria célula "Último sinal" — fecha o laço do E9 ✓: hoje a fila diz onde está frio e obriga a sair dela para registrar qualquer coisa; ~0,75 dia, e é o item que mais aumenta a chance do gate de adoção abaixo, porque o registro passa a caber no gesto de varrer a lista, inclusive no celular); **B27** (combobox + chips + auto-save nos Assessores — é literalmente a tela em que o CG divide as carteiras no onboarding: hoje procura o nome numa coluna de checkboxes e confirma num botão; ~0,75 dia, casa com B24 pelo mesmo endpoint e com B23 pelo mesmo gatilho); **B28** (e-mail + celular na lista de lideranças — copy + WhatsApp como em assessores, sem abrir a ficha; ~0,5–0,75 dia, quick win de campo no onboarding da rede); **B30** (convidar para completar cadastro por WhatsApp na mesma lista — reusa o convite que já existe no detalhe, sem abrir a ficha; ~0,25–0,5 dia, o quick win mais barato desta leva porque não toca action/collection nenhuma; casa com B28 pela mesma linha, ícone distinto); **B31** (coluna de dobradinhas na mesma lista — é o preenchimento em lote que o onboarding exige: o E4R ✓ deixou dobradinha só no município e as fichas vazias, e completar uma a uma pelo detalhe é o gesto que trava a mesa; ~1 dia, casa com B27 pela mesma peça de célula — quem for primeiro define o padrão do chip+combobox in-list); **B32** (editar Status de apoio na célula da mesma lista — pedido de produto explícito para não repetir o toggle "Editar" do B19 ✓; fecha o adiado do próprio B29; ~0,4–0,5 dia, o quick win mais barato entre os popovers de célula desta leva, casa com B24 pelo guard CSRF compartilhado); **B34** (chips editáveis de municípios na mesma lista — a coluna "Municípios" ganha o mesmo tratamento da coluna homônima em Assessores (B19 ✓): chips com link + Popover de edição com combobox, em vez do texto morto atual; ~1–1,25 dia, casa com B27/B31 pela mesma peça de célula editável — quem entrar antes define o padrão do combobox+chip in-list que os outros herdam); **B37** (o mesmo tratamento de chips editáveis na coluna "Municípios" de `/campanha/dobradinhas` — hoje um número morto, `municipalityCount`; ~0,75–1 dia, herda a lib promovida pelo B34 se ele já tiver landado, senão promove ela mesma); **B36** (a coluna "Lideranças" da mesma lista ganha o mesmo tratamento — chips nomeados + Popover + busca + auto-save por delta, reusando sem alteração a mutação que o B31 desenha para o lado oposto da mesma relação; ~0,5–1,5 dia dependendo de quem entre B31/B36 chega primeiro na corrida de extração da célula compartilhada); **B33** (sort + filtro no header de `/campanha/dobradinhas` — pedido explícito de reuso: Nome/Partido no header e filtro por Partido, sem duplicar o chrome de `/campanha/municipios`; ~0,5–1,5 dia dependendo de quem chega primeiro na corrida de extração do chrome compartilhado com B29/B21; entra perto de B29 por reusar a mesma peça); **B29** (sort + filtro no header da mesma lista — é a tela onde o assessor recebe a carteira e precisa recortar "quem ainda está a abordar aqui" / "quem ainda não tem acesso ao app" antes de convidar; ~1,5–2 dias porque paga também a extração do head compartilhado, então entra depois dos quick wins — mas antes se B21 for adiado, para o head nascer compartilhado); **B17** (seletor de colunas) / **B18** (filtros salvos + atalho sidebar) se folga de UX da lista no onboarding; fill-in **renomear coluna Cobertura** (header curto na fila — quarto de dia; glossário/KPI intactos); fill-in **remover coluna Tipo** (densidade morta na fila — quarto/meio dia; Salvador via slugs cobre ZE); fill-in **selecionados no topo nos filtros do header** (desmarcar sem caçar checkbox; helper puro que B21 herda — quarto de dia); **C13 ✓** (renomeado "Plano de Ação" → "Atividade" em toda a vertical, incluindo a migration data-preserving — entregue 2026-07-25); gate de adoção: sinal Little Hire — ≥1 update espontâneo até 30/07, cobrança da tabela dispensada (planilhas já em `docs/sheets/`) — acompanhamento em [IMPROVE-APP-PLAN.md](IMPROVE-APP-PLAN.md); **R6** critique/polish; **E9 ✓** entregue no mesmo dia; **B14** (atalho geo) se sobrar folga de campo no onboarding; Onda 0 jurídica em paralelo (externa). **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. @@ -318,7 +318,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`). -**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 (mapa continua agregado no município; manter F1 bairros); **D2** push (adiar); **A6**; **B14** município mais próximo (lista/busca e Recentes continuam); **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 (o botão "Salvar" continua funcionando e a justificativa continua editável em `/[slug]/editar` — cortar custa coerência com a célula vizinha e um desvio conhecido do princípio 4; é o corte mais barato desta lista, mas também o mais barato de fazer); **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 (o botão do detalhe continua gerando o convite; cortar custa só o atalho sem abrir a ficha — é o corte mais barato desta faixa, porque não toca nenhuma action/collection); **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 (a ficha `/liderancas/[id]` continua trocando o status pelo `NativeSelect` do formulário — cortar custa só o atalho de um clique a mais por linha; é o corte mais barato desta faixa, junto com B30, porque não toca nenhum contrato de dado); **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 (a contagem `leadershipCount` continua legível 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 (a busca por nome e a ordem fixa por nome continuam funcionando — lista pequena hoje, cortar custa só o recorte por partido e a ordenação por partido; corta antes de B29 se o chrome compartilhado ainda não tiver sido pago por nenhum dos dois); **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); ~~**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 (manter `priority` alta/normal); **B8 F2** polígonos (mapa continua agregado no município; manter F1 bairros); **D2** push (adiar); **A6**; **B14** município mais próximo (lista/busca e Recentes continuam); **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 (o botão do detalhe continua gerando o convite; cortar custa só o atalho sem abrir a ficha — é o corte mais barato desta faixa, porque não toca nenhuma action/collection); **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 (a ficha `/liderancas/[id]` continua trocando o status pelo `NativeSelect` do formulário — cortar custa só o atalho de um clique a mais por linha; é o corte mais barato desta faixa, junto com B30, porque não toca nenhum contrato de dado); **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 (a contagem `leadershipCount` continua legível 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 (a busca por nome e a ordem fixa por nome continuam funcionando — lista pequena hoje, cortar custa só o recorte por partido e a ordenação por partido; corta antes de B29 se o chrome compartilhado ainda não tiver sido pago por nenhum dos dois); **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); ~~**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/page.tsx b/src/app/(campaign)/campanha/(app)/municipios/page.tsx index 24788576..d2785f80 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/page.tsx +++ b/src/app/(campaign)/campanha/(app)/municipios/page.tsx @@ -32,10 +32,7 @@ import { } from '@/utilities/municipalityListUrl' import { loadMunicipalityListPageBundle } from '@/utilities/municipalityPageData' import { getEligibleAdvisorOptions, loadAdvisorSummaries } from '@/utilities/municipalityViewModels' -import { - createMunicipalityListSignalFormAction, - setMunicipalityPoliticalTrendFormAction, -} from './municipalityStaffFormActions' +import { createMunicipalityListSignalFormAction } from './municipalityStaffFormActions' type MunicipalitiesPageProps = { searchParams: Promise> @@ -129,7 +126,6 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie isCoordinator={isCoordinator} advisorOptions={advisorOptions} columnFilterOptions={columnFilterOptions} - trendFormAction={setMunicipalityPoliticalTrendFormAction} signalFormAction={createMunicipalityListSignalFormAction} state={state} /> diff --git a/src/app/(campaign)/campanha/(app)/municipios/political-trend/route.ts b/src/app/(campaign)/campanha/(app)/municipios/political-trend/route.ts new file mode 100644 index 00000000..5be1df18 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/municipios/political-trend/route.ts @@ -0,0 +1,57 @@ +import { NextResponse } from 'next/server' + +import { municipalityStaffEditSafeMessages } from '@/app/(campaign)/campanha/(app)/municipios/municipalityStaffEditMessages' +import { setMunicipalityPoliticalTrend } from '@/app/(campaign)/campanha/actions/municipality' +import { municipalityPoliticalTrendSchema } from '@/lib/schemas/municipality' +import { positiveRelationshipId } from '@/lib/schemas/primitives' +import { + campaignJsonMutationErrorResponse, + parseCampaignJsonRequestBody, +} from '@/utilities/campaignJsonMutationRoute' +import { isSameOriginRequest } from '@/utilities/sameOriginRequest' + +import type { MunicipalityListPoliticalTrendResponse } from './types' + +export type { MunicipalityListPoliticalTrendResponse } from './types' + +export const dynamic = 'force-dynamic' + +/** JSON body uses `municipalityId`; the action schema uses `municipality`. */ +const bodySchema = municipalityPoliticalTrendSchema + .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, status, note } = bodySchema.parse(parsed.body) + const updated = await setMunicipalityPoliticalTrend({ + municipality: municipalityId, + status, + note, + }) + + return NextResponse.json({ + status: 'success', + message: 'Tendência política registrada.', + savedTrend: { + status: updated.politicalTrend?.status ?? null, + note: updated.politicalTrend?.note ?? null, + }, + }) + } catch (error) { + return campaignJsonMutationErrorResponse(error, { + safeMessages: municipalityStaffEditSafeMessages, + genericMessage: + 'Não foi possível salvar a tendência. Verifique seu acesso e tente novamente.', + }) + } +} diff --git a/src/app/(campaign)/campanha/(app)/municipios/political-trend/types.ts b/src/app/(campaign)/campanha/(app)/municipios/political-trend/types.ts new file mode 100644 index 00000000..0318852a --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/municipios/political-trend/types.ts @@ -0,0 +1,17 @@ +import type { PoliticalTrendStatusValue } from '@/lib/schemas/municipality' + +export type MunicipalityListSavedPoliticalTrend = { + status: PoliticalTrendStatusValue | null + note: string | null +} + +export type MunicipalityListPoliticalTrendResponse = + | { + status: 'success' + message: string + savedTrend: MunicipalityListSavedPoliticalTrend + } + | { + status: 'error' + message: string + } diff --git a/src/components/campaign/municipality/MunicipalityList.tsx b/src/components/campaign/municipality/MunicipalityList.tsx index 145b1f48..875ba4bf 100644 --- a/src/components/campaign/municipality/MunicipalityList.tsx +++ b/src/components/campaign/municipality/MunicipalityList.tsx @@ -85,7 +85,6 @@ export type MunicipalityListProps = { isCoordinator: boolean advisorOptions: EligibleAdvisorOption[] columnFilterOptions: MunicipalityColumnFilterOptions - trendFormAction: MunicipalityStaffFormAction signalFormAction: MunicipalityStaffFormAction state: MunicipalityListState } @@ -249,7 +248,6 @@ const municipalityListColumns = ({ columnFilterOptions, advisorNamesById, advisorOptions, - trendFormAction, signalFormAction, }: MunicipalityListProps): Array> => [ { @@ -407,10 +405,8 @@ const municipalityListColumns = ({ cell: (municipality) => ( ), }, @@ -501,15 +497,8 @@ const municipalityListColumns = ({ ] export const MunicipalityList = (props: MunicipalityListProps) => { - const { - municipalities, - advisorNamesById, - isStaffView, - isCoordinator, - advisorOptions, - trendFormAction, - state, - } = props + const { municipalities, advisorNamesById, isStaffView, isCoordinator, advisorOptions, state } = + props const { sort: activeSort, dir: activeDir } = resolveMunicipalityListSort(state) const sortSummary = formatMunicipalityListSortSummary(activeSort, activeDir) const columns = municipalityListColumns(props) @@ -563,10 +552,8 @@ export const MunicipalityList = (props: MunicipalityListProps) => {

diff --git a/src/components/campaign/municipality/MunicipalityListTrendControl.tsx b/src/components/campaign/municipality/MunicipalityListTrendControl.tsx index b017ee11..9c169f67 100644 --- a/src/components/campaign/municipality/MunicipalityListTrendControl.tsx +++ b/src/components/campaign/municipality/MunicipalityListTrendControl.tsx @@ -1,53 +1,224 @@ 'use client' -import { useActionState, useEffect, useState } from 'react' -import { toast } from 'sonner' +import { useEffect, useRef, useState } from 'react' +import type { + MunicipalityListPoliticalTrendResponse, + MunicipalityListSavedPoliticalTrend, +} from '@/app/(campaign)/campanha/(app)/municipios/political-trend/types' import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip' import { Alert, AlertDescription } from '@/components/ui/Alert' import { Badge } from '@/components/ui/Badge' -import { Button } from '@/components/ui/button' import { Field, 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 type { CampaignFormActionState } from '@/utilities/campaignFormActionError' +import { Textarea } from '@/components/ui/textarea' +import { parsePoliticalTrendStatusFormValue } from '@/lib/schemas/municipality' +import { cn } from '@/lib/utils' import { politicalTrendBadgeVariant, politicalTrendLabels } from '@/utilities/municipalityLabels' -import type { MunicipalityPoliticalTrendViewModel } from '@/utilities/municipalityViewModels' + +const NOTE_AUTOSAVE_MS = 600 +const STATUS_AUTOSAVE_MS = 150 +const POLITICAL_TREND_ENDPOINT = '/campanha/municipios/political-trend' +const SAVE_ERROR_MESSAGE = 'Não foi possível salvar a tendência. Tente novamente.' +const NOTE_MAX_LENGTH = 2000 + +const normalizeNote = (note: string | null | undefined): string | null => { + const trimmed = note?.trim() + return trimmed ? trimmed : null +} + +const trendsEqual = ( + left: MunicipalityListSavedPoliticalTrend, + right: MunicipalityListSavedPoliticalTrend, +): boolean => left.status === right.status && normalizeNote(left.note) === normalizeNote(right.note) type MunicipalityListTrendControlProps = { municipalityID: number - municipalitySlug: string - status: MunicipalityPoliticalTrendViewModel['status'] + status: MunicipalityListSavedPoliticalTrend['status'] trendNote: string | null - formAction: ( - state: CampaignFormActionState, - formData: FormData, - ) => Promise } export const MunicipalityListTrendControl = ({ municipalityID, - municipalitySlug, status, trendNote, - formAction, }: MunicipalityListTrendControlProps) => { + const initialTrend: MunicipalityListSavedPoliticalTrend = { + status, + note: normalizeNote(trendNote), + } const [open, setOpen] = useState(false) - const [state, submitAction, isPending] = useActionState(formAction, {}) + const [displayTrend, setDisplayTrend] = useState(initialTrend) + const [draft, setDraft] = useState(initialTrend) + const [noteDraft, setNoteDraft] = useState(trendNote ?? '') + const [isDirty, setIsDirty] = useState(false) + const [isPending, setIsPending] = useState(false) + const [errorMessage, setErrorMessage] = useState(null) + const saveTimeoutRef = useRef | undefined>(undefined) + const abortRef = useRef(null) + const committedTrendRef = useRef(initialTrend) + const lastPropsTrendRef = useRef(initialTrend) + const saveGenerationRef = useRef(0) + /** Skip abort+re-POST when blur and popover-close flush the same payload. */ + const inFlightTrendRef = useRef(null) + const adoptTrend = (trend: MunicipalityListSavedPoliticalTrend) => { + setDisplayTrend(trend) + setDraft(trend) + setNoteDraft(trend.note ?? '') + } + + // Adopt server props only when they change from outside (navigation / RSC refresh). useEffect(() => { - if (state.status !== 'success') return - toast.success(state.message) - setOpen(false) - }, [state.message, state.status]) + const next: MunicipalityListSavedPoliticalTrend = { + status, + note: normalizeNote(trendNote), + } + if (trendsEqual(next, lastPropsTrendRef.current)) return + lastPropsTrendRef.current = next + committedTrendRef.current = next + adoptTrend(next) + setIsDirty(false) + }, [status, trendNote]) - const hasNote = Boolean(trendNote?.trim()) + useEffect( + () => () => { + clearTimeout(saveTimeoutRef.current) + abortRef.current?.abort() + }, + [], + ) + + const saveDraft = async (values: MunicipalityListSavedPoliticalTrend) => { + const normalized: MunicipalityListSavedPoliticalTrend = { + status: values.status, + note: normalizeNote(values.note), + } + if (trendsEqual(normalized, committedTrendRef.current)) return + if (inFlightTrendRef.current && trendsEqual(normalized, inFlightTrendRef.current)) return + + const generation = ++saveGenerationRef.current + abortRef.current?.abort() + const controller = new AbortController() + abortRef.current = controller + inFlightTrendRef.current = normalized + + setIsPending(true) + setErrorMessage(null) + + try { + const response = await fetch(POLITICAL_TREND_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + credentials: 'same-origin', + signal: controller.signal, + body: JSON.stringify({ + municipalityId: municipalityID, + status: normalized.status, + note: normalized.note, + }), + }) + + const payload = (await response.json()) as MunicipalityListPoliticalTrendResponse + + if (generation !== saveGenerationRef.current) return + + if (!response.ok || payload.status !== 'success') { + adoptTrend(committedTrendRef.current) + setIsDirty(false) + setErrorMessage(payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE) + return + } + + committedTrendRef.current = payload.savedTrend + adoptTrend(payload.savedTrend) + setIsDirty(false) + } catch { + if (controller.signal.aborted || generation !== saveGenerationRef.current) return + adoptTrend(committedTrendRef.current) + setIsDirty(false) + setErrorMessage(SAVE_ERROR_MESSAGE) + } finally { + if (generation === saveGenerationRef.current) { + inFlightTrendRef.current = null + setIsPending(false) + } + } + } + + const scheduleSave = (values: MunicipalityListSavedPoliticalTrend, delayMs: number) => { + if (trendsEqual(values, committedTrendRef.current)) { + clearTimeout(saveTimeoutRef.current) + setIsDirty(false) + return + } + clearTimeout(saveTimeoutRef.current) + saveTimeoutRef.current = setTimeout(() => { + void saveDraft(values) + }, delayMs) + } + + const handleStatusChange = (raw: string) => { + const next: MunicipalityListSavedPoliticalTrend = { + status: parsePoliticalTrendStatusFormValue(raw || undefined), + note: normalizeNote(noteDraft), + } + setIsDirty(true) + setDraft(next) + setDisplayTrend(next) + scheduleSave(next, STATUS_AUTOSAVE_MS) + } + + const handleNoteChange = (raw: string) => { + const next: MunicipalityListSavedPoliticalTrend = { + status: draft.status, + note: normalizeNote(raw), + } + setNoteDraft(raw) + setIsDirty(true) + setDraft(next) + setDisplayTrend(next) + scheduleSave(next, NOTE_AUTOSAVE_MS) + } + + const flushDraft = () => { + clearTimeout(saveTimeoutRef.current) + const next: MunicipalityListSavedPoliticalTrend = { + status: draft.status, + note: normalizeNote(noteDraft), + } + if (!trendsEqual(next, committedTrendRef.current)) { + void saveDraft(next) + } + setIsDirty(false) + } + + const handleOpenChange = (nextOpen: boolean) => { + if (nextOpen) { + setIsDirty(false) + setErrorMessage(null) + adoptTrend(displayTrend) + } else if (open) { + flushDraft() + } + setOpen(nextOpen) + } + + const hasNote = Boolean(displayTrend.note) + const statusMessage = errorMessage + ? errorMessage + : isPending + ? 'Salvando tendência.' + : isDirty + ? 'Alterações serão salvas automaticamente.' + : '' return ( - + {trendNote}

: null} + content={hasNote ?

{displayTrend.note}

: null} align="start" openOnTouch={false} disabled={open} @@ -55,12 +226,18 @@ export const MunicipalityListTrendControl = ({
- - - - - - {hasNote ? ( -

{trendNote}

+ event.preventDefault()} + > +
+ {isPending ? ( + ) : null} Tendência handleStatusChange(event.target.value)} className="min-h-11 w-full" > Não registrada @@ -94,16 +276,30 @@ export const MunicipalityListTrendControl = ({ )} - {state.message && state.status !== 'success' ? ( - - {state.message} + + + Justificativa + +