Merge branch 'cursor/48663cd8'

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-28 09:23:37 -03:00
commit fcd258e1d7
33 changed files with 1300 additions and 217 deletions

View file

@ -27,7 +27,7 @@ Conceitos e decisões: `docs/ARCHITECTURE.md`. Operação (setup/seeds/deploy/se
## Sistema de listas (Pass 2 W1 — use, não reinvente)
- URL: `utilities/campaignListUrl.ts` (parse/canonicalize/`resolveListUrl`) + módulo por domínio (`municipalityListUrl.ts`, `supporterUi.ts`, `activityUi.ts`, parsers nos `*Data.ts`). Contrato de URL congelado (B18).
- Tabela: `shared/CampaignTable.tsx` — colunas como dado (`id`, `head`, `cell`, seams `mandatory`/`defaultVisible` p/ B17); heads ricos = ilhas (`municipality/MunicipalitySortableHead` + `MunicipalityHeaderFilter`).
- Tabela: `shared/CampaignTable.tsx` — colunas como dado (`id`, `label` **obrigatório**, `head` opcional (default = `<CampaignTableHead>{label}</CampaignTableHead>`; declare só para alinhamento, `description` do B22 ou ilha de head), `cell`, `mandatory`); heads ricos = ilhas (`municipality/MunicipalitySortableHead` + `MunicipalityHeaderFilter`). Seletor de colunas (B17): passe `columnVisibility` — **um** valor, devolvido por `readCampaignColumnVisibility(listId)` (`utilities/campaignColumnVisibilityCookie.ts`, `server-only`) em quem renderiza a tabela — e ela monta o `CampaignColumnPicker` e filtra por `resolveVisibleColumns`; regra pura em `lib/campaignColumnVisibility.ts`. Só o conjunto de **ocultas** é guardado (`defaultVisible` não existe): coluna nova nasce visível.
- Shells: `CampaignSearchForm`, `CampaignFilterChips`, `CampaignListFooter`, `CampaignListEmptyState`, `CampaignListPagination`, pending compartilhado em `CampaignListPending.tsx` (dima o resultado, não o controle).
- Relação por chips numa célula: `shared/MunicipalityPortfolioCell` (B34) — sempre montada em `pointer-fine`, Drawer em `pointer-coarse`; costuras `minItems`/`maxItems`/`addableIds`/`draft`/`commitAction`. Serve lideranças e assessores; não escrever uma terceira. Lógica pura em `lib/municipalityPortfolio.ts`, índice em `utilities/municipalityPortfolioIndex.ts`.
- Exceções documentadas: atividades = cards; `TerritoryOverviewTable` = sort no cliente; comparação de candidatos, LeaderContacts e preview de import ficam fora.

View file

@ -7,6 +7,7 @@ alwaysApply: false
## Status
- **B17 entregue em código (2026-07-28):** seletor de colunas, e **nas sete superfícies** do sistema de listas (municípios, lideranças, dobradinhas, organizações, demandas, apoiadores, territórios), não só em municípios — o picker nasceu como capacidade do `CampaignTable` (uma prop `columnVisibility`, devolvida inteira por `readCampaignColumnVisibility(listId)`, liga o controle e filtra), então o custo marginal por lista foi `label` nas colunas e uma prop. Fora: `/campanha/assessores`, cujo `AdvisorsTable` ainda é `ui/Table` cru com `table-fixed`. **Duas premissas do plano de 24/07 morreram.** (1) `localStorage` — e o motivo é de tipo, não de gosto: `CampaignTableColumn.cell` é **função**, o array de colunas não atravessa a fronteira server/client, e esconder no cliente exigiria `data-column` + `<style>` por lista. Virou o cookie `campaign_columns` (path `/campanha`, `SameSite=Lax`, 1 ano, legível pelo cliente porque é ele quem escreve) com um mapa `listId → ids ocultos` em formato compacto (`municipios:kind~trend|demandas:requester`), **sem `JSON` e sem percent-encoding** — `document.cookie` e o `cookies()` do Next discordam sobre decodificação, e todo caractere usado é legal num cookie-value pela RFC 6265; o servidor filtra, então a coluna oculta **não viaja no payload RSC**. O preço é um `router.refresh()` por sessão de edição — **fechar o menu é o commit**, com timer de 3 s como rede, e o `/simplify` separou o que essa rede faz: ela grava **só o cookie, sem refresh** — um timer que também repintasse recriaria o custo por marcação num ritmo mais lento, que é exatamente a falha pela qual os 400 ms do primeiro corte foram recusados (rajada não é ritmo de leitura, e cada perda re-paga os ~229 ms / 371 statements de `/campanha/liderancas`). Durabilidade e repintura são perguntas diferentes, então têm gatilhos diferentes; sessão que termina onde começou não dá refresh, e menu aberto na hora que a rota desmonta grava pelo cleanup (o Radix não emite fechamento numa navegação de sidebar, e a escolha sumia calada) — dentro da transição compartilhada, com o resultado dimando como em qualquer filtro dessas telas. (2) O seam `defaultVisible` **morreu**: era `true` em 10 colunas de `TerritoryListColumns` e `false` em nenhuma, e guardar só o conjunto de **ocultas** é o que faz coluna nova nascer visível — contrato que E9 ✓ e E14 ✓ já assumiam. Em troca, `label: string` virou **obrigatório** em `CampaignTableColumn` (o picker precisa de rótulo serializável), preenchido em ~55 definições — e como em 24 colunas o `head` passou a só repetir esse `label`, o `/simplify` tornou `head` **opcional**, com default `<CampaignTableHead>{label}</CampaignTableHead>`; quatro arquivos deixaram de importar `CampaignTableHead`. `mandatory` é **refiltrado no servidor** (`resolveVisibleColumns`), para um cookie velho não deixar a linha sem a única célula linkada; no menu a coluna aparece desabilitada com o motivo. **O bug real que a revisão pegou** é o de sempre nesta base em roupa nova: o payload RSC do toggle A chega dentro da janela de debounce de B, e adotar o servidor ali desfazia B **e commitava o conjunto sem ele** — a reconciliação passou a rodar só sem escrita pendente, pinada em `campaignColumnPicker.unit.spec.ts` (verificado vermelho contra o código anterior). **Lição de operação, não de código:** duas rodadas de e2e sugeriram que "`router.refresh()` não aplica o cookie"; era a máquina em **load 40** com outro worktree compilando, e o `expect` padrão de 5 s não cobre um round-trip RSC de rota pesada em dev com dois workers — as asserções de refresh ganharam orçamento próprio, e a suíte passa a 1 e a 2 workers. Sem migration, collection, action ou `Consent`. Plano as-built: [`seletor-colunas-lista-municipios.md`](../../../docs/plans/seletor-colunas-lista-municipios.md).
- **B32+ entregue em código (2026-07-28):** escala e DRY pós-B32 (+564 / −802 linhas em 15 arquivos, mais quatro novos; sem migration e sem mudança de contrato de rede). Toda rota JSON de `/campanha` agora nasce de **`campaignJsonMutationRoute(config, handler)`** — origem verificada, corpo parseado e `catch` mapeado por construção, com o `bodySchema.parse` **dentro do mesmo `try`** para não trocar a mensagem de corpo inválido; `parseCampaignJsonRequestBody`/`campaignJsonMutationErrorResponse` deixaram de ser exportados e um **guard de convenção** em `codebaseConventions.unit.spec.ts` recusa um `POST` que não passe pelo wrapper. Isso existe porque a política de mesma origem estava copiada em cinco rotas: a que esquecesse a linha aceitaria POST cross-origin em silêncio. `engagement-level` mantém o 409 dentro do handler. No cliente, `postCampaignJson` (transporte) e **`useCampaignCellAutosave`** (`shared/`) absorvem os três controles de auto-save. Três achados da auditoria que valem mais que o refactor: `display`/`draft` **nunca divergiram** (eram setados juntos em todos os caminhos — o hook tem um valor só, e o `noteDraft` do Trend fica no chamador porque texto cru e nota normalizada são coisas diferentes); `reportFailure` **não** existia nos quatro controles como o plano afirmava (o de assessores não importava `sonner`); e `flush()` idempotente elimina na raiz o `inFlightRef` que o B24 tinha como contorno de dois caminhos para o mesmo save. O hook é dono do `open` — fechar É o commit —, e `valueRef` avança sincronamente para não reler o render que agendou a mudança (bug que B33+ e B34 já pagaram). Casca: a região `aria-live` mudou para **fora** do Popover/Drawer (prop `statusMessage`), já que o fechamento que dispara a gravação desmontava justamente o nó que anunciaria "Salvando…"; e `variant="sheet"` ganhou slot `footer`, que tirou "Registrar movimento" de baixo da dobra no controle de nível (E14) e permitiu ao Sinal (B26) largar o Popover/Drawer próprios — `formId` na casca acabou desnecessário, porque `<form id>` no chamador + `<button form={id}>` no footer é HTML padrão, e `campaignCellEditTriggerClassName` virou privada por falta de quem a redigitasse. Cobertura nova: `campaignCellAutosave.unit.spec.ts` (fake timers) e `campaignJsonMutationRoute.unit.spec.ts`; essa máquina só tinha rede e2e antes. Plano: `docs/plans/escala-dry-pos-b32.md`.
- **B29 entregue em código (2026-07-28):** sort + filtro no header de `/campanha/liderancas`. Contrato próprio em `leadershipListUrl.ts` / `leadershipListFilters.ts` (`q`, `status`, `sector`, `municipality`, `access`, `sort`, `dir`, `page`; default `-updatedAt` omitido). Wrappers `LeadershipSortableHead` / `LeadershipHeaderFilter` / `LeadershipFilters` consomem o chrome do **B21 ✓** e o hook **B33+ ✓** (substitui `CampaignSearchForm`). Busca e sort por nome via join `contact.name` (int verde; teto `limit: 200` eliminado). Colunas novas Setor + Última atualização (idade relativa visível). Facet de municípios cross-filtrado; empty via `CampaignTable empty`; mobile sem barra empilhada (só header). Sem migration/Consent/action. Gate: tsc/lint/format/knip(P3)/cycles/652 unit/448 int/build; Aikido 0 findings. Plano: [`ordenacao-filtros-lista-liderancas.md`](../../../docs/plans/ordenacao-filtros-lista-liderancas.md).
- **E14 entregue em código (2026-07-27):** níveis de envolvimento N0–N4 por município, e o **primeiro escritor de produção do `allocationDecision`** (C12 ✓ existia desde 2026-07-24 com access e testes, sem nenhum caller). Migration `20260727_161752_add_municipality_engagement_level`: `engagementLevel` (select `n0…n4`, indexado, **sem default**), `levelNote` (textarea 2000), `levelChangedAt` (date indexada, `readOnly`, escrita por `canSetCampaignSystemField`) e o valor `movimento` no enum de `allocationDecision.outcome` — mover de nível não é `aceita` nem `descarta`, e fingir que era teria envenenado a triagem do E11. **Sem backfill:** o plano pedia default N2; carimbar N2 em 435 municípios afirmaria uma decisão que ninguém tomou, então nulo renderiza "Sem nível" e o filtro por ele **é** a fila de triagem inicial. Mover é decisão de realocação → `canManageMunicipalityEngagementLevel` (`isCampaignUnrestricted`: coordinator/candidate), leitura no bloco staff normal (`canReadCampaignStaffField`); o assessor lê e propõe por sinal. `setMunicipalityEngagementLevelRecord` segura `municipality-engagement-level:{id}` **antes** de ler o nível atual — sem o lock o `from` do snapshot é chute sob concorrência — e grava os dois documentos na mesma transação, com `snapshot = { from, to, reversalSignals, triangulatedShock, violations, overridden, previousLevelChangedAt }`: só os valores sobre os quais a decisão foi tomada, nunca o documento. Histerese do relatório §6.8 em `src/lib/engagementLevel.ts` (puro, client-safe): salto de 2 níveis só com choque triangulado, 3 semanas de proteção pós-promoção (só contra rebaixamento) e **um movimento por mês**, derivável só de `levelChangedAt` — nenhuma query extra. **A rota tem três estados, não dois:** `blocked` (409) devolve as violações tipadas em vez de virar "não foi possível salvar", porque um movimento barrado é uma decisão que a coordenação pode tomar mesmo assim — o Popover mantém o rascunho e oferece o override, que entra no snapshot com exatamente as regras que contrariou. **Submit explícito, não auto-save** (exceção que `campanha-edit-where-you-see` prevê para flows com nota/confirmação; efeito colateral: o controle não vira o 4º call site da máquina de debounce que o **B32+** vai auditar). Lista: 11ª coluna staff com badge outline numerado, filtro `?level=` **nativo** (ao contrário de `classe` (E10), o campo é armazenado e entra no `where`; "sem nível" não cabe num `in` e vira ramo `or` com `{ exists: false }`) e sort `nivel` **em memória**: `n0…n4` até ordena lexicograficamente, mas o Postgres põe NULL primeiro no DESC e sem backfill "N4 primeiro" abriria com uma página de "Sem nível" — `sortByNullableValue` afunda o nulo nas duas direções, como `sem_base` em `classe`, ao custo de carregar o escopo inteiro (o que o sort padrão `deficit` já faz). Verbete `nivel-de-envolvimento` no E18. **Dois achados:** com `overrideAccess: false` o Payload **descarta o campo em silêncio** quando o ator não pode escrevê-lo, em vez de rejeitar o update — o int do assessor prova que o nível **não mudou**, não que a chamada lançou; e o banco de dev local estava fora do histórico de migrations (`allocation_decision.outcome` era `varchar`, sem o tipo `enum_allocation_decision_outcome` que o `teqo_test`, construído só por migrations, tinha) — reparado à mão no `teqo`, nada a fazer em produção. **Rebase sobre o B42:** o merge encaixou o bloco "Nível" dentro da gaveta mobile que o B42 acabara de criar, então o controle largou o Popover próprio e passou a receber `variant` como os três irmãos (`CampaignCellEditOverlay`: Popover no `md+`, Drawer abaixo) — o ramo Popover do container é ponto a ponto o que o E14 tinha à mão, e no toque o trigger passa a soletrar o nível. Suítes pós-rebase: 66 unit (630) e 54 int (436); Aikido 0 findings. Plano as-built: [`niveis-de-envolvimento.md`](../../../docs/plans/niveis-de-envolvimento.md).

File diff suppressed because one or more lines are too long

View file

@ -47,7 +47,7 @@ flowchart LR
- **Leituras** passam `user` + `overrideAccess: false` (bypass admin só documentado, ex. resolução de nomes sobre linhas já checadas).
- **Escritas** saem de server actions em `src/app/(campaign)/campanha/actions/*`; multi-collection = transação Payload com `req: { transactionID }`. As `formActions.ts` por rota são cascas finas sobre os wrappers compartilhados: `runCampaignFormAction` (fica na página, estado de sucesso/erro) e `runCampaignRedirectFormAction` (cria e redireciona) em `campaignFormActionError.ts` — exceções documentadas nos próprios arquivos (planos, apoiadores/[id], convite login).
- **Listas** usam o sistema do Pass 2 W1: parse/canonicalização de URL em `campaignListUrl.ts` (+ módulo por domínio, ex. `municipalityListUrl.ts`), colunas como dado em `CampaignTable` (`shared/CampaignTable.tsx`, seams `mandatory`/`defaultVisible` para B17), shells `CampaignSearchForm`/`CampaignFilterChips`/`CampaignListFooter`/`CampaignListEmptyState`/`CampaignListPagination`, pending compartilhado via `CampaignListPendingBoundary` (dima o RESULTADO, não o controle — princípio "Feel the action"). Contrato de URL congelado (B18 depende).
- **Listas** usam o sistema do Pass 2 W1: parse/canonicalização de URL em `campaignListUrl.ts` (+ módulo por domínio, ex. `municipalityListUrl.ts`), colunas como dado em `CampaignTable` (`shared/CampaignTable.tsx`: `id`, `label` obrigatório, `head` opcional derivado do `label`, `cell`, `mandatory`), shells `CampaignSearchForm`/`CampaignFilterChips`/`CampaignListFooter`/`CampaignListEmptyState`/`CampaignListPagination`, pending compartilhado via `CampaignListPendingBoundary` (dima o RESULTADO, não o controle — princípio "Feel the action"). Contrato de URL congelado (B18 depende). **Visibilidade de coluna (B17) não é estado de URL:** é preferência de dispositivo no cookie `campaign_columns` (`lib/campaignColumnVisibility.ts` puro + `utilities/campaignColumnVisibilityCookie.ts` `server-only`), lida por quem renderiza a tabela — `readCampaignColumnVisibility(listId)` devolve id e ocultas como **um** valor, para que a chave lida e a escrita não possam divergir — e aplicada **no servidor** — a coluna oculta não chega ao payload RSC. Guarda-se só o conjunto de ocultas, então coluna nova nasce visível.
- **Páginas de detalhe** roteiam tabs por querystring (`detailTabUi.ts` → `municipalityDetailTabUi`/`activityDetailTabUi`); cada tab é uma section RSC própria colocada na rota (`MunicipalityDetailTabs.tsx`, `ActionPlanOverviewTab.tsx`), streamando atrás de `Suspense` quando a leitura é pesada.
## Escada de caching (decidir nesta ordem)

View file

@ -4,31 +4,31 @@ Ledger of known engineering debt. Items large enough to be scheduled work become
## Debt Ledger
| Item | Location | Type | Risk | Effort | Priority | Status | Ref |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
| 164 unused exports + 60 unused types reported by knip (warn-level; 158 at Pass 1) | god modules, ui kit families | dead code | low | M | P2 | closed 2026-07-25 (Pass 2 W4b): 0 findings; `exports`/`types`/`enumMembers` at **error** in CI; tests entered the knip graph; `duplicates` off (only hits were the 6 deliberate access aliases `canUpdateX = canReadX`) | Pass 2 W4b |
| knip loader noise: `Error loading src/payload.config.ts` (server-only guard) | knip run output | tooling noise | none (exit 0, static graph resolves) | S | P3 | open | |
| Aikido scan unusable: `aikido_full_scan` answers Opengrep **exit code 2** for any input, including a one-line probe file, so the per-change security scan the rules mandate cannot complete. What it reported is an artifact of the failed run, not attributable to the code under review; the Aikido feed lists nothing for this repo. The `shpjs` devDependency added by B8 F2 is therefore SCA-unverified | Aikido MCP / local scanner | tooling outage (security gate) | medium — the gate is skipped, not passed | S | P2 | recovered 2026-07-27 (B34): `aikido_full_scan` completed with 0 findings on the changed files; only taint analysis timed out. **Exit 2 did return in the same day's follow-up pass, and the useful detail is that it is not a total outage:** the wrapper reports the error while Opengrep's own summary says `Ran 316 rules on 1 file: 0 findings`, so SAST results are present and clean — the exit code tracks "some files were skipped or only partially analyzed", not a failed scan. Read the summary text before treating a red scan as a blocked gate. The SCA half (incl. `shpjs`) remains unverified | |
| Missing-consent window fixture releases its exclusive lease during the observation window — readers can interleave (rare flake). Full fix: session-level advisory lock spanning delete→op→restore + updated fault-path pins | `tests/helpers/testDatabaseLease.ts` (`withMissingInviteConsentFixture`) | test-infra race | low (ms-wide window; shared leases added 2026-07-23 narrow it) | M | P2 | open | |
| `users` collection has no roles (all admins omnipotent) | `src/collections/Users.ts` | RBAC | medium (small trusted admin set) | M (schema migration) | P1 before widening `/admin` | closed 2026-07-27 (`admin`\|`editor`; migration `20260727_032523_add_users_roles`; [roles-admin-payload.md](plans/roles-admin-payload.md)) | AGENTS Known Gaps #1 |
| Any campaign user can enumerate staff names/roles (`canReadCampaignUsers`) | `src/utilities/access/campaignUsers.ts` | info disclosure | low | S | P2 | open | |
| `removeSupporterData` deletes via `overrideAccess: true` while `canDeleteSupporter` is admin-only | `src/app/(campaign)/campanha/actions/supporter.ts` | access asymmetry (likely intentional LGPD path) | low | S | P3 | open — document or align | |
| Legacy public consent flows hardcode document IDs (`consent: 2`) | `src/app/(frontend)/actions/submitWhatsapp.ts` | correctness | medium | S | P1 | closed 2026-07-25 (Pass 2 D3): resolves `Consent.key = 'whatsapp-inscricao'` fail-closed; migration `20260725_170000` tags the live doc (id 2, key vazio) com a chave | AGENTS Known Gaps #2 |
| E2E suite thin (7 specs; most campaign CRUD int-only) | `tests/e2e/` | coverage | medium | L | P2 | open | |
| CI runs on GitHub mirror only (no Codeberg/Woodpecker pipeline) | `.github/workflows/ci.yml` | process | low | M | P3 | open | |
| E2E campaign journeys flake with 2 workers — **root cause narrowed 2026-07-26 (B13)**, and it is not only latency: (a) `campaignMunicipalities`/`campaign-pwa` click the leader consent checkbox before hydration attaches the handler, and (b) the municipality fixture picks names by regex, so "Conde" also matches "Condeúba". Reproduced on a clean `main` tree, so it is not a B13 regression; both specs pass in isolation | `tests/e2e/campaignMunicipalities.e2e.spec.ts`, `tests/e2e/campaign-pwa.e2e.spec.ts` | test-infra | medium (masks real failures in the delivery gate) | S | P2 | open — wait for an interactive-state signal instead of the element existing, and anchor fixture lookups to exact names/slugs instead of substring regex | |
| Two e2e tests fail **deterministically at one worker on a clean `main` tree** (measured 2026-07-27 by stashing the whole B34 delivery and re-running): `campaignNearestMunicipality` "coordinator in Salvador is sent to the zone list" cannot find the `Salvador — ZE 1` link on `?q=Salvador`, and `campaignMunicipalities` "advisor opens a demand and decides it" cannot find its demand heading. These are **not** the 2-worker flake in the row above — retrying and lowering concurrency do not help — so the gate currently ships with two red specs whose failure nobody has diagnosed | `tests/e2e/campaignNearestMunicipality.e2e.spec.ts:156`, `tests/e2e/campaignMunicipalities.e2e.spec.ts:394` | broken test (or real regression) | high — a permanently red gate trains everyone to ignore it, which is how a real failure ships | S | **P1** | open 2026-07-27 (B34 `/simplify`) — start from the saved `test-results/**/error-context.md`: the Salvador list renders (zones ZE 5/9/10–15 are in the snapshot) but the assertion's exact accessible name is absent, so suspect the E9 `deficit` default sort plus pagination rather than the card | |
| Post listing cache holds all published posts, filters in memory | `src/utilities/posts.ts` | scale (fine at ~tens of posts) | low | M | P3 | open — watch corpus growth | |
| Petition `generateStaticParams` uses uncached `getDocuments` without `select`/`depth: 0` | `src/utilities/documents.ts` consumers | over-fetch (build-time) | low | S | P2 | closed 2026-07-23 (Phase 2: `getPetitionIds` with `depth: 0` + empty `select`) | |
| Compare mode (`?compare=`) still queries per candidate uncached cross-request | `src/utilities/municipalityMapData.ts` | perf | low | M | P3 | open — React `cache()` dedups in-request; add `unstable_cache` if profiling warrants | |
| Map bundle serializes all years × scenarios (~100KB raw), **+63.6 KB after B13** (`competitiveRankByYear` is ~57 KB of it: three years × 417 codes × `{rank, candidates}`, paid on every dashboard load even by the coordinator who never opens the "posição no município" scale) | `municipalityMapContract.ts` consumers | payload size | low — gzip crushes repeating keys to ~10KB on the wire; instant year/scale switching is a core analysis flow worth the bytes | M | P3 | the 2026-07-23 deferral asked for profiling before acting; B13 supplied the number, so the trim is now scheduled as **B13+ F4** (roadmap fill-in) | B13+ F4 |
| Props→state `useEffect` mirrors in list controls/comboboxes | `MunicipalityListExpectedVotesControl`, `AsyncSearchCombobox`, `StrictCombobox` | pattern | none — reviewed 2026-07-23: guarded-equality sync is the correct "server echo vs local draft" reconciliation (A10 pattern), not derived state | — | — | closed (accepted as correct) | |
| User-visible "Praça" copy survived the M1 rename (map panel title "Mapa das Praças", admin field descriptions, provisional Onda 0 consent texts, `SupportStatusBadge` summary, fallback/error strings, code comments) | `rg -n 'Praça' src` (~96 hits in 53 files, ~55 user-visible — 2026-07-25 audit) | copy drift | low (cosmetic; consent texts are provisional Onda 0 copies) | S | P2 | open — scheduled Pass 2 W4f (consent texts stay for the legal batch) | Pass 2 W4f |
| Access checks re-read the actor per Local API call: `getFreshCampaignUser` caches in `req.context`, but a `payload.find({ user, overrideAccess: false })` that passes no `req` gets a fresh context, so one `/campanha/municipios` render issues ~5 identical `campaignUser` reads on top of the layout's own pair. **Re-measured 2026-07-27 (B34) on `/campanha/liderancas`, where it is an order of magnitude worse:** `payload.find` on `leadership` at `depth: 1` with an actor and `overrideAccess: false` costs **371 statements / 185 ms**, of which **367 are the identical `select … from "campaign_user"`** — the same read once per document _and_ per populated relation. The controls isolate the cause: `overrideAccess: true` is **4 SQL / 11 ms**, and `depth: 0` with the actor is 103 SQL / 47 ms, so it is the access layer on the populate path getting a `req` that does not carry the memo | `src/utilities/access/shared.ts` + every `overrideAccess: false` caller | perf (round-trip fan-out) | medium — 185 ms of a 229 ms page load, and B34 made that page one the mesa re-renders on every chip toggle (see F7); each statement is ~2 ms local but crosses `gru1`↔`sa-east-1` in production | M | **P1** | open — measured 2026-07-26, re-measured 2026-07-27 (B34). A React `cache()` layer would dedupe across requests, but it changes actor freshness inside server actions that mutate roles (`reloadUnrestrictedActor`), so it needs its own delivery with pins. Cheap adjacent win found in the same pass: `getAdvisorMunicipalityIds` (2 SQL / 10,7 ms) is a textbook `cache()` candidate — same request, same args, no dedup — and B34 added a call site in `liderancas/page.tsx` | |
| `authenticateCampaignToken` reads the actor twice: Payload's JWT strategy already fetches the user document, then `findByID(depth: 1)` re-reads it — the second read's only unique contribution is populating `avatar` for the sidebar | `src/utilities/campaignAuth.ts` | perf (a round trip in the layout, so on every page) | low | S | P2 | open — dropping it needs the avatar resolved another way, plus pins on `removePrivateAuthFields` and on role-downgrade freshness | RS+ |
| `LeadershipStateDeputyRelationCell` (B31) keeps its own Popover, its own `ResizeObserver` collapse math and a 16 px "Ver mais…", while the sibling column in the same table now uses the shared `MunicipalityPortfolioCell` (always-mounted on `pointer-fine`, Drawer on `pointer-coarse`, 24 px targets, ARIA combobox). Two chip editors, one table, different keyboard and touch contracts | `src/components/campaign/leadership/LeadershipStateDeputyRelationCell.tsx` vs `src/components/campaign/shared/MunicipalityPortfolioCell.tsx` | duplication + a11y drift | low functionally, medium for consistency — the dobradinha chips are the only ones in the table a thumb cannot edit safely | M | P2 | open 2026-07-27 (B34), **trigger fired** — the reuse review measured ~105 line-for-line duplicated lines of `ResizeObserver` collapse math between the two cells (constants, the first-callback guard, the `lastVisibleTop + 1` filter, the trailing-width loop, the `expandToggle`), including one verbatim comment. Scheduled as **B34+ F2** (`useChipRowOverflow`); the duplication predates B34 (it was born between `AdvisorMunicipalityCell` and B31), which is why it is an extraction and not a regression. B37 still does not by itself justify a generic abstraction — it plugs the shared cell into a third relation. **Re-measured post-rebase: ~165 lines, not ~105** — the twin also copies the `sameIdSet` prop adoption, the `showAll`/`measuring`/`clamping` derivation, the `expandToggle`, and the optimistic delta with per-delta rollback and toast, so what comes out is `useRelationChipCell` (measurement **plus** the optimistic reducer). Which means **B37 does not retire this**: whoever deletes `LeadershipStateDeputyRelationCell` does. The drift is now three-way in one row — that cell opens a **Popover** from a pencil on touch, while the column beside it opens a Drawer, and the Status column's own comment asserts the table is Popover-only | B34 |
| B34 shipped a **measured** First Load JS regression: `/campanha/liderancas` 246 → **304 kB** and `/campanha/assessores` 253 → **295 kB** (Next's minified figure, two full builds against a stashed clean `main`). Assessores already imported the static Bahia catalog, so its +42 kB is the **cell** (Drawer, links, combobox contract, measuring machinery) and only the remaining ~16 kB on lideranças is the catalog reaching a route that lacked it — the same cost B14 already named. The two fixes **oppose each other**: pulling the catalog out of the browser fattens the payload of every chip toggle, shrinking the payload to `{id,slug}` keeps the bundle | `src/lib/municipalityPortfolio.ts`, `src/utilities/municipalityPortfolioIndex.ts`, `src/components/campaign/shared/MunicipalityPortfolioCell.tsx` | perf (bundle + per-render payload) | medium — two of the most-used staff lists, on phones over 4G | M | P2 | open 2026-07-27 (B34) — direction recommended in the fill-in (payload-minimal, since staff edit far more than they navigate), but it trades the DB's `region` for the snapshot-tested catalog's, so it needs an explicit call. A third, orthogonal lever: load the Drawer behind `next/dynamic` on first touch — it only opens on `pointer-coarse`. **Sibling, not duplicate:** B33+ registers the same catalog in `/campanha/municipios`'s bundle, but by accident (a predicate dragging the module) rather than by need, so the two fixes differ — pay them in one pass if at all, since the measurement bench is shared | B34+ |
| `AdvisorsTable` mixes an edit _mode_ (name/e-mail/phone behind an "Editar nome e contato" toggle) with a column that is always live (municípios). The toggle survives because those are text fields with no chip readout, but the row now speaks two interaction grammars at once | `src/components/campaign/advisor/AdvisorsTable.tsx` | UX inconsistency | low | M | P3 | open 2026-07-27 (B34) — resolve by giving the text fields the same edit-in-place treatment, not by re-hiding the chips | B34 |
| Item | Location | Type | Risk | Effort | Priority | Status | Ref |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
| 164 unused exports + 60 unused types reported by knip (warn-level; 158 at Pass 1) | god modules, ui kit families | dead code | low | M | P2 | closed 2026-07-25 (Pass 2 W4b): 0 findings; `exports`/`types`/`enumMembers` at **error** in CI; tests entered the knip graph; `duplicates` off (only hits were the 6 deliberate access aliases `canUpdateX = canReadX`) | Pass 2 W4b |
| knip loader noise: `Error loading src/payload.config.ts` (server-only guard) | knip run output | tooling noise | none (exit 0, static graph resolves) | S | P3 | open | |
| Aikido scan unusable: `aikido_full_scan` answers Opengrep **exit code 2** for any input, including a one-line probe file, so the per-change security scan the rules mandate cannot complete. What it reported is an artifact of the failed run, not attributable to the code under review; the Aikido feed lists nothing for this repo. The `shpjs` devDependency added by B8 F2 is therefore SCA-unverified | Aikido MCP / local scanner | tooling outage (security gate) | medium — the gate is skipped, not passed | S | P2 | recovered 2026-07-27 (B34): `aikido_full_scan` completed with 0 findings on the changed files; only taint analysis timed out. **Exit 2 did return in the same day's follow-up pass, and the useful detail is that it is not a total outage:** the wrapper reports the error while Opengrep's own summary says `Ran 316 rules on 1 file: 0 findings`, so SAST results are present and clean — the exit code tracks "some files were skipped or only partially analyzed", not a failed scan. Read the summary text before treating a red scan as a blocked gate. The SCA half (incl. `shpjs`) remains unverified | |
| Missing-consent window fixture releases its exclusive lease during the observation window — readers can interleave (rare flake). Full fix: session-level advisory lock spanning delete→op→restore + updated fault-path pins | `tests/helpers/testDatabaseLease.ts` (`withMissingInviteConsentFixture`) | test-infra race | low (ms-wide window; shared leases added 2026-07-23 narrow it) | M | P2 | open | |
| `users` collection has no roles (all admins omnipotent) | `src/collections/Users.ts` | RBAC | medium (small trusted admin set) | M (schema migration) | P1 before widening `/admin` | closed 2026-07-27 (`admin`\|`editor`; migration `20260727_032523_add_users_roles`; [roles-admin-payload.md](plans/roles-admin-payload.md)) | AGENTS Known Gaps #1 |
| Any campaign user can enumerate staff names/roles (`canReadCampaignUsers`) | `src/utilities/access/campaignUsers.ts` | info disclosure | low | S | P2 | open | |
| `removeSupporterData` deletes via `overrideAccess: true` while `canDeleteSupporter` is admin-only | `src/app/(campaign)/campanha/actions/supporter.ts` | access asymmetry (likely intentional LGPD path) | low | S | P3 | open — document or align | |
| Legacy public consent flows hardcode document IDs (`consent: 2`) | `src/app/(frontend)/actions/submitWhatsapp.ts` | correctness | medium | S | P1 | closed 2026-07-25 (Pass 2 D3): resolves `Consent.key = 'whatsapp-inscricao'` fail-closed; migration `20260725_170000` tags the live doc (id 2, key vazio) com a chave | AGENTS Known Gaps #2 |
| E2E suite thin (7 specs; most campaign CRUD int-only) | `tests/e2e/` | coverage | medium | L | P2 | open | |
| CI runs on GitHub mirror only (no Codeberg/Woodpecker pipeline) | `.github/workflows/ci.yml` | process | low | M | P3 | open | |
| E2E campaign journeys flake with 2 workers — **root cause narrowed 2026-07-26 (B13)**, and it is not only latency: (a) `campaignMunicipalities`/`campaign-pwa` click the leader consent checkbox before hydration attaches the handler, and (b) the municipality fixture picks names by regex, so "Conde" also matches "Condeúba". Reproduced on a clean `main` tree, so it is not a B13 regression; both specs pass in isolation | `tests/e2e/campaignMunicipalities.e2e.spec.ts`, `tests/e2e/campaign-pwa.e2e.spec.ts` | test-infra | medium (masks real failures in the delivery gate) | S | P2 | open — wait for an interactive-state signal instead of the element existing, and anchor fixture lookups to exact names/slugs instead of substring regex | |
| Two e2e tests fail **deterministically at one worker on a clean `main` tree** (measured 2026-07-27 by stashing the whole B34 delivery and re-running): `campaignNearestMunicipality` "coordinator in Salvador is sent to the zone list" cannot find the `Salvador — ZE 1` link on `?q=Salvador`, and `campaignMunicipalities` "advisor opens a demand and decides it" cannot find its demand heading. These are **not** the 2-worker flake in the row above — retrying and lowering concurrency do not help — so the gate currently ships with two red specs whose failure nobody has diagnosed. **Widened 2026-07-28 (B17), same stash-and-compare method: it is not two tests but most of `campaignMunicipalities`** — five failed at one worker on the clean tree (`:56`, `:125`, `:292`, `:394`, `:446`) against four with the delivery applied, and the failing set is not stable between runs (`page.waitForURL` timeouts, a `getByLabel('Média')` strict-mode violation matching both the popover trigger and its input, a click whose target the bottom nav intercepts). Practical consequence for the next delivery: comparing the count of red tests against a stashed tree is the only way to read this spec — naming the two tests above is no longer enough | `tests/e2e/campaignNearestMunicipality.e2e.spec.ts:156`, `tests/e2e/campaignMunicipalities.e2e.spec.ts:56,125,292,394,446` | broken test (or real regression) | high — a permanently red gate trains everyone to ignore it, which is how a real failure ships | S | **P1** | open 2026-07-27 (B34 `/simplify`), widened 2026-07-28 (B17) — start from the saved `test-results/**/error-context.md`: the Salvador list renders (zones ZE 5/9/10–15 are in the snapshot) but the assertion's exact accessible name is absent, so suspect the E9 `deficit` default sort plus pagination rather than the card. The strict-mode violation is a real ambiguity in the spec's own locator, fixable on its own | |
| Post listing cache holds all published posts, filters in memory | `src/utilities/posts.ts` | scale (fine at ~tens of posts) | low | M | P3 | open — watch corpus growth | |
| Petition `generateStaticParams` uses uncached `getDocuments` without `select`/`depth: 0` | `src/utilities/documents.ts` consumers | over-fetch (build-time) | low | S | P2 | closed 2026-07-23 (Phase 2: `getPetitionIds` with `depth: 0` + empty `select`) | |
| Compare mode (`?compare=`) still queries per candidate uncached cross-request | `src/utilities/municipalityMapData.ts` | perf | low | M | P3 | open — React `cache()` dedups in-request; add `unstable_cache` if profiling warrants | |
| Map bundle serializes all years × scenarios (~100KB raw), **+63.6 KB after B13** (`competitiveRankByYear` is ~57 KB of it: three years × 417 codes × `{rank, candidates}`, paid on every dashboard load even by the coordinator who never opens the "posição no município" scale) | `municipalityMapContract.ts` consumers | payload size | low — gzip crushes repeating keys to ~10KB on the wire; instant year/scale switching is a core analysis flow worth the bytes | M | P3 | the 2026-07-23 deferral asked for profiling before acting; B13 supplied the number, so the trim is now scheduled as **B13+ F4** (roadmap fill-in) | B13+ F4 |
| Props→state `useEffect` mirrors in list controls/comboboxes | `MunicipalityListExpectedVotesControl`, `AsyncSearchCombobox`, `StrictCombobox` | pattern | none — reviewed 2026-07-23: guarded-equality sync is the correct "server echo vs local draft" reconciliation (A10 pattern), not derived state | — | — | closed (accepted as correct) | |
| User-visible "Praça" copy survived the M1 rename (map panel title "Mapa das Praças", admin field descriptions, provisional Onda 0 consent texts, `SupportStatusBadge` summary, fallback/error strings, code comments) | `rg -n 'Praça' src` (~96 hits in 53 files, ~55 user-visible — 2026-07-25 audit) | copy drift | low (cosmetic; consent texts are provisional Onda 0 copies) | S | P2 | open — scheduled Pass 2 W4f (consent texts stay for the legal batch) | Pass 2 W4f |
| Access checks re-read the actor per Local API call: `getFreshCampaignUser` caches in `req.context`, but a `payload.find({ user, overrideAccess: false })` that passes no `req` gets a fresh context, so one `/campanha/municipios` render issues ~5 identical `campaignUser` reads on top of the layout's own pair. **Re-measured 2026-07-27 (B34) on `/campanha/liderancas`, where it is an order of magnitude worse:** `payload.find` on `leadership` at `depth: 1` with an actor and `overrideAccess: false` costs **371 statements / 185 ms**, of which **367 are the identical `select … from "campaign_user"`** — the same read once per document _and_ per populated relation. The controls isolate the cause: `overrideAccess: true` is **4 SQL / 11 ms**, and `depth: 0` with the actor is 103 SQL / 47 ms, so it is the access layer on the populate path getting a `req` that does not carry the memo | `src/utilities/access/shared.ts` + every `overrideAccess: false` caller | perf (round-trip fan-out) | medium — 185 ms of a 229 ms page load, and B34 made that page one the mesa re-renders on every chip toggle (see F7); each statement is ~2 ms local but crosses `gru1`↔`sa-east-1` in production | M | **P1** | open — measured 2026-07-26, re-measured 2026-07-27 (B34). A React `cache()` layer would dedupe across requests, but it changes actor freshness inside server actions that mutate roles (`reloadUnrestrictedActor`), so it needs its own delivery with pins. Cheap adjacent win found in the same pass: `getAdvisorMunicipalityIds` (2 SQL / 10,7 ms) is a textbook `cache()` candidate — same request, same args, no dedup — and B34 added a call site in `liderancas/page.tsx` | |
| `authenticateCampaignToken` reads the actor twice: Payload's JWT strategy already fetches the user document, then `findByID(depth: 1)` re-reads it — the second read's only unique contribution is populating `avatar` for the sidebar | `src/utilities/campaignAuth.ts` | perf (a round trip in the layout, so on every page) | low | S | P2 | open — dropping it needs the avatar resolved another way, plus pins on `removePrivateAuthFields` and on role-downgrade freshness | RS+ |
| `LeadershipStateDeputyRelationCell` (B31) keeps its own Popover, its own `ResizeObserver` collapse math and a 16 px "Ver mais…", while the sibling column in the same table now uses the shared `MunicipalityPortfolioCell` (always-mounted on `pointer-fine`, Drawer on `pointer-coarse`, 24 px targets, ARIA combobox). Two chip editors, one table, different keyboard and touch contracts | `src/components/campaign/leadership/LeadershipStateDeputyRelationCell.tsx` vs `src/components/campaign/shared/MunicipalityPortfolioCell.tsx` | duplication + a11y drift | low functionally, medium for consistency — the dobradinha chips are the only ones in the table a thumb cannot edit safely | M | P2 | open 2026-07-27 (B34), **trigger fired** — the reuse review measured ~105 line-for-line duplicated lines of `ResizeObserver` collapse math between the two cells (constants, the first-callback guard, the `lastVisibleTop + 1` filter, the trailing-width loop, the `expandToggle`), including one verbatim comment. Scheduled as **B34+ F2** (`useChipRowOverflow`); the duplication predates B34 (it was born between `AdvisorMunicipalityCell` and B31), which is why it is an extraction and not a regression. B37 still does not by itself justify a generic abstraction — it plugs the shared cell into a third relation. **Re-measured post-rebase: ~165 lines, not ~105** — the twin also copies the `sameIdSet` prop adoption, the `showAll`/`measuring`/`clamping` derivation, the `expandToggle`, and the optimistic delta with per-delta rollback and toast, so what comes out is `useRelationChipCell` (measurement **plus** the optimistic reducer). Which means **B37 does not retire this**: whoever deletes `LeadershipStateDeputyRelationCell` does. The drift is now three-way in one row — that cell opens a **Popover** from a pencil on touch, while the column beside it opens a Drawer, and the Status column's own comment asserts the table is Popover-only | B34 |
| B34 shipped a **measured** First Load JS regression: `/campanha/liderancas` 246 → **304 kB** and `/campanha/assessores` 253 → **295 kB** (Next's minified figure, two full builds against a stashed clean `main`). Assessores already imported the static Bahia catalog, so its +42 kB is the **cell** (Drawer, links, combobox contract, measuring machinery) and only the remaining ~16 kB on lideranças is the catalog reaching a route that lacked it — the same cost B14 already named. The two fixes **oppose each other**: pulling the catalog out of the browser fattens the payload of every chip toggle, shrinking the payload to `{id,slug}` keeps the bundle | `src/lib/municipalityPortfolio.ts`, `src/utilities/municipalityPortfolioIndex.ts`, `src/components/campaign/shared/MunicipalityPortfolioCell.tsx` | perf (bundle + per-render payload) | medium — two of the most-used staff lists, on phones over 4G | M | P2 | open 2026-07-27 (B34) — direction recommended in the fill-in (payload-minimal, since staff edit far more than they navigate), but it trades the DB's `region` for the snapshot-tested catalog's, so it needs an explicit call. A third, orthogonal lever: load the Drawer behind `next/dynamic` on first touch — it only opens on `pointer-coarse`. **Sibling, not duplicate:** B33+ registers the same catalog in `/campanha/municipios`'s bundle, but by accident (a predicate dragging the module) rather than by need, so the two fixes differ — pay them in one pass if at all, since the measurement bench is shared | B34+ |
| `AdvisorsTable` mixes an edit _mode_ (name/e-mail/phone behind an "Editar nome e contato" toggle) with a column that is always live (municípios). The toggle survives because those are text fields with no chip readout, but the row now speaks two interaction grammars at once | `src/components/campaign/advisor/AdvisorsTable.tsx` | UX inconsistency | low | M | P3 | open 2026-07-27 (B34) — resolve by giving the text fields the same edit-in-place treatment, not by re-hiding the chips | B34 |
## Smell Inventory

View file

@ -0,0 +1,123 @@
# Escala e DRY pós-B17 (seletor de colunas)
Status: rascunho
Atualizado em: 2026-07-28
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins abertos, **B17+**)
Impeccable: A — N/A na F1 (só loader); a F2 move um bloco já desenhado (`CampaignListEmptyState`) para dentro de um shell existente, sem pixel novo
Appetite: ~0,5 dia eng, duas fases independentes. Se a F1 sozinha estourar meio dia, a F2 é cortável.
Responsável: —
## Dados → decisão → apresentação
- **Vou apresentar dados?** Não (N/A) — nenhuma métrica, série ou mapa nasce aqui. A F1 **remove** uma query, a F2 mantém montado um controle que já existe.
- **Anti-goals de dado:** N/A.
## Contexto
O B17 (entregue 2026-07-28) fez o seletor de colunas filtrar **no servidor**: `resolveVisibleColumns` corta a coluna antes do loop de linhas, então uma coluna oculta não gasta 25 chamadas de `cell()` e não entra no payload RSC. Em `/campanha/liderancas` isso significa 25 ilhas `MunicipalityPortfolioCell` ou `LeadershipStateDeputyRelationCell` a menos — um ganho bem maior que qualquer custo do picker.
O `/simplify` do fechamento (2026-07-28) mediu o que **não** foi economizado e achou duas lacunas, ambas de estrutura, nenhuma de cálculo:
**F1 — a coluna oculta ainda paga a query da relação.** As páginas carregam o catálogo da relação dentro do `Promise.all` e leem o cookie **depois**:
```ts
// src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx:153-161
const [{ rows, totalDocs, totalPages, filterFacets }, leadershipOptions] = await Promise.all([
loadStateDeputyListPageData(payload, user, state),
loadLeadershipOptions(payload, user),
])
const columnVisibility = await readCampaignColumnVisibility('dobradinhas')
```
`loadLeadershipOptions` (`src/utilities/campaignRelationOptions.ts:114`) é `limit: 0, pagination: false, depth: 1` — toda liderança que o ator enxerga, com o `contact` populado — e existe só para alimentar a célula de relação da coluna "Lideranças". Esconder a coluna não desliga a query. `loadStateDeputyOptions` (`:85`) é o gêmeo em `/campanha/liderancas`. `cookies()` não faz I/O (lê o request store já parseado), então antecipar a leitura é de graça: é ordem de statements, não uma query nova.
**F2 — o picker some junto com a tabela no estado vazio.** Três superfícies trocam a tabela inteira por um bloco vazio (`{rows.length ? <tabela/> : <CampaignListEmptyState/>}` em `demandas/page.tsx:149`, `organizacoes/page.tsx:103` e no ternário de `apoiadores/page.tsx:65`), enquanto municípios, lideranças e territórios mantêm a tabela montada e passam o vazio pela prop `empty` que a `CampaignTable` já expõe (`CampaignTable.tsx:153`, renderizada em `:210`). Antes do B17 a diferença era invisível; agora, quem filtra até zero linhas nessas três rotas perde junto o botão "Colunas" — inclusive quando a coluna oculta não tem nada a ver com o filtro. A saída existe (limpar o filtro), mas o controle desaparecer por causa de um estado não relacionado é inconsistência do sistema de listas, não escolha de tela.
## Objetivos
- `/campanha/dobradinhas` e `/campanha/liderancas` **não** executam o loader de opções da relação quando a coluna correspondente está oculta no cookie do ator.
- Sem mudança de contrato de URL, sem migration, sem collection, sem `Consent`, sem server action nova.
- Acesso inalterado: os loaders continuam com `user` + `overrideAccess: false`; pular a query nunca amplia escopo, só evita trabalho.
- As sete superfícies expõem o botão "Colunas" no mesmo lugar independentemente de haver linhas.
- Cobertura: a F1 ganha um pin de que a coluna oculta não dispara o loader; a F2 encosta no e2e existente do picker.
## Decisões travadas
- **A leitura do cookie sobe para antes do `Promise.all`, e não vira um segundo `await` em série.** `cookies()` é síncrono sobre o request store; colocá-lo antes custa zero round-trip e é o que permite montar o array do `Promise.all` condicionalmente. **Rejeitado:** manter a leitura onde está e passar `leadershipOptions: []` quando oculta — economiza o payload mas não a query, que é o custo real; **rejeitado também** um terceiro `await` em série depois do `Promise.all`, que trocaria uma query por uma ida a mais no caminho crítico.
- **O fallback de uma coluna oculta é `[]`, não `undefined`.** A coluna não é construída, então ninguém lê o valor; um array vazio mantém a assinatura do factory de colunas sem espalhar opcionalidade por tipos que hoje são concretos. **Rejeitado:** tornar `options` opcional na célula de relação — empurra `?.` para dentro de um componente que sempre teve o catálogo.
- **A F2 usa a prop `empty` da `CampaignTable`, não um segundo shell.** É o padrão que três superfícies já seguem; o bloco `CampaignListEmptyState` continua o mesmo, só muda de lugar. **Rejeitado:** renderizar só a barra de ações acima do bloco vazio — duplicaria a montagem do picker fora da tabela, que é justamente o acoplamento que o B17 evitou ao fazer dele uma capacidade da tabela.
- **i18n e naming** seguem o AGENTS.md: identificadores em inglês, strings visíveis em pt-BR (o bloco vazio não muda de texto).
## Questões em aberto
- **A F1 deve virar um helper compartilhado pelas duas rotas?** **Opções:** (A) inline nas duas páginas; (B) um `loadRelationOptionsUnlessHidden(...)` em `campaignRelationOptions.ts`. **Recomendação:** **A**. São dois call sites com loaders de assinatura diferente, e a casa exige 3+ para abstrair; o gatilho natural é uma terceira lista ganhar coluna de relação com catálogo.
- **Vale estender a F1 às outras cinco superfícies?** **Opções:** (A) só as duas medidas; (B) varrer as sete. **Recomendação:** **A** — nas outras cinco não há loader que exista **só** para uma coluna; varrer produziria condicional sem economia.
## Abordagem proposta
```mermaid
flowchart LR
cookie["readCampaignColumnVisibility(listId)"] --> gate{"coluna de relação visível?"}
gate -->|sim| load["loadLeadershipOptions / loadStateDeputyOptions"]
gate -->|não| skip["[] — query não roda"]
load --> cols["factory de colunas"]
skip --> cols
cols --> table["CampaignTable (filtra + monta o picker)"]
table --> empty["prop empty: bloco vazio DENTRO da tabela"]
```
Componentes:
- **`src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx`** (F1): mover o `readCampaignColumnVisibility('dobradinhas')` para antes do `Promise.all` e condicionar `loadLeadershipOptions(payload, user)` a `resolveVisibleColumns` conter a coluna `leaderships` — o `Promise.all` passa a receber `columnVisible ? loadLeadershipOptions(...) : Promise.resolve([])`. Mesma leitura com `user` + `overrideAccess: false` de hoje.
- **`src/app/(campaign)/campanha/(app)/liderancas/page.tsx`** (F1): idem para `loadStateDeputyOptions` e a coluna `stateDeputies`. Esta rota é a ledgerada P1 (~229 ms / 371 statements), então é a que mais paga.
- **`src/lib/campaignColumnVisibility.ts`** (F1): nenhuma mudança esperada — a pergunta "esta coluna está visível?" já é `resolveVisibleColumns` ou um `includes` no `hiddenColumnIds`; usar o mesmo helper que a tabela usa, para que o gate e o corte não possam divergir (mesmo racional do `showsVoteColumn` em `MunicipalityList`).
- **`demandas/page.tsx`, `organizacoes/page.tsx`, `apoiadores/page.tsx`** (F2): trocar o ternário que substitui a tabela por `empty={<CampaignListEmptyState …/>}` na `CampaignTable`, como municípios/lideranças/territórios já fazem.
- **Testes**: um int/unit pin de que a coluna oculta não chama o loader (espiar o loader ou contar queries), e a asserção e2e de que o botão "Colunas" continua presente com zero linhas.
- **Sem migration, sem collection, sem server action, sem `Consent`.**
## Dependências
- **B17 ✓** (entregue 2026-07-28) — `readCampaignColumnVisibility`, `resolveVisibleColumns` e a prop `columnVisibility` são dele.
- **B29 ✓** / **B34 ✓** — donos das duas colunas de relação que a F1 desliga (`stateDeputies` em lideranças, o portfólio de municípios); a F1 não muda o comportamento delas quando visíveis.
- Nenhuma dependência dura de item aberto. Paralelizável.
## Não escopo
- Picker em `/campanha/assessores` — `AdvisorsTable` ainda é `ui/Table` cru com `table-fixed`; migrar para o sistema de listas é trabalho próprio (fill-in do B17, não deste plano).
- Picker abaixo de `md` — lacuna conhecida registrada na as-built de [seletor-colunas-lista-municipios.md](seletor-colunas-lista-municipios.md); o seam por chamador é lá.
- Preferência de colunas por `campaignUser` (server) em vez de cookie por dispositivo — adiado com gatilho no plano do B17.
## Rabbit holes
- **"Já que estou no loader, deixo todas as sete condicionais."** Nas outras cinco rotas o dado carregado serve a mais de uma coluna ou à própria linha; condicionar tudo produz ramos que nunca economizam e que precisam ser lidos para sempre. **Mitigação:** só as duas medidas, e a Questão em aberto acima já registra o gatilho.
- **"O `empty` da F2 pede um shell de vazio unificado."** Os três blocos têm ícone, título, descrição e ação próprios; unificá-los vira um componente de configuração. **Mitigação:** só mudar o **lugar** onde o bloco é renderizado, não o bloco.
- **"Pular a query pede um cache."** Nada aqui justifica `unstable_cache`: os catálogos são dados vivos de campanha, e a escada de cache do `engineering-standards` proíbe cachear isso sem invalidação na escrita. **Mitigação:** a economia é não perguntar, não guardar a resposta.
## Adiado com gatilho
- **`loadRelationOptionsUnlessHidden` compartilhado.** Revisitar quando: 3ª lista ganhar coluna de relação com catálogo próprio.
- **`createStaff(role)` no fixture e2e** (`tests/helpers/campaignE2EFixtures.ts`) — o bloco de criar coordenador + login está repetido em 8+ specs (`campaignLeaderships` ×2, `campaignActivity`, `campaignConcepts`, `campaignNearestMunicipality` ×2, `campaignColumnPicker` ×2). Pré-existente ao B17, que só seguiu a convenção. Revisitar quando: 9º call site ou quando o tempo de e2e virar problema medido.
- **`CampaignPopoverMenu` (shell dividido entre `CampaignColumnPicker` e `CampaignHeaderFilterPopover`).** Hoje 2 call sites, e as linhas são coisas diferentes (âncoras de navegação × checkboxes), então o componente não deve ser reusado como está. Revisitar quando: o seam do picker mobile criar o 3º uso do mesmo chrome.
## Explicitamente fora (descartes do `/simplify` de 2026-07-28)
- **Picker hidratando onde é `hidden md:flex`** (+~8 kB gz em demandas/organizações/apoiadores, que ganharam Popover+Checkbox novos). Medido e abaixo do precedente de 21 kB do B14; mover o corpo do Popover para `next/dynamic` custa mais complexidade do que o número justifica.
- **Mover `readCookie`/`writeCookie` para um módulo irmão de `recentVisits.ts`.** Placement melhor pela convenção de plumbing de browser, ganho cosmético — as duas funções têm um consumidor só.
- **`children` opcional em `LeadershipFilterHead` (B29)** para eliminar os dois últimos literais de lideranças (`Municípios`, `Acesso ao app`, hoje também em `FILTER_LABELS` privado de `LeadershipHeaderFilter.tsx`). Mexe na API de uma entrega recém-chegada por duas strings.
- **Gêmeos `nextAdvisorIdsAfterMembership` / `leadershipStateDeputyMembership`.** Duplicação real e reconhecida em comentário, mas pré-existente e semanticamente distinta do `toggleHiddenColumn` do B17 (aqueles retornam `null` para "já está assim" e lançam no teto — semântica de write path, inútil para um checkbox).
- **Cookie acima de 2048 zerando as preferências das outras listas na escrita seguinte.** Real no código (`parse` devolve `{}` e o `writeCookie` mescla sobre esse parse), inalcançável na prática: as sete listas somadas ficam em ~300 caracteres.
## Já resolvido no `/simplify` (não reabrir)
Rótulos de header herdados do registro de sort (16 `children` redundantes removidos; só `frescor` diverge, e lê o registro); cadência dividida em `persist` (cookie, sem refresh) × `commit` (fechar, com refresh); guarda de sessão-sem-mudança; flush no unmount; `columnVisibility` obrigatório em `MunicipalityList`; `MAX_HIDDEN_PER_LIST` removido; `resolveVisibleColumns` sem cópia; alias local do tipo de coluna; `open` controlado removido; mock de `next/navigation` espalhando o módulo real; export morto `LeadershipListAccessFilter` (B29) — o knip falhava em `main` também.
## Referências
- `docs/roadmap.md` (Fill-ins abertos, **B17+**)
- [`docs/plans/seletor-colunas-lista-municipios.md`](seletor-colunas-lista-municipios.md) — as-built do B17, incl. os dois `/simplify` e a lacuna mobile
- `src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx:149-161` e `.../liderancas/page.tsx:315-355` — os dois `Promise.all` da F1
- `src/utilities/campaignRelationOptions.ts:85,114` — os loaders `limit: 0, pagination: false` que a F1 condiciona
- `src/components/campaign/shared/CampaignTable.tsx:153,210` — a prop `empty` que a F2 adota
- `src/app/(campaign)/campanha/(app)/demandas/page.tsx:149`, `.../organizacoes/page.tsx:103`, `.../apoiadores/page.tsx:65` — os três ternários da F2
- `src/lib/campaignColumnVisibility.ts` — `resolveVisibleColumns`, o mesmo helper que o gate da F1 deve usar
- AGENTS.md — Local API com `user` sempre com `overrideAccess: false`; escada de cache; gate por entrega

View file

@ -146,6 +146,9 @@ Saldo do lote: **+564 / −802 linhas** em 15 arquivos, mais quatro novos (`useC
- **Miudezas do mesmo passe:** `CampaignJsonErrorBody` era exportado sem consumidor externo (knip trata export morto como ERROR); o docblock de `sameOriginRequest` ainda descrevia a topologia antiga ("compartilhado por todo endpoint"), quando o compartilhamento agora é o wrapper; o pré-filtro do guard casava a palavra `POST` em qualquer lugar, o que faria de uma rota GET com "POST" num comentário um falso ofensor; e o `statusMessage` deixou de ser lido pelo `optionsRef` durante o render (o ref existe para o trabalho assíncrono).
- **Recusado com motivo:** reusar `useCampaignCellAutosave` nos três editores de conjunto (assessores, portfolio, dobradinhas) seria errado. Eles disparam uma requisição **por delta, sem debounce**, com várias em voo, e revertem só o delta que falhou — reconciliando por número de sequência, que o abort de geração única do hook não sabe expressar (ele **aborta** a requisição superada, o que para deltas independentes descarta um save). Generalizar exigiria parametrizar baseline, unidade de reversão, política de abort e transporte, e o hook deixaria de ser a máquina que ele documenta. `AdvisorDebouncedTextCell` é o quarto auto-save com debounce e o alvo mais forte que sobrou, mas ele salva por server action e não tem overlay: adotá-lo pediria transporte injetável e `open`/`onOpenChange` opcionais — fica recusado explicitamente aqui em vez de virar dívida silenciosa. **Gatilho:** quando aparecer um 2º auto-save com debounce que grave por server action, o transporte injetável passa a ter dois clientes e a recusa cai.
- **Onde o hook para, medido pelo B17 ✓ no rebase (2026-07-28).** `CampaignColumnPicker` é o 6º lugar com o esqueleto de temporizador (`schedule`/`cancel`/flush-ao-fechar + limpeza no unmount), e **não** adota `useCampaignCellAutosave`: ele não tem endpoint — escreve `document.cookie` e chama `router.refresh()` —, então `endpoint`/`buildBody`/`readSaved`/abort/revert não teriam o que fazer, e herdar rede que não se usa é o serviço genérico que `campanha-edit-where-you-see` proíbe. Ou seja, o corte deste item ficou no lugar certo: quem fala com rota ganhou a máquina inteira; quem não fala mantém ~15 linhas de temporizador. Um `useDebouncedCommit` só de mecânica ainda seria defensável (6º call site), mas é 3ª camada num item já entregue — **gatilho:** um 7º chamador sem rota. As duas entregas convergiram sozinhas no mesmo invariante, o que é o argumento mais forte a favor dele: **fechar é o commit**.
- **A política de adoção de props ficou decidida — nos dois sentidos, e a diferença tem causa.** O hook adota num efeito guardado por `equals` contra `lastPropsRef`, **sem** gate de pendência, porque `committedRef`/`inFlightRef` já sabem "com o que o servidor vai terminar". O picker do B17 ✓ mantém o gate no render (`commitTimer.current === null`), porque não existe requisição em voo para comparar: entre a marcação e o commit não há nada escrito, e um payload RSC vindo de outro controle da mesma página desfaria a marcação e commitaria o conjunto sem ela. Regra derivável: **gate de pendência quando não há request em voo para servir de referência; `committedRef` quando há.**
## Referências
- `docs/roadmap.md` (Fill-ins abertos — **B32+**)

View file

@ -1,11 +1,48 @@
# B17 — Seletor de colunas na lista de Municípios
# B17 — Seletor de colunas (todas as listas do sistema `CampaignTable`)
Status: rascunho — **costuras prontas desde o Pass 2 W1 (2026-07-25):** as colunas da lista são dado (`CampaignTable`, `CampaignTableColumn.id`/`mandatory`/`defaultVisible`); o seletor vira um toggle de visibilidade sobre esses ids + `localStorage`
Atualizado em: 2026-07-24
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Demais itens abertos, B17; superfície de coordenação)
Impeccable: B — encaixe em `MunicipalityList` / barra slim de `/campanha/municipios`; sem rota nova
Appetite: ~0,5–1 dia eng; Popover de colunas + persistência local + render condicional desktop; sem migration, sem collection, sem Consent
Responsável: —
Status: **entregue 2026-07-28** — e com escopo maior e mecanismo diferente do que este plano previa; ver "Como ficou" abaixo antes de ler o resto, que é o desenho de 2026-07-24 preservado como registro
Atualizado em: 2026-07-28
Item do roadmap: [docs/roadmap.md](../roadmap.md) (B17; superfície de coordenação)
Impeccable: B — encaixe no `CampaignTable`; sem rota nova
Appetite: revisado para ~1,3 dia eng (o plano previa 0,5–1 dia para **uma** lista; sete superfícies + a peça compartilhada + `label` em ~55 definições de coluna explicam a diferença); sem migration, sem collection, sem Consent
## Como ficou (as-built, 2026-07-28)
**Escopo: sete superfícies, não uma.** O picker nasceu como capacidade do `CampaignTable` — uma prop `columnVisibility` liga o controle e filtra as colunas — então municípios, lideranças, dobradinhas, organizações, demandas, apoiadores e territórios ganharam a mesma barra "Colunas" no mesmo lugar (`hidden md:flex`, alinhada à direita, acima da tabela). Fora: `/campanha/assessores`, cujo `AdvisorsTable` ainda é um `ui/Table` cru com `table-fixed` — migrá-lo ao sistema de listas é outro trabalho.
**Persistência: cookie, não `localStorage`.** A decisão travada abaixo ("`localStorage`, sem RSC round-trip") **morreu na implementação, por um fato de tipo, não de gosto**: `CampaignTableColumn.cell` é uma **função**, então o array de colunas não atravessa a fronteira server/client e o cliente não tem como reconstruir a tabela sem ela; esconder no cliente exigiria marcar cada célula com `data-column` e injetar `<style>` por lista. Persistência virou o cookie `campaign_columns` (path `/campanha`, `SameSite=Lax`, 1 ano, legível pelo cliente porque é ele quem escreve), com um mapa `listId → ids ocultos` num formato compacto (`municipios:kind~trend|demandas:requester`) — **sem `JSON` e sem percent-encoding**, porque `document.cookie` e o `cookies()` do Next discordam sobre decodificação e todo caractere usado é legal num cookie-value pela RFC 6265. O servidor filtra: a coluna oculta **não é renderizada nem viaja no payload RSC**. O custo é um `router.refresh()` por sessão de edição — **fechar o menu é o commit**; o timer de 3 s é rede de segurança para um Popover deixado aberto, e grava só o cookie, sem refresh —, dentro da transição compartilhada — o resultado dima como em qualquer filtro dessas telas.
**O seam `defaultVisible` morreu.** Era declarado `true` em 10 colunas de `TerritoryListColumns` e `false` em nenhuma. Guardar apenas o **conjunto de ocultas** é o que garante que uma coluna nova nasça visível para quem nunca tocou no picker — o contrato que E9 ✓ e E14 ✓ já assumiam. Em troca, `label: string` passou a ser **obrigatório** em `CampaignTableColumn`: o picker precisa de um rótulo serializável, e `head` continua `ReactNode` opaco.
**`mandatory` é refiltrado no servidor.** Cada superfície declara a sua coluna de identidade como `mandatory`; ela aparece no menu, desabilitada e com o motivo ("sempre visível"), e `resolveVisibleColumns` a mantém **mesmo que o cookie diga o contrário** — um cookie velho não pode deixar ninguém sem a única célula linkada da linha.
**O bug que a revisão pegou** é o de sempre nesta base, em roupa nova: o payload RSC do toggle A chega enquanto B ainda está na janela de debounce; adotar o servidor ali desfazia B **e depois commitava o conjunto sem ele**, perdendo-o em silêncio. A reconciliação passou a rodar só quando não há escrita pendente, pinada em `tests/unit/campaignColumnPicker.unit.spec.ts` (verificado vermelho contra o código anterior).
**Nota de operação, não de código:** durante a sessão, duas rodadas de e2e sugeriram que "`router.refresh()` não aplica o cookie". Era a máquina em load 40 com outro worktree compilando — a suíte passa a 1 e a 2 workers, e a lição registrada é que o `expect` padrão de 5 s não cobre um round-trip RSC de rota pesada em dev com dois workers (as asserções de refresh têm orçamento próprio).
**O `/simplify` mudou três coisas do primeiro corte, e uma delas era uma premissa escrita em comentário.** (1) `listId` + `hiddenColumnIds` viraram **um** valor, `columnVisibility: CampaignColumnVisibility`, devolvido inteiro por `readCampaignColumnVisibility(listId)`: cada superfície nomeia a sua lista **uma vez**. Como duas props, o cookie podia ser lido numa chave e escrito noutra — ambas válidas, nada quebrando, e o sintoma seria "o picker não faz nada". (2) `head` passou a ser **opcional**, com default `<CampaignTableHead>{label}</CampaignTableHead>`: em 24 colunas o `head` só repetia o `label` recém-adicionado, e essa duplicação nascia com este item. Quatro arquivos deixaram de importar `CampaignTableHead` — medida de que a duplicação era real. O `label` continua obrigatório, e a única divergência legítima entre os dois está documentada no tipo: um header pode ser telegráfico porque está em cima dos próprios dados (municípios: "2022"), enquanto a mesma palavra sozinha num menu de nomes de coluna não diz nada ("Votação 2022"). (3) A caption de municípios decidia se cita a coluna "2022" com `hiddenColumnIds.includes('votos')` — uma segunda implementação de "esta coluna está visível?", cega a `mandatory`; agora pergunta a `resolveVisibleColumns`, a mesma função que a tabela usa.
**A segunda rodada do `/simplify` (reuso e performance) achou duplicação que já tinha dono.** (1) O `contentKey` (sort + join) do picker era `sameIdSet` reescrito — o helper cuja docstring descreve **exatamente** esta política; ele foi generalizado para `string | number`, o picker virou o 4º chamador e um `useState` que guardava string derivada morreu junto. (2) Os `label` eram uma segunda fonte de verdade onde já existia módulo de rótulos: territórios acertou (`territoryListSortLabels`), municípios e dobradinhas não. Agora `municipalityColumnLabels` — em `municipalityListUrl.ts`, porque `municipalityLabels.ts` já é importado por ele e o contrário seria ciclo — é `Record<MunicipalityListColumnId, string>` e cita `municipalityListSortLabels` nas dez que **precisam** bater com o header (é de lá que `MunicipalitySortableHead` resolve o texto), deixando as duas divergências de propósito escritas com o motivo ao lado (`votos`, `lastSignal`). Na esteira, o `id` das colunas de municípios passou a ser **tipado** contra `MunicipalityListColumnId`, o que exigiu alinhar o membro `nivel` → `level`: o id da coluna e o filtro `?level=` já eram `level` (o sort continua `nivel`, e as duas grafias de URL estão congeladas pelo B18). Id, descrição (B22 ✓) e rótulo do picker são a mesma chave — coluna nova não nasce sem os três, e renomear o header renomeia a entrada do menu. (3) Dois nits com precedente a três arquivos de distância: `aria-label` no `PopoverContent` (o Radix dá `role="dialog"` sem nome — a mesma correção que `CampaignCellEditOverlay` já documenta) e `secure` no cookie quando o protocolo é https, alinhando com `campaignCookieOptions`.
**A cadência do refresh mudou por medição, e o número que importa não é o do debounce.** O primeiro corte commitava com debounce de 400 ms, o que agrupa **rajada**, não sessão de leitura: desmarcar quatro colunas lendo os rótulos entre os cliques eram quatro `router.refresh()` de rota inteira. Em `/campanha/liderancas` cada um re-paga os ~229 ms / 371 statements fichados como P1 no `TECH-DEBT` (367 deles o mesmo N+1 de access), com o resultado dimando a cada clique. Agora **fechar o menu é o commit** e o timer virou rede de segurança de 3 s para quem deixa o Popover aberto. O que se perde está dito porque é real: a tabela atrás do menu não atualiza mais a cada marcação — a checkbox é o feedback do controle, o refresh é o resultado. O caso "1 s entre duas marcações = **um** refresh" está pinado em `campaignColumnPicker.unit.spec.ts` com fake timers (verificado vermelho contra os 400 ms), junto do caso da rede de segurança.
**Rejeitado com razão medida.** `CampaignTable` ler o cookie sozinho — o que apagaria as 7 leituras nas páginas e as 3 props de passagem — funcionaria, porque todo chamador é RSC; mas `tests/unit/campaignTable.unit.spec.ts` renderiza a tabela com `renderToStaticMarkup`, que não espera componente async, e foi exatamente esse fato que fez as listas voltarem a ser síncronas durante a implementação. Trocar cobertura por menos props não paga. Também rejeitada a casca compartilhada de painel de Popover: 2 chamadores, e o 3º corpo de multi-seleção já é gatilho registrado no B32+.
**Lacuna conhecida (fill-in, não bug):** o picker é `hidden md:flex` porque municípios e apoiadores escondem esta tabela abaixo de `md` e mostram cards. As **outras cinco** superfícies renderizam a tabela em qualquer largura, então num celular elas rolam a tabela sem ter o controle. Dar picker a elas é um seam por caller, não um breakpoint: mover o `className` do caller para um wrapper externo quebraria o `overflow-visible` de que a coluna sticky de B41 depende. Gatilho: alguém usar essas listas no celular de verdade.
Arquivos: `src/lib/campaignColumnVisibility.ts` (puro, client-safe), `src/utilities/campaignColumnVisibilityCookie.ts` (`server-only`), `src/components/campaign/shared/CampaignColumnPicker.tsx`, seams em `src/components/campaign/shared/CampaignTable.tsx`.
**Segundo `/simplify` (pós-rebase em `main`), e o achado principal os três revisores encontraram sozinhos.** Os quatro `*SortableHead` **já** resolviam o próprio texto do registro de rótulos (`children ?? municipalityListSortLabels[sortKey]`), e `TerritoryListColumns` já dependia disso — passar `children` iguais ao registro era duplicação pura. O `label` novo do B17 tinha tornado a duplicação **tripla** em vez de apagá-la, e o docblock que promete "renomear o header renomeia a entrada do menu" era falso onde mais parecia verdadeiro. Foram **16** literais removidos (10 em municípios, 4 em lideranças pós-B29, 2 em dobradinhas); sobra um único `children`, o de `frescor`, que diverge de propósito ("Último sinal" na coluna, "Frescor do sinal" na ordenação) e agora lê `municipalityColumnLabels.lastSignal`, então nem esse tem a string escrita duas vezes. O `sortLabel` do `CampaignSortableHead` sempre veio do registro, então o nome acessível não mudou — só o texto visível deixou de ter uma segunda fonte.
**A cadência ganhou uma separação que faltava.** O timer de 3 s era descrito como rede de segurança, mas commitava igual ao fechamento: refresh incluído. Como três segundos é um intervalo comum entre cliques quando se está lendo rótulos desconhecidos, ele reproduzia — mais devagar — exatamente a falha pela qual os 400 ms foram recusados. Agora `persist()` (timer) grava só o cookie e `commit()` (fechar) grava e repinta. Duas guardas nasceram com a separação: **sessão que termina onde começou não dá refresh** (`sameIdSet` contra o conjunto renderizado — marcar e desmarcar a mesma coluna re-pagava a rota inteira para produzir a mesma página), e **desmontar com o menu aberto grava pelo cleanup**, porque o Radix não emite `onOpenChange` numa navegação de sidebar e a escolha sumia sem aviso. As duas especificações foram verificadas vermelhas contra o código anterior. Com as guardas dentro de `commit`, o `open` controlado virou supérfluo e saiu.
**Miudezas do mesmo passe:** `columnVisibility` era opcional em `MunicipalityList` **só** para poupar os testes (o comentário dizia isso), então virou obrigatório e o objeto de props compartilhado dos specs ganhou o valor; `MAX_HIDDEN_PER_LIST` era um segundo teto sem ameaça atrás dele (id que não casa com coluna nenhuma é inerte) e ainda assimétrico, porque `serialize` não o aplicava — o teto de tamanho do cookie basta; `resolveVisibleColumns` copiava o array no caminho comum só para satisfazer o tipo de retorno, que agora é `readonly`; o tipo da coluna de municípios, escrito três vezes, virou um alias local; e o mock de `next/navigation` do spec passou a espalhar o módulo real como os três specs irmãos, em vez de substituí-lo inteiro.
**Herdado do rebase, corrigido aqui porque o gate exige:** `LeadershipListAccessFilter` (B29) era exportado sem consumidor externo, e knip trata export morto como ERROR — `pnpm exec knip` falhava em `origin/main` também.
**Recusado com razão, e o motivo importa mais que a recusa.** Mover as leituras de `readCampaignColumnVisibility` para **antes** do `Promise.all` das páginas permitiria pular a query da coluna oculta — `/campanha/dobradinhas` roda `loadLeadershipOptions` (`limit: 0`, `depth: 1`, toda liderança visível) mesmo com a coluna "Lideranças" escondida, e `cookies()` não faz I/O, então a leitura antecipada é grátis. É o maior ganho disponível e **não** é limpeza: carregamento condicional muda o que cada página garante ao construir as colunas. Fica como **F1 do [B17+](escala-dry-pos-b17.md)**, junto com a F2 que o mesmo passe encontrou — o botão "Colunas" some com a tabela no estado vazio de demandas/organizações/apoiadores, que substituem a tabela inteira em vez de usar a prop `empty` que a `CampaignTable` expõe. Também recusados: adotar o shell de Popover do `CampaignHeaderFilterPopover` (as linhas dele são âncoras de navegação, as do picker são checkboxes — seriam dois componentes com um nome só); mover `readCookie`/`writeCookie` para um módulo irmão de `recentVisits.ts` (placement melhor, ganho cosmético); e dar `children` opcional ao `LeadershipFilterHead` do B29 para matar os dois últimos literais de lideranças (mexe na API de uma entrega recém-chegada).
## Desenho original (2026-07-24, preservado)
## Design (Impeccable)
@ -116,6 +153,8 @@ Depth check: reusa `Popover`/`Checkbox`/`Button` e keys de `municipalityUi`; sem
- **Preferência por `campaignUser` (server).** Revisitar quando: o mesmo ator reclamar em 2+ dispositivos **e** houver appetite para migration de preferências UI.
- **Picker em outras listas `/campanha`.** Revisitar quando: 2º call site real (não especulativo).
- **Ids/novas colunas de E9 no picker.** Entram no item E9 (default on); este item só deixa o contrato pronto.
- **`CampaignPopoverMenu` (chrome dividido com `CampaignHeaderFilterPopover`).** Revisitar quando: o seam do picker abaixo de `md` criar o 3º uso do mesmo chrome. Hoje 2 call sites com linhas de natureza diferente.
- **`createStaff(role)` no fixture e2e.** O bloco "cria coordenador + login" está em 8+ specs; pré-existente, este item só seguiu a convenção. Revisitar quando: 9º call site ou tempo de e2e medido como problema. Registrado no [B17+](escala-dry-pos-b17.md).
## Referências

File diff suppressed because one or more lines are too long

View file

@ -18,6 +18,7 @@ import { SupporterListOverview } from '@/components/campaign/supporter/Supporter
import { Button } from '@/components/ui/button'
import { isCampaignCoordinator, isCampaignUnrestricted } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
import { campaignRoleLabels } from '@/utilities/campaignUserProfile'
import { loadSupportersPageData } from '@/utilities/supporterPageData'
import {
@ -43,12 +44,14 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro
const { result, state, redirectHref, municipalityOptions, overview } =
await loadSupportersPageData(payload, user, rawSearchParams)
if (redirectHref) redirect(redirectHref)
const columnVisibility = await readCampaignColumnVisibility('apoiadores')
const listBody = result.docs.length ? (
<>
{overview ? <SupporterListOverview view={overview} now={now} /> : null}
<SupporterList
supporters={result.docs.map((supporter) => toSupporterListItemViewModel(supporter))}
columnVisibility={columnVisibility}
/>
<CampaignListFooter
totalDocs={result.totalDocs}

View file

@ -11,11 +11,7 @@ import {
CampaignListPendingBoundary,
CampaignListResults,
} from '@/components/campaign/shared/CampaignListPending'
import {
CampaignTable,
CampaignTableHead,
type CampaignTableColumn,
} from '@/components/campaign/shared/CampaignTable'
import { CampaignTable, type CampaignTableColumn } from '@/components/campaign/shared/CampaignTable'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
@ -27,6 +23,7 @@ import {
} from '@/lib/schemas/campaignDemand'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
import {
buildDemandListHref,
loadDemandListPageData,
@ -54,8 +51,8 @@ const dateFormatter = new Intl.DateTimeFormat('pt-BR', { dateStyle: 'short' })
const demandColumns: Array<CampaignTableColumn<DemandRowViewModel>> = [
{
id: 'title',
label: 'Demanda',
mandatory: true,
head: <CampaignTableHead>Demanda</CampaignTableHead>,
cellClassName: 'max-w-64 whitespace-normal',
cell: (row) => (
<Link
@ -68,31 +65,31 @@ const demandColumns: Array<CampaignTableColumn<DemandRowViewModel>> = [
},
{
id: 'kind',
head: <CampaignTableHead>Tipo</CampaignTableHead>,
label: 'Tipo',
cell: (row) => campaignDemandKindLabels[row.kind],
},
{
id: 'municipality',
head: <CampaignTableHead>Município</CampaignTableHead>,
label: 'Município',
cellClassName: 'text-muted-foreground',
cell: (row) => row.municipalityName,
},
{
id: 'requester',
head: <CampaignTableHead>Solicitante</CampaignTableHead>,
label: 'Solicitante',
cellClassName: 'text-muted-foreground',
cell: (row) => row.requesterName ?? '—',
},
{
id: 'status',
head: <CampaignTableHead>Status</CampaignTableHead>,
label: 'Status',
cell: (row) => (
<Badge variant={statusVariant[row.status]}>{campaignDemandStatusLabels[row.status]}</Badge>
),
},
{
id: 'createdAt',
head: <CampaignTableHead>Aberta em</CampaignTableHead>,
label: 'Aberta em',
cell: (row) => dateFormatter.format(new Date(row.createdAt)),
},
]
@ -109,6 +106,7 @@ export default async function DemandsPage({ searchParams }: DemandsPageProps) {
user,
state,
)
const columnVisibility = await readCampaignColumnVisibility('demandas')
const hrefForStatus = (status?: CampaignDemandStatus) =>
buildDemandListHref({ ...state, status }, 1)
@ -150,7 +148,12 @@ export default async function DemandsPage({ searchParams }: DemandsPageProps) {
<CampaignListResults>
{rows.length ? (
<>
<CampaignTable columns={demandColumns} rows={rows} rowKey={(row) => row.id} />
<CampaignTable
columns={demandColumns}
columnVisibility={columnVisibility}
rows={rows}
rowKey={(row) => row.id}
/>
<CampaignListFooter
totalDocs={totalDocs}
singular="demanda"

View file

@ -10,11 +10,7 @@ import {
CampaignListResults,
CampaignTransitionAnchor,
} from '@/components/campaign/shared/CampaignListPending'
import {
CampaignTable,
CampaignTableHead,
type CampaignTableColumn,
} from '@/components/campaign/shared/CampaignTable'
import { CampaignTable, type CampaignTableColumn } from '@/components/campaign/shared/CampaignTable'
import {
LeadershipStateDeputyRelationCell,
type RelationCellOption,
@ -34,6 +30,7 @@ import {
import { cn } from '@/lib/utils'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
import { loadLeadershipOptions } from '@/utilities/campaignRelationOptions'
import {
loadStateDeputyListPageData,
@ -49,6 +46,7 @@ import {
formatStateDeputyListSortSummary,
resolveStateDeputyListSort,
resolveStateDeputyListUrl,
stateDeputyListSortLabels,
type StateDeputyListState,
} from '@/utilities/stateDeputyListUrl'
@ -91,12 +89,9 @@ const stateDeputyColumns = (
): Array<CampaignTableColumn<StateDeputyRowViewModel>> => [
{
id: 'name',
label: stateDeputyListSortLabels.name,
mandatory: true,
head: (
<StateDeputySortableHead state={state} sortKey="name">
Nome
</StateDeputySortableHead>
),
head: <StateDeputySortableHead state={state} sortKey="name" />,
cell: (row) => (
<Link
href={`/campanha/dobradinhas/${row.slug}`}
@ -108,28 +103,27 @@ const stateDeputyColumns = (
},
{
id: 'party',
label: stateDeputyListSortLabels.party,
head: (
<StateDeputySortableHead
state={state}
sortKey="party"
filterOptions={partyFilterOptions}
hasNoPartyOption={hasNoPartyOption}
>
Partido
</StateDeputySortableHead>
/>
),
cellClassName: 'text-muted-foreground',
cell: (row) => row.party ?? '—',
},
{
id: 'municipalities',
head: <CampaignTableHead>Municípios</CampaignTableHead>,
label: 'Municípios',
cellClassName: 'tabular-nums',
cell: (row) => row.municipalityCount,
},
{
id: 'leaderships',
head: <CampaignTableHead>Lideranças</CampaignTableHead>,
label: 'Lideranças',
cellClassName: 'max-w-72 whitespace-normal',
cell: (row) => (
<LeadershipStateDeputyRelationCell
@ -164,6 +158,7 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
const { state } = resolvedUrl
const columnVisibility = await readCampaignColumnVisibility('dobradinhas')
const { sort, dir } = resolveStateDeputyListSort(state)
const sortSummary = formatStateDeputyListSortSummary(sort, dir)
const partyFilterOptions = filterFacets.parties.map((party) => ({ value: party, label: party }))
@ -214,6 +209,7 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
<CampaignTable
caption={`${sortSummary}. Deputados estaduais com quem a campanha dobra.`}
columns={columns}
columnVisibility={columnVisibility}
rows={rows}
rowKey={(row) => row.id}
empty={<StateDeputyListEmptyState state={state} />}

View file

@ -45,6 +45,7 @@ import { MAX_LEADERSHIP_MUNICIPALITIES, isLeadershipSector } from '@/lib/schemas
import { cn } from '@/lib/utils'
import { getAdvisorMunicipalityIds, isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
import { loadStateDeputyOptions } from '@/utilities/campaignRelationOptions'
import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime'
import { formatRelativeAge } from '@/utilities/formatRelativeAge'
@ -58,6 +59,7 @@ import {
import {
buildLeadershipListHref,
formatLeadershipListSortSummary,
leadershipListSortLabels,
resolveLeadershipListSort,
resolveLeadershipListUrl,
type LeadershipListState,
@ -125,12 +127,9 @@ const leadershipColumns = ({
}): Array<CampaignTableColumn<LeadershipRowViewModel>> => [
{
id: 'name',
label: leadershipListSortLabels.name,
mandatory: true,
head: (
<LeadershipSortableHead state={state} sortKey="name">
Nome
</LeadershipSortableHead>
),
head: <LeadershipSortableHead state={state} sortKey="name" />,
cell: (row) => (
<Link
href={`/campanha/liderancas/${row.id}`}
@ -142,13 +141,13 @@ const leadershipColumns = ({
},
{
id: 'email',
head: <CampaignTableHead>E-mail</CampaignTableHead>,
label: 'E-mail',
cellClassName: 'max-w-56',
cell: (row) => <CampaignCopyableCell value={row.email} label="E-mail" />,
},
{
id: 'phone',
head: <CampaignTableHead>Celular</CampaignTableHead>,
label: 'Celular',
cell: (row) => (
<CampaignCopyableCell
value={row.phone}
@ -160,10 +159,9 @@ const leadershipColumns = ({
},
{
id: 'supportStatus',
label: leadershipListSortLabels.supportStatus,
head: (
<LeadershipSortableHead state={state} sortKey="supportStatus" filterParam="supportStatus">
Status
</LeadershipSortableHead>
<LeadershipSortableHead state={state} sortKey="supportStatus" filterParam="supportStatus" />
),
cell: (row) => (
<LeadershipListSupportStatusControl leadershipID={row.id} status={row.supportStatus} />
@ -171,11 +169,8 @@ const leadershipColumns = ({
},
{
id: 'sector',
head: (
<LeadershipSortableHead state={state} sortKey="sector" filterParam="sector">
Setor
</LeadershipSortableHead>
),
label: leadershipListSortLabels.sector,
head: <LeadershipSortableHead state={state} sortKey="sector" filterParam="sector" />,
cell: (row) => {
if (!row.sector || !isLeadershipSector(row.sector)) return row.sector || '—'
return leadershipSectorLabels[row.sector]
@ -183,6 +178,7 @@ const leadershipColumns = ({
},
{
id: 'municipalities',
label: 'Municípios',
head: (
<LeadershipFilterHead
state={state}
@ -211,13 +207,13 @@ const leadershipColumns = ({
},
{
id: 'organizations',
head: <CampaignTableHead>Organizações</CampaignTableHead>,
label: 'Organizações',
cellClassName: 'max-w-56 whitespace-normal text-muted-foreground',
cell: (row) => row.organizationNames.join(', ') || '—',
},
{
id: 'stateDeputies',
head: <CampaignTableHead>Dobradinhas</CampaignTableHead>,
label: 'Dobradinhas',
cellClassName: 'max-w-56 whitespace-normal',
cell: (row) => (
<LeadershipStateDeputyRelationCell
@ -237,6 +233,7 @@ const leadershipColumns = ({
},
{
id: 'appAccess',
label: 'Acesso ao app',
head: (
<LeadershipFilterHead state={state} filterParam="access">
Acesso ao app
@ -250,11 +247,8 @@ const leadershipColumns = ({
},
{
id: 'updatedAt',
head: (
<LeadershipSortableHead state={state} sortKey="updatedAt">
Última atualização
</LeadershipSortableHead>
),
label: leadershipListSortLabels.updatedAt,
head: <LeadershipSortableHead state={state} sortKey="updatedAt" />,
cell: (row) => {
const absolute = formatBahiaDateTimeLabel(row.updatedAt)
const relative = formatRelativeAge(new Date(row.updatedAt).getTime(), nowMs)
@ -268,6 +262,7 @@ const leadershipColumns = ({
},
{
id: 'actions',
label: 'Ações',
head: (
<CampaignTableHead align="right">
<span className="sr-only">Ações</span>
@ -357,6 +352,7 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP
const { sort, dir } = resolveLeadershipListSort(state)
const sortSummary = formatLeadershipListSortSummary(sort, dir)
const columnVisibility = await readCampaignColumnVisibility('liderancas')
const columns = leadershipColumns({
state,
stateDeputyOptions: stateDeputyOptions.map((option) => ({
@ -402,6 +398,7 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP
<CampaignTable
caption={`${sortSummary}. Uma ficha por pessoa — cada liderança pode atuar em vários municípios e organizações.`}
columns={columns}
columnVisibility={columnVisibility}
rows={rows}
rowKey={(row) => row.id}
empty={<LeadershipListEmptyState state={state} />}

View file

@ -21,6 +21,7 @@ import {
isCampaignUnrestricted,
} from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
import { getCampaignScopeLabel } from '@/utilities/municipalityLabels'
import {
buildMunicipalityListVisitHref,
@ -64,6 +65,7 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
const { state } = resolvedUrl
const listVisitLabel = buildMunicipalityListVisitLabel(state)
const columnVisibility = await readCampaignColumnVisibility('municipios')
// One read covers both needs: the listed municipalities' avatars and the
// (cross-filtered) advisor column-filter labels.
@ -129,6 +131,7 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
columnFilterOptions={columnFilterOptions}
signalFormAction={createMunicipalityListSignalFormAction}
state={state}
columnVisibility={columnVisibility}
/>
<CampaignListFooter
totalDocs={totalDocs}

View file

@ -11,17 +11,14 @@ import {
CampaignListResults,
} from '@/components/campaign/shared/CampaignListPending'
import { CampaignSearchForm } from '@/components/campaign/shared/CampaignSearchForm'
import {
CampaignTable,
CampaignTableHead,
type CampaignTableColumn,
} from '@/components/campaign/shared/CampaignTable'
import { CampaignTable, type CampaignTableColumn } from '@/components/campaign/shared/CampaignTable'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { organizationKindLabels } from '@/lib/schemas/organization'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
import {
buildOrganizationListHref,
loadOrganizationListPageData,
@ -36,8 +33,8 @@ type OrganizationsPageProps = {
const organizationColumns: Array<CampaignTableColumn<OrganizationRowViewModel>> = [
{
id: 'name',
label: 'Nome',
mandatory: true,
head: <CampaignTableHead>Nome</CampaignTableHead>,
cell: (row) => (
<Link
href={`/campanha/organizacoes/${row.slug}`}
@ -49,18 +46,18 @@ const organizationColumns: Array<CampaignTableColumn<OrganizationRowViewModel>>
},
{
id: 'kind',
head: <CampaignTableHead>Tipo</CampaignTableHead>,
label: 'Tipo',
cell: (row) => <Badge variant="secondary">{organizationKindLabels[row.kind]}</Badge>,
},
{
id: 'municipalities',
head: <CampaignTableHead>Municípios de atuação</CampaignTableHead>,
label: 'Municípios de atuação',
cellClassName: 'max-w-64 whitespace-normal text-muted-foreground',
cell: (row) => row.municipalityNames.join(', ') || '—',
},
{
id: 'leaderships',
head: <CampaignTableHead>Lideranças</CampaignTableHead>,
label: 'Lideranças',
cellClassName: 'tabular-nums',
cell: (row) => row.leadershipCount,
},
@ -74,6 +71,7 @@ export default async function OrganizationsPage({ searchParams }: OrganizationsP
const state = parseOrganizationListParams(rawSearchParams)
const { rows, totalDocs, totalPages } = await loadOrganizationListPageData(payload, user, state)
const columnVisibility = await readCampaignColumnVisibility('organizacoes')
return (
<CampaignPageShell>
@ -104,7 +102,12 @@ export default async function OrganizationsPage({ searchParams }: OrganizationsP
<CampaignListResults>
{rows.length ? (
<>
<CampaignTable columns={organizationColumns} rows={rows} rowKey={(row) => row.id} />
<CampaignTable
columns={organizationColumns}
columnVisibility={columnVisibility}
rows={rows}
rowKey={(row) => row.id}
/>
<CampaignListFooter
totalDocs={totalDocs}
singular="organização"

View file

@ -12,6 +12,7 @@ import {
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { isCampaignLeader } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
import { loadTerritoryOverview } from '@/utilities/loadTerritoryOverview'
import { resolveTerritoryListSort, resolveTerritoryListUrl } from '@/utilities/territoryListUrl'
import { filterTerritoryRows, sortTerritoryRows } from '@/utilities/territoryOverview'
@ -31,6 +32,7 @@ export default async function TerritoriesPage({ searchParams }: TerritoriesPageP
if (isCampaignLeader(user)) redirect('/campanha')
const allRows = await loadTerritoryOverview(payload, user)
const columnVisibility = await readCampaignColumnVisibility('territorios')
const { state } = resolvedUrl
const { sort, dir } = resolveTerritoryListSort(state)
const rows = sortTerritoryRows(filterTerritoryRows(allRows, state), sort, dir)
@ -52,7 +54,12 @@ export default async function TerritoriesPage({ searchParams }: TerritoriesPageP
<CampaignListPendingBoundary>
<TerritoryFilters state={state} regionOptions={regionOptions} />
<CampaignListResults>
<TerritoryList rows={rows} state={state} regionOptions={regionOptions} />
<TerritoryList
rows={rows}
state={state}
regionOptions={regionOptions}
columnVisibility={columnVisibility}
/>
<CampaignListFooter
totalDocs={rows.length}
singular="território encontrado"

View file

@ -29,6 +29,10 @@ import {
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
import {
resolveVisibleColumns,
type CampaignColumnVisibility,
} from '@/lib/campaignColumnVisibility'
import {
formatElectionNumber,
formatPlacementOrdinal,
@ -45,6 +49,7 @@ import {
municipalityPriorityLabels,
territorialClassBadgeVariant,
territorialClassLabels,
type MunicipalityListColumnId,
} from '@/utilities/municipalityLabels'
import {
clearMunicipalityListFilters,
@ -53,6 +58,7 @@ import {
import {
buildMunicipalityListHref,
formatMunicipalityListSortSummary,
municipalityColumnLabels,
resolveMunicipalityListSort,
type MunicipalityListState,
} from '@/utilities/municipalityListUrl'
@ -98,6 +104,7 @@ export type MunicipalityListProps = {
columnFilterOptions: MunicipalityColumnFilterOptions
signalFormAction: MunicipalityStaffFormAction
state: MunicipalityListState
columnVisibility: CampaignColumnVisibility
}
const VotePositionReadout = ({
@ -244,6 +251,14 @@ const advisorNames = (
): string[] => advisorEntries(municipality, advisorNamesById).map((advisor) => advisor.name)
/** The desktop table as column definitions (Pass 2 W1 list system). */
/**
* The id is the picker's key, the cookie's key and the key of the B22
* description record, so it is typed instead of widening to `string`.
*/
type MunicipalityColumn = CampaignTableColumn<MunicipalityListViewModel> & {
id: MunicipalityListColumnId
}
const municipalityListColumns = ({
state,
isStaffView,
@ -253,9 +268,10 @@ const municipalityListColumns = ({
advisorNamesById,
advisorOptions,
signalFormAction,
}: MunicipalityListProps): Array<CampaignTableColumn<MunicipalityListViewModel>> => [
}: MunicipalityListProps): Array<MunicipalityColumn> => [
{
id: 'name',
label: municipalityColumnLabels.name,
mandatory: true,
head: (
<MunicipalitySortableHead
@ -266,9 +282,7 @@ const municipalityListColumns = ({
showPriorityFilter={isStaffView}
description={municipalityColumnDescriptions.name}
className="sticky left-0 z-20 min-w-56 bg-background"
>
Município
</MunicipalitySortableHead>
/>
),
cellClassName: 'sticky left-0 z-[5] min-w-56 whitespace-normal bg-background',
cell: (municipality) => (
@ -287,6 +301,7 @@ const municipalityListColumns = ({
},
{
id: 'region',
label: municipalityColumnLabels.region,
head: (
<MunicipalitySortableHead
state={state}
@ -294,29 +309,27 @@ const municipalityListColumns = ({
filterParam="region"
filterOptions={columnFilterOptions.region}
description={municipalityColumnDescriptions.region}
>
Território
</MunicipalitySortableHead>
/>
),
cellClassName: 'max-w-56 whitespace-normal text-muted-foreground',
cell: (municipality) => <TerritoryLink region={municipality.region} />,
},
{
id: 'kind',
label: municipalityColumnLabels.kind,
head: (
<MunicipalitySortableHead
state={state}
sortKey="kind"
filterParam="kind"
description={municipalityColumnDescriptions.kind}
>
Tipo
</MunicipalitySortableHead>
/>
),
cell: (municipality) => municipalityKindLabels[municipality.kind],
},
{
id: 'votos',
label: municipalityColumnLabels.votos,
head: (
<MunicipalitySortableHead
state={state}
@ -339,15 +352,14 @@ const municipalityListColumns = ({
// Sits right after "2022": both read the same TSE artifact, and the
// class is the one-word summary of the votes beside it.
id: 'classe',
label: municipalityColumnLabels.classe,
head: (
<MunicipalitySortableHead
state={state}
sortKey="classe"
filterParam="class"
description={municipalityColumnDescriptions.classe}
>
Classe
</MunicipalitySortableHead>
/>
),
cell: (municipality) => <TerritorialClassReadout municipality={municipality} />,
// The factors behind the label — the cell keeps them as `sr-only`
@ -360,15 +372,14 @@ const municipalityListColumns = ({
{
// Right after "Classe": the pair reads diagnosis then decision.
id: 'level',
label: municipalityColumnLabels.level,
head: (
<MunicipalitySortableHead
state={state}
sortKey="nivel"
filterParam="level"
description={municipalityColumnDescriptions.nivel}
>
Nível
</MunicipalitySortableHead>
description={municipalityColumnDescriptions.level}
/>
),
cell: (municipality) =>
canMoveEngagementLevel ? (
@ -398,6 +409,7 @@ const municipalityListColumns = ({
},
{
id: 'advisors',
label: municipalityColumnLabels.advisors,
head: (
<MunicipalitySortableHead
state={state}
@ -405,9 +417,7 @@ const municipalityListColumns = ({
filterParam="advisor"
filterOptions={columnFilterOptions.advisor}
description={municipalityColumnDescriptions.advisors}
>
Assessores
</MunicipalitySortableHead>
/>
),
cell: (municipality) =>
isCoordinator ? (
@ -436,15 +446,14 @@ const municipalityListColumns = ({
},
{
id: 'trend',
label: municipalityColumnLabels.trend,
head: (
<MunicipalitySortableHead
state={state}
sortKey="trend"
filterParam="trend"
description={municipalityColumnDescriptions.trend}
>
Tendência
</MunicipalitySortableHead>
/>
),
cell: (municipality) => (
<MunicipalityListTrendControl
@ -458,15 +467,14 @@ const municipalityListColumns = ({
},
{
id: 'expectedVotes',
label: municipalityColumnLabels.expectedVotes,
head: (
<MunicipalitySortableHead
state={state}
sortKey="expectedVotes"
align="center"
description={municipalityColumnDescriptions.expectedVotes}
>
Votos estimados
</MunicipalitySortableHead>
/>
),
cellClassName: 'relative overflow-visible align-middle text-center',
cell: (municipality) => (
@ -483,13 +491,16 @@ const municipalityListColumns = ({
},
{
id: 'lastSignal',
label: municipalityColumnLabels.lastSignal,
head: (
<MunicipalitySortableHead
state={state}
sortKey="frescor"
description={municipalityColumnDescriptions.lastSignal}
>
Último sinal
{/* The one header that does NOT inherit its sort label: the column
shows the signal, `frescor` sorts by how old it is. */}
{municipalityColumnLabels.lastSignal}
</MunicipalitySortableHead>
),
cell: (municipality) => (
@ -507,14 +518,13 @@ const municipalityListColumns = ({
},
{
id: 'goalCoverage',
label: municipalityColumnLabels.goalCoverage,
head: (
<MunicipalitySortableHead
state={state}
sortKey="deficit"
description={municipalityColumnDescriptions.goalCoverage}
>
Cobertura da meta
</MunicipalitySortableHead>
/>
),
cell: (municipality) => (
<MunicipalityListGoalCoverageCell
@ -522,25 +532,24 @@ const municipalityListColumns = ({
/>
),
},
] satisfies Array<CampaignTableColumn<MunicipalityListViewModel>>)
] satisfies Array<MunicipalityColumn>)
: ([
{
id: 'lastUpdateAt',
label: municipalityColumnLabels.lastUpdateAt,
head: (
<MunicipalitySortableHead
state={state}
sortKey="lastUpdateAt"
description={municipalityColumnDescriptions.lastUpdateAt}
>
Última atualização
</MunicipalitySortableHead>
/>
),
cell: (municipality) =>
municipality.lastUpdateAt
? dateFormatter.format(new Date(municipality.lastUpdateAt))
: 'Sem atualização',
},
] satisfies Array<CampaignTableColumn<MunicipalityListViewModel>>)),
] satisfies Array<MunicipalityColumn>)),
]
export const MunicipalityList = (props: MunicipalityListProps) => {
@ -549,6 +558,12 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
const { sort: activeSort, dir: activeDir } = resolveMunicipalityListSort(state)
const sortSummary = formatMunicipalityListSortSummary(activeSort, activeDir)
const columns = municipalityListColumns(props)
// Asks the same function the table asks, so a column that becomes
// `mandatory` cannot be on screen while the caption denies it.
const showsVoteColumn = resolveVisibleColumns(
columns,
props.columnVisibility.hiddenColumnIds,
).some((column) => column.id === 'votos')
return (
<>
@ -726,10 +741,12 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
headerClassName="[&_th]:sticky [&_th]:top-0 [&_th]:z-10 [&_th]:bg-background [&_th]:shadow-[inset_0_-1px_0_var(--border)] [&_th:first-child]:rounded-tl-xl [&_th:last-child]:rounded-tr-xl [&_tr]:border-b-0"
caption={
<>
{sortSummary}. Coluna 2022: {municipalityColumnDescriptions.votos}
{sortSummary}
{showsVoteColumn ? `. Coluna 2022: ${municipalityColumnDescriptions.votos}` : null}
</>
}
columns={columns}
columnVisibility={props.columnVisibility}
rows={municipalities}
rowKey={(municipality) => municipality.id}
empty={<MunicipalityListEmptyState state={state} />}

View file

@ -12,6 +12,7 @@ import {
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
import type { CampaignColumnVisibility } from '@/lib/campaignColumnVisibility'
import { territoryAnchorId } from '@/lib/territoryAnchor'
import { cn } from '@/lib/utils'
import {
@ -54,10 +55,12 @@ export const TerritoryList = ({
rows,
state,
regionOptions,
columnVisibility,
}: {
rows: TerritoryOverviewRow[]
state: TerritoryListState
regionOptions: TerritoryFilterOption[]
columnVisibility: CampaignColumnVisibility
}) => {
const { sort, dir } = resolveTerritoryListSort(state)
const sortSummary = formatTerritoryListSortSummary(sort, dir)
@ -73,6 +76,7 @@ export const TerritoryList = ({
containerClassName="overflow-x-auto"
headerClassName="[&_th]:sticky [&_th]:top-0 [&_th]:z-10 [&_th]:bg-background [&_th]:shadow-[inset_0_-1px_0_var(--border)] [&_th:first-child]:rounded-tl-xl [&_th:last-child]:rounded-tr-xl [&_tr]:border-b-0"
columns={territoryListColumns({ state, regionOptions })}
columnVisibility={columnVisibility}
rows={flattenTerritoryRows(rows)}
rowKey={(row) =>
row.variant === 'parent' ? row.region : `${row.parentRegion}-${row.label}`

View file

@ -17,7 +17,7 @@ import {
} from '@/utilities/municipalityLabels'
import type { TerritoryFilterOption } from '@/utilities/territoryListFilters'
import { territoryColumnDescriptions } from '@/utilities/territoryListLabels'
import type { TerritoryListState } from '@/utilities/territoryListUrl'
import { territoryListSortLabels, type TerritoryListState } from '@/utilities/territoryListUrl'
import type { TerritoryTableRow } from '@/utilities/territoryOverview'
const percentFormatter = new Intl.NumberFormat('pt-BR', {
@ -146,7 +146,7 @@ export const territoryListColumns = ({
{
id: 'region',
mandatory: true,
defaultVisible: true,
label: territoryListSortLabels.region,
head: (
<TerritorySortableHead
state={state}
@ -173,7 +173,7 @@ export const territoryListColumns = ({
},
{
id: 'municipalities',
defaultVisible: true,
label: territoryListSortLabels.municipalities,
head: (
<TerritorySortableHead
state={state}
@ -187,7 +187,7 @@ export const territoryListColumns = ({
},
{
id: 'votes2022',
defaultVisible: true,
label: territoryListSortLabels.votes2022,
head: (
<TerritorySortableHead
state={state}
@ -201,7 +201,7 @@ export const territoryListColumns = ({
},
{
id: 'pct',
defaultVisible: true,
label: territoryListSortLabels.pct,
head: (
<TerritorySortableHead
state={state}
@ -216,7 +216,7 @@ export const territoryListColumns = ({
},
{
id: 'validVotes2022',
defaultVisible: true,
label: territoryListSortLabels.validVotes2022,
head: (
<TerritorySortableHead
state={state}
@ -230,7 +230,7 @@ export const territoryListColumns = ({
},
{
id: 'estimate2026',
defaultVisible: true,
label: territoryListSortLabels.estimate2026,
head: (
<TerritorySortableHead
state={state}
@ -245,7 +245,7 @@ export const territoryListColumns = ({
},
{
id: 'cobertura',
defaultVisible: true,
label: territoryListSortLabels.cobertura,
head: (
<TerritorySortableHead
state={state}
@ -271,7 +271,7 @@ export const territoryListColumns = ({
},
{
id: 'captura',
defaultVisible: true,
label: territoryListSortLabels.captura,
head: (
<TerritorySortableHead
state={state}
@ -286,7 +286,7 @@ export const territoryListColumns = ({
},
{
id: 'classe',
defaultVisible: true,
label: territoryListSortLabels.classe,
head: (
<TerritorySortableHead
state={state}
@ -302,7 +302,7 @@ export const territoryListColumns = ({
},
{
id: 'coverage',
defaultVisible: true,
label: territoryListSortLabels.coverage,
head: (
<TerritorySortableHead
state={state}

View file

@ -0,0 +1,237 @@
'use client'
import { Columns3Icon } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useEffect, useRef, useState } from 'react'
import { useCampaignListTransition } from '@/components/campaign/shared/CampaignListPending'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/Checkbox'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import {
CAMPAIGN_COLUMNS_COOKIE,
CAMPAIGN_COLUMNS_COOKIE_MAX_AGE,
CAMPAIGN_COLUMNS_COOKIE_PATH,
parseCampaignHiddenColumns,
serializeCampaignHiddenColumns,
toggleHiddenColumn,
type CampaignListId,
} from '@/lib/campaignColumnVisibility'
import { sameIdSet } from '@/lib/sameIdSet'
type CampaignColumnPickerColumn = {
id: string
label: string
mandatory?: boolean
}
type CampaignColumnPickerProps = {
listId: CampaignListId
/** Every column of the table, hidden ones included — this is the menu. */
columns: readonly CampaignColumnPickerColumn[]
/** Server truth: what this device has hidden on this list. */
hiddenColumnIds: readonly string[]
}
/**
* Closing the menu is what refreshes the table — an editing session is one
* navigation, not one per checkbox, because each refresh re-runs the whole
* route (on `/campanha/liderancas`, the ~229 ms / 371-statement render ledgered
* as P1).
*
* The timer exists so a menu left open does not lose the choice, and it writes
* the cookie WITHOUT refreshing. Any timer that also refreshed would recreate
* the per-toggle cost at a slower pace: three seconds is an ordinary gap
* between clicks when the labels are being read. Durability and repaint are
* separate questions, so they get separate triggers.
*
* It reads refs, never the state of the render that scheduled it — the
* `setTimeout`-captures-stale-state bug B33+ pinned across the list filters.
*/
const PERSIST_IDLE_MS = 3_000
const readCookie = () =>
parseCampaignHiddenColumns(
document.cookie
.split('; ')
.find((entry) => entry.startsWith(`${CAMPAIGN_COLUMNS_COOKIE}=`))
?.slice(CAMPAIGN_COLUMNS_COOKIE.length + 1),
)
const writeCookie = (listId: CampaignListId, hiddenColumnIds: readonly string[]) => {
// Re-read before writing: one cookie carries every list, and another tab may
// have changed a different one since this page rendered.
const next = { ...readCookie(), [listId]: [...hiddenColumnIds] }
document.cookie = [
`${CAMPAIGN_COLUMNS_COOKIE}=${serializeCampaignHiddenColumns(next)}`,
`path=${CAMPAIGN_COLUMNS_COOKIE_PATH}`,
`max-age=${CAMPAIGN_COLUMNS_COOKIE_MAX_AGE}`,
'samesite=lax',
// Matches `campaignCookieOptions`; keyed off the protocol rather than
// NODE_ENV because `secure` on plain http silently drops the write.
...(window.location.protocol === 'https:' ? ['secure'] : []),
].join('; ')
}
export const CampaignColumnPicker = ({
listId,
columns,
hiddenColumnIds,
}: CampaignColumnPickerProps) => {
const router = useRouter()
// Only the transition — the busy state belongs to `CampaignListResults`,
// which wraps this table and already dims and marks itself `aria-busy`.
const { startTransition } = useCampaignListTransition()
const [hidden, setHidden] = useState<string[]>(() => [...hiddenColumnIds])
const [serverIds, setServerIds] = useState<readonly string[]>(hiddenColumnIds)
const latestHidden = useRef(hidden)
/** What the cookie already holds, so an undone edit writes nothing. */
const persistedIds = useRef<readonly string[]>(hiddenColumnIds)
const persistTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
// The server is the source of truth: once its payload catches up, it wins.
// But never over an edit that has not been written yet — the payload of
// toggle A lands while B is still in the debounce window, and adopting it
// there would roll B back and then commit the rolled-back set, losing it.
// Same policy as `useCampaignListFilterNavigation`, where a pending debounce
// outranks the URL; `sameIdSet` is the shared "did the server really change".
if (!sameIdSet(serverIds, hiddenColumnIds) && persistTimer.current === null) {
setServerIds(hiddenColumnIds)
setHidden([...hiddenColumnIds])
latestHidden.current = [...hiddenColumnIds]
// The payload we just adopted was rendered FROM the cookie, so it is also
// what the cookie holds — otherwise the next write would look redundant.
persistedIds.current = hiddenColumnIds
}
// A menu still open when the route unmounts (sidebar navigation) never gets
// its close event, so the cookie is written here instead. No refresh: the
// navigation already renders the next route, which reads the cookie.
useEffect(
() => () => {
if (!persistTimer.current) return
clearTimeout(persistTimer.current)
if (sameIdSet(persistedIds.current, latestHidden.current)) return
writeCookie(listId, latestHidden.current)
},
[listId],
)
/** Durability, on its own: the cookie only, never a repaint. */
const persist = () => {
if (persistTimer.current) {
clearTimeout(persistTimer.current)
persistTimer.current = null
}
if (sameIdSet(persistedIds.current, latestHidden.current)) return
persistedIds.current = latestHidden.current
writeCookie(listId, latestHidden.current)
}
const commit = () => {
persist()
// Toggling a column and undoing it inside one session leaves the rendered
// table already correct; refreshing it would re-pay the route for nothing.
if (sameIdSet(serverIds, latestHidden.current)) return
startTransition(() => {
router.refresh()
})
}
const apply = (next: string[]) => {
// Advance the ref synchronously: two toggles batched into one tick must not
// drop each other, and the timer reads the ref.
latestHidden.current = next
setHidden(next)
if (persistTimer.current) clearTimeout(persistTimer.current)
persistTimer.current = setTimeout(persist, PERSIST_IDLE_MS)
}
const toggle = (columnId: string, visible: boolean) => {
apply(toggleHiddenColumn(latestHidden.current, columnId, visible))
}
const hiddenSet = new Set(hidden)
const hiddenCount = columns.filter(
(column) => !column.mandatory && hiddenSet.has(column.id),
).length
const headingId = `${listId}-column-picker`
return (
<Popover
onOpenChange={(next) => {
// Closing is the commit: the whole session lands in one refresh. Both
// halves of `commit` are guarded, so a close with nothing pending — or
// one fired by a layer sweep rather than by the user — costs nothing.
if (!next) commit()
}}
>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="lg"
aria-label={
hiddenCount
? `Mostrar ou ocultar colunas. ${hiddenCount} oculta${hiddenCount > 1 ? 's' : ''}`
: 'Mostrar ou ocultar colunas'
}
>
<Columns3Icon aria-hidden="true" />
Colunas
{hiddenCount ? (
<span className="text-muted-foreground tabular-nums">({hiddenCount})</span>
) : null}
</Button>
</PopoverTrigger>
{/* Radix gives the content `role="dialog"` and no name — same reason
`CampaignCellEditOverlay` labels its own. */}
<PopoverContent align="end" aria-label="Mostrar colunas" className="w-64 p-2">
<div className="flex items-center justify-between gap-2 px-2 py-1.5">
<p id={headingId} className="text-xs font-medium text-muted-foreground">
Mostrar colunas
</p>
<Button
type="button"
variant="ghost"
size="xs"
disabled={hiddenCount === 0}
onClick={() => apply([])}
>
Restaurar todas
</Button>
</div>
<div
role="group"
aria-labelledby={headingId}
className="flex max-h-80 flex-col gap-0.5 overflow-y-auto"
>
{columns.map((column) => {
const checkboxId = `${listId}-column-${column.id}`
return (
<label
key={column.id}
htmlFor={checkboxId}
className="flex min-h-11 items-center gap-2 rounded-md px-2 text-sm hover:bg-muted has-disabled:hover:bg-transparent"
>
<Checkbox
id={checkboxId}
checked={column.mandatory || !hiddenSet.has(column.id)}
disabled={column.mandatory}
onCheckedChange={(next) => toggle(column.id, next === true)}
/>
<span className="min-w-0 flex-1 whitespace-normal">{column.label}</span>
{column.mandatory ? (
<span className="text-xs text-muted-foreground">sempre visível</span>
) : null}
</label>
)
})}
</div>
</PopoverContent>
</Popover>
)
}

View file

@ -1,6 +1,7 @@
import { Fragment, type ReactNode } from 'react'
import { CampaignCellTooltip } from '@/components/campaign/shared/CampaignCellTooltip'
import { CampaignColumnPicker } from '@/components/campaign/shared/CampaignColumnPicker'
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
import {
Table,
@ -11,6 +12,10 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/Table'
import {
resolveVisibleColumns,
type CampaignColumnVisibility,
} from '@/lib/campaignColumnVisibility'
import {
campaignHoverExplanationClassName,
campaignHoverTooltipAlign,
@ -24,14 +29,25 @@ import { cn } from '@/lib/utils'
* a definition instead of a new table.
*/
export type CampaignTableColumn<Row> = {
/** Stable identifier — the future column-picker key (B17). */
/** Stable identifier — the column-picker key (B17). */
id: string
/**
* Full `<th>` node. Plain labels use `<CampaignTableHead>`; rich columns
* render their own head island (e.g. `MunicipalitySortableHead`), which owns
* `aria-sort` and the filter popover.
* Short pt-BR name of the column, and the default header copy. `head` is an
* opaque `ReactNode` (often a client island), so the picker — which only
* receives serializable data — needs the name spelled out here.
*
* It normally IS the header copy, but it does not have to be: a header can
* afford to be terse because it sits above its own data (municípios' "2022"),
* while the same word alone in a menu of column names says nothing.
*/
head: ReactNode
label: string
/**
* Full `<th>` node, defaulting to `<CampaignTableHead>{label}</CampaignTableHead>`.
* Declare it only when the header is more than its name: an alignment, a
* B22 `description`, or a head island of its own (`MunicipalitySortableHead`)
* owning `aria-sort` and the filter popover.
*/
head?: ReactNode
cell: (row: Row) => ReactNode
/**
* Extra reading for the cell content, shown on hover/focus/tap. Return
@ -46,9 +62,8 @@ export type CampaignTableColumn<Row> = {
*/
cellTooltip?: (row: Row) => ReactNode
cellClassName?: string | ((row: Row) => string | undefined)
/** B17 seams — a mandatory column is not hideable; hidden-by-default starts unchecked. */
/** B17 — a mandatory column is listed in the picker but cannot be unchecked. */
mandatory?: boolean
defaultVisible?: boolean
}
/**
@ -124,6 +139,12 @@ export const CampaignTableHead = ({
type CampaignTableProps<Row> = {
columns: Array<CampaignTableColumn<Row>>
/**
* B17 — passing this turns the column picker on. The caller reads the cookie
* (`readCampaignColumnVisibility`) and hands the result over whole: hidden
* columns are never rendered, so they cost nothing in the RSC payload either.
*/
columnVisibility?: CampaignColumnVisibility
rows: readonly Row[]
rowKey: (row: Row) => string | number
/** Optional DOM id per row (e.g. hash targets on `/campanha/territorios`). */
@ -143,6 +164,7 @@ type CampaignTableProps<Row> = {
export const CampaignTable = <Row,>({
columns,
columnVisibility,
rows,
rowKey,
rowId,
@ -152,55 +174,78 @@ export const CampaignTable = <Row,>({
containerClassName,
headerClassName,
rowClassName,
}: CampaignTableProps<Row>) => (
<div className={cn('overflow-hidden rounded-xl border', className)}>
<Table containerClassName={containerClassName}>
{caption ? <TableCaption className="sr-only">{caption}</TableCaption> : null}
<TableHeader className={headerClassName}>
<TableRow>
{columns.map((column) => (
<Fragment key={column.id}>{column.head}</Fragment>
))}
</TableRow>
</TableHeader>
<TableBody>
{rows.length === 0 && empty ? (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={columns.length} className="whitespace-normal">
{empty}
</TableCell>
</TableRow>
) : null}
{rows.map((row) => (
<TableRow
key={rowKey(row)}
id={rowId?.(row)}
className={typeof rowClassName === 'function' ? rowClassName(row) : rowClassName}
>
{columns.map((column) => {
const cell = column.cell(row)
const tooltip = column.cellTooltip?.(row)
}: CampaignTableProps<Row>) => {
const visibleColumns = resolveVisibleColumns(columns, columnVisibility?.hiddenColumnIds)
return (
<TableCell
key={column.id}
className={
typeof column.cellClassName === 'function'
? column.cellClassName(row)
: column.cellClassName
}
>
{tooltip ? (
<CampaignCellTooltip content={tooltip}>{cell}</CampaignCellTooltip>
) : (
cell
)}
return (
<>
{columnVisibility ? (
// Gated at `md:` because municípios and apoiadores hide this table
// below that width (`className="hidden md:block"`) and show curated
// cards instead — a picker over a table nobody can see is noise. The
// cost is that the five surfaces which DO scroll this table on a phone
// have no picker there; giving them one needs a per-caller seam, not a
// breakpoint (registered as a fill-in in the B17 plan).
<div className="hidden justify-end md:flex">
<CampaignColumnPicker
listId={columnVisibility.listId}
columns={columns.map(({ id, label, mandatory }) => ({ id, label, mandatory }))}
hiddenColumnIds={columnVisibility.hiddenColumnIds}
/>
</div>
) : null}
<div className={cn('overflow-hidden rounded-xl border', className)}>
<Table containerClassName={containerClassName}>
{caption ? <TableCaption className="sr-only">{caption}</TableCaption> : null}
<TableHeader className={headerClassName}>
<TableRow>
{visibleColumns.map((column) => (
<Fragment key={column.id}>
{column.head ?? <CampaignTableHead>{column.label}</CampaignTableHead>}
</Fragment>
))}
</TableRow>
</TableHeader>
<TableBody>
{rows.length === 0 && empty ? (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={visibleColumns.length} className="whitespace-normal">
{empty}
</TableCell>
)
})}
</TableRow>
))}
</TableBody>
</Table>
</div>
)
</TableRow>
) : null}
{rows.map((row) => (
<TableRow
key={rowKey(row)}
id={rowId?.(row)}
className={typeof rowClassName === 'function' ? rowClassName(row) : rowClassName}
>
{visibleColumns.map((column) => {
const cell = column.cell(row)
const tooltip = column.cellTooltip?.(row)
return (
<TableCell
key={column.id}
className={
typeof column.cellClassName === 'function'
? column.cellClassName(row)
: column.cellClassName
}
>
{tooltip ? (
<CampaignCellTooltip content={tooltip}>{cell}</CampaignCellTooltip>
) : (
cell
)}
</TableCell>
)
})}
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
)
}

View file

@ -1,20 +1,18 @@
import { PhoneIcon } from 'lucide-react'
import Link from 'next/link'
import {
CampaignTable,
CampaignTableHead,
type CampaignTableColumn,
} from '@/components/campaign/shared/CampaignTable'
import { CampaignTable, type CampaignTableColumn } from '@/components/campaign/shared/CampaignTable'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import type { CampaignColumnVisibility } from '@/lib/campaignColumnVisibility'
import { formatBrazilianPhoneInput } from '@/lib/phone'
import { supporterVoteIntentionLabels } from '@/utilities/supporterUi'
import type { SupporterListItemViewModel } from '@/utilities/supporterViewModels'
export type SupporterListProps = {
supporters: SupporterListItemViewModel[]
columnVisibility: CampaignColumnVisibility
}
const voteIntentionBadgeVariant = (
@ -83,8 +81,8 @@ const SupporterCard = ({ supporter }: { supporter: SupporterListItemViewModel })
const supporterColumns: Array<CampaignTableColumn<SupporterListItemViewModel>> = [
{
id: 'name',
label: 'Nome',
mandatory: true,
head: <CampaignTableHead>Nome</CampaignTableHead>,
cellClassName: 'max-w-52 whitespace-normal',
cell: (supporter) => (
<Link
@ -97,12 +95,12 @@ const supporterColumns: Array<CampaignTableColumn<SupporterListItemViewModel>> =
},
{
id: 'city',
head: <CampaignTableHead>Cidade</CampaignTableHead>,
label: 'Cidade',
cell: (supporter) => supporter.city ?? '—',
},
{
id: 'municipality',
head: <CampaignTableHead>Município</CampaignTableHead>,
label: 'Município',
cellClassName: 'max-w-48 whitespace-normal',
cell: (supporter) =>
supporter.municipalityName ? (
@ -118,7 +116,7 @@ const supporterColumns: Array<CampaignTableColumn<SupporterListItemViewModel>> =
},
{
id: 'voteIntention',
head: <CampaignTableHead>Intenção</CampaignTableHead>,
label: 'Intenção',
cell: (supporter) => {
const voteIntentionLabel = supporter.voteIntention
? supporterVoteIntentionLabels[supporter.voteIntention]
@ -134,13 +132,13 @@ const supporterColumns: Array<CampaignTableColumn<SupporterListItemViewModel>> =
},
{
id: 'phone',
head: <CampaignTableHead>Telefone</CampaignTableHead>,
label: 'Telefone',
cellClassName: 'tabular-nums',
cell: (supporter) => (supporter.phone ? formatBrazilianPhoneInput(supporter.phone) : '—'),
},
]
export const SupporterList = ({ supporters }: SupporterListProps) => (
export const SupporterList = ({ supporters, columnVisibility }: SupporterListProps) => (
<>
<div data-view="mobile-cards" className="flex flex-col gap-4 md:hidden">
{supporters.map((supporter) => (
@ -151,6 +149,7 @@ export const SupporterList = ({ supporters }: SupporterListProps) => (
<CampaignTable
className="hidden md:block"
columns={supporterColumns}
columnVisibility={columnVisibility}
rows={supporters}
rowKey={(supporter) => supporter.id}
/>

View file

@ -0,0 +1,126 @@
/**
* B17 — which columns of a `CampaignTable` the actor chose to hide.
*
* Pure and client-safe: the picker (client) serializes, the list surfaces
* (RSC) parse and filter. Persistence is a cookie rather than `localStorage`
* because the column array never crosses the server/client boundary
* (`CampaignTableColumn.cell` is a function), so the server is the only place
* that can drop a column — and a cookie is the only per-device preference the
* server can read on the first paint, with no flash of the full table.
*/
export const CAMPAIGN_COLUMNS_COOKIE = 'campaign_columns'
/** Scoped like the campaign session cookie: the picker only exists under /campanha. */
export const CAMPAIGN_COLUMNS_COOKIE_PATH = '/campanha'
export const CAMPAIGN_COLUMNS_COOKIE_MAX_AGE = 60 * 60 * 24 * 365
/** A malformed or oversized cookie shows the full table instead of guessing. */
const MAX_COOKIE_LENGTH = 2048
const CAMPAIGN_LIST_IDS = [
'municipios',
'liderancas',
'dobradinhas',
'organizacoes',
'demandas',
'apoiadores',
'territorios',
] as const
export type CampaignListId = (typeof CAMPAIGN_LIST_IDS)[number]
export type CampaignHiddenColumns = Partial<Record<CampaignListId, string[]>>
/**
* What one list surface needs to run the picker, as ONE value. The id is what
* the cookie is keyed by and what the picker writes back, so the reader and
* the table must agree on it — as two props they could disagree between two
* perfectly valid ids, and the only symptom would be a picker that does nothing.
*/
export type CampaignColumnVisibility = {
listId: CampaignListId
hiddenColumnIds: readonly string[]
}
/**
* `listId:colA~colB|otherList:colC`. Every character here is legal in a
* cookie value (RFC 6265 forbids comma, semicolon, whitespace, quotes and
* backslash), so nothing is percent-encoded — which matters because
* `document.cookie` and Next's `cookies()` disagree about decoding.
*/
const LIST_SEPARATOR = '|'
const LIST_ID_SEPARATOR = ':'
const COLUMN_SEPARATOR = '~'
const COLUMN_ID_PATTERN = /^[A-Za-z0-9_-]+$/
const isCampaignListId = (value: string): value is CampaignListId =>
CAMPAIGN_LIST_IDS.some((listId) => listId === value)
export const parseCampaignHiddenColumns = (raw: string | undefined): CampaignHiddenColumns => {
if (!raw || raw.length > MAX_COOKIE_LENGTH) return {}
const parsed: CampaignHiddenColumns = {}
for (const entry of raw.split(LIST_SEPARATOR)) {
const separatorIndex = entry.indexOf(LIST_ID_SEPARATOR)
if (separatorIndex <= 0) continue
const listId = entry.slice(0, separatorIndex)
if (!isCampaignListId(listId)) continue
// An id matching nothing on the table is inert, so the length cap above is
// the only bound worth having — a per-list one would also have to be
// mirrored in `serialize`, or writing 41 and reading back 40 would differ.
const columnIds = [
...new Set(
entry
.slice(separatorIndex + 1)
.split(COLUMN_SEPARATOR)
.filter((columnId) => COLUMN_ID_PATTERN.test(columnId)),
),
]
if (columnIds.length) parsed[listId] = columnIds
}
return parsed
}
export const serializeCampaignHiddenColumns = (value: CampaignHiddenColumns): string =>
CAMPAIGN_LIST_IDS.flatMap((listId) => {
const columnIds = (value[listId] ?? []).filter((columnId) => COLUMN_ID_PATTERN.test(columnId))
if (!columnIds.length) return []
return [`${listId}${LIST_ID_SEPARATOR}${columnIds.join(COLUMN_SEPARATOR)}`]
}).join(LIST_SEPARATOR)
/**
* Records one toggle. Hiding is stored (never the visible set), so a column
* shipped by a later item — E9's derived signals, E14's level — is visible to
* everyone who never touched the picker, which is the contract those items
* assumed.
*/
export const toggleHiddenColumn = (
hiddenColumnIds: readonly string[],
columnId: string,
visible: boolean,
): string[] => {
const without = hiddenColumnIds.filter((id) => id !== columnId)
return visible ? without : [...without, columnId]
}
type HideableColumn = { id: string; mandatory?: boolean }
/**
* A `mandatory` column is never dropped, whatever the cookie says — an actor
* who lands with a stale cookie must not end up with a table he cannot read
* (or, on `/campanha/municipios`, without the only linked name in the row).
*/
export const resolveVisibleColumns = <Column extends HideableColumn>(
columns: readonly Column[],
hiddenColumnIds: readonly string[] | undefined,
): readonly Column[] => {
if (!hiddenColumnIds?.length) return columns
const hidden = new Set(hiddenColumnIds)
return columns.filter((column) => column.mandatory || !hidden.has(column.id))
}

View file

@ -4,7 +4,10 @@
* (navigation/RSC refresh) apart from a re-render carrying the same
* pre-edit content (which must not clobber in-flight optimistic state).
*/
export const sameIdSet = (left: readonly number[], right: readonly number[]): boolean => {
export const sameIdSet = <Id extends string | number>(
left: readonly Id[],
right: readonly Id[],
): boolean => {
if (left.length !== right.length) return false
const rightSet = new Set(right)
return left.every((id) => rightSet.has(id))

View file

@ -0,0 +1,24 @@
import 'server-only'
import { cookies } from 'next/headers'
import {
CAMPAIGN_COLUMNS_COOKIE,
parseCampaignHiddenColumns,
type CampaignColumnVisibility,
type CampaignListId,
} from '@/lib/campaignColumnVisibility'
/**
* B17 — what this device hid on one list, ready to hand to `<CampaignTable>`.
* Called by whichever server component renders the table; `/campanha` is
* already dynamic (auth), so reading a cookie costs no caching.
*/
export const readCampaignColumnVisibility = async (
listId: CampaignListId,
): Promise<CampaignColumnVisibility> => {
const cookieStore = await cookies()
const raw = cookieStore.get(CAMPAIGN_COLUMNS_COOKIE)?.value
return { listId, hiddenColumnIds: parseCampaignHiddenColumns(raw)[listId] ?? [] }
}

View file

@ -27,7 +27,7 @@ export type LeadershipListSortKey = 'name' | 'supportStatus' | 'sector' | 'updat
export type LeadershipListSortDirection = 'asc' | 'desc'
/** Acesso ao app — exclusive filter (com | sem), same shape as municipality coverage. */
export type LeadershipListAccessFilter = 'com' | 'sem'
type LeadershipListAccessFilter = 'com' | 'sem'
type LeadershipSector = (typeof leadershipSectors)[number]

View file

@ -196,7 +196,9 @@ export type MunicipalityListColumnId =
| 'kind'
| 'votos'
| 'classe'
| 'nivel'
// Matches the column's own `id` and its `?level=` filter param; the SORT key
// for the same column is `nivel`, and both URL spellings are frozen (B18).
| 'level'
| 'advisors'
| 'trend'
| 'expectedVotes'
@ -226,7 +228,7 @@ export const municipalityColumnDescriptions: Record<MunicipalityListColumnId, st
kind: 'Município inteiro ou zona eleitoral de Salvador (ZE 1–19).',
votos: formatMunicipalityConcentrationHint(),
classe: CLASS_COLUMN_DESCRIPTION,
nivel: campaignConceptOneLiner('nivel-de-envolvimento'),
level: campaignConceptOneLiner('nivel-de-envolvimento'),
advisors: 'Assessor(es) responsável(is) pelo município — o coordenador atribui clicando aqui.',
trend: 'Tendência política percebida pela equipe: favorável, neutra ou desfavorável.',
expectedVotes: campaignConceptOneLiner('meta'),

View file

@ -23,6 +23,7 @@ import {
import {
politicalTrendLabels,
territorialClassLabels,
type MunicipalityListColumnId,
type PoliticalTrendStatus,
} from '@/utilities/municipalityLabels'
import type { MunicipalityTerritorialClass } from '@/utilities/municipalityTerritorialClass'
@ -92,6 +93,32 @@ export const municipalityListSortLabels: Record<MunicipalityListSortKey, string>
nivel: 'Nível',
}
/**
* B17 — the name each column answers to in the column picker. Ten of the
* twelve quote the sort label, and the header renders that same record
* (`MunicipalitySortableHead` falls back to it when given no children, which
* is why those heads pass none), so renaming a header renames the menu entry
* with it. The two that differ do so on purpose: a header sits above its own
* data and can be telegraphic, while the same word alone in a list of column
* names says nothing. `lastSignal` is read back by the header it names.
*/
export const municipalityColumnLabels: Record<MunicipalityListColumnId, string> = {
name: municipalityListSortLabels.name,
region: municipalityListSortLabels.region,
kind: municipalityListSortLabels.kind,
/** Header is the bare year, under the "2022" group of the table. */
votos: 'Votação 2022',
classe: municipalityListSortLabels.classe,
level: municipalityListSortLabels.nivel,
advisors: municipalityListSortLabels.coverage,
trend: municipalityListSortLabels.trend,
expectedVotes: municipalityListSortLabels.expectedVotes,
/** The column shows the signal; `frescor` sorts by how old it is. */
lastSignal: 'Último sinal',
goalCoverage: municipalityListSortLabels.deficit,
lastUpdateAt: municipalityListSortLabels.lastUpdateAt,
}
export type MunicipalityListState = {
page: number
q?: string

View file

@ -0,0 +1,100 @@
import { expect, test } from './fixtures/campaignE2EFixtures.js'
/**
* B17 — the column picker is shared by every `CampaignTable` surface, so what
* is pinned here is the contract, not one screen: hiding survives a reload
* (cookie), one list's choice does not leak into another, and the mandatory
* column cannot be unchecked.
*
* Own file on purpose: `campaignMunicipalities.e2e.spec.ts` carries two
* deterministic failures at one worker, ledgered as P1.
*/
/**
* Toggling costs a full RSC round-trip of a heavy list route. In dev, with the
* suite's two workers competing for the same server, that overruns the 5 s
* default of `expect` — so the assertions that wait on the refresh get their
* own budget instead of a flake nobody can reproduce at one worker.
*/
const REFRESH = { timeout: 20_000 }
test.describe('Seletor de colunas', () => {
test('hides a column, keeps it hidden across reloads and restores it', async ({
campaign,
page,
}) => {
// Crosses two heavy list routes; `/campanha/liderancas` only finishes
// compiling in dev when an authenticated request reaches its tree.
test.slow()
const { fixtures } = campaign
const password = fixtures.value('senha')
const email = `${fixtures.value('colunas-coordinator')}@example.com`
await campaign.payload.create({
collection: 'campaignUser',
data: {
name: fixtures.value('Coordenadora Colunas'),
email,
password,
role: 'coordinator',
},
depth: 0,
})
await campaign.login(page, email, password)
await page.goto('/campanha/municipios')
const kindHeader = page.getByRole('columnheader', { name: /Tipo/ })
await expect(kindHeader).toBeVisible()
const picker = page.getByRole('button', { name: /Mostrar ou ocultar colunas/ })
await picker.click()
await page.getByRole('checkbox', { name: 'Tipo' }).click()
// Closing flushes the batched cookie write and refreshes the RSC.
await page.keyboard.press('Escape')
await expect(kindHeader).toHaveCount(0, REFRESH)
await expect(page.getByRole('button', { name: /1 oculta/ })).toBeVisible()
await page.reload()
await expect(page.getByRole('columnheader', { name: /Tipo/ })).toHaveCount(0, REFRESH)
// The cookie carries one key per list: another list is untouched. The
// territories list is the honest neighbour here — its 27 rows come from
// the catalog, so it never falls into an empty state.
await page.goto('/campanha/territorios')
await expect(page.getByRole('columnheader', { name: /Municípios/ })).toBeVisible()
await expect(page.getByRole('button', { name: 'Mostrar ou ocultar colunas' })).toBeVisible()
await page.goto('/campanha/municipios')
await page.getByRole('button', { name: /Mostrar ou ocultar colunas/ }).click()
await page.getByRole('button', { name: 'Restaurar todas' }).click()
await page.keyboard.press('Escape')
await expect(page.getByRole('columnheader', { name: /Tipo/ })).toBeVisible(REFRESH)
})
test('lists the mandatory column without letting it be unchecked', async ({ campaign, page }) => {
const { fixtures } = campaign
const password = fixtures.value('senha')
const email = `${fixtures.value('colunas-mandatory')}@example.com`
await campaign.payload.create({
collection: 'campaignUser',
data: {
name: fixtures.value('Coordenadora Obrigatória'),
email,
password,
role: 'coordinator',
},
depth: 0,
})
await campaign.login(page, email, password)
await page.goto('/campanha/municipios')
await page.getByRole('button', { name: /Mostrar ou ocultar colunas/ }).click()
// The name carries the reason too ("Município sempre visível").
const mandatory = page.getByRole('checkbox', { name: /^Município/ })
await expect(mandatory).toBeChecked()
await expect(mandatory).toBeDisabled()
})
})

View file

@ -0,0 +1,186 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { createElement } from 'react'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { CampaignColumnPicker } from '@/components/campaign/shared/CampaignColumnPicker'
const refresh = vi.fn()
// Spread the real module, like the sibling specs: replacing it wholesale would
// break the day the picker renders anything reading `usePathname`.
vi.mock('next/navigation', async (importActual) => ({
...(await importActual()),
useRouter: () => ({ refresh }),
}))
/**
* jsdom scopes `document.cookie` by the document URL, and the real cookie is
* written with `path=/campanha` — so a jar that ignores attributes is what
* lets these tests read back what the picker wrote.
*/
let cookieJar = ''
const COLUMNS = [
{ id: 'name', label: 'Município', mandatory: true },
{ id: 'kind', label: 'Tipo' },
{ id: 'trend', label: 'Tendência' },
]
const picker = (hiddenColumnIds: string[]) =>
createElement(CampaignColumnPicker, {
listId: 'municipios' as const,
columns: COLUMNS,
hiddenColumnIds,
})
const menuTrigger = () => screen.getByRole('button', { name: /Mostrar ou ocultar colunas/ })
const openMenu = () => fireEvent.click(menuTrigger())
/** Closing is the commit path — the idle timer is only a safety net. */
const closeMenu = () => fireEvent.click(menuTrigger())
beforeAll(() => {
Object.defineProperty(document, 'cookie', {
configurable: true,
get: () => cookieJar,
set: (value: string) => {
const [pair = ''] = value.split('; ')
const separator = pair.indexOf('=')
const name = pair.slice(0, separator)
const others = cookieJar.split('; ').filter((entry) => entry && !entry.startsWith(`${name}=`))
cookieJar = [...others, pair].join('; ')
},
})
})
beforeEach(() => {
cookieJar = ''
refresh.mockClear()
})
afterEach(cleanup)
afterAll(() => {
Reflect.deleteProperty(document, 'cookie')
})
describe('CampaignColumnPicker', () => {
it('batches a whole editing session into one cookie write and one refresh', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
try {
render(picker([]))
openMenu()
fireEvent.click(screen.getByRole('checkbox', { name: 'Tipo' }))
// Reading the next label takes longer than any debounce worth having:
// a second between checkboxes must still be one refresh, not two.
await vi.advanceTimersByTimeAsync(1_000)
fireEvent.click(screen.getByRole('checkbox', { name: 'Tendência' }))
await vi.advanceTimersByTimeAsync(1_000)
expect(refresh).not.toHaveBeenCalled()
closeMenu()
expect(refresh).toHaveBeenCalledTimes(1)
expect(cookieJar).toContain('campaign_columns=municipios:kind~trend')
} finally {
vi.useRealTimers()
}
})
it('saves without repainting when the menu is left open', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
try {
render(picker([]))
openMenu()
fireEvent.click(screen.getByRole('checkbox', { name: 'Tipo' }))
await vi.advanceTimersByTimeAsync(3_000)
// The idle timer is durability, not the commit: a slow reader who takes
// more than the interval between checkboxes must not pay a route render
// per column, which is the failure the 400 ms debounce was rejected for.
expect(cookieJar).toContain('campaign_columns=municipios:kind')
expect(refresh).not.toHaveBeenCalled()
closeMenu()
expect(refresh).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})
it('does not refresh a session that ends where it started', () => {
render(picker(['kind']))
openMenu()
fireEvent.click(screen.getByRole('checkbox', { name: 'Tipo' }))
fireEvent.click(screen.getByRole('checkbox', { name: 'Tipo' }))
closeMenu()
expect(refresh).not.toHaveBeenCalled()
})
it('saves a pending edit when the list unmounts with the menu open', () => {
const { unmount } = render(picker([]))
openMenu()
// Radix reports no close event here, so without the unmount flush this
// choice would be lost to a sidebar navigation with no feedback at all.
fireEvent.click(screen.getByRole('checkbox', { name: 'Tipo' }))
unmount()
expect(cookieJar).toContain('campaign_columns=municipios:kind')
})
it('never lets the mandatory column be unchecked', () => {
render(picker([]))
openMenu()
const mandatory = screen.getByRole('checkbox', { name: /^Município/ })
expect(mandatory.getAttribute('aria-checked')).toBe('true')
expect(mandatory).toHaveProperty('disabled', true)
})
it('keeps an edit made while the previous one is still in flight', async () => {
const { rerender } = render(picker([]))
openMenu()
fireEvent.click(screen.getByRole('checkbox', { name: 'Tipo' }))
closeMenu()
await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1))
// The second toggle opens a new window …
openMenu()
fireEvent.click(screen.getByRole('checkbox', { name: 'Tendência' }))
// … and the RSC payload of the FIRST one lands inside it. Adopting it here
// would uncheck nothing and re-commit `kind` alone, dropping `trend`.
rerender(picker(['kind']))
expect(screen.getByRole('checkbox', { name: 'Tendência' }).getAttribute('aria-checked')).toBe(
'false',
)
closeMenu()
await waitFor(() => expect(refresh).toHaveBeenCalledTimes(2))
expect(cookieJar).toContain('campaign_columns=municipios:kind~trend')
})
it('adopts the server payload once nothing is pending', async () => {
const { rerender } = render(picker([]))
rerender(picker(['kind']))
openMenu()
await waitFor(() =>
expect(screen.getByRole('checkbox', { name: 'Tipo' }).getAttribute('aria-checked')).toBe(
'false',
),
)
expect(refresh).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest'
import {
parseCampaignHiddenColumns,
resolveVisibleColumns,
serializeCampaignHiddenColumns,
toggleHiddenColumn,
} from '@/lib/campaignColumnVisibility'
describe('parseCampaignHiddenColumns', () => {
it('reads one list and several lists', () => {
expect(parseCampaignHiddenColumns('municipios:kind~trend')).toEqual({
municipios: ['kind', 'trend'],
})
expect(parseCampaignHiddenColumns('municipios:kind|liderancas:organizations')).toEqual({
municipios: ['kind'],
liderancas: ['organizations'],
})
})
it('shows the full table when the cookie is absent, empty or malformed', () => {
expect(parseCampaignHiddenColumns(undefined)).toEqual({})
expect(parseCampaignHiddenColumns('')).toEqual({})
expect(parseCampaignHiddenColumns('garbage')).toEqual({})
expect(parseCampaignHiddenColumns(':kind')).toEqual({})
expect(parseCampaignHiddenColumns('municipios:')).toEqual({})
})
it('drops unknown lists and column ids that are not identifiers', () => {
expect(parseCampaignHiddenColumns('assessores:email|municipios:kind')).toEqual({
municipios: ['kind'],
})
expect(parseCampaignHiddenColumns('municipios:kind~<script>~trend')).toEqual({
municipios: ['kind', 'trend'],
})
})
it('dedupes and refuses an oversized cookie instead of trusting it', () => {
expect(parseCampaignHiddenColumns('municipios:kind~kind')).toEqual({ municipios: ['kind'] })
expect(parseCampaignHiddenColumns(`municipios:${'a'.repeat(4000)}`)).toEqual({})
})
})
describe('serializeCampaignHiddenColumns', () => {
it('round-trips through parse and omits lists with nothing hidden', () => {
const value = { municipios: ['kind', 'trend'], demandas: ['requester'] }
const serialized = serializeCampaignHiddenColumns(value)
expect(parseCampaignHiddenColumns(serialized)).toEqual(value)
expect(serializeCampaignHiddenColumns({ municipios: [] })).toBe('')
})
it('never emits a character that would break the cookie value', () => {
const serialized = serializeCampaignHiddenColumns({ municipios: ['kind', 'trend'] })
expect(serialized).not.toMatch(/[;,\s"\\]/)
})
})
describe('toggleHiddenColumn', () => {
it('hides, shows and never duplicates an id', () => {
const hidden = toggleHiddenColumn([], 'kind', false)
expect(hidden).toEqual(['kind'])
expect(toggleHiddenColumn(hidden, 'kind', false)).toEqual(['kind'])
expect(toggleHiddenColumn(hidden, 'kind', true)).toEqual([])
})
it('leaves the other columns of the list alone', () => {
expect(toggleHiddenColumn(['trend', 'kind'], 'kind', true)).toEqual(['trend'])
expect(toggleHiddenColumn(['trend'], 'kind', false)).toEqual(['trend', 'kind'])
})
})
describe('resolveVisibleColumns', () => {
const columns = [{ id: 'name', mandatory: true }, { id: 'kind' }, { id: 'trend' }]
it('drops the hidden columns and keeps the declared order', () => {
expect(resolveVisibleColumns(columns, ['trend']).map((column) => column.id)).toEqual([
'name',
'kind',
])
})
it('never drops a mandatory column, whatever the cookie says', () => {
expect(resolveVisibleColumns(columns, ['name', 'kind', 'trend']).map((c) => c.id)).toEqual([
'name',
])
})
it('returns every column when nothing is hidden', () => {
expect(resolveVisibleColumns(columns, undefined)).toHaveLength(3)
expect(resolveVisibleColumns(columns, [])).toHaveLength(3)
})
})

View file

@ -16,6 +16,7 @@ import { CampaignScopeBadge } from '@/components/campaign/shared/CampaignScopeBa
import { Progress } from '@/components/ui/Progress'
import { Toggle } from '@/components/ui/Toggle'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup'
import type { CampaignColumnVisibility } from '@/lib/campaignColumnVisibility'
import { toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { createEmptyGoalCoverageByScenario } from '@/utilities/goalCoverage'
@ -41,6 +42,10 @@ const noopListFormAction = async (
const municipalityListDefaultProps = {
isCoordinator: false,
columnVisibility: {
listId: 'municipios',
hiddenColumnIds: [],
} satisfies CampaignColumnVisibility,
canMoveEngagementLevel: false,
advisorOptions: [],
columnFilterOptions: { name: [], region: [], advisor: [] },

View file

@ -17,7 +17,7 @@ const renderTable = (cellTooltip?: (row: Row) => string | null) =>
columns: [
{
id: 'name',
head: createElement(CampaignTableHead, {}, 'Município'),
label: 'Município',
cell: (row) => row.name,
cellTooltip,
},
@ -27,6 +27,38 @@ const renderTable = (cellTooltip?: (row: Row) => string | null) =>
}),
)
/**
* B17: `label` is what the column picker shows, and it is the header too
* unless the column asks for more. Declaring both is how the two drift.
*/
describe('CampaignTable head', () => {
it('builds the header from the label when the column declares no head', () => {
const html = renderTable()
expect(html).toContain('>Município<')
expect(html.match(/<th[\s>]/g)).toHaveLength(1)
})
it('lets a column own its header when it needs more than its name', () => {
const html = renderToStaticMarkup(
createElement(CampaignTable<Row>, {
columns: [
{
id: 'name',
label: 'Município',
head: createElement(CampaignTableHead, { align: 'right' }, 'Município'),
cell: (row) => row.name,
},
],
rows,
rowKey: (row) => row.id,
}),
)
expect(html).toContain('text-right')
})
})
/**
* B23 seam consumed by E10's "Classe" column: the tooltip is declared by the
* column, rendered by the table. These pin the two halves of the contract that