- AGENTS.md: Known Gap #2 resolvido (D3), arvore de estrutura (+AllocationDecision, PrivacyPolicy, CampaignGoals; components por dominio), URLs novas (/campanha/assessores|conceitos|perfil), entrada 'Recently resolved' do Pass 2 - tracker: todos os workstreams done; secao de achados de drift de docs de produto (PRODUCT/DESIGN/CUSTOMER - reportados, nao aplicados) - TESTING.md: 46/48/7 + linhas do safety net (formActions wrapper, voteTrend, submitWhatsapp) - TECH-DEBT: smells fechados (municipalityUi split, detail RSCs, formActions, components subpastas, electionInsights) - planos: status entregue nos 5 do Pass 2; escala-dry-pos-b16 absorvido; C8 F3/F4 fechados em escala-dry-pos-c6; notas de costura em B17/B18/cenario-junto; nota de conflito E4R em mapa-projecao - roadmap: entrada Pass 2 no 'Ja entregue', B17/B18 marcados como triviais, B16+ riscado, header atualizado - notebook do projeto: entrada Pass 2 - DESIGN.md: secao do sistema de listas (unico drift aplicado, conforme plano)
14 KiB
Escala e DRY pós-B16 (filtros no header da lista)
Status: absorvido pelo Pass 2 W1-D1 (2026-07-25) — Fase 1 (useOptimistic no MunicipalityHeaderFilter, onNavigate dentro da transition), Fase 2 (facet de município como slugs rotulados no cliente), Fase 3 (hrefs de opção via serializador canônico confiável, 1 serialização por opção com pin de byte-igualdade) e Fase 4 (facets no mesmo Promise.all, catálogo DRY, clearMunicipalityListFilters) entregues em sistema-listas-campanha.md; selects mobile otimistas continuam adiados com gatilho
Atualizado em: 2026-07-25
Item do roadmap: docs/roadmap.md (B16+, fill-in de engenharia)
Impeccable: A — N/A (sem superfície UI nova; as fases preservam o comportamento visível de B16)
Appetite: ~0,5–1 dia eng; três fases independentes, nenhuma com migration
Responsável: —
Dados → decisão → apresentação
- Vou apresentar dados? Não (N/A) — o lote é custo de render/transferência das opções de filtro já entregues em B16; nenhuma métrica nova.
- Anti-goals de dado: N/A.
Contexto
B16 (filtros-no-header-lista-municipios.md, entregue 2026-07-25) colocou um Popover de filtro em cada header da lista de municípios, com multi-seleção OR em município (435 opções), território, assessores e tendência. A passagem /simplify da entrega (três revisores em paralelo — quality / performance / reuse, 2026-07-25) aplicou o cleanup barato na hora e deixou três achados maiores que cleanup, registrados aqui.
Defeitos funcionais achados na revisão e corrigidos na entrega (não reabrir): (1) as facetas omitiam só o param multi, então marcar "Prioritária" escondia todo município não-prioritário da lista onde o próprio check mora, e "Sem assessor" esvaziava o popover de Assessores — agora cada faceta omite o grupo de params do seu popover (slugs+priority, advisors+coverage), coberto por int test; (2) "todas as tendências" tinha duas codificações — selecionar as três deixava três trend= na URL filtrando nada, com o funil se declarando inativo — agora parseMunicipalityListParams canonicaliza o conjunto completo para ausente e narrowingMunicipalityTrends sumiu; (3) o Território tinha desaparecido do mobile (o filtro virou multi e o mobile só montava single|toggle), de volta como MobileMultiFilterField alimentado pela faceta.
Já resolvido no simplify (não reabrir): setter único sobre params crus em municipalityUi.ts (elimina as duas escadas de if e os três casts as BahiaIdentityTerritory[] / as PoliticalTrendStatus[] / as MunicipalityListState['coverage'], além do state.trends!); cadeia de três funções do resumo de filtros colapsada com helper firstNamesLabel; remoção do array municipalitySlugFilterOptions (435 objetos montados no bundle do cliente e nunca lidos), do modo de seleção 'name' e do campo clearWhenOptionsAtLeast; unificação das duas linhas de opção do Popover num FilterOptionLink com prop checkbox; useEffect de limpeza da busca dobrado no onOpenChange; dois onClick={stopPropagation} que não protegiam nada; memoização dos rótulos normalizados para busca; empty state da tabela navegando pela transição compartilhada e preservando sort/dir; colSpan derivado de uma constante junto do header; facets reusando o escopo já carregado na request (loadMunicipalityScope passou a selecionar region); getEligibleAdvisorOptions de volta a coordenador-only, com loadAdvisorSummaries rotulando o facet de assessores e as duas leituras em Promise.all; allParamValues sem o split por vírgula que nada emitia.
Objetivos
- Um único mecanismo de estado otimista nos controles de filtro/sort, sem janela de reversão visível em cliques encadeados.
- Abrir o Popover de município (435 opções) sem custo de render proporcional a duas serializações de estado por opção.
- Parar de mandar ~20 KB de rótulos de município do servidor para o cliente a cada navegação da lista.
- Guardrails: sem migration, sem collection, sem
Consent; contrato de URL (?slug=/?region=/?advisor=/?trend=) e comportamento visível de B16 inalterados.
Decisões travadas
- Um plano B16+, três fases independentes. Mesmo precedente de
A9+/O0+/RS+: um registro no roadmap, PR por fase, nenhuma fase bloqueia a outra. Rejeitado: um item de trilha B (não é feature, é custo herdado); três fill-ins separados (poluiria a lista para ~0,5d de trabalho). - Contrato de URL congelado. As fases são internas ao render e ao payload; qualquer mudança em nome de param ou semântica de "todas as tendências = sem filtro" sai do escopo — B18 (filtros salvos) já vai depender desse contrato. Rejeitado: aproveitar a Fase 2 para encurtar params (
?s=), que quebraria URLs compartilhadas e oresolveMunicipalityListUrl. - i18n e naming seguem o AGENTS.md: identificadores em inglês (
useOptimistic,buildMunicipalityFilterHref,municipalityFilterOptionsForSlugs), strings visíveis em pt-BR.
Questões em aberto
- Fase 2: mandar slugs e rotular no cliente, ou só omitir quando o facet = catálogo inteiro? Opções: (A)
columnFilterOptions.namevirastring[]e o Popover rotula communicipalityNameBySlug(já no bundle); (B) servidor mandaundefinedquando o facet cobre as 435 e o cliente cai no catálogo; (C) manter. Recomendação: A — corta o payload nos dois casos (filtrado e não filtrado) e mantém uma fonte de rótulo só; o custo é a assimetria de a colunanamereceber valores enquantoregion/advisorrecebem pares. - Fase 3:
URLSearchParamsbase + swap de um param, ou cap de lista? Opções: (A) montar a base uma vez e trocar só o param da opção; (B) renderizar as primeiras N até a busca estreitar; (C) as duas. Recomendação: A — mantém a lista completa navegável por teclado/scroll (B muda UX sem pedido de campo) e é onde está o custo real (~870 parses por abertura). - Fase 1 vale o risco? A janela de reversão só aparece em dois cliques antes de o servidor responder. Recomendação: fazer, mas com verificação manual do caso que originou o mecanismo atual (clicar em "Prioritária" e ver se não desmarca sozinha) antes de considerar pronta — foi exatamente o bug relatado em campo em 2026-07-25.
Abordagem proposta
flowchart TD
B16["B16 ✓ Filtros no header"] --> F1
F1["Fase 1 — useOptimistic<br/>no Popover + onNavigate<br/>dentro do startTransition"]
B16 --> F2["Fase 2 — payload dos rótulos<br/>de município (slugs no lugar de pares)"]
B16 --> F3["Fase 3 — hrefs do Popover<br/>a partir de uma base URLSearchParams"]
B16 --> F4["Fase 4 — faceta no mesmo Promise.all<br/>+ DRY dos gêmeos de filtro"]
F1 -.se a config atrapalhar.-> F5["Fase 5 (condicional) — comportamento<br/>na config de filtros ou params explícitos"]
Componentes:
src/components/campaign/MunicipalityHeaderFilter.tsx(Fase 1): trocar o par{ baseKey, next }emuseStateporuseOptimistic(state, (_, next: MunicipalityListState) => next). React descarta o valor otimista quando a transição de navegação assenta, o que apaga a comparaçãooptimistic?.baseKey === stateKey. Precedente no código:ActionPlanTaskChecklist.tsx.src/components/campaign/CampaignListPending.tsx(Fase 1): moveronNavigate?.()para dentro do callback destartTransition—useOptimisticfora de transição dispara warning do React. O anchor é compartilhado (MunicipalitySortableHead, paginação, empty state), então a mudança precisa de smoke nas três superfícies.src/app/(campaign)/campanha/(app)/municipios/page.tsx+MunicipalityList.tsx(Fase 2):columnFilterOptions.namepassa a carregar slugs; o Popover rotula communicipalityFilterOptionsForSlugs(já existe emmunicipalityUi.ts) no cliente.src/utilities/municipalityUi.ts(Fase 3): expor um builder que receba os params canônicos uma vez e devolva o href de cada opção trocando um único valor, em vez detoggleMunicipalityMultiFilterValue+buildMunicipalityFilterHref(duas voltas state→raw→parse) por opção renderizada.src/utilities/municipalityPageData.ts(Fase 4): hojeloadMunicipalityListFilterFacetsé aguardado depois doPromise.alldas leituras principais, então toda request com filtro ativo paga um round-trip serial a mais. A chave da semente sai dewherede forma síncrona, então dá para passar a promise do escopo como semente e entrar no mesmoPromise.all.- DRY que caiu no mesmo lote (Fase 4, tudo pequeno):
municipalitySlugSet/municipalityNameBySlugrefazem o queisMunicipalitySlug/getMunicipalityCatalogEntryjá exportam emmunicipalityCatalog.ts; "limpar tudo preservando o sort" está escrito literalmente igual emMunicipalityFilters.tsxeMunicipalityList.tsx(viraclearMunicipalityListFilters); os selects mobile continuam lendo a verdade do servidor enquanto os popovers desktop já são otimistas — se a Fase 1 virar um hook, o mobile usa o mesmo. - Sem migration, sem collection, sem server action.
Dependências
- B16 ✓ (entregue 2026-07-25) — este plano só existe sobre o que ele entregou.
- Suave com B17 (seletor de colunas) e B18 (filtros salvos): ambos pousam na mesma barra/header; fazer a Fase 1 antes evita replicar o mecanismo otimista atual num terceiro controle.
- Reusa
campaignListUrl.ts,municipalityUi.ts,campaignMunicipalityScope.ts,municipalityViewModels.ts.
Não escopo
- Qualquer mudança de UX dos filtros (novos params, chips, ordenação de opções) → B17/B18 ou fill-in próprio.
- Unificar a busca do Popover com a busca da lista (
matchesAtWordStartvsincludes) → ver Adiado com gatilho. - Resiliência do build do site público ao global
metadataausente → bullet próprio na seção "Site público" do roadmap (pai diferente; não entra neste lote).
Rabbit holes
- "Já que estou no Popover, uso
ui/Command." O componente existe (src/components/ui/command.tsx), mas trocar a lista porCommandreescreve seleção, teclado e o modo checkbox de quatro filtros de uma vez. Mitigação: fora deste lote; ver gatilho abaixo. - "Aproveito para tornar a config de filtros realmente genérica."
municipalityFilterDefinitionshoje é genérica na forma e especializada em todo consumidor. Consertar de verdade significa mover comportamento (toApplied(state, value)) para a config e reescrever os dois consumidores. Mitigação: Fase 4 condicional — só se a Fase 1 esbarrar nela; senão, aceitar e documentar. - "Enquanto mexo no payload, mando o catálogo inteiro pro cliente e acabou." Isso ressuscita os 435 objetos que o simplify acabou de remover do bundle. Mitigação: a Fase 2 manda slugs do facet, não o catálogo.
Adiado com gatilho
MunicipalityFilterHeadvsMunicipalitySortableHead(gêmeos). Revisitar quando existir um 3º header não-ordenável com filtro — hoje só "Assessores" justifica o par.- Popover sobre
ui/Command. Revisitar quando um 2º Popover de busca com multi-seleção aparecer no produto (candidato: B17/B18). - Predicado de busca unificado (
includesno Popover vsmatchesAtWordStartna lista). Revisitar se alguém em campo estranhar que buscar "cruz" no Popover casa "Santa Cruz" mas a busca da lista não se comporta igual. - Multi-seleção mobile honesta. O
<select>comvalue=""e prefixo✓é um stand-in deliberado (chips deRelationMultiSelect/CampaignFilterChipssão a alternativa pronta). Revisitar se o R6 (critique da vertical) apontar o controle, ou se um assessor reclamar em uso real. - Cap/virtualização das 435 âncoras do popover. Só vale junto com a Fase 3 (ou com
ui/Command); sozinho troca custo por UX pior. Revisitar se a Fase 3 não bastar em telefone de campo. - Leitura única do catálogo acessível (≤435 linhas) com filtro/faceta/sort em memória. Colapsaria list query + escopo + até 3 facetas numa leitura, mas mexe na semântica de E8/E9. Revisitar se as leituras full-scope aparecerem no p95 do
/campanha/municipios. getEligibleAdvisorOptionsdevolvendophonepara o coordenador derivarloadAdvisorSummariesem memória (−1 query). Revisitar quando a lista de assessores crescer além de algumas dezenas.onNavigate+ estado otimista emCampaignFilterChipse na paginação. Revisitar quando a Fase 1 extrair o hook — aí é adoção, não duplicação.
Explicitamente fora (simplify desta sessão — não reabrir)
- Renomear/mover o gêmeo
MunicipalityFilterHead, tirartext-left/text-muted-foregroundredundantes do header, branchesalign === 'right'|'center'inalcançáveis no caminho com filtro (score ≤2). aria-livedo resumo de filtros re-anunciando por tecla: pré-existente, fora do diff de B16 — só entra se virar achado de a11y no R6.- Tolerância a params separados por vírgula em
allParamValues: removida no simplify; não reintroduzir sem um caso real de URL editada à mão. columnCount = isStaffView ? 9 : 5derivado de um array de descritores de coluna; over-select das facetas (chavear o cache porwhere + selectquebraria a semente que torna o caso comum grátis); bundle de metas rodando no caminho de zero resultados (deliberado — a visão geral fica zerada na tela); quarta grafia de{ value, label }e o comparadorlocaleCompare('pt-BR')repetido (drift pré-existente, não regressão deste diff).CampaignListEmptyStatecompartilhado e as quatro cópias locais defirstValue: absorvidos no C8 (escala-dry-pos-c6.md) — são de outro pai (shells de lista de campanha), não de B16.
Referências
- filtros-no-header-lista-municipios.md — B16, entrega que originou o lote
- escala-dry-pos-reset-senha-perfil.md — precedente de plano por fases pós-simplify
src/components/campaign/MunicipalityHeaderFilter.tsx,MunicipalitySortableHead.tsx,MunicipalityList.tsx,MunicipalityFilters.tsxsrc/components/campaign/CampaignListPending.tsx,ActionPlanTaskChecklist.tsx(precedenteuseOptimistic)src/utilities/municipalityUi.ts,src/utilities/municipalityPageData.ts,src/utilities/campaignListUrl.tssrc/app/(campaign)/campanha/(app)/municipios/page.tsxdocs/roadmap.md(fill-ins, B16+)- AGENTS.md — naming em inglês,
overrideAccess: false, client boundary