Merge branch 'cursor/48663cd8'
This commit is contained in:
commit
fcd258e1d7
33 changed files with 1300 additions and 217 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
123
docs/plans/escala-dry-pos-b17.md
Normal file
123
docs/plans/escala-dry-pos-b17.md
Normal 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
|
||||
|
|
@ -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+**)
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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} />}
|
||||
|
|
|
|||
|
|
@ -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} />}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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} />}
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
237
src/components/campaign/shared/CampaignColumnPicker.tsx
Normal file
237
src/components/campaign/shared/CampaignColumnPicker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
126
src/lib/campaignColumnVisibility.ts
Normal file
126
src/lib/campaignColumnVisibility.ts
Normal 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))
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
24
src/utilities/campaignColumnVisibilityCookie.ts
Normal file
24
src/utilities/campaignColumnVisibilityCookie.ts
Normal 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] ?? [] }
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
100
tests/e2e/campaignColumnPicker.e2e.spec.ts
Normal file
100
tests/e2e/campaignColumnPicker.e2e.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
186
tests/unit/campaignColumnPicker.unit.spec.ts
Normal file
186
tests/unit/campaignColumnPicker.unit.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
95
tests/unit/campaignColumnVisibility.unit.spec.ts
Normal file
95
tests/unit/campaignColumnVisibility.unit.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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: [] },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue