From 38c310a1db8cdabbd5e12390775ba7328e272bd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Tue, 28 Jul 2026 17:17:50 -0300 Subject: [PATCH] Lift selected options to the top in shared header multi-filters. Freeze order on Popover open so rows do not slide under the pointer, and keep the same presentation on the mobile NativeSelect without a snapshot. --- .cursor/rules/codebase-map.mdc | 2 +- .cursor/rules/projects/nucleos-eleitorais.mdc | 2 + .../selecionados-no-topo-filtros-header.md | 16 +- docs/roadmap.md | 4 +- .../shared/CampaignHeaderFilterPopover.tsx | 51 ++++- .../shared/CampaignMobileMultiFilterField.tsx | 49 +++-- src/lib/listFilterOptions.ts | 33 +++ .../campaignHeaderFilterPopover.unit.spec.ts | 202 ++++++++++++++++++ tests/unit/listFilterOptions.unit.spec.ts | 53 +++++ 9 files changed, 378 insertions(+), 34 deletions(-) create mode 100644 src/lib/listFilterOptions.ts create mode 100644 tests/unit/campaignHeaderFilterPopover.unit.spec.ts create mode 100644 tests/unit/listFilterOptions.unit.spec.ts diff --git a/.cursor/rules/codebase-map.mdc b/.cursor/rules/codebase-map.mdc index 88c69dd7..f67c88d8 100644 --- a/.cursor/rules/codebase-map.mdc +++ b/.cursor/rules/codebase-map.mdc @@ -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`, `label` **obrigatório**, `head` opcional (default = `{label}`; 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. +- Tabela: `shared/CampaignTable.tsx` — colunas como dado (`id`, `label` **obrigatório**, `head` opcional (default = `{label}`; declare só para alinhamento, `description` do B22 ou ilha de head), `cell`, `mandatory`); heads ricos = ilhas de domínio sobre `shared/CampaignSortableHead` + `shared/CampaignHeaderFilterPopover` (multi-seleção: opções marcadas sobem ao topo via `lib/listFilterOptions.ts` — snapshot na abertura; gate = `row.checkbox`; single-select intocado). 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. diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index 1471b5e9..db54d3d6 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -9,6 +9,8 @@ alwaysApply: false - **Fill-in ícone de prioridade na lista entregue em código (2026-07-28):** em `/campanha/municipios`, o Badge destrutivo "Prioritária" na coluna do nome (tabela) e no cabeçalho do card mobile (`priority === 'alta'`, staff) virou `MunicipalityPriorityIndicator` — `FlagIcon` `text-primary`, `aria-label` e `CampaignHoverTooltip` (B23 ✓) com copy fixa **"Município prioritário"** (filtro/forms/StrategyCard/Dossier continuam no rótulo curto "Prioritária"). No card, `className="relative size-11"` no trigger para ficar acima do link esticado B42. Ilha client mínima; `MunicipalityList` segue RSC. Pin unit: staff vê `aria-label="Município prioritário"`; liderança não. Sem migration/action/Consent. Gate: tsc/lint/format/cycles; unit 770; knip P3 pré-existente; Aikido 0 findings no indicador. Impeccable B; harden/optimize out. Plano: [`icone-prioridade-lista-municipios.md`](../../../docs/plans/icone-prioridade-lista-municipios.md). - **Fill-in remover lista de zonas do Início entregue em código (2026-07-28):** sumiu o bloco "Municípios por zona eleitoral" sob o mapa de `/campanha`; `zoneBreakdown` / `MunicipalityZoneBreakdownRow` / `zoneVotes*BySlug` saíram do contrato (`municipalityMapContract`) e do loader (`municipalityMapData`); a ressalva de aproximação do **B8+ F3 ✓** permanece gated por `hasZoneMunicipalities`. Int `municipalityMapData` e `TESTING.md` atualizados. Sem migration/action/Consent. Plano: [`remover-lista-zonas-mapa-inicio.md`](../../../docs/plans/remover-lista-zonas-mapa-inicio.md). +- **Fill-in "Selecionados no topo nos filtros do header" entregue em código (2026-07-28):** opções já marcadas sobem ao topo nos Popovers multi-seleção do sistema de listas (municípios, lideranças, territórios, dobradinhas) e nos `CampaignMobileMultiFilterField`. Helper puro `orderFilterOptionsSelectedFirst` em `src/lib/listFilterOptions.ts`; snapshot da seleção na abertura do Popover (ordem congelada enquanto aberto; checkbox continua live); hairline entre grupos, oculto durante a busca (polish do critique). Gate multi = `optionRows.every(checkbox)` — single-select intocado. A premissa "não extrair head genérico" do plano caducou: B21/B29 já tinham pago `CampaignHeaderFilterPopover` compartilhado, então o item pousou em 2 arquivos e 4 listas. Specs unit do helper + do Popover (freeze). Sem migration/action/Consent. Plano: [`selecionados-no-topo-filtros-header.md`](../../../docs/plans/selecionados-no-topo-filtros-header.md). + - **E11 entregue em código (2026-07-28):** motor de sugestões v1 — os 8 padrões de maior confiança do relatório §6.1 (P1/P2/P3/P5/P6/P7/K-A/K-B) viram uma fila de decisão com humano no loop: painel "Sugestões" no Início staff (top-5 por triagem + pauta do silêncio) e card na visão geral do município, aceitar/adiar/descartar gravando `allocationDecision`. **Arquitetura em três camadas:** `src/lib/suggestionCatalog.ts` (client-safe — predicados puros sobre `MunicipalityTriggerInput`, um tipo só de números/flags já derivados, + a copy exata dos cards: leitura provável, "descartes" que viram quick-picks do formulário de descarte, menu ordenado barato-antes-de-caro, contraindicação, estatuto; âncoras ilustrativas num objeto interno `SUGGESTION_ANCHORS`, calibração = E15); `src/utilities/municipalityTriggers.ts` (`server-only`, molde do `visitPlannerData.ts` do E13: leitura própria de `municipality` porque o select do scope compartilhado não tem `engagementLevel`/`lastUpdateAt`, + 6 leituras em `Promise.all` — pledges, `countLeadershipsByMunicipality` exportado do planner, metas `cache()`, sinais `kind: 'sinal'` ≤28d, atividades ≥now−42d, últimas decisões ≤90d — e LQ por ano com a fórmula EXATA do classificador E10, sem uma terceira escrita); `resolveSuggestionRecord` (action) que **reavalia o padrão no servidor** antes de gravar — o snapshot registra os fatores do momento da decisão, nunca os do render, e padrão fora da fila recusa com `SUGGESTION_STALE_MESSAGE` (safe message) em vez de registrar decisão sobre fila que andou; lock `municipality-suggestion:{id}:{patternId}` **antes** de reler a última decisão (dois staff no mesmo card serializam). **A auditoria matou o "sem migration" do plano:** o enum real de `outcome` era `aceita|descarta|movimento` — os valores `descartada|adiada` que o plano citava nunca existiram — então "Adiar" custou a migration `20260728_041547_add_allocation_decision_adiada_outcome` (precedente exato do `movimento` do E14); adiamento registra `postponeDays` derivado do nível recomputado (7d; 14d nos níveis 4–5) e `suppressUntil` no snapshot, sem coluna nova. **Supressão é pura e unit-testada** (`isSuggestionSuppressedByDecision`, no catálogo): aceita 14d, descarta 30d, adiada até o snooze (snapshot malformado cai no adiamento mínimo, nunca "para sempre") — com a regra "nível 1 fura a fila": gatilho que volta como risco confirmado atravessa supressão registrada em urgência menor. **Diferencial estrutural codificado (§6.3):** P2 cede a P3 (correligionário segura o campo) e a K-B (série diz perdida); P7 cede a K-A quando há atividade realizada no ciclo — nunca dois veredictos sobre o mesmo dado. **P3 dispara só pelo artefato** (campo ≥ 35% dos válidos + fatia intracampo < 0,5× o padrão estadual próprio + gate de volume na mediana): `runningAgain2026` existe mas está 100% `desconhecido` e sem read path, então "concorre de novo?" é o 1º item do menu (inteligência), não gatilho. **Pauta do silêncio** = alta/N2+ sem gatilho **disparado** (supressão não é silêncio — decisão registrada é resposta) e sem sinal ≥30d; inclui `priority: alta` porque E14 nasceu sem backfill. UI: submit explícito nos três desfechos (exceção prevista em `campanha-edit-where-you-see` para flows com nota/confirmação — aceite carrega a ação do menu escolhida + nota, descarte exige leitura alternativa com quick-picks; adiamento é um toque com a duração no rótulo), radios nativos (`accent-primary` — primeiro radio precedente no repo), foco gerenciado na troca trigger↔form (abrir foca o primeiro campo, cancelar devolve ao trigger — verificado no browser), só nível 1 usa `destructive`, estatuto no rodapé do painel. **Um leak de base CSS achado no craft:** o `ul { my-6 ml-6 list-disc }` do artigo público vaza para superfícies campaign — a lista de fatores precisou do reset `m-0 list-none p-0 [&>li]:mt-0` (mesmo remédio do B20). **Testes com taxa medida:** os padrões ancorados no artefato não são encenáveis por linhas do banco, então os int tests que precisam de propriedade de artefato (quartil superior do P6, município quieto do silêncio) alocam municípios pelo alocador das fixtures até um qualificar — slug explícito colidiria com o alocador (sequence com wrap, ~23% de colisão por run); taxas medidas com script sobre o artefato: 109/435 quartil superior, 205/435 quietos, e **reduto+coreBlock são só 13/435**, o que tirou P1 do alcance de int (predicado fica pinado nos 45 casos unit; a costura sinais→P1 foi verificada no browser com dados reais do dev, que têm sinais de adversário). Verbetes E18 `triagem-de-sugestoes` e `pauta-do-silencio`. First Load JS `/campanha` 268 → 277 kB (catálogo + ilha do card, medido pós-simplify). Suíte 453 (45 unit novos + 9 int novos) em ~84s; **as falhas rotativas de timeout na suíte cheia eram contenção de ambiente** (outra worktree Cursor subiu dev server no meio das runs — árvore limpa 70s e a minha 84s, ambas verdes com a máquina calma). Aikido 0 findings em todos os arquivos com lógica nova. **Adjacentes registrados no plano:** ligar o override do "não vá" do compositor de giros (E13) ao `resolveSuggestion`; fase 2 do catálogo (T/I/J, P4/P8–P12, K-C) com gatilho de 2 semanas de uso + 20 decisões; rota dedicada se >15 sugestões ativas recorrentes. **Pós-`/simplify` (mesma sessão):** o achado que manda era de perf — o dashboard varria `votePledge` **duas vezes** por request (o avaliador re-agregava o que `loadMunicipalityScope`, React `cache()` já pago pelo `getCampaignDashboardData`, segurava em `pledgeAggregates`); o caminho sem filtro agora reusa o scope e o caminho de 1 município mantém a agregação própria. Também: a janela de decisões caiu de 90d chutados para 30d **derivados** das constantes de supressão; o catálogo importava `ELECTION_YEAR_*` de `electionResults`, cujo 1º import é `bahiaTerritories` — três números arrastavam a tabela de territórios para o chunk do card (a lição do B14, de novo; anos locais com comentário); `triggerSummary` era copy morta (8 parágrafos que nada renderizava — deletado); `silence.totalCount` era derivado (bundle virou array); o filtro de tipo de sinal desceu para o `where` e matou o único cast do delivery; LQ 2022 reusa `classification.lq` memoizado; copy dos fatores alinhada ao canônico "do candidato" (a docstring do E10 já proibia variação do possessivo); card adotou `FieldSet`/`FieldLegend`/`FieldLabel` de `ui/field`; `DAY_MS` e `SUGGESTION_TEXT_MAX_LENGTH` unificados no catálogo; `formatSilenceAgeLabel` mudou para `municipalitySignal.ts`. **Correção de registro:** suítes são 685 unit / 453 int (o "453" acima era só a fase int). **Ficou com gatilho:** consolidar as leituras repetidas de pledges/lideranças da `OverviewTab` (3 loaders não coordenados, pré-existente); `formatDominanceAgainstOwnStandard` → `lib/` quando outro módulo lib precisar. **Rodada 2 do `/simplify` (pós-rebase):** o rebase trouxe B17/B29/B32+/B37/B40 sem matar premissa nenhuma (o card corretamente não adota a máquina de célula do B32+; o cache hit do scope sobreviveu) — mas entregou o 3º call site da extração adiada: `firstFormActionMessage` nasceu em `campaignFormFields.ts` e o card, o `RelationChipCell` (B37) e o `toMessageOnlyState` de apoiadores o consomem. **Um P2 real:** a `rationale` composta (rótulo do menu + nota) estourava o cap de 2000 da collection com nota máxima — create rejeitado com erro genérico e sem retry possível; clamp + caso int pinando. Também: falha anuncia num canal só (a linha `role="alert"` — o toast dizia duas vezes), `metrics` (~209 B/card) não serializa mais ao cliente (pick campo a campo no painel), `SUGGESTION_OUTCOMES` exportado do schema, janelas de adiamento derivadas por `Math.min/max` dos níveis, `metrics` 100% de `input`. Plano as-built: [`motor-de-sugestoes.md`](../../../docs/plans/motor-de-sugestoes.md). - **B40 entregue em código (2026-07-28):** login com biometria (WebAuthn) em `/campanha` — enrollment opt-in depois do login com senha (toast dismissível no shell, no molde do `InstallPwaToast`, + seção permanente "Acesso por biometria" em `/campanha/perfil`), botão de platform authenticator na tela de login e revogação por dispositivo. Vale para **todos os papéis, liderança inclusive** (é quem tem mais atrito: celular + senha), e **não abre chave de `Consent`** — o SO nunca libera a digital, guardamos só uma chave pública, então o art. 11 não entra e o lote da Onda 0 não reabre. Collection `campaignWebAuthnCredential` (migration `20260728_041958_add_campaign_webauthn_credentials`), 4 rotas JSON em `campanha/webauthn/` **fora de `(app)`** (duas são anônimas; o path `/campanha` do cookie já as cobre), `@simplewebauthn/server@13` no servidor e `browser@13` no cliente. **A lacuna que a auditoria abriu e que era o coração da entrega:** o Payload 3.82.0 **não tem caminho password-less** (`loginOperation` exige `password`), então `issueCampaignWebAuthnSession` forja a sessão só com exports públicos — `payload.db.findOne` → `checkLoginPermission` → `addSessionToUser` (`payload/shared`) → `getFieldsToSign` + `jwtSign` → o `setCampaignAuthCookie` do **B39 ✓**, com TTL longo porque cadastrar o aparelho _é_ o opt-in de confiança. **A mina, confirmada em código:** `addSessionToUser` faz read-modify-write do documento **inteiro** do usuário, e carregar com `payload.findByID` passaria pelo `afterRead` `removePrivateAuthFields` — gravar isso de volta apagaria `sessions`/`email` (derrubando **todas as outras sessões**) e expandiria `avatar` em objeto; daí `db.findOne` cru com o comentário nomeando o perigo, `user.updatedAt = null` como o próprio login do Payload faz, lock `campaign-webauthn-session:{id}` numa transação (reescrever o array inteiro faz dois logins simultâneos perderem sessão) e int spec provando que a sessão pré-existente sobrevive ao mint. **Duas armadilhas que a auditoria não previu, e as duas se escondem atrás de um caminho que "funciona":** (1) `getCampaignWebAuthnRelyingParty` derivava o RP da origem canônica e **tinha sucesso** num preview da Vercel — o botão aparecia e o browser estourava `SecurityError`, porque quem serve a página é outro host; agora a função compara o host do request (`origin`, ou `x-forwarded-host`/`host`) com o host derivado e devolve `null` quando divergem, que é o "a UI se esconde" que o plano prometia; (2) o challenge do cookie HMAC precisa ser **os mesmos bytes** que o `@simplewebauthn/server` gerou — a primeira versão gerava o seu ao lado do dele e **todo `register` respondia 400**, achado só pelo e2e. Também: deletar um `campaignUser` quebrava, porque a FK que o Payload emite para a relação obrigatória é `ON DELETE SET NULL` sobre coluna `NOT NULL` → `beforeDelete` em `CampaignUser` apagando as credenciais na mesma transação (cascade explícito, sem editar a migration à mão); `checkLoginPermission` lança o erro genérico do Payload, capturado e relançado em pt-BR para que a conta trancada saiba que é bloqueio e não falha de biometria; e o erro do botão vivia no `FieldError` **e** na região `aria-live`, anunciado duas vezes pelo leitor de tela (o `sr-only` ficou só com o estado pendente). **Medido:** `@simplewebauthn/browser` custa **3,0 kB** gzip contra o orçamento de 10 kB do plano, e o `/simplify` o tirou do First Load JS de vez — a metade sempre carregada (probe de capacidade + tipo de erro) virou `src/lib/campaignWebAuthnSupport.ts`, que não importa nada, e as três ilhas chegam à cerimônia por `import()` dentro do handler: `/campanha/login` caiu de 130 para **126 kB** e `/campanha`, onde o toast de enrollment monta em toda navegação autenticada, ficou **byte-idêntico** aos 268 kB de antes do item. e2e com **autenticador virtual por CDP** (`WebAuthn.addVirtualAuthenticator`, `transport: 'internal'`, que também faz `isUserVerifyingPlatformAuthenticatorAvailable()` responder `true`) cobrindo cadastrar → sair → entrar com biometria → revogar; para o caso da credencial revogada o fixture ganhou `expectedRequestFailurePaths`, porque o teste provoca um 4xx de propósito e o guard global tratava o `console.error` do fetch como erro não tratado. Aikido 0 findings. Plano as-built: [`login-biometrico-campanha.md`](../../../docs/plans/login-biometrico-campanha.md). **O `/simplify` cobrou uma simplificação minha de volta, e essa é a lição transferível:** tirar a anotação `Promise` da action pareceu redundância removida e o `tsc` provou o contrário — `runCampaignFormAction` tipa o ramo de falha **sem a chave `status`**, então só o tipo anotado (onde ela é opcional) permite ao chamador estreitar por `status === "success"`; voltou com o motivo escrito ao lado. O resto do passe: `canWriteWebAuthnCredentials` virou `payloadAdminOnly` em vez de reescrever o predicado; a sonda de disponibilidade (duas cópias com o mesmo guard de cancelamento) virou `useCampaignBiometricsAvailable`; o card passou a usar `FieldError` como o botão — e por isso perdeu a mensagem de erro da região `sr-only`, pelo mesmo motivo de anúncio duplo já corrigido no botão; as três frases declaradas dos dois lados foram para `campaignAuthCopy.ts`, e a que importava era "Este aparelho já está cadastrado.", que o servidor trata como entrada do allowlist `safeMessages` por **string exata** enquanto o cliente a reproduzia à mão; `expectedRequestFailurePaths` virou opção do Playwright (`test.use` no topo em vez de `push` no meio do teste); e o int que dizia "a senha ainda funciona" passou a **logar com a senha**, em vez de reafirmar o e-mail que a asserção acima já cobria. Dois débitos ficaram de fora com motivo: o núcleo HMAC é ~35 linhas iguais às de `supporterImportToken.ts` (2 call sites, e extrair mexeria no caminho do token de import) e `postJson` era a 6ª cópia do mesmo fetch. **Essa segunda o `main` resolveu enquanto o item estava aberto** (B32+ ✓), então o rebase não foi só um merge: as 4 rotas nasceram de novo sobre `campaignJsonMutationRoute` e o transporte passou a ser `postCampaignJson` — não por gosto, porque o B32+ trouxe um **guard de convenção** que reprova um `route.ts` exportando `POST` fora da casca, e as quatro estavam nomeadas nele. Duas coisas a saber de quem adotar a casca num caminho não-célula: ela **sempre** parseia um corpo, e as duas rotas `*-options` não têm nenhum — daí `campaignWebAuthnNoBodySchema`, com o cliente postando `{}`, que é o preço honesto de ter a verificação de origem por construção em vez de por disciplina; e `postJson` sobrevive como casca de **uma linha** sobre `postCampaignJson` que só descarta o `ok`, porque o discriminante do envelope é que decide. **A tradução de `SyntaxError` que a primeira versão dessa casca tinha caiu no `/simplify` do fechamento, e o motivo generaliza para qualquer ilha destas três telas:** ela escrevia "Resposta inválida do servidor." num `Error` cru, e as três ilhas leem `message` **só** de um `CampaignWebAuthnError` — a frase era redigida para a pessoa e jogada no canal que descarta mensagens. Deletar em vez de re-tipar, porque o fallback de cada ilha ("…Use sua senha.") é a copy melhor para quem está em campo; nada muda na tela, e o comentário agora registra por que o ramo está ausente. Verificado no servidor de produção local, sem browser: cross-origin 403, sem sessão 401, corpo não-JSON no 400 compartilhado, e o corpo `{}` chegando ao handler. - **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` + `