B24: auto-save e justificativa no popover de Tendência.

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.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-26 14:05:36 -03:00
parent e0d0936fab
commit b3283ee061
13 changed files with 457 additions and 104 deletions

View file

@ -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<codarea, Record<year, { rank, candidates }>>` — 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.

View file

@ -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/<slug>.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 \<ID\>" 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ó \<ID\> (os demais são sem schema) — ou "nenhum".
- Serializar depois: \<ID\> — colide com \<ID\> em \<superfície\>.
- 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).

View file

@ -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).

View file

@ -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, `<p className="sr-only" aria-live="polite">`. Some o `<form>`, 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.

File diff suppressed because one or more lines are too long

View file

@ -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<Record<string, string | string[] | undefined>>
@ -129,7 +126,6 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
isCoordinator={isCoordinator}
advisorOptions={advisorOptions}
columnFilterOptions={columnFilterOptions}
trendFormAction={setMunicipalityPoliticalTrendFormAction}
signalFormAction={createMunicipalityListSignalFormAction}
state={state}
/>

View file

@ -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<NextResponse<MunicipalityListPoliticalTrendResponse>> {
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.',
})
}
}

View file

@ -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
}

View file

@ -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<CampaignTableColumn<MunicipalityListViewModel>> => [
{
@ -407,10 +405,8 @@ const municipalityListColumns = ({
cell: (municipality) => (
<MunicipalityListTrendControl
municipalityID={municipality.id}
municipalitySlug={municipality.slug}
status={municipality.politicalTrendStatus}
trendNote={municipality.politicalTrendNote}
formAction={trendFormAction}
/>
),
},
@ -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) => {
<dd>
<MunicipalityListTrendControl
municipalityID={municipality.id}
municipalitySlug={municipality.slug}
status={municipality.politicalTrendStatus}
trendNote={municipality.politicalTrendNote}
formAction={trendFormAction}
/>
</dd>
</div>

View file

@ -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<CampaignFormActionState>
}
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<string | null>(null)
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const abortRef = useRef<AbortController | null>(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<MunicipalityListSavedPoliticalTrend | null>(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 (
<Popover open={open} onOpenChange={setOpen}>
<Popover open={open} onOpenChange={handleOpenChange}>
<CampaignHoverTooltip
content={hasNote ? <p className="whitespace-pre-wrap">{trendNote}</p> : null}
content={hasNote ? <p className="whitespace-pre-wrap">{displayTrend.note}</p> : null}
align="start"
openOnTouch={false}
disabled={open}
@ -55,12 +226,18 @@ export const MunicipalityListTrendControl = ({
<PopoverTrigger asChild>
<button
type="button"
className="min-h-11 rounded-md px-1 hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-expanded={open}
aria-haspopup="dialog"
aria-busy={isPending || undefined}
className={cn(
'min-h-11 rounded-md px-1 hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
open ? 'bg-muted/60' : undefined,
)}
aria-label="Editar tendência política"
>
{status ? (
<Badge variant={politicalTrendBadgeVariant[status]}>
{politicalTrendLabels[status]}
{displayTrend.status ? (
<Badge variant={politicalTrendBadgeVariant[displayTrend.status]}>
{politicalTrendLabels[displayTrend.status]}
</Badge>
) : (
<Badge variant="outline">Não registrada</Badge>
@ -68,20 +245,25 @@ export const MunicipalityListTrendControl = ({
</button>
</PopoverTrigger>
</CampaignHoverTooltip>
<PopoverContent align="start" className="w-72">
<form action={submitAction} className="flex flex-col gap-3">
<input type="hidden" name="municipalityId" value={municipalityID} />
<input type="hidden" name="municipalitySlug" value={municipalitySlug} />
<input type="hidden" name="trendNote" value={trendNote ?? ''} />
{hasNote ? (
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{trendNote}</p>
<PopoverContent
align="start"
sideOffset={8}
className="w-72 p-3"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<div className="relative flex flex-col gap-3">
{isPending ? (
<Spinner
className="absolute top-0 right-0 size-3.5 text-muted-foreground"
aria-label="Salvando tendência"
/>
) : null}
<Field>
<FieldLabel htmlFor={`municipality-list-trend-${municipalityID}`}>Tendência</FieldLabel>
<NativeSelect
id={`municipality-list-trend-${municipalityID}`}
name="trendStatus"
defaultValue={status ?? ''}
value={draft.status ?? ''}
onChange={(event) => handleStatusChange(event.target.value)}
className="min-h-11 w-full"
>
<NativeSelectOption value="">Não registrada</NativeSelectOption>
@ -94,16 +276,30 @@ export const MunicipalityListTrendControl = ({
)}
</NativeSelect>
</Field>
{state.message && state.status !== 'success' ? (
<Alert variant="destructive">
<AlertDescription>{state.message}</AlertDescription>
<Field>
<FieldLabel htmlFor={`municipality-list-trend-note-${municipalityID}`}>
Justificativa
</FieldLabel>
<Textarea
id={`municipality-list-trend-note-${municipalityID}`}
value={noteDraft}
onChange={(event) => handleNoteChange(event.target.value)}
onBlur={flushDraft}
maxLength={NOTE_MAX_LENGTH}
rows={3}
className="min-h-20 resize-y"
placeholder="Por que essa leitura?"
/>
</Field>
{errorMessage ? (
<Alert variant="destructive" className="py-2">
<AlertDescription className="text-xs">{errorMessage}</AlertDescription>
</Alert>
) : null}
<Button type="submit" disabled={isPending} className="min-h-11 w-full">
{isPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
Salvar
</Button>
</form>
<p className="sr-only" aria-live="polite">
{statusMessage}
</p>
</div>
</PopoverContent>
</Popover>
)

View file

@ -186,6 +186,60 @@ test.describe('Municípios — jornadas por papel', () => {
).toBeVisible()
})
test('coordinator sets trend status and justification with auto-save (B24)', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const password = fixtures.value('senha')
const coordinator = await campaign.payload.create({
collection: 'campaignUser',
data: {
name: fixtures.value('Coordenador Tendência'),
email: `${fixtures.value('coordinator-trend')}@example.com`,
password,
role: 'coordinator',
},
depth: 0,
})
const municipality = await fixtures.claimMunicipality()
const note = fixtures.value('Vereador confirmou apoio local')
await campaign.login(page, coordinator.email!, password)
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
await expect(page.getByRole('heading', { name: 'Municípios', exact: true })).toBeVisible()
await page.getByRole('button', { name: 'Editar tendência política' }).click()
const trendPopover = page.locator('[data-slot="popover-content"]')
await expect(trendPopover).toBeVisible()
await expect(trendPopover.getByRole('button', { name: 'Salvar' })).toHaveCount(0)
await trendPopover.getByLabel('Justificativa').fill(note)
await trendPopover.getByLabel('Tendência', { exact: true }).selectOption('favoravel')
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/campanha/municipios/political-trend') &&
response.request().method() === 'POST' &&
response.ok(),
),
trendPopover.getByLabel('Justificativa').blur(),
])
await expect(page.getByRole('button', { name: 'Editar tendência política' })).toContainText(
'Favorável',
)
await page.keyboard.press('Escape')
await page.reload()
await page.getByRole('button', { name: 'Editar tendência política' }).click()
const reopened = page.locator('[data-slot="popover-content"]')
await expect(reopened.getByLabel('Tendência', { exact: true })).toHaveValue('favoravel')
await expect(reopened.getByLabel('Justificativa')).toHaveValue(note)
})
test('coordinator registers a typed signal from the list freshness cell', async ({
campaign,
page,

View file

@ -23,7 +23,11 @@ test('prewarms shared Next route bundles sequentially', async ({ request }) => {
// for any client currently connected — which aborts an in-flight fetch mid
// test. An unauthenticated POST never succeeds, but it still forces the
// compile before any spec's client makes the real request.
for (const path of ['/campanha/municipios/advisors', '/campanha/municipios/expected-votes']) {
for (const path of [
'/campanha/municipios/advisors',
'/campanha/municipios/expected-votes',
'/campanha/municipios/political-trend',
]) {
await request.post(`${baseURL}${path}`, { data: {} }).catch(() => undefined)
}
})

View file

@ -43,8 +43,6 @@ const municipalityListDefaultProps = {
isCoordinator: false,
advisorOptions: [],
columnFilterOptions: { name: [], region: [], advisor: [] },
trendFormAction: noopListFormAction,
advisorsFormAction: noopListFormAction,
signalFormAction: noopListFormAction,
state: { page: 1 },
}
@ -598,24 +596,22 @@ describe('campaign visual foundation', () => {
const withNote = renderToStaticMarkup(
createElement(MunicipalityListTrendControl, {
municipalityID: 1,
municipalitySlug: 'seabra',
status: 'favoravel',
trendNote: 'Vereador migrou para a base',
formAction: noopListFormAction,
}),
)
expect(withNote.match(/data-slot="tooltip-trigger"/g)).toHaveLength(1)
// Radix mounts tooltip content lazily — it never reaches the server
// markup, same contract as `campaignTable.unit.spec.ts`.
expect(withNote).not.toContain('Vereador migrou para a base')
expect(withNote).toContain('aria-haspopup="dialog"')
expect(withNote).not.toContain('Salvar')
const withoutNote = renderToStaticMarkup(
createElement(MunicipalityListTrendControl, {
municipalityID: 1,
municipalitySlug: 'seabra',
status: 'favoravel',
trendNote: null,
formAction: noopListFormAction,
}),
)
expect(withoutNote).not.toContain('data-slot="tooltip-trigger"')