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:
parent
e0d0936fab
commit
b3283ee061
13 changed files with 457 additions and 104 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"')
|
||||
|
|
|
|||
Loading…
Reference in a new issue